Files
quak/test/live-photo.ts
T
clawbot 73846b32fc
check / check (push) Waiting to run
quak backup --verify re-hashes stored originals and downloads again any that do not match (closes #168)
`--verify`, or `lib.backup({ verify: true })`, hashes each original already
at its save path as the download check does, streamed, a live photo as
`<imageHash>:<videoHash>`. A mismatch is logged, removed and fetched again in
the same run; a failed fetch goes into `failures.json`. A file with no
recorded hash counts as unchecked. The result, the summary and `--json` gain
`verified`, `mismatched` and `unchecked`.

Judgement call: a stored original that cannot be read for hashing is recorded as failed and left in place.
Judgement call: the summary prints the three counts only with `--verify`.
Judgement call: the README's `BackupOptions` list does not name `verify`, to stay clear of #178's edit of that paragraph; Backup layout documents it.

Model: opus-5-5
2026-10-06 14:57:36 +00:00

79 lines
3.0 KiB
TypeScript

/**
* Live photo fixtures for the download, content cache, backup and CLI tests.
*
* Ente stores a live photo as one ZIP holding its image and its video, which
* Ente's clients name `image.<ext>` and `video.<ext>`. The ZIP is built here
* with fflate from small fixed bytes and encrypted the way the server serves a
* file under 4 MiB, as one secretstream chunk. `cdnSource` serves it to the
* real download layer, so a test checks what quak stores for a real one.
*/
import { createHash } from "node:crypto";
import { zipSync } from "fflate";
import { ApiClient } from "../src/api/client.js";
import { encryptBlob, init, toBase64 } from "../src/crypto/index.js";
import {
makeDownloadContentSource,
type ContentSource,
} from "../src/library/content.js";
import type { EnteFile } from "../src/model/types.js";
export const IMAGE = new TextEncoder().encode("the still image");
export const VIDEO = new TextEncoder().encode("the few seconds of video");
// A live photo's ZIP; by default the entries an iPhone's live photo gets.
export const livePhotoZip = (
entries: Record<string, Uint8Array> = {
"image.heic": IMAGE,
"video.mov": VIDEO,
},
): Uint8Array => zipSync(entries);
// The content hash Ente's clients record for an original's bytes.
export const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of
// the image and of the video, each in standard base64, joined by a colon.
export const livePhotoHash = (image = IMAGE, video = VIDEO): string =>
`${blake2b(image)}:${blake2b(video)}`;
// `file` as a live photo whose original is `zip` and whose recorded hash is
// `hash`, and `body`, what the server serves for it: `zip` encrypted under the
// file's key and header.
export const asLivePhoto = async (
file: EnteFile,
zip = livePhotoZip(),
hash = livePhotoHash(),
): Promise<{ file: EnteFile; body: Uint8Array }> => {
await init();
const key = new Uint8Array(32).fill(file.id & 0xff);
const { header, ciphertext } = encryptBlob(zip, key);
return {
file: {
...file,
key,
metadata: { ...file.metadata, fileType: "livePhoto", hash },
file: { decryptionHeader: toBase64(header) },
},
body: ciphertext,
};
};
// A content source that downloads through the real download layer from a
// stand-in server, which serves `bodies` by file ID and a 404 for any other.
export const cdnSource = (bodies: Map<number, Uint8Array>): ContentSource =>
makeDownloadContentSource(
new ApiClient({
fetch: (async (url: string | URL) => {
const fileID = new URL(String(url)).searchParams.get("fileID");
const body = bodies.get(Number(fileID));
return body === undefined
? new Response("not found", { status: 404 })
: new Response(body);
}) as typeof globalThis.fetch,
retry: { attempts: 1 },
}),
);