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:
2026-10-18 17:03:14 +00:00
parent 982b878d69
commit fd40714a6d
10 changed files with 290 additions and 27 deletions
+93 -1
View File
@@ -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