// File names built from server-supplied metadata. // // A file's title and a collection's name are decrypted from data the server // hands us, and quak does not trust the server. Any name taken from them and // used on disk goes through here, so it can only ever name one file inside the // directory the caller chose: never a path, never `..`, never hidden, never a // Windows device name. // // A path the user typed (`--out`, `outPath`) is not passed through here: the // caller is trusted, the server is not. import { extname } from "node:path"; // Path separators, characters Windows forbids in file names, and control // characters (NUL included). // eslint-disable-next-line no-control-regex const UNSAFE_CHARACTERS = /[/\\:*?"<>|\x00-\x1f\x7f]/g; // Names Windows reserves for devices, with or without an extension. const RESERVED_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i; // `name` made safe to use as a single file name. Each unsafe character becomes // `_`, a leading run of dots becomes one `_`, and a device name gets a leading // `_`. A name with none of these comes back unchanged. An empty name becomes // `fallback`, which the caller derives from the record's ID. export const sanitizeFileName = (name: string, fallback: string): string => { if (name === "") return fallback; const cleaned = name.replace(UNSAFE_CHARACTERS, "_").replace(/^\.+/, "_"); return RESERVED_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned; }; // The extension of `title` (".jpg"), or ".bin" when it has none or it holds // anything but letters and digits. export const safeExtension = (title: string): string => { const ext = extname(title); return /^\.[A-Za-z0-9]+$/.test(ext) ? ext : ".bin"; };