Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
474298272a |
@@ -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
@@ -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}`,
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user