check / check (push) Waiting to run
`defaultFileName` in `src/filename.ts` now gives the name a file is saved under when the caller gives no path, and `originalName`, `thumbnailName`, `downloadFile` and `downloadThumbnail` all call it. For a live photo's image and video it puts the part's own extension in before cutting the name short, so those names fit too; a path from `--out` or `outPath` is kept as given. `shortenKeepingExtension` keeps the first character before the extension, cutting a long extension short to leave room for it, so a cut name no longer starts with a dot. The backup's links for such a title change to match. TODO.md names the open bug and describes what the code does. Model: opus-5-5
220 lines
8.5 KiB
TypeScript
220 lines
8.5 KiB
TypeScript
// 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-<id> 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`,
|
|
);
|
|
});
|
|
});
|