[agent] Filed by the scheduled architecture audit routine (CLI and core). Register: discussion #560 register comment.
Kind: bug. Source: new finding, register C42 (related to C21, atomic writers, and C41, hash case policy).
Problem
Two code paths write the content-addressed store .socket/blobs/<hash>, and they follow different rules.
- The fetch path (
blob_fetcher.rs#L533-L556) recomputes the git-sha256 of the downloaded bytes and refuses a mismatch. It then writes through write_cache_entry_atomic, which stages and renames. That writer's doc comment (blob_fetcher.rs#L451-L471) explains why: get_missing_blobs only checks that a file exists (blob_fetcher.rs#L77-L89), so an entry whose content doesn't hash to its name "is then trusted forever".
- The
get path (get.rs#L284-L310) handles the inline blobContent / beforeBlobContent of a patch view. It checks only that the hash is 64-hex, decodes the base64 with a hand-rolled decoder (get.rs#L3814-L3820) although the base64 crate is already a CLI dependency, and then:
let existed = tokio::fs::try_exists(&target).await.unwrap_or(false);
tokio::fs::write(&target, &decoded) // truncate + write in place, no hash check
One existing unit test pins the missing check: write_blob_entry_accepts_valid_sha256_hash stores "patched\n" under the hash 1111…1.
Proof by execution on 045d7ec: a temporary unit test in get.rs, run twice and not committed. It seeded blobs/<H> with "pristine\n", where H = git_sha256("pristine\n"), which stands in for a live record's verified before-blob. It then called write_blob_entry(blobs, base64("patched\n"), H, …). The result was Ok(false), the file now holds "patched\n", and the hash of its content no longer matches its name. On both runs, the fetch path's own test fetch_missing_blobs_rejects_content_hash_mismatch_and_writes_nothing shows that the same payload is refused there.
Impact
- Store poisoning: a mismatched inline blob from a server bug, a proxy or a stale cache is stored under a name it doesn't match. Because
get_missing_blobs checks only presence, apply and repair never fetch it again. Agent-mode apply then fails verification (HashMismatch), and the before-blob rollback needs is wrong, until a user deletes the file by hand.
- Destroying good data: the write overwrites an existing blob that another live record references (the code even probes
existed first), so a correct blob is replaced with unverified bytes.
- Torn writes:
fs::write truncates in place, so ENOSPC or a kill mid-write leaves a partial file under a valid name, which is exactly the failure write_cache_entry_atomic was written to prevent.
Size: one function, about 30 production lines.
Proposed change
- Move
write_cache_entry_atomic into a shared core helper, for example api::blob_fetcher::store_verified_blob(blobs_dir, hash, bytes): verify git_sha256(bytes) against hash with the shared comparison, then stage and rename. Make it the only writer of .socket/blobs/<hash>.
get::write_blob_entry calls it. When the target already exists and verifies, skip the write entirely, so a verified blob is never rewritten. On a mismatch, report it the same way as a fetch mismatch.
- Replace the hand-rolled
base64_decode with base64::engine::general_purpose::STANDARD, keeping the pinned error message as its doc comment requires, and delete the hand-rolled decoder.
- Fix
write_blob_entry_accepts_valid_sha256_hash to use a real hash.
Size and scope
crates/socket-patch-cli/src/commands/get.rs and crates/socket-patch-core/src/api/blob_fetcher.rs: about 60 production lines, mostly deletions, plus tests. Out of scope: the fs.rs writer consolidation (C21) and the hash case policy (#707), although the shared comparison should be the one #707 settles on.
Acceptance criteria
Dependencies
None blocking. It touches the same helper as #707 (hash case), and either can land first. Related: #607 (streaming blob downloads), which changes the fetch path's write.
[agent] Filed by the scheduled architecture audit routine (CLI and core). Register: discussion #560 register comment.
Kind: bug. Source: new finding, register C42 (related to C21, atomic writers, and C41, hash case policy).
Problem
Two code paths write the content-addressed store
.socket/blobs/<hash>, and they follow different rules.blob_fetcher.rs#L533-L556) recomputes the git-sha256 of the downloaded bytes and refuses a mismatch. It then writes throughwrite_cache_entry_atomic, which stages and renames. That writer's doc comment (blob_fetcher.rs#L451-L471) explains why:get_missing_blobsonly checks that a file exists (blob_fetcher.rs#L77-L89), so an entry whose content doesn't hash to its name "is then trusted forever".getpath (get.rs#L284-L310) handles the inlineblobContent/beforeBlobContentof a patch view. It checks only that the hash is 64-hex, decodes the base64 with a hand-rolled decoder (get.rs#L3814-L3820) although thebase64crate is already a CLI dependency, and then:write_blob_entry_accepts_valid_sha256_hashstores"patched\n"under the hash1111…1.Proof by execution on
045d7ec: a temporary unit test inget.rs, run twice and not committed. It seededblobs/<H>with"pristine\n", whereH = git_sha256("pristine\n"), which stands in for a live record's verified before-blob. It then calledwrite_blob_entry(blobs, base64("patched\n"), H, …). The result wasOk(false), the file now holds"patched\n", and the hash of its content no longer matches its name. On both runs, the fetch path's own testfetch_missing_blobs_rejects_content_hash_mismatch_and_writes_nothingshows that the same payload is refused there.Impact
get_missing_blobschecks only presence,applyandrepairnever fetch it again. Agent-mode apply then fails verification (HashMismatch), and the before-blob rollback needs is wrong, until a user deletes the file by hand.existedfirst), so a correct blob is replaced with unverified bytes.fs::writetruncates in place, so ENOSPC or a kill mid-write leaves a partial file under a valid name, which is exactly the failurewrite_cache_entry_atomicwas written to prevent.Size: one function, about 30 production lines.
Proposed change
write_cache_entry_atomicinto a shared core helper, for exampleapi::blob_fetcher::store_verified_blob(blobs_dir, hash, bytes): verifygit_sha256(bytes)againsthashwith the shared comparison, then stage and rename. Make it the only writer of.socket/blobs/<hash>.get::write_blob_entrycalls it. When the target already exists and verifies, skip the write entirely, so a verified blob is never rewritten. On a mismatch, report it the same way as a fetch mismatch.base64_decodewithbase64::engine::general_purpose::STANDARD, keeping the pinned error message as its doc comment requires, and delete the hand-rolled decoder.write_blob_entry_accepts_valid_sha256_hashto use a real hash.Size and scope
crates/socket-patch-cli/src/commands/get.rsandcrates/socket-patch-core/src/api/blob_fetcher.rs: about 60 production lines, mostly deletions, plus tests. Out of scope: thefs.rswriter consolidation (C21) and the hash case policy (#707), although the shared comparison should be the one #707 settles on.Acceptance criteria
getgiven an inline blob whose content doesn't hash to itsafterHash/beforeHashwrites nothing and reports a mismatch.blobs/<H>is byte-identical aftergetprocesses a patch view that namesHwith different bytes.tokio::fs::writeof a blob left inget.rs(grep).base64_decodeis deleted; the existing decode-error test stays green.write_blob_entry_*traversal tests andblob_fetcher_edges_e2estay green.Dependencies
None blocking. It touches the same helper as #707 (hash case), and either can land first. Related: #607 (streaming blob downloads), which changes the fetch path's write.