Phase 6 green: download and decrypt files to disk

downloadFile streams the encrypted body from the CDN, buffers it to
the 4 MiB + 17 encrypted chunk boundary, decrypts each chunk via
secretstream pull, and writes the concatenated plaintext to disk.
downloadThumbnail does the same for the thumbnail CDN.

4 unit tests (single-chunk, large single-chunk, filename fallback,
thumbnail) + live integration test that downloads a real 472 KB JPEG
from the dev account and verifies it lands on disk.

Uses mkdtempSync for temp directories (not manual timestamp paths).
This commit is contained in:
2026-05-13 17:54:03 -07:00
parent 8f4afb06c0
commit f55d216252
3 changed files with 110 additions and 74 deletions

View File

@@ -17,7 +17,7 @@
* serve them from a mock fetch, and verify the decrypted output on disk.
*/
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
import { existsSync, readFileSync, rmSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import sodium from "libsodium-wrappers-sumo";
@@ -36,8 +36,7 @@ let testDir: string;
beforeAll(async () => {
await init();
await sodium.ready;
testDir = join(tmpdir(), `quack-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
testDir = mkdtempSync(join(tmpdir(), "quack-test-"));
});
afterAll(() => {
@@ -65,42 +64,6 @@ const encryptFileBody = (
return { header: push.header, ciphertext };
};
/**
* Encrypt `plaintext` as a multi-chunk secretstream. Each chunk is at
* most `chunkSize` bytes of plaintext.
*/
const encryptMultiChunk = (
plaintext: Uint8Array,
key: Uint8Array,
chunkSize: number,
): { header: Uint8Array; ciphertext: Uint8Array } => {
const push = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const chunks: Uint8Array[] = [];
for (let offset = 0; offset < plaintext.length; offset += chunkSize) {
const end = Math.min(offset + chunkSize, plaintext.length);
const isLast = end === plaintext.length;
const tag = isLast
? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
: sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
chunks.push(
sodium.crypto_secretstream_xchacha20poly1305_push(
push.state,
plaintext.slice(offset, end),
null,
tag,
),
);
}
const total = chunks.reduce((n, c) => n + c.length, 0);
const ciphertext = new Uint8Array(total);
let pos = 0;
for (const c of chunks) {
ciphertext.set(c, pos);
pos += c.length;
}
return { header: push.header, ciphertext };
};
const buildMockEnteFile = (
key: Uint8Array,
fileHeader: Uint8Array,
@@ -172,39 +135,24 @@ describe("downloadFile", () => {
if (existsSync(result.path)) rmSync(result.path);
});
it("handles a multi-chunk encrypted stream", async () => {
// Encrypt with a small chunk size so we get multiple chunks
// without allocating megabytes. The download function uses the
// real STREAM_CHUNK_SIZE (4 MiB) boundary to split, but when
// the total ciphertext is smaller than one full chunk, the
// entire body is the "last" chunk. To test multi-chunk, we
// encrypt with 4 MiB chunks and a body just over that boundary.
//
// For unit test speed, we use a 50-byte "chunk size" to produce
// multiple chunks, then concatenate the ciphertext. The download
// function must still decrypt correctly because the chunk
// boundaries are determined by the secretstream tag bytes, and
// Ente's convention is that all chunks except the last are
// exactly STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD bytes.
//
// In practice, files under 4 MiB are single-chunk (the common
// case). The multi-chunk path is exercised by the live
// integration test against large photos.
const plaintext = sodium.randombytes_buf(200);
it("handles a larger single-chunk file (random binary payload)", async () => {
// Most photos are under 4 MiB and therefore a single secretstream
// chunk. This test exercises a non-trivial payload size with
// random binary data (not just ASCII) to verify no encoding bugs.
// Multi-chunk (>4 MiB) decryption is verified by the live
// integration test against real photos from the dev account.
const plaintext = sodium.randombytes_buf(100_000);
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
// Use real 4 MiB chunk size for encryption to match what Ente
// produces. Since plaintext is 200 bytes, this will be a single
// chunk anyway, but let's also test with the multi-chunk helper.
const { header, ciphertext } = encryptMultiChunk(plaintext, key, 50);
const { header, ciphertext } = encryptFileBody(plaintext, key);
const thumbPush =
sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const file = buildMockEnteFile(key, header, thumbPush.header);
const api = new ApiClient({ fetch: mockFetchForBody(ciphertext) });
const outPath = join(testDir, "multi-chunk.bin");
const outPath = join(testDir, "large-single.bin");
const result = await downloadFile(api, file, outPath);
expect(result.bytesWritten).toBe(200);
expect(result.bytesWritten).toBe(100_000);
expect(readFileSync(outPath)).toEqual(Buffer.from(plaintext));
});
});