Downloads: verify secretstream TAG_FINAL and write output atomically #1
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
streamDecryptinsrc/download/index.tsdiscards the secretstream tag returned bypullStreamChunk(lines 42 and 49) and never checks that the stream terminated onTAG_FINAL, even though the comment insrc/crypto/stream.ts:40-42explicitly says thecaller must. A download truncated by a dropped connection therefore decrypts cleanly up to
the last whole chunk and is written to disk as a successful result.
Both
downloadFile(src/download/index.ts:66-77) anddownloadThumbnail(:79-90)then
writeFilestraight to the final destination path.runBackup(src/backup.ts:91)skips any existing file whose size is greater than zero, so a truncated original is treated
as complete on every subsequent run and is never repaired. A resilient backup that silently
writes corrupt files is worse than one that crashes.
This is also a prerequisite for the retry-policy work: a retry can never fire on a truncated
download while truncation is indistinguishable from success.
Definition of done
streamDecryptthrows when the stream ends without a chunk carryingcrypto_secretstream_xchacha20poly1305_TAG_FINAL. The error message must say the streamwas truncated.
downloadFileanddownloadThumbnailwrite to a temporary path in the same directoryas the destination and
renameinto place only after decryption has completedsuccessfully.
path, and the temporary file is removed.
DownloadResultshape are unchanged.test/download/download.test.tscovering, for bothdownloadFileanddownloadThumbnail:DownloadResult.make checkis green.TODO.mdis updated in the same commit (add to Completed Steps; leave the retry policy asthe Next Step, since that is tracked separately).
Out of scope
Retry and backoff. That is a separate issue and must not be started here.
Implementation requirements
Where the code lives
src/download/index.ts—streamDecrypt(:19-64),downloadFile(:66-77),downloadThumbnail(:79-90).src/crypto/stream.ts—pullStreamChunk(:59-71) already returns{ plaintext, tag }.decryptBlob(:46-57) is the existing precedent for how to checkthe tag; follow that pattern (compare against
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL).Tag verification
streamDecryptas chunks are pulled. After the read loopexits, throw if the last tag was not
TAG_FINAL. Also throw if zero chunks were pulled(an empty body is a truncated stream, not a zero-byte file — Ente always emits at least one
chunk, and
encryptBlobproves a zero-length plaintext still produces aTAG_FINALchunk).libsodium-wrappers-sumodirectly intosrc/download/index.ts. Export anamed constant from
src/crypto/stream.ts(e.g.STREAM_TAG_FINAL) and re-export it viasrc/crypto/index.ts, so the download layer keeps its existing "no direct sodium import"shape.
src/crypto/stream.ts, fix the misplaced comment: lines 40-42 documentpullStreamChunkbut sit directly abovedecryptBlob. Move it to the right function.Atomic write
renamestays on one filesystem. Derivethe directory with
node:pathdirname, and give the temp file a random suffix(
node:cryptorandomBytes/randomUUID) so concurrent downloads cannot collide.node:fs/promiseswriteFilethenrename. On any thrown error,rmthe temp filewith
{ force: true }and rethrow the original error — never mask the original failurewith a cleanup failure.
resolvedPathis computed before the request today; keep that ordering so the destinationname is unchanged.
Do not
runBackup's skip heuristic. Separate issue.file.metadata.title. Separate issue.Tests
test/download/download.test.tsalready has amockFetchForBodyhelper (:83-87) thatreturns the same body for every call. Build the truncated case by slicing the encrypted
body so the last chunk is dropped; assert with
await expect(...).rejects.toThrow(...).mkdtempSyncfor temp directories in tests, per the repo README.them alone — tests are this repo's canonical API documentation.
Process
main. First commit is the failing tests, per the README development workflow;the implementation lands in a later commit on the same branch.
make check/script/entrypoints. Never invokevitest,eslint,prettier, ortscdirectly.TODO.mdchange goes in the same commit as the implementation.Implementation plan
Branch
download-tag-final-atomic-writeoffmain, TDD per the README workflow: first commit is the failing tests, implementation lands in a later commit.Commit 1 — failing tests (
test/download/download.test.ts)New helpers alongside the existing
encryptFileBody/mockFetchForBody:encryptMultiChunkBody(chunks)— pushes N secretstream chunks, the first N-1 withTAG_MESSAGEat exactlySTREAM_CHUNK_SIZEplaintext bytes each (so the download reader's chunk framing lines up) and the last withTAG_FINAL. Returns the header, the full body, and the byte offset where the final chunk starts, so a test can slice the body to drop it.New cases, for both
downloadFileanddownloadThumbnail:TAG_FINALchunk sliced off rejects with an error whose message says the stream was truncated;existsSync(destination)is false;DownloadResult(path,bytesWritten).Every case gets prose comments explaining why the behavior matters, since the tests are this repo's canonical API documentation.
Commit 2 — implementation +
TODO.mdsrc/crypto/stream.ts:STREAM_TAG_FINAL(=sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL), re-exported fromsrc/crypto/index.ts, sosrc/download/index.tskeeps its no-direct-sodium-import shape;pullStreamChunkdoc comment (currently sitting abovedecryptBlob) ontopullStreamChunk.src/download/index.ts:streamDecrypttracks the last observed tag and a pulled-chunk count; after the read loop it throws a truncation error if zero chunks were pulled or the last tag was notSTREAM_TAG_FINAL;downloadFile/downloadThumbnailkeep computingresolvedPathbefore the request, thenwriteFileto a sibling temp path (dirname(resolvedPath)+ random suffix fromnode:cryptorandomUUID) andrenameinto place only after decryption succeeded. Any thrown error triggersrm(tmp, { force: true })and rethrows the original error — a cleanup failure never masks the real one.DownloadResultshape are unchanged.TODO.md: add a Completed Steps entry for this work; the retry policy stays as the Next Step (tracked as #2). Markdown formatted withmake fmt.Out of scope, deliberately untouched
No retry/backoff/timeouts, no change to
runBackup's skip heuristic, nofile.metadata.titlesanitizing, no new runtime dependencies.Verification
make check(test + lint + fmt-check) only; no directvitest/eslint/prettier/tscinvocations.make buildis known-broken onmain(#3) and is not in scope.Implemented in PR #20: #20
Branch
download-tag-final-atomic-write, two commits in TDD order —1f894bais the tests in a failing state (verified red: 9 failures),9990527is the implementation plus theTODO.mdupdate. All seven definition-of-done items are addressed;make checkis green (134 tests, eslint and prettier clean).One deviation worth flagging:
STREAM_TAG_FINALis exported fromsrc/crypto/stream.tsand re-exported fromsrc/crypto/index.tsas requested, but it is declared as the literal3rather than read fromsodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL. libsodium only attaches its constants to the module object aftersodium.readyresolves, so a module-level read would bindundefined.test/crypto/stream.test.tspins the literal against libsodium's own constant so they cannot drift.The quadratic buffer accumulation in
streamDecryptthat this work surfaced is filed separately as #21 and was not touched here.