// 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. Such a name can still be too long for a file name, and // `shortenToBytes` or `shortenKeepingExtension` cuts one short. // `defaultFileName` does both for the name a file is saved under when the // caller gives no path. // // 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, it holds // anything but letters and digits, or it is more than 16 of them long. export const safeExtension = (title: string): string => { const ext = extname(title); return /^\.[A-Za-z0-9]{1,16}$/.test(ext) ? ext : ".bin"; }; // `name` with its extension, if it has one, replaced by `ext` (".mov"). export const withExtension = (name: string, ext: string): string => name.slice(0, name.length - extname(name).length) + ext; // The longest file name, in bytes, that ext4 and most other file systems allow. export const MAX_NAME_BYTES = 255; // `name` cut short to at most `maxBytes` bytes of UTF-8, the encoding a file // name is written to disk in, between two characters, never inside one. A name // that fits comes back unchanged. export const shortenToBytes = (name: string, maxBytes: number): string => { let bytes = 0; let end = 0; for (const character of name) { bytes += Buffer.byteLength(character); if (bytes > maxBytes) break; end += character.length; } return name.slice(0, end); }; // `name` cut short to fit in `maxBytes` bytes with `insert` put right before // its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes` // makes it. The part before the extension is cut first, so the extension is // kept whenever it fits, but that part always keeps its first character: // when the extension leaves no room for `insert` and that character, the // extension is cut short too, and the part before it keeps the bytes that cut // leaves over. So a name that does not start with a dot never comes back // starting with one, which would hide it. `insert` is never cut. With no // `insert`, a name that fits comes back unchanged. export const shortenKeepingExtension = ( name: string, maxBytes: number, insert = "", ): string => { const fullExt = extname(name); const stem = name.slice(0, name.length - fullExt.length); const first = Array.from(stem)[0] ?? ""; const ext = shortenToBytes( fullExt, maxBytes - Buffer.byteLength(insert + first), ); const room = maxBytes - Buffer.byteLength(insert + ext); return shortenToBytes(stem, room) + insert + ext; }; // The name a file is saved under in the working directory when the caller // gives no path (`quak get` without `--out`, `downloadFile` without // `outPath`): `prefix` (`thumb_` for a thumbnail), then the file's title with // unsafe characters replaced, or `file-` when the title is empty, cut // short so that the whole fits in a file name, keeping the title's extension // whenever it fits. `ext` (".mov"), when given, takes the place of the // title's extension before the name is cut short; a live photo's image and // video are each named with their own. The cut can leave a device name // (`CONSOLE.` becomes `CON.`); that name gets // a leading `_`, as `sanitizeFileName` gives one, and is cut a byte shorter to // leave room for it. export const defaultFileName = ( title: string, fileID: number, opts: { prefix?: string; ext?: string } = {}, ): string => { const { prefix = "", ext } = opts; const safe = sanitizeFileName(title, `file-${fileID}`); const name = ext === undefined ? safe : withExtension(safe, ext); const room = MAX_NAME_BYTES - Buffer.byteLength(prefix); const cut = prefix + shortenKeepingExtension(name, room); if (!RESERVED_DEVICE_NAME.test(cut)) return cut; return `_${prefix}${shortenKeepingExtension(name, room - 1)}`; };