fix: harden the desktop floor store read-modify-write and intent length prefix - #968
Conversation
|
Warning Review limit reached
Next review available in: 41 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
WalkthroughThe floor store now serializes concurrent floor raises. Intent encoding rejects oversized keys, and intent decoding checks for slice-bound overflow. Tests cover concurrency, oversized lengths, overflow, round trips, and recovery setup. ChangesFloor store hardening
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/desktop-seams/src/floor_store.rs (1)
298-322: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy liftMake the concurrency regression deterministic.
The barrier only synchronizes the start of both raises. Without
write_lock, either raise can complete its full read-modify-write before the other starts its read. The test can then pass without the lock.Add a test-only hook after
read_floor. Pause the low raise there, start the high raise, and verify that the high raise cannot complete until the low raise proceeds. Then assert that the persisted floor isSome(2). This fails when the critical section does not cover both the read and the write.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/desktop-seams/src/floor_store.rs` around lines 298 - 322, Make concurrent_raises_never_regress_a_floor deterministic by adding a test-only hook immediately after read_floor that pauses the low raise. Coordinate the test to release the low raise only after starting the high raise, and assert the high raise cannot complete while the low raise is paused; then release it and verify the persisted floor is Some(2), ensuring the read-modify-write critical section is locked.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/desktop-seams/src/floor_store.rs`:
- Around line 298-322: Make concurrent_raises_never_regress_a_floor
deterministic by adding a test-only hook immediately after read_floor that
pauses the low raise. Coordinate the test to release the low raise only after
starting the high raise, and assert the high raise cannot complete while the low
raise is paused; then release it and verify the persisted floor is Some(2),
ensuring the read-modify-write critical section is locked.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: fa92db3b-2ba8-48a2-bce0-dfa631c9146e
📒 Files selected for processing (1)
crates/desktop-seams/src/floor_store.rs
…he intent length prefix Two concurrent raises on one floor key could interleave read/read/write-high/ write-low and durably regress the floor, a loss no intent record can heal because both commits succeed and clear their own record. Hold a mutex across the whole read-modify-write in raise_floor; a poisoned lock fails closed. Check the end offset in intent_slice: the length comes from the corrupt-controlled prefix and can wrap usize on a 32-bit target. Mirror it on the encode side, where a truncating `as u32` cast would reframe the record so the decoder parses key bytes as the next raise's value. Closes #704
Review pass: the added doc blocks stated the same two rationales three times each and defended the truncating cast the change removed. State each once at its home, scope the write-lock guarantee to the handle, and let the test names carry what they already say.
c39e455 to
5a097f2
Compare
Review sweep — rebased onto
|
Closes #704
Retro
/security-reviewhardening oncrates/desktop-seams/src/floor_store.rs. Both items are LOW / defence-in-depth. I re-verified the reachability claims rather than taking them on trust — both hold — so this makes the store's monotonic-floor invariant structural rather than incidental, without changing observable behaviour.1 — Unserialized read-modify-write in
raise_floorraise_floorread the stored floor, tookmax, then wrote, with nothing serializing the two halves. Two concurrent raises on one key could interleave read/read/write-high/write-low and durably regress the floor. That loss is unhealable: both commits returnOkand clear their own intent records, so replay has nothing left to roll forward.Fix: a
Mutex<()>onFileFloorStore, held across the whole RMW inraise_floor—floor_store.rs:38-42and:88-92. A poisoned lock fails closed with aSeamError.Per-key serialization rather than per-batch is deliberate and sufficient. Monotonic-max is a per-key invariant, and the seam contract makes the desktop store roll-forward rather than transactional across keys, so an interleave between the keys of two batches heals idempotently on replay.
apply_raisesandreplay_intentsinherit the lock throughraise_floor; there is one acquisition site, so no nesting and no deadlock edge. The guard never spans an.await—raise_flooris a sync fn.Scope boundary: the lock is in-process, per handle. Two
FileFloorStorehandles over the same directory, or two processes, are not serialized by it. The engine constructs one store per session, and cross-process file locking is well beyond this issue.2 — 32-bit intent overflow in
intent_sliceintent_slicecomputedstart + lenon alentaken from the corrupt-controlled 4-byte length prefix, which can wrapusizeon a 32-bit target and panic in debug rather than returning the cleancorrupt_intent()error. Fix:start.checked_add(len).and_then(|end| bytes.get(start..end))—floor_store.rs:223-231.While there, the encode side had the mirroring gap that AGENTS.md rule 8 requires closing:
encode_intentwroteraise.key.len() as u32, a silently truncating cast. A truncated prefix does not merely produce a record the decoder rejects — it reframes the record, so the decoder reads a short key and then parses key bytes as the next raise's value.encode_intentnow returnsSeamResult<Vec<u8>>and rejects throughintent_key_len—floor_store.rs:190-197. Release-activeErr, nodebug_assert!.Reachability — verified, neither is live
Schedulerspawns viatokio::task::spawn_local(scheduler.rs:19,:49), which requires aLocalSeton a current-thread runtime, so engine tasks are single-threaded.raise_flooris a sync fn with no.awaitbetween read and write, so a task cannot yield mid-RMW even under a multi-threaded executor.FileFloorStoreis also not yet wired into a shell — the only in-tree constructions are thelib.rsre-export and the conformance suite.ipnsNamebytes.Tests
concurrent_raises_never_regress_a_floor— 32 keys, two barrier-synced threads per key raising 2 and 1. Negative control: with the mutex removed this fails immediately with a durableSome(1)whereSome(2)is required, reproducing the exact regression the issue describes. It passes deterministically with the fix.intent_slice_fails_closed_on_an_overflowing_end— theusizeboundary, on any pointer width.decode_rejects_an_oversized_key_length— a record claimingu32::MAXkey bytes.encode_rejects_a_key_longer_than_its_length_prefix— theu32::MAXencode boundary.intent_key_lentakes au64precisely so this is testable without a 4 GiB allocation, and so the test is pointer-width independent.All four are ordinary
#[test]s, so they fire in a release build.Gates
All exit 0, rebased onto
mainat 20d03b8, with a per-worktreeCARGO_TARGET_DIR:cargo fmt --all --checkcargo clippy --workspace --all-targets -- -D warningscargo check --workspace --all-targetscargo test -p cipherbox-engine -p cipherbox-core -p cipherbox-desktop-seams— all green;cipherbox-desktop-seams19 lib + 13 conformance passedcargo check -p cipherbox-wasm --target wasm32-unknown-unknownParallel work
Confined to
floor_store.rs. It does not touchfs_util.rs,staging_store.rs, ortests/conformance.rs, so it does not collide with #952.Summary by CodeRabbit
Bug Fixes
Tests