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

@@ -1,5 +1,11 @@
// Stub: see the README "Development workflow" section for TDD policy.
import { writeFile } from "node:fs/promises";
import {
fromBase64,
initStreamPull,
pullStreamChunk,
STREAM_CHUNK_OVERHEAD,
STREAM_CHUNK_SIZE,
} from "../crypto/index.js";
import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js";
@@ -8,18 +14,77 @@ export interface DownloadResult {
bytesWritten: number;
}
const ENC_CHUNK_SIZE = STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
const streamDecrypt = async (
stream: ReadableStream<Uint8Array>,
header: Uint8Array,
key: Uint8Array,
): Promise<Uint8Array> => {
const state = initStreamPull(header, key);
const reader = stream.getReader();
let buffer = new Uint8Array(0);
const plainChunks: Uint8Array[] = [];
let totalPlain = 0;
for (;;) {
const { done, value } = await reader.read();
if (value) {
const merged = new Uint8Array(buffer.length + value.length);
merged.set(buffer);
merged.set(value, buffer.length);
buffer = merged;
}
while (buffer.length >= ENC_CHUNK_SIZE) {
const encChunk = buffer.slice(0, ENC_CHUNK_SIZE);
buffer = buffer.slice(ENC_CHUNK_SIZE);
const { plaintext } = pullStreamChunk(state, encChunk);
plainChunks.push(plaintext);
totalPlain += plaintext.length;
}
if (done) {
if (buffer.length > 0) {
const { plaintext } = pullStreamChunk(state, buffer);
plainChunks.push(plaintext);
totalPlain += plaintext.length;
}
break;
}
}
const result = new Uint8Array(totalPlain);
let offset = 0;
for (const chunk of plainChunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
};
export const downloadFile = async (
_api: ApiClient,
_file: EnteFile,
_outPath?: string,
api: ApiClient,
file: EnteFile,
outPath?: string,
): Promise<DownloadResult> => {
throw new Error("download.downloadFile not implemented");
const resolvedPath = outPath ?? file.metadata.title;
const stream = await api.getFileStream(file.id);
const header = fromBase64(file.file.decryptionHeader);
const plaintext = await streamDecrypt(stream, header, file.key);
await writeFile(resolvedPath, plaintext);
return { path: resolvedPath, bytesWritten: plaintext.length };
};
export const downloadThumbnail = async (
_api: ApiClient,
_file: EnteFile,
_outPath?: string,
api: ApiClient,
file: EnteFile,
outPath?: string,
): Promise<DownloadResult> => {
throw new Error("download.downloadThumbnail not implemented");
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
const stream = await api.getThumbnailStream(file.id);
const header = fromBase64(file.thumbnail.decryptionHeader);
const plaintext = await streamDecrypt(stream, header, file.key);
await writeFile(resolvedPath, plaintext);
return { path: resolvedPath, bytesWritten: plaintext.length };
};