Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 474298272a Check downloaded originals against their recorded content hash (closes #68)
check / check (push) Successful in 2m40s
downloadFile, shared by quak get, the content cache and backup, hashes
the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and stores
nothing on a mismatch, failing with an error naming the file ID. A live
photo ZIP is unpacked with fflate and its image and video hashed
separately as <imageHash>:<videoHash>. decryptFile reads the older
imageHash/videoHash fields for live photos. A file with no recorded
hash is stored unchecked.

Model: opus-5-5
2026-09-23 03:32:05 +00:00
4 changed files with 46 additions and 147 deletions
+4 -4
View File
@@ -22,10 +22,10 @@ Tag v1.0.0.
(issue 68). `downloadFile`, which `quak get`, the content cache and backup all (issue 68). `downloadFile`, which `quak get`, the content cache and backup all
use, hashes the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and use, hashes the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and
stores nothing on a mismatch, failing with an error naming the file ID. A live stores nothing on a mismatch, failing with an error naming the file ID. A live
photo ZIP is unpacked as it streams with `fflate` and its image and video photo ZIP is unpacked with `fflate` and its image and video hashed separately
hashed separately as `<imageHash>:<videoHash>`. `decryptFile` reads older as `<imageHash>:<videoHash>`. `decryptFile` reads older clients' `imageHash`
clients' `imageHash` and `videoHash` fields for live photos. A file with no and `videoHash` fields for live photos. A file with no recorded hash is stored
recorded hash is stored unchecked. unchecked.
- 2026-09-23: Single-sourced the version string (issue 5). `package.json` is the - 2026-09-23: Single-sourced the version string (issue 5). `package.json` is the
only place it is written: `src/index.ts` imports it for `VERSION` and only place it is written: `src/index.ts` imports it for `VERSION` and
`bin/quak.ts` passes `VERSION` to commander. tsc copies `package.json` to `bin/quak.ts` passes `VERSION` to commander. tsc copies `package.json` to
+42 -87
View File
@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { open, rename, rm } from "node:fs/promises"; import { open, readFile, rename, rm } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { Unzip, UnzipInflate } from "fflate"; import { unzipSync } from "fflate";
import { import {
chunkHashFinal, chunkHashFinal,
chunkHashInit, chunkHashInit,
@@ -181,7 +181,8 @@ export const fsyncPath = async (path: string): Promise<void> => {
}; };
// Stage a write to `destination` atomically and durably, then rename it into // Stage a write to `destination` atomically and durably, then rename it into
// place. `fill` writes the contents into the open temp file handle — either the // place. `fill` writes the contents into the open temp file handle (whose path
// it is also given, so it can read back what it wrote) — either the
// whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt // whole buffer at once (`writeAtomic`) or chunk by chunk as they decrypt
// (`decryptToTemp`). The temp file is a sibling of the destination (same // (`decryptToTemp`). The temp file is a sibling of the destination (same
// directory, so the rename cannot cross a filesystem boundary), so callers // directory, so the rename cannot cross a filesystem boundary), so callers
@@ -201,7 +202,7 @@ export const fsyncPath = async (path: string): Promise<void> => {
// scratch file is left to fill the disk on repeated failures. // scratch file is left to fill the disk on repeated failures.
const stageAtomic = async ( const stageAtomic = async (
destination: string, destination: string,
fill: (handle: FileHandle) => Promise<void>, fill: (handle: FileHandle, tmpPath: string) => Promise<void>,
): Promise<void> => { ): Promise<void> => {
const dir = dirname(destination); const dir = dirname(destination);
// The random suffix keeps concurrent downloads of the same destination // The random suffix keeps concurrent downloads of the same destination
@@ -210,7 +211,7 @@ const stageAtomic = async (
try { try {
const handle = await open(tmpPath, "w"); const handle = await open(tmpPath, "w");
try { try {
await fill(handle); await fill(handle, tmpPath);
await handle.sync(); await handle.sync();
} finally { } finally {
await handle.close(); await handle.close();
@@ -241,81 +242,36 @@ export const writeAtomic = async (
): Promise<void> => ): Promise<void> =>
stageAtomic(destination, (handle) => handle.writeFile(plaintext)); stageAtomic(destination, (handle) => handle.writeFile(plaintext));
// Hashes an original's bytes as they are decrypted, for comparison with the const hashBytes = (bytes: Uint8Array): string => {
// hash its uploader recorded.
interface ContentHasher {
update: (plaintext: Uint8Array) => void;
digest: () => string;
}
const fileHasher = (): ContentHasher => {
const state = chunkHashInit(); const state = chunkHashInit();
return { chunkHashUpdate(state, bytes);
update: (plaintext) => chunkHashUpdate(state, plaintext), return chunkHashFinal(state);
digest: () => chunkHashFinal(state),
};
}; };
// A live photo is stored as a ZIP of its image and its video, and its recorded // A live photo is stored as a ZIP of its image and its video, and its recorded
// hash is `<imageHash>:<videoHash>`, each over that part's own bytes. Like the // hash is `<imageHash>:<videoHash>`, each over that part's own bytes. Like the
// upstream client's decoder, this takes the first entries whose names start // upstream client's decoder, this takes the entries whose names start with
// with `image` and `video`. // `image` and `video`. The ZIP is read whole: a live photo is a few megabytes.
// const livePhotoHash = async (
// The ZIP is chosen by its uploader and may expand enormously, so entries are fileID: number,
// hashed as they decompress and never held. fflate's `Unzip` inflates each zipPath: string,
// push in one piece, and deflate expands at most about 1000-fold, so the ZIP ): Promise<string> => {
// is pushed in 4 KiB slices to keep each decompressed piece near 4 MiB, one let entries: [string, Uint8Array][];
// plaintext chunk. Every entry is started, even one that is not hashed, try {
// because fflate keeps an unstarted entry's data in memory. entries = Object.entries(unzipSync(await readFile(zipPath)));
const livePhotoHasher = (fileID: number): ContentHasher => { } catch (err) {
const sliceSize = 4096; throw new Error(`download: file ${fileID}: live photo is not a ZIP`, {
const fail = (message: string, cause?: unknown): Error => cause: err,
new Error(`download: file ${fileID}: ${message}`, { cause }); });
const claimed = new Set<string>(); }
const hashes = new Map<string, string>(); const image = entries.find(([name]) => name.startsWith("image"));
const unzip = new Unzip((entry) => { const video = entries.find(([name]) => name.startsWith("video"));
const part = ["image", "video"].find((p) => entry.name.startsWith(p)); if (image === undefined || video === undefined) {
const target = throw new Error(
part === undefined || claimed.has(part) `download: file ${fileID}: live photo ZIP does not hold both an image and a video`,
? undefined );
: { part, state: chunkHashInit() }; }
if (target !== undefined) claimed.add(target.part); return `${hashBytes(image[1])}:${hashBytes(video[1])}`;
entry.ondata = (err, data, final) => {
if (err) throw err;
if (target === undefined) return;
chunkHashUpdate(target.state, data);
if (final) hashes.set(target.part, chunkHashFinal(target.state));
};
entry.start();
});
unzip.register(UnzipInflate);
// fflate reports a bad ZIP by throwing, sometimes a TypeError, which the
// retry would take for a network failure; a bad ZIP is never retried.
const push = (data: Uint8Array, final: boolean): void => {
try {
unzip.push(data, final);
} catch (err) {
throw fail("live photo is not a readable ZIP", err);
}
};
return {
update: (plaintext) => {
for (let i = 0; i < plaintext.length; i += sliceSize) {
push(plaintext.subarray(i, i + sliceSize), false);
}
},
digest: () => {
push(new Uint8Array(0), true);
const image = hashes.get("image");
const video = hashes.get("video");
if (image === undefined || video === undefined) {
throw fail(
"live photo ZIP does not hold both an image and a video",
);
}
return `${image}:${video}`;
},
};
}; };
// Decrypt `stream` straight to `destination`, one plaintext chunk at a time, // Decrypt `stream` straight to `destination`, one plaintext chunk at a time,
@@ -327,8 +283,8 @@ const livePhotoHasher = (fileID: number): ContentHasher => {
// //
// `original` is the file whose original this is (none for a thumbnail, which // `original` is the file whose original this is (none for a thumbnail, which
// has no recorded hash). When its metadata has a hash, the decrypted bytes // has no recorded hash). When its metadata has a hash, the decrypted bytes
// must match it or nothing is stored. Both a plain file and a live photo's // must match it or nothing is stored: a plain file is hashed as it streams, a
// parts are hashed as they stream. The mismatch error is not retried. // live photo from the finished ZIP. The mismatch error is not retried.
const decryptToTemp = async ( const decryptToTemp = async (
destination: string, destination: string,
stream: ReadableStream<Uint8Array>, stream: ReadableStream<Uint8Array>,
@@ -338,27 +294,26 @@ const decryptToTemp = async (
original?: EnteFile, original?: EnteFile,
): Promise<number> => { ): Promise<number> => {
const expected = original?.metadata.hash; const expected = original?.metadata.hash;
const hasher = const livePhoto = original?.metadata.fileType === "livePhoto";
original === undefined || expected === undefined const hashState =
? undefined expected !== undefined && !livePhoto ? chunkHashInit() : undefined;
: original.metadata.fileType === "livePhoto"
? livePhotoHasher(original.id)
: fileHasher();
let bytesWritten = 0; let bytesWritten = 0;
try { try {
await stageAtomic(destination, async (handle) => { await stageAtomic(destination, async (handle, tmpPath) => {
bytesWritten = await streamDecrypt( bytesWritten = await streamDecrypt(
stream, stream,
header, header,
key, key,
async (plaintext) => { async (plaintext) => {
hasher?.update(plaintext); if (hashState) chunkHashUpdate(hashState, plaintext);
await handle.write(plaintext); await handle.write(plaintext);
}, },
onProgress, onProgress,
); );
if (original === undefined || hasher === undefined) return; if (original === undefined || expected === undefined) return;
const actual = hasher.digest(); const actual = hashState
? chunkHashFinal(hashState)
: await livePhotoHash(original.id, tmpPath);
if (actual !== expected) { if (actual !== expected) {
throw new Error( throw new Error(
`download: file ${original.id}: content hash ${actual} does not match the hash its uploader recorded, ${expected}`, `download: file ${original.id}: content hash ${actual} does not match the hash its uploader recorded, ${expected}`,
-45
View File
@@ -189,31 +189,7 @@ 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.
*/
const hashHook = vi.hoisted(() => ({
lengths: [] as number[],
}));
vi.mock("../../src/crypto/index.js", async (importOriginal) => {
const actual =
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);
},
};
});
beforeEach(() => { beforeEach(() => {
hashHook.lengths.length = 0;
renameHook.calls.length = 0; renameHook.calls.length = 0;
renameHook.failWith = null; renameHook.failWith = null;
durabilityHook.events.length = 0; durabilityHook.events.length = 0;
@@ -1713,27 +1689,6 @@ describe("downloadFile content hash", () => {
expectSameBytes(readFileSync(t.outPath), livePhotoZip); expectSameBytes(readFileSync(t.outPath), livePhotoZip);
}); });
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 });
const t = setup(zip, {
fileType: "livePhoto",
hash: `${blake2b(image)}:${blake2b(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,
);
});
it("rejects a live photo whose hash does not match", async () => { it("rejects a live photo whose hash does not match", async () => {
// The whole ZIP's hash is not the recorded one: each part is hashed. // The whole ZIP's hash is not the recorded one: each part is hashed.
const t = setup(livePhotoZip, { const t = setup(livePhotoZip, {
-11
View File
@@ -378,17 +378,6 @@ describe("model.decryptFile", () => {
expect( expect(
hashOf({ fileType: 2, imageHash: "I", videoHash: 7 }), hashOf({ fileType: 2, imageHash: "I", videoHash: 7 }),
).toBeUndefined(); ).toBeUndefined();
// An empty string counts as absent, not as a hash to match.
expect(hashOf({ fileType: 0, hash: "" })).toBeUndefined();
expect(
hashOf({ fileType: 2, hash: "", imageHash: "I", videoHash: "V" }),
).toBe("I:V");
expect(
hashOf({ fileType: 2, imageHash: "", videoHash: "V" }),
).toBeUndefined();
expect(
hashOf({ fileType: 2, imageHash: "I", videoHash: "" }),
).toBeUndefined();
}); });
it("maps fileType numbers to FileType strings", () => { it("maps fileType numbers to FileType strings", () => {