Store live photos as their image and their video (closes #107)
check / check (push) Successful in 41s

A live photo, which Ente stores as one ZIP, is unpacked as it downloads
into its image and its video, each `<fileID>.<ext>` with its extension
from the ZIP, beside `<fileID>.livephoto.json`, which names the two.
Both are checked against the recorded hash and renamed into place only
when both are complete. A ZIP with a second image or video, or whose
parts come to more than 20 times its size plus 16 MiB, is refused. The
backup and the content cache count a live photo as stored only with both
files, album folders link both, `quak get` writes both, and the content
result gives the video as `videoPath`. A ZIP an earlier version stored
is replaced.

Model: opus-5-5
This commit was merged in pull request #128.
This commit is contained in:
2026-09-28 18:06:01 +02:00
parent 04094a8cfb
commit 9e6deb21eb
17 changed files with 1746 additions and 366 deletions
+257 -69
View File
@@ -55,6 +55,7 @@ import {
readFileSync,
rmSync,
mkdtempSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
@@ -81,6 +82,7 @@ import {
writeAtomic,
} from "../../src/download/index.js";
import type { EnteFile, FileMetadata } from "../../src/model/types.js";
import { IMAGE, livePhotoHash, livePhotoZip, VIDEO } from "../live-photo.js";
// ---------------------------------------------------------------------------
// Test helpers
@@ -106,6 +108,8 @@ import type { EnteFile, FileMetadata } from "../../src/model/types.js";
const renameHook = vi.hoisted(() => ({
calls: [] as { from: string; to: string; sourceExisted: boolean }[],
failWith: null as Error | null,
// When set, only a rename to this path fails.
failTo: null as string | null,
}));
/**
@@ -181,7 +185,10 @@ vi.mock("node:fs/promises", async (importOriginal) => {
sourceExisted: sourceExists(from),
});
durabilityHook.events.push(`rename:${to}`);
if (renameHook.failWith !== null) {
if (
renameHook.failWith !== null &&
(renameHook.failTo === null || renameHook.failTo === to)
) {
throw renameHook.failWith;
}
await actual.rename(from, to);
@@ -190,12 +197,12 @@ vi.mock("node:fs/promises", async (importOriginal) => {
});
/**
* `chunkHashUpdate` is wrapped to record the length of every piece hashed, so
* a test can show that a live photo entry reaches the hash in pieces far
* smaller than the entry, rather than decompressed whole first.
* `pullStreamChunk` is wrapped to note, as each chunk is decrypted, how many
* writes `writeHook` has seen by then, so a test can tell what reached disk
* before the last chunk was decrypted.
*/
const hashHook = vi.hoisted(() => ({
lengths: [] as number[],
const pullHook = vi.hoisted(() => ({
writesSeen: [] as number[],
}));
vi.mock("../../src/crypto/index.js", async (importOriginal) => {
@@ -203,19 +210,20 @@ vi.mock("../../src/crypto/index.js", async (importOriginal) => {
await importOriginal<typeof import("../../src/crypto/index.js")>();
return {
...actual,
chunkHashUpdate: (
...args: Parameters<typeof actual.chunkHashUpdate>
): void => {
hashHook.lengths.push(args[1].length);
actual.chunkHashUpdate(...args);
pullStreamChunk: (
...args: Parameters<typeof actual.pullStreamChunk>
): ReturnType<typeof actual.pullStreamChunk> => {
pullHook.writesSeen.push(writeHook.writes.length);
return actual.pullStreamChunk(...args);
},
};
});
beforeEach(() => {
hashHook.lengths.length = 0;
pullHook.writesSeen.length = 0;
renameHook.calls.length = 0;
renameHook.failWith = null;
renameHook.failTo = null;
durabilityHook.events.length = 0;
writeHook.writes.length = 0;
});
@@ -283,6 +291,34 @@ const encryptFileBody = (
return { header: push.header, ciphertext };
};
/**
* Encrypt `plaintext` the way the server does a file of any size: one
* secretstream chunk per `STREAM_CHUNK_SIZE` bytes, the last tagged TAG_FINAL.
*/
const encryptInChunks = (
plaintext: Uint8Array,
key: Uint8Array,
): { header: Uint8Array; ciphertext: Uint8Array } => {
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const chunks: Uint8Array[] = [];
let offset = 0;
do {
const piece = plaintext.subarray(offset, offset + STREAM_CHUNK_SIZE);
offset += STREAM_CHUNK_SIZE;
chunks.push(
sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
piece,
null,
offset >= plaintext.length
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE,
),
);
} while (offset < plaintext.length);
return { header: push.header, ciphertext: concat(chunks) };
};
/**
* Encrypt a body that spans more than one secretstream chunk, the way the
* server does for files larger than the 4 MiB plaintext chunk size.
@@ -1642,38 +1678,35 @@ describe.each(entryPoints)("$name progress", ({ name, download }) => {
});
});
describe("downloadFile content hash", () => {
// Node's own BLAKE2b-512 is the reference, so these tests do not depend
// on the code under test to compute what they expect.
const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// Node's own BLAKE2b-512 is the reference, so the tests below do not depend on
// the code under test to compute what they expect.
const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// Serve `plaintext` encrypted as file 999 with the given metadata. Four
// responses are scripted so a retried mismatch would show in `requests`.
const setup = (plaintext: Uint8Array, metadata: Partial<FileMetadata>) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(plaintext, key);
const file = buildMockEnteFile(key, header, header);
file.metadata = { ...file.metadata, ...metadata };
const body = { kind: "body", bytes: ciphertext } as const;
const { fetch, requests } = scriptedCdnFetch(body, body, body, body);
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 4 } });
const dir = mkdtempSync(join(testDir, "hash-"));
const outPath = join(dir, "f.bin");
return {
run: () => downloadFile(api, file, outPath),
dir,
outPath,
requests,
};
// Serve `plaintext` encrypted as file 999 with the given metadata, to be
// written to `f.bin` in a fresh directory. Four responses are scripted so a
// retried failure would show in `requests`.
const setup = (plaintext: Uint8Array, metadata: Partial<FileMetadata>) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptInChunks(plaintext, key);
const file = buildMockEnteFile(key, header, header);
file.metadata = { ...file.metadata, ...metadata };
const body = { kind: "body", bytes: ciphertext } as const;
const { fetch, requests } = scriptedCdnFetch(body, body, body, body);
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 4 } });
const dir = mkdtempSync(join(testDir, "hash-"));
const outPath = join(dir, "f.bin");
return {
run: () => downloadFile(api, file, outPath),
api,
file,
dir,
outPath,
requests,
};
};
const livePhotoZip = zipSync({
"image.heic": patternBytes(500, 81),
"video.mov": patternBytes(900, 82),
});
const livePhotoHash = `${blake2b(patternBytes(500, 81))}:${blake2b(patternBytes(900, 82))}`;
describe("downloadFile content hash", () => {
it("stores a file whose hash matches", async () => {
const plaintext = patternBytes(700, 80);
const t = setup(plaintext, { hash: blake2b(plaintext) });
@@ -1706,58 +1739,73 @@ describe("downloadFile content hash", () => {
});
it("stores a live photo whose image and video hashes match", async () => {
const t = setup(livePhotoZip, {
const t = setup(livePhotoZip(), {
fileType: "livePhoto",
hash: livePhotoHash,
hash: livePhotoHash(),
});
await t.run();
expectSameBytes(readFileSync(t.outPath), livePhotoZip);
expect(readFileSync(join(t.dir, "f.heic"))).toEqual(Buffer.from(IMAGE));
expect(readFileSync(join(t.dir, "f.mov"))).toEqual(Buffer.from(VIDEO));
});
it("hashes a large live photo entry as it decompresses, never whole", async () => {
// 64 MiB of zeros deflates to a few kilobytes, the shape of a ZIP
// that would exhaust memory if expanded whole.
const image = new Uint8Array(64 * 1024 * 1024);
const video = patternBytes(900, 83);
const zip = zipSync({ "image.heic": image, "video.mov": video });
it("writes a live photo's image as it decompresses, before the last chunk is decrypted", async () => {
// The image, 12 MiB of zeros, deflates to a few kilobytes at the
// start of the ZIP. The video, stored as it is, carries the ZIP into
// a second chunk.
const image = new Uint8Array(12 * 1024 * 1024);
const video = patternBytes(STREAM_CHUNK_SIZE, 83);
const zip = zipSync({
"image.heic": image,
"video.mov": [video, { level: 0 }],
});
const t = setup(zip, {
fileType: "livePhoto",
hash: `${blake2b(image)}:${blake2b(video)}`,
hash: livePhotoHash(image, video),
});
await t.run();
expectSameBytes(readFileSync(t.outPath), zip);
const hashed = hashHook.lengths.reduce((a, b) => a + b, 0);
expect(hashed).toBe(image.length + video.length);
expect(Math.max(...hashHook.lengths)).toBeLessThanOrEqual(
2 * STREAM_CHUNK_SIZE,
);
// All of the image was written before the second chunk was decrypted,
// and no write held more than two chunks' worth.
expect(pullHook.writesSeen).toHaveLength(2);
const imageTemp = renameHook.calls.find(
(c) => c.to === join(t.dir, "f.heic"),
)!.from;
const imageWrittenBeforeLastChunk = writeHook.writes
.slice(0, pullHook.writesSeen[1])
.filter((w) => w.path === imageTemp)
.reduce((n, w) => n + w.length, 0);
expect(imageWrittenBeforeLastChunk).toBe(image.length);
expect(
Math.max(...writeHook.writes.map((w) => w.length)),
).toBeLessThanOrEqual(2 * STREAM_CHUNK_SIZE);
expect(statSync(join(t.dir, "f.heic")).size).toBe(image.length);
expectSameBytes(readFileSync(join(t.dir, "f.mov")), video);
});
it("rejects a live photo whose hash does not match", async () => {
it("rejects a live photo whose hash does not match, keeping what was there", async () => {
// The whole ZIP's hash is not the recorded one: each part is hashed.
const t = setup(livePhotoZip, {
fileType: "livePhoto",
hash: blake2b(livePhotoZip),
});
const zip = livePhotoZip();
const t = setup(zip, { fileType: "livePhoto", hash: blake2b(zip) });
writeFileSync(t.outPath, "an earlier download");
await expect(t.run()).rejects.toThrow(
/file 999: content hash .* does not match/,
);
expect(readdirSync(t.dir)).toEqual([]);
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(readFileSync(t.outPath, "utf-8")).toBe("an earlier download");
});
it("rejects a live photo that is not a readable ZIP and does not retry", async () => {
// Bytes 8-9 of a ZIP entry's local header name its compression
// method; 99 is one no reader knows, so the entry cannot be read.
const zip = livePhotoZip.slice();
const zip = livePhotoZip();
zip[8] = 99;
zip[9] = 0;
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo is not a readable ZIP/,
@@ -1768,8 +1816,8 @@ describe("downloadFile content hash", () => {
});
it("rejects a live photo ZIP with no image entry", async () => {
const zip = zipSync({ "video.mov": patternBytes(900, 82) });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash });
const zip = livePhotoZip({ "video.mov": VIDEO });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo ZIP does not hold both an image and a video/,
@@ -1779,8 +1827,8 @@ describe("downloadFile content hash", () => {
});
it("rejects a live photo ZIP with no video entry", async () => {
const zip = zipSync({ "image.heic": patternBytes(500, 81) });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash });
const zip = livePhotoZip({ "image.heic": IMAGE });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo ZIP does not hold both an image and a video/,
@@ -1789,3 +1837,143 @@ describe("downloadFile content hash", () => {
expect(readdirSync(t.dir)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Live photos
//
// A live photo arrives as a ZIP of its image and its video. It is written as
// those two files, which a photo viewer can open, each named after the
// destination with its own extension from the ZIP, the way Ente's clients name
// them when they save one.
// ---------------------------------------------------------------------------
describe("downloadFile live photos", () => {
const livePhoto = { fileType: "livePhoto", hash: livePhotoHash() } as const;
it("names the image and the video after the title when no outPath is given", async () => {
const t = setup(livePhotoZip(), {
...livePhoto,
title: "IMG_1234.HEIC",
});
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
// `bytesWritten` is the length of the decrypted ZIP.
expect(result).toEqual({
path: "IMG_1234.heic",
videoPath: "IMG_1234.mov",
bytesWritten: livePhotoZip().length,
});
expect(readdirSync(t.dir).sort()).toEqual([
"IMG_1234.heic",
"IMG_1234.mov",
]);
});
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);
const result = await downloadFile(t.api, t.file, join(t.dir, "f.HEIC"));
expect(result.path).toBe(join(t.dir, "f.JPG"));
expect(result.videoPath).toBe(join(t.dir, "f.bin"));
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
});
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
const t = setup(livePhotoZip(), livePhoto);
writeFileSync(t.outPath, livePhotoZip());
await t.run();
expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]);
});
it("renames the image and then the video into place, each from its own temp file", async () => {
const t = setup(livePhotoZip(), livePhoto);
await t.run();
expect(
renameHook.calls.map((c) => [
dirname(c.from),
c.to,
c.sourceExisted,
]),
).toEqual([
[t.dir, join(t.dir, "f.heic"), true],
[t.dir, join(t.dir, "f.mov"), true],
]);
});
it("stores neither part when the video cannot be renamed into place", async () => {
const t = setup(livePhotoZip(), livePhoto);
renameHook.failWith = new Error("simulated rename failure");
renameHook.failTo = join(t.dir, "f.mov");
await expect(t.run()).rejects.toThrow("simulated rename failure");
expect(readdirSync(t.dir)).toEqual([]);
});
it("refuses an image and a video with the same extension, storing nothing", async () => {
// On a file system that ignores case, the two would be one file.
const zip = livePhotoZip({ "image.mov": IMAGE, "video.MOV": VIDEO });
const t = setup(zip, livePhoto);
writeFileSync(t.outPath, "an earlier download");
await expect(t.run()).rejects.toThrow(
/file 999: live photo's image and video have the same extension/,
);
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(t.requests()).toBe(1);
});
it.each([
["image", "image.jpg"],
["video", "video.mp4"],
])(
"refuses a live photo ZIP holding a second %s, storing nothing",
async (kind, second) => {
const zip = livePhotoZip({
"image.heic": IMAGE,
"video.mov": VIDEO,
[second]: IMAGE,
});
const t = setup(zip, livePhoto);
writeFileSync(t.outPath, "an earlier download");
await expect(t.run()).rejects.toThrow(
`file 999: live photo ZIP holds more than one ${kind}`,
);
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(t.requests()).toBe(1);
},
);
it("stops unpacking once the parts pass 20 times the ZIP's size plus 16 MiB, storing nothing", async () => {
// 17 MiB of zeros deflates to a few kilobytes, so its ZIP may expand
// to little more than 16 MiB.
const image = new Uint8Array(17 * 1024 * 1024);
const zip = livePhotoZip({ "image.heic": image, "video.mov": VIDEO });
const t = setup(zip, {
fileType: "livePhoto",
hash: livePhotoHash(image, VIDEO),
});
writeFileSync(t.outPath, "an earlier download");
await expect(t.run()).rejects.toThrow(
"file 999: live photo ZIP expands to more than 20 times its size plus 16 MiB",
);
const written = writeHook.writes.reduce((n, w) => n + w.length, 0);
expect(written).toBeLessThanOrEqual(20 * zip.length + 16 * 1024 * 1024);
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(t.requests()).toBe(1);
});
});