check / check (push) Waiting to run
`--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
79 lines
3.0 KiB
TypeScript
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 },
|
|
}),
|
|
);
|