Skip to content

fix(content-store): confine hash paths to the store, write blobs atomically, repair damaged ones - #163

Merged
hyperpolymath merged 1 commit into
mainfrom
claude/vibrant-cori-lirqrr
Oct 27, 2026
Merged

hyperpolymath merged 1 commit into
mainfrom
claude/vibrant-cori-lirqrr

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Summary

First step out of a proof-statement audit of PROOF-NEEDS.adoc. These are bugs in reversible-core's ContentStore that make the "content store never returns wrong content" obligation false as stated. They are fixed before anything is proved about it.

Each defect was reproduced by a test that failed before this change.

1. A hash string could name a path outside the store

content_path split the hash text after two characters and joined it onto the store root. Path::join replaces the root when the right-hand side is absolute, so a hash such as sha256:ab/<absolute path> resolved to that path. delete acts on that path, and so does obliterate, which calls content_path and then overwrites and removes the file. ContentHash has a public field and derives Deserialize, so its text is untrusted.

Not shown: that stored metadata can actually deliver such a hash to those callers. I only showed that the store API accepted it. Please treat this as a possible security issue when deciding how to talk about it.

Fix: content_path fails closed. Anything other than sha256: plus 64 lowercase hex digits maps to a fixed path under the root that no blob occupies. The layout for well-formed hashes is unchanged, pinned by well_formed_hash_keeps_the_existing_layout (which passes on the old code too).

2. A crash could leave a blob truncated for good

store wrote in place, and its deduplication check was path.exists(). After a crash, every later store of that content was a no-op and every retrieve failed. store now writes a temporary file in the blob's directory, flushes it, and renames it into place, so the final path only ever holds a complete blob.

3. A damaged blob was never repaired

Deduplication now reads the existing blob back and trusts it only if its hash matches; otherwise it is replaced. This also heals blobs damaged by an older crash or by bit rot.

count() ignores .tmp- files, which a crash can orphan. Nothing collects them yet.

Cost (your call whether it is acceptable)

Measured on one VM, release mode, 200 distinct 4 KiB blobs:

before after
per blob, plain 0.07 ms 0.5–0.6 ms
per blob, gzip 0.14–0.20 ms 0.8–1.3 ms

Almost all of that is the fsync, so it depends on the disk and will be worse on spinning or network storage. Storing content that is already stored now also reads the existing blob back: about +20 ms at 16 MiB and +90–130 ms at 64 MiB. The fsync is what makes the write durable across power loss. If you would rather trade that for speed, the atomic rename alone still covers a process crash.

Verification

  • New tests in content_store.rs: malformed_hash_never_leaves_the_store_root (11 malformed hashes, plain and gzip), well_formed_hash_keeps_the_existing_layout, store_repairs_a_blob_truncated_by_a_crash, store_leaves_no_temp_files_and_count_ignores_strays. Run before the fix: 3 failed, 1 passed (the layout pin).
  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace --locked all pass (123 tests).
  • TOPOLOGY.adoc and READINESS.adoc: 119 → 123 tests, as dashboard-check requires.
  • dashboard-check reports no divergence except the Idris2 and Agda proof rows. I could not run those here because the binaries are not installed in my sandbox; CI has them.

Not in this PR

  • Validating ContentHash on deserialization. content_path is now safe regardless, but rejecting bad metadata earlier would be cleaner. Follow-up.
  • The audit-log field coverage, tail truncation and the ObliterationProof wording, and the transaction-log atomic write (already tracked as T-5 in proofs: transaction atomicity in the Agda kernel (T-1..T-3), and the gaps it exposes (T-4..T-7) #161). Each is its own change.
  • Restating the PROOF-NEEDS.adoc obligations. The audit that motivated this is a separate document.

Generated by Claude Code

…ically, repair damaged ones

Three defects in reversible-core's ContentStore, each reproduced by a test
that failed before this change (see the proof-statement audit).

1. A hash string could name a path outside the store. `content_path` split
   the text after two characters and joined it onto the root. `Path::join`
   replaces the root when the right side is absolute, so a ContentHash such
   as "sha256:ab/<absolute path>" resolved to that path, and `delete` (and
   `obliterate`, which calls `content_path`) acted on it. ContentHash has a
   public field and derives Deserialize, so its text is untrusted. Whether
   stored metadata can supply such a hash to those callers was not shown.
   `content_path` now fails closed: anything other than "sha256:" plus 64
   lowercase hex digits maps to a fixed path under the root that no blob
   occupies. The layout for well-formed hashes is unchanged (pinned by a
   test).

2. A crash left a blob truncated at its final path for good. `store` wrote
   in place, and its deduplication check was `path.exists()`, so every later
   store of the same content was a no-op and every retrieve failed. `store`
   now writes a temporary file in the blob's directory, flushes it, and
   renames it into place, so the final path only ever holds a complete blob.

3. A blob already damaged (by an older crash or by bit rot) was never
   repaired. Deduplication now reads the existing blob back and trusts it
   only if its hash matches; otherwise it is replaced.

`count()` ignores ".tmp-" files, which a crash can orphan. They are not
collected here.

Cost, measured on one VM in release mode, 200 distinct 4 KiB blobs: about
0.5-0.6 ms per blob (plain) and 0.8-1.3 ms (gzip), up from 0.07 ms and
0.14-0.20 ms, almost all of it the fsync. Storing content that is already
stored now also reads the existing blob back: about +20 ms at 16 MiB and
+90-130 ms at 64 MiB.

TOPOLOGY.adoc and READINESS.adoc: 119 -> 123 tests (four added), as
dashboard-check requires.

Checked: cargo fmt --check; cargo clippy --workspace --all-targets
-D warnings; cargo test --workspace --locked (all pass, 123 tests);
dashboard-check reports no divergence other than the Idris2/Agda proof
rows, whose binaries are not installed in this environment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013CuBToxr5h9catptGiTsFX
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 45 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: b2cb71a7-fd42-4b1d-bfb7-dfbef715e1b2
Commits

Reviewing files that changed from the base of the PR and between 3f3cc1d and 286f933.

Files selected for processing: 3
  • READINESS.adoc
  • TOPOLOGY.adoc
  • crates/reversible-core/src/content_store.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hyperpolymath
hyperpolymath marked this pull request as ready for review October 27, 2026 01:32
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T01:35:23.566597Z 286f933 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copy link
Copy Markdown
Owner Author

CI on 286f933: the 20 check runs that exist are green or skipped, but five workflows failed with zero jobs, so they produced no check run: codeql.yml, governance.yml, hypatia-scan.yml, rust-ci.yml, secret-scanner.yml (runs 40432281559, …579, …581, …584, …634). The "no check suite failed" notice does not cover them.

Not caused by this PR. The same five fail the same way on main at 3f3cc1d (push run, 2026-10-27 00:07, before this PR existed), and the diff here touches only content_store.rs and two test counts. They are the standards-reusable callers fixed in #159.

What changed is outside the repo. On 10-22 the same main commit (10f0bc2) ran Rust CI, Secret Scanner and Hypatia successfully; the scheduled Rust CI on that commit on 10-26 failed with zero jobs.

Ruled out (by checking live remotes):

  • standards a85aa8e, the pinned callee, is still reachable from standards main.
  • Every locked tag/branch commit I could check (dtolnay/rust-toolchain, Swatinem/rust-cache, the squabbler) is still in its ref's history, even where the ref has moved. I did not check hyperpolymath/deed-ecosystem or k9-ecosystem.

Not known: the failure text. The API gives no reason for a zero-job run, and the run page is not reachable from my environment. Opening one of the runs above will show the banner and settle whether this is another GitHub-side validation change. I have not changed anything in response.

Squabble (CI dependency chains) now passes here, since #160 moved the action's lock record to a symbolic ref.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Add Carrot credits or activate Agent usage billing to use Autofix

@hyperpolymath
hyperpolymath enabled auto-merge (squash) October 27, 2026 01:41
@hyperpolymath
hyperpolymath merged commit 771a19d into main Oct 27, 2026
20 checks passed
@hyperpolymath
hyperpolymath deleted the claude/vibrant-cori-lirqrr branch October 27, 2026 02:06
hyperpolymath pushed a commit that referenced this pull request Oct 27, 2026
main gained #163 (content-store fixes, +4 tests), which edits the same
test-count lines as this branch. The merged total is 123 + 9 = 132, checked
against `cargo test --workspace --locked -- --list`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kjdf4Mf4Z41bNQh2TrsEp9
hyperpolymath pushed a commit that referenced this pull request Oct 27, 2026
The remote merge of main (42cacae) kept this branch's 128 tests, but main's
#163 adds 4, so the declared total is 132 (checked against
`cargo test --workspace --locked -- --list`). dashboard-check would have
failed on the stale 128.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kjdf4Mf4Z41bNQh2TrsEp9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants