Names quak builds from server data fit in 255 bytes (closes #205)
The default names of `quak get`, `quak get-thumb`, `downloadFile` and `downloadThumbnail`, and the album folders of `backup-metadata`, could be over 255 bytes, which a file system refuses. `shortenKeepingExtension` in `src/filename.ts` cuts a name short, keeping its extension when it fits, and can put text right before the extension; the backup's `withID` now calls it instead of doing the same itself. The `get` defaults use it with `thumb_` counted. A `backup-metadata` folder keeps its `<id>-` and has the name after it cut short. `safeExtension` gives `.bin` for an extension of more than 16 letters and digits, so a save path's name always fits. Model: opus-5-5
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PassThrough } from "node:stream";
|
||||
import * as jpegJs from "jpeg-js";
|
||||
@@ -562,6 +562,98 @@ describe("get and get-thumb", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A file system refuses a file name over 255 bytes. The account here is one
|
||||
// album, whose name is 300 bytes, holding one file, 700, with the title given.
|
||||
describe("names from an album name or a title over 255 bytes", () => {
|
||||
const longNames = (title: string): Client =>
|
||||
({
|
||||
...fakeClient(),
|
||||
collectionsSince: async () => ({
|
||||
collections: [collection(7, "A".repeat(300))],
|
||||
deleted: [],
|
||||
cursor: 1,
|
||||
}),
|
||||
filesSince: async () => ({
|
||||
files: [file(700, 7, title)],
|
||||
deleted: [],
|
||||
cursor: 1,
|
||||
}),
|
||||
}) as unknown as Client;
|
||||
const longTitle = `${"t".repeat(296)}.jpg`;
|
||||
|
||||
// Run `command` in a new directory, where `get` and `get-thumb` without
|
||||
// `--out` write, and return its exit code and the directory.
|
||||
const inNewDirectory = async (
|
||||
command: () => Promise<number>,
|
||||
): Promise<{ code: number; dir: string }> => {
|
||||
const dir = mkdtempSync(join(root, "cwd-"));
|
||||
const previous = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
return { code: await command(), dir };
|
||||
} finally {
|
||||
process.chdir(previous);
|
||||
}
|
||||
};
|
||||
|
||||
it("get cuts the title short to 255 bytes, keeping its extension, and exits 0", async () => {
|
||||
const { code, dir } = await inNewDirectory(() =>
|
||||
getCommand(context(longNames(longTitle)), "700", {}),
|
||||
);
|
||||
|
||||
const name = `${"t".repeat(251)}.jpg`;
|
||||
expect(Buffer.byteLength(name)).toBe(255);
|
||||
expect(code).toBe(0);
|
||||
expect(readdirSync(dir)).toEqual([name]);
|
||||
expect(stderr.text).toBe(`7 bytes -> ${name}\n`);
|
||||
});
|
||||
|
||||
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
|
||||
const { code, dir } = await inNewDirectory(() =>
|
||||
getThumbCommand(context(longNames(longTitle)), "700", {}),
|
||||
);
|
||||
|
||||
const name = `thumb_${"t".repeat(245)}.jpg`;
|
||||
expect(Buffer.byteLength(name)).toBe(255);
|
||||
expect(code).toBe(0);
|
||||
expect(readdirSync(dir)).toEqual([name]);
|
||||
expect(stderr.text).toBe(`3 bytes -> ${name}\n`);
|
||||
});
|
||||
|
||||
it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => {
|
||||
const dir = join(root, "dump");
|
||||
|
||||
const code = await backupMetadataCommand(
|
||||
context(longNames("beach.jpg")),
|
||||
dir,
|
||||
{},
|
||||
);
|
||||
|
||||
const folder = `7-${"A".repeat(253)}`;
|
||||
expect(Buffer.byteLength(folder)).toBe(255);
|
||||
expect(code).toBe(0);
|
||||
expect(readdirSync(join(dir, "collections"))).toEqual([folder]);
|
||||
expect(readdirSync(join(dir, "collections", folder)).sort()).toEqual([
|
||||
"700.json",
|
||||
"_collection.json",
|
||||
]);
|
||||
});
|
||||
|
||||
// The title's extension is 300 letters, too long for the save path's
|
||||
// name, so the original is saved with `.bin`.
|
||||
it("backup saves a title with a 300-letter extension as .bin, and exits 0", async () => {
|
||||
const dir = join(root, "backup");
|
||||
const title = `b.${"e".repeat(300)}`;
|
||||
|
||||
const code = await backupCommand(context(longNames(title)), dir, {});
|
||||
|
||||
const path = savePath(dir, file(700, 7, title));
|
||||
expect(code).toBe(0);
|
||||
expect(basename(path)).toMatch(/^\d{4}-\d{2}-\d{2}\.700\.bin$/);
|
||||
expect(readFileSync(path)).toEqual(Buffer.alloc(7, 700 & 0xff));
|
||||
});
|
||||
});
|
||||
|
||||
// A live photo, which Ente stores as one ZIP, is written as its image and its
|
||||
// video, which a photo viewer can open. The account here is Vacation holding
|
||||
// one live photo, 300, downloaded through the real download layer
|
||||
|
||||
@@ -658,6 +658,26 @@ describe("downloadFile", () => {
|
||||
expect(readdirSync(parent)).toEqual(["cwd"]);
|
||||
});
|
||||
|
||||
// A file system refuses a file name over 255 bytes.
|
||||
it("cuts a title too long for a file name short, keeping its extension", async () => {
|
||||
const plaintext = new Uint8Array([1, 2, 3]);
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const { header, ciphertext } = encryptFileBody(plaintext, key);
|
||||
const thumbPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
const file = buildMockEnteFile(key, header, thumbPush.header);
|
||||
file.metadata.title = `${"t".repeat(296)}.jpg`;
|
||||
const dir = mkdtempSync(join(testDir, "long-title-"));
|
||||
|
||||
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
|
||||
const result = await inDirectory(dir, () => downloadFile(api, file));
|
||||
|
||||
const name = `${"t".repeat(251)}.jpg`;
|
||||
expect(Buffer.byteLength(name)).toBe(255);
|
||||
expect(result.path).toBe(name);
|
||||
expect(readdirSync(dir)).toEqual([name]);
|
||||
});
|
||||
|
||||
it("uses an explicit outPath verbatim, even one with ..", async () => {
|
||||
// The caller is trusted: its path is not sanitized.
|
||||
const { api, file } = fixtureFor(
|
||||
@@ -751,6 +771,28 @@ describe("downloadThumbnail", () => {
|
||||
expect(result.path).toBe("thumb__etc_passwd");
|
||||
expect(readdirSync(dir)).toEqual(["thumb__etc_passwd"]);
|
||||
});
|
||||
|
||||
it("leaves room for thumb_ when it cuts a title too long for a file name short", async () => {
|
||||
const plaintext = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const filePush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
|
||||
const { header: thumbHeader, ciphertext: thumbCipher } =
|
||||
encryptFileBody(plaintext, key);
|
||||
const file = buildMockEnteFile(key, filePush.header, thumbHeader);
|
||||
file.metadata.title = `${"t".repeat(296)}.jpg`;
|
||||
const dir = mkdtempSync(join(testDir, "long-thumb-title-"));
|
||||
|
||||
const api = new ApiClient({ fetch: mockFetchForBody(thumbCipher) });
|
||||
const result = await inDirectory(dir, () =>
|
||||
downloadThumbnail(api, file),
|
||||
);
|
||||
|
||||
const name = `thumb_${"t".repeat(245)}.jpg`;
|
||||
expect(Buffer.byteLength(name)).toBe(255);
|
||||
expect(result.path).toBe(name);
|
||||
expect(readdirSync(dir)).toEqual([name]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
safeExtension,
|
||||
sanitizeFileName,
|
||||
shortenKeepingExtension,
|
||||
shortenToBytes,
|
||||
} from "../../src/filename.js";
|
||||
|
||||
@@ -94,6 +95,12 @@ describe("safeExtension", () => {
|
||||
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", () => {
|
||||
@@ -118,3 +125,42 @@ describe("shortenToBytes", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
it("cuts the extension short when it alone does not fit", () => {
|
||||
expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe(
|
||||
`.${"e".repeat(254)}`,
|
||||
);
|
||||
// "€" 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(` (7).${"e".repeat(250)}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user