// File names built from server-supplied metadata. // // quak does not trust the server. A file's title and a collection's name are // decrypted from data the server hands us, and a hostile server (or a // compromised account) can set them to anything. quak uses them to name files // on disk: `quak get` without `--out`, `downloadFile` without `outPath`, the // backup's symlink and collection directories, and the extension of every file // in the originals cache. Each of those goes through `sanitizeFileName` or // `safeExtension`, so a title can only ever name one file inside the directory // the caller chose. // // A path the user supplies (`--out`, `outPath`) is never sanitized: the caller // is trusted, the server is not. import { describe, expect, it } from "vitest"; import { defaultFileName, safeExtension, sanitizeFileName, shortenKeepingExtension, shortenToBytes, } from "../../src/filename.js"; const FALLBACK = "file-42"; describe("sanitizeFileName", () => { it("passes a normal title through unchanged", () => { expect(sanitizeFileName("IMG_0001.HEIC", FALLBACK)).toBe( "IMG_0001.HEIC", ); expect(sanitizeFileName("Holiday 2024 (1).jpg", FALLBACK)).toBe( "Holiday 2024 (1).jpg", ); expect(sanitizeFileName("café.jpg", FALLBACK)).toBe("café.jpg"); }); it("cannot climb out of the directory with ../", () => { // Without sanitizing, this would overwrite the user's SSH keys. expect(sanitizeFileName("../../.ssh/authorized_keys", FALLBACK)).toBe( "__.._.ssh_authorized_keys", ); expect(sanitizeFileName("..", FALLBACK)).toBe("_"); expect(sanitizeFileName("..\\..\\x", FALLBACK)).toBe("__.._x"); }); it("cannot name an absolute path", () => { expect(sanitizeFileName("/etc/passwd", FALLBACK)).toBe("_etc_passwd"); expect(sanitizeFileName("C:\\Windows\\x.dll", FALLBACK)).toBe( "C__Windows_x.dll", ); }); it("replaces embedded separators, so the name stays one file", () => { expect(sanitizeFileName("a/b\\c.jpg", FALLBACK)).toBe("a_b_c.jpg"); }); it("replaces NUL and other control characters", () => { // A NUL truncates the path in C code and makes Node's fs throw. expect(sanitizeFileName("evil\0.jpg", FALLBACK)).toBe("evil_.jpg"); expect(sanitizeFileName("line\nbreak\x7f.jpg", FALLBACK)).toBe( "line_break_.jpg", ); }); it("does not produce a hidden file", () => { expect(sanitizeFileName(".bashrc", FALLBACK)).toBe("_bashrc"); }); it("does not produce a Windows device name", () => { expect(sanitizeFileName("CON", FALLBACK)).toBe("_CON"); expect(sanitizeFileName("nul.txt", FALLBACK)).toBe("_nul.txt"); expect(sanitizeFileName("LPT1", FALLBACK)).toBe("_LPT1"); // Only the exact names are reserved. expect(sanitizeFileName("console.jpg", FALLBACK)).toBe("console.jpg"); }); it("falls back to the given name for an empty title", () => { expect(sanitizeFileName("", FALLBACK)).toBe(FALLBACK); }); }); describe("safeExtension", () => { it("keeps a normal extension", () => { expect(safeExtension("IMG_0001.HEIC")).toBe(".HEIC"); expect(safeExtension("clip.mp4")).toBe(".mp4"); }); it("uses .bin when there is no extension", () => { expect(safeExtension("")).toBe(".bin"); expect(safeExtension("README")).toBe(".bin"); }); it("uses .bin when the extension holds anything but letters and digits", () => { expect(safeExtension("x.j\\..\\pg")).toBe(".bin"); expect(safeExtension("x.jp g")).toBe(".bin"); expect(safeExtension("x.jpg\0")).toBe(".bin"); }); it("keeps an extension of 16 letters and digits, and uses .bin for a longer one", () => { expect(safeExtension(`x.${"e".repeat(16)}`)).toBe(`.${"e".repeat(16)}`); expect(safeExtension(`x.${"e".repeat(17)}`)).toBe(".bin"); expect(safeExtension(`b.${"e".repeat(300)}`)).toBe(".bin"); }); }); describe("shortenToBytes", () => { it("returns a name that fits unchanged", () => { expect(shortenToBytes("beach", 5)).toBe("beach"); expect(shortenToBytes("café", 5)).toBe("café"); expect(shortenToBytes("", 0)).toBe(""); }); it("cuts a name to the number of bytes given", () => { expect(shortenToBytes("L".repeat(300), 255)).toBe("L".repeat(255)); }); // In UTF-8 "é" takes two bytes and an emoji four, which a JavaScript // string holds as two halves. it("cuts between two characters, never inside one", () => { expect(shortenToBytes("ééé", 5)).toBe("éé"); expect(shortenToBytes("a\u{1F600}b", 4)).toBe("a"); expect(shortenToBytes("a\u{1F600}b", 5)).toBe("a\u{1F600}"); expect(Buffer.byteLength(shortenToBytes("é".repeat(200), 255))).toBe( 254, ); }); }); describe("shortenKeepingExtension", () => { it("returns a name that fits unchanged", () => { expect(shortenKeepingExtension("beach.jpg", 9)).toBe("beach.jpg"); expect(shortenKeepingExtension("beach", 5)).toBe("beach"); }); it("cuts the part before the extension short and keeps the extension", () => { expect(shortenKeepingExtension(`${"t".repeat(296)}.jpg`, 255)).toBe( `${"t".repeat(251)}.jpg`, ); expect(shortenKeepingExtension("é".repeat(150), 255)).toBe( "é".repeat(127), ); }); // A name cut down to its extension would start with a dot, and so be // hidden. it("keeps the first character before the extension, cutting the extension short to leave room for it", () => { expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe( `b.${"e".repeat(253)}`, ); expect(shortenKeepingExtension(`ab.${"e".repeat(300)}`, 255)).toBe( `a.${"e".repeat(253)}`, ); expect(shortenKeepingExtension(`é.${"e".repeat(300)}`, 255)).toBe( `é.${"e".repeat(252)}`, ); // "€" takes three bytes, so the extension is cut to 253 and the // part before it keeps the two bytes left over, for the "a". expect(shortenKeepingExtension(`a.${"€".repeat(90)}`, 255)).toBe( `a.${"€".repeat(84)}`, ); }); it("puts `insert` right before the extension and leaves room for it", () => { expect(shortenKeepingExtension("beach.jpg", 255, " (7)")).toBe( "beach (7).jpg", ); expect( shortenKeepingExtension(`${"t".repeat(296)}.jpg`, 255, " (7)"), ).toBe(`${"t".repeat(247)} (7).jpg`); expect( shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"), ).toBe(`b (7).${"e".repeat(249)}`); }); }); describe("defaultFileName", () => { it("is the title when it is safe and fits", () => { expect(defaultFileName("IMG_0001.HEIC", 42)).toBe("IMG_0001.HEIC"); }); it("replaces unsafe characters, and is file- for an empty title", () => { expect(defaultFileName("../x.jpg", 42)).toBe("__x.jpg"); expect(defaultFileName("", 42)).toBe("file-42"); }); it("cuts a title over 255 bytes short, keeping its extension", () => { expect(defaultFileName(`${"t".repeat(296)}.jpg`, 42)).toBe( `${"t".repeat(251)}.jpg`, ); expect(defaultFileName(`b.${"e".repeat(300)}`, 42)).toBe( `b.${"e".repeat(253)}`, ); }); it("puts `prefix` before the title and leaves room for it", () => { expect(defaultFileName("beach.jpg", 42, { prefix: "thumb_" })).toBe( "thumb_beach.jpg", ); expect( defaultFileName(`${"t".repeat(296)}.jpg`, 42, { prefix: "thumb_" }), ).toBe(`thumb_${"t".repeat(245)}.jpg`); }); // A live photo's image and video are each named with their own // extension, and the name is cut short once that extension is in it. it("puts `ext` in place of the title's extension before cutting the name short", () => { expect(defaultFileName("IMG_0001.HEIC", 42, { ext: ".mov" })).toBe( "IMG_0001.mov", ); expect(defaultFileName("", 42, { ext: ".mov" })).toBe("file-42.mov"); expect( defaultFileName(`${"t".repeat(251)}.jpg`, 42, { ext: ".heic" }), ).toBe(`${"t".repeat(250)}.heic`); expect(defaultFileName("t".repeat(300), 42, { ext: ".heic" })).toBe( `${"t".repeat(250)}.heic`, ); }); // Cutting `CONSOLE` or `NULL` short before a long extension leaves `CON` // or `NUL`, so the name gets the `_` `sanitizeFileName` gives a device // name, and the part before the extension gives up a byte for it. it("never returns a Windows device name, even once cut short", () => { const ext = `.${"e".repeat(251)}`; expect(defaultFileName(`CONSOLE${ext}`, 42)).toBe(`_CO${ext}`); expect(defaultFileName(`NULL${ext}`, 42)).toBe(`_NU${ext}`); }); });