Names quak builds from server data fit in 255 bytes (closes #205)
check / check (push) Waiting to run
check / check (push) Waiting to run
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. `defaultFileName` and `shortenKeepingExtension` in `src/filename.ts` now cut such a name short, keeping its extension when it fits; a live photo's image and video get their own extension before the cut. A cut name never starts with a dot and is never a Windows device name. `--out` and `outPath` are kept as given. A `backup-metadata` folder keeps its `<id>-`. `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 was merged in pull request #213.
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1881,6 +1923,53 @@ describe("downloadFile live photos", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// A file system refuses a file name over 255 bytes. Each name is cut
|
||||
// short once its own extension is in it.
|
||||
const imageName = `${"t".repeat(250)}.heic`;
|
||||
const videoName = `${"t".repeat(251)}.mov`;
|
||||
|
||||
// The title fits, but ".heic" is one byte longer than ".jpg".
|
||||
it("cuts the image's name short for a 255-byte title ending in .jpg when no outPath is given", async () => {
|
||||
const title = `${"t".repeat(251)}.jpg`;
|
||||
const t = setup(livePhotoZip(), { ...livePhoto, title });
|
||||
|
||||
const result = await inDirectory(t.dir, () =>
|
||||
downloadFile(t.api, t.file),
|
||||
);
|
||||
|
||||
expect(Buffer.byteLength(title)).toBe(255);
|
||||
expect(Buffer.byteLength(imageName)).toBe(255);
|
||||
expect(Buffer.byteLength(videoName)).toBe(255);
|
||||
expect(result.path).toBe(imageName);
|
||||
expect(result.videoPath).toBe(videoName);
|
||||
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
|
||||
});
|
||||
|
||||
it("cuts the names of the image and the video short for a 300-byte title with no extension when no outPath is given", async () => {
|
||||
const t = setup(livePhotoZip(), {
|
||||
...livePhoto,
|
||||
title: "t".repeat(300),
|
||||
});
|
||||
|
||||
const result = await inDirectory(t.dir, () =>
|
||||
downloadFile(t.api, t.file),
|
||||
);
|
||||
|
||||
expect(result.path).toBe(imageName);
|
||||
expect(result.videoPath).toBe(videoName);
|
||||
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
|
||||
});
|
||||
|
||||
// The caller's path is used as is; only the extension is each part's own.
|
||||
it("keeps an outPath too long for a file name as given", async () => {
|
||||
const t = setup(livePhotoZip(), livePhoto);
|
||||
const outPath = join(t.dir, `${"t".repeat(251)}.jpg`);
|
||||
|
||||
await expect(downloadFile(t.api, t.file, outPath)).rejects.toThrow(
|
||||
/ENAMETOOLONG/,
|
||||
);
|
||||
});
|
||||
|
||||
it("gives each part its own extension from the ZIP, letters and digits only", async () => {
|
||||
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
|
||||
const t = setup(zip, livePhoto);
|
||||
|
||||
Reference in New Issue
Block a user