[agent] Filed by the scheduled architecture audit routine (CLI and core). Register: discussion #560 register.
Kind: bug. Source: new finding; register C41. It is related to C17 (#706).
Problem (main @ 045d7ec)
The case policy for comparing a hash is implemented separately at each site, and the sites disagree:
- Blob download is case-insensitive on purpose.
blob_fetcher::blob_hash_matches uses eq_ignore_ascii_case. Its doc says the client's validator "accepts uppercase hex too — so a manifest … that uses uppercase would download byte-for-byte correct content and then be wrongly rejected by a case-sensitive comparison", and a regression test pins this. apply::is_valid_blob_hash and client::is_valid_sha256_hex both accept uppercase as well.
- Agent-mode verification is case-sensitive.
compute_git_sha256_from_bytes emits lowercase, and it is compared with ==/!=:
- Vendored verification is split as well.
vendor/verify.rs and vendor/pypi.rs use eq_ignore_ascii_case, while vendor/redownload.rs, bun_workspace.rs and bun_binary.rs use ==/!=.
The manifest loader doesn't normalize beforeHash/afterHash, so an uppercase hash travels unchanged to every one of these sites.
Proof by execution (temporary core integration test, run twice on 045d7ec, not committed):
- Setup: a package file whose content matches
beforeHash, and the before and after blobs present in blobs/ under the manifest's spelling.
- Run 1 used the hashes as computed, and run 2 the same hashes uppercased:
lower: is_valid_blob_hash=true verify=Ready apply.success=true apply.err=None rollback_verify=Ready
UPPER: is_valid_blob_hash=true verify=HashMismatch apply.success=false apply.err=Some("Cannot apply patch: package/index.js - File hash does not match expected value") rollback_verify=HashMismatch
So the same patch, with the same bytes on disk and its blobs present, passes download and the blob-name check, then fails both apply and rollback verification.
Symptoms and impact
- No open issue reports this yet.
- The API emits lowercase, so only hand-edited or third-party manifests hit it today.
- But the code explicitly supports uppercase in one place and breaks it in another. Each new comparison site picks a rule ad hoc: the vendored side already has both.
Proposed change
Pick one rule and enforce it at one boundary. Recommended: normalize at the boundary. Lowercase beforeHash/afterHash when the manifest is deserialized, or reject non-lowercase hex there. Then:
- delete
blob_hash_matches in favor of plain ==;
- give
digest one hex_eq used by the vendored sites, or normalize their pins at parse time the same way.
The alternative is a shared digest::hex_eq(a, b) used by every comparison above. That's more call sites, but no load-time change.
Size and scope
About 60 production lines across manifest/schema.rs (or the loader), blob_fetcher.rs, apply.rs, rollback.rs and the five vendored files listed. Out of scope: hosted-mode SRI/checksum pins, which have their own documented case policy in utils::digest (is_hex64_lower for Cargo).
Acceptance criteria
Dependencies
Easier after #706 (one utils::digest), but not blocked by it.
[agent] Filed by the scheduled architecture audit routine (CLI and core). Register: discussion #560 register.
Kind: bug. Source: new finding; register C41. It is related to C17 (#706).
Problem (main @
045d7ec)The case policy for comparing a hash is implemented separately at each site, and the sites disagree:
blob_fetcher::blob_hash_matchesuseseq_ignore_ascii_case. Its doc says the client's validator "accepts uppercase hex too — so a manifest … that uses uppercase would download byte-for-byte correct content and then be wrongly rejected by a case-sensitive comparison", and a regression test pins this.apply::is_valid_blob_hashandclient::is_valid_sha256_hexboth accept uppercase as well.compute_git_sha256_from_bytesemits lowercase, and it is compared with==/!=:verify_file_patch, and the patched-bytes check;vendor/verify.rsandvendor/pypi.rsuseeq_ignore_ascii_case, whilevendor/redownload.rs,bun_workspace.rsandbun_binary.rsuse==/!=.The manifest loader doesn't normalize
beforeHash/afterHash, so an uppercase hash travels unchanged to every one of these sites.Proof by execution (temporary core integration test, run twice on
045d7ec, not committed):beforeHash, and the before and after blobs present inblobs/under the manifest's spelling.So the same patch, with the same bytes on disk and its blobs present, passes download and the blob-name check, then fails both apply and rollback verification.
Symptoms and impact
Proposed change
Pick one rule and enforce it at one boundary. Recommended: normalize at the boundary. Lowercase
beforeHash/afterHashwhen the manifest is deserialized, or reject non-lowercase hex there. Then:blob_hash_matchesin favor of plain==;digestonehex_eqused by the vendored sites, or normalize their pins at parse time the same way.The alternative is a shared
digest::hex_eq(a, b)used by every comparison above. That's more call sites, but no load-time change.Size and scope
About 60 production lines across
manifest/schema.rs(or the loader),blob_fetcher.rs,apply.rs,rollback.rsand the five vendored files listed. Out of scope: hosted-mode SRI/checksum pins, which have their own documented case policy inutils::digest(is_hex64_lowerfor Cargo).Acceptance criteria
beforeHash/afterHashmanifest:applypatches androllbackrestores, or both refuse at load with a clear error (whichever rule is chosen, applied consistently).==/!=between a computed digest and a manifest or pin hash remains outside the chosen helper or boundary. A grep or architecture test guards it.test_blob_hash_matches_is_case_insensitiveis updated or removed to match the chosen rule.apply,rollbackand vendored verify tests stay green.Dependencies
Easier after #706 (one
utils::digest), but not blocked by it.