v0.11 item 3, after review: seven defects #203 merged with - #205
Merged
Merged
Conversation
§4.6's laundering hole: verify_anchors built its anchored-checkpoint set from held | cached, and cached is the local table its own docstring calls a cache and never the record. One INSERT bought supersession, with a hash that was not a hash of anything, and the row was never checked against the provider. Supersession now comes from the provider alone and the pair must match, not merely the seq. On SQLite the prune held no lock through its destructive half: put_anchor and put_checkpoint use 'with connection:', which commits, so BEGIN IMMEDIATE ended at the first of them. Postgres had the guard from the start; SQLite did not, because the defect was found on Postgres and the fix was applied where it was found. SQLite is the default backend. A prune that failed after the checkpoint left the row behind and the reader announced a gap in an intact chain. A hold placed while a prune was in flight was ignored and its receipts deleted. The holds table does not contend with the receipt_chain row lock, so consulting inside the transaction closed nothing; put_hold now takes the same lock. The prune's bound came from the receipt_chain row, which is the row §2.1 assumes an attacker rewrites. One UPDATE turned --through 8 into a delete of every receipt, after which both readers reported clean. The rule-2 simulation filtered to Receipt and re-derived the head, so one unreadable row cost the whole retention feature and a damaged head row refused honest prunes for a break the store already had. The checkpoint could name a (seq, hash) pair that never existed, and anchor it through the provider, which corrupts the external record §4.6 rests on. A refused prune and a successful one left byte-identical receipts, and --older-than was not in the record at all. Signed-off-by: arpan <contact@arpanghoshal.com>
T546 asserted a constant rather than the branch that reads it, T544's forced refusal stopped reaching the refusal once the simulation became faithful, three Postgres guards had no test at all, and T550's forged row carried a hash that the pair check alone refuses, so the source check never fired. Signed-off-by: arpan <contact@arpanghoshal.com>
R1 was the last surviving mutation in item 3's table: delete rule 2's comparison entirely, `if caused:` to `if False:`, and all 34 tests in `test_retention.py` still passed. The reason is worth stating rather than papering over. Once `T554` made the checkpoint name the pair that really exists, and `T553` made the simulation the store rather than a tidier version of it, a prefix prune can no longer introduce a break. Every construction that would is caught before rule 2 runs: by the head bound, by the forward-only checkpoint, or by the missing-hash refusal. `T556` looked like it covered this and did not, because its assertion is an `or` that the earlier hash refusal satisfies. So the comparison is a backstop, and `T556c` keeps it honest by feeding it a simulation reporting one more break than the store does, then asserting the refusal names the `seq` and that nothing was deleted and no checkpoint left behind. A guard nothing can trigger is still a guard somebody will edit. Mutation table for item 3: 22 of 22 caught. Signed-off-by: arpan <contact@arpanghoshal.com>
|
Warning Review limit reachedNext included review available in 27 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (6)
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happened
#203 merged before its independent review landed. The review found seven defects, each demonstrated against the merged code with a script, and
maincarries all seven right now. This is those fixes, plus the tests that fail without them.The one that matters most is the first.
The seven
1. The checkpoint was launderable. §4.6 says a checkpoint that is itself anchored supersedes the anchors below it, so that pruning and anchoring do not cancel.
verify_anchorsbuilt the set of anchored checkpoints from the union of what the provider returned and what the store's ownanchorstable held:An attacker who erases a prefix, writes a checkpoint row to explain it, and writes one row into the local
anchorstable beside it getssupersededinstead ofanchor_broken. The hash in that row is never read, sosha256:not-a-hash-at-allworks. The whole of what §4.6 is supposed to buy is that the prune leaves a record outside the store, and it bought nothing.Supersession now comes from the provider alone, and the anchor's
(seq, hash)must be the pair the checkpoint claims:T547bpassed through all of this, because it writes the checkpoint row and noanchorsrow beside it.T550andT559are the tests that fail without the fix.2. The SQLite prune dropped its lock at the first write.
pruning()openedBEGIN IMMEDIATE, and then everyput_anchorandput_checkpointwent throughwith connection:and committed it. The prune held the lock for one statement. Postgres had the guard and SQLite, the default backend, did not, because the defect was found on Postgres and fixed only where it was found. A failed prune left[('missing', 4), ('link_broken', 1)]on a chain that was intact when it started.T551.3. A hold placed during a prune was ignored and its receipts deleted:
put_holdon Postgres took no row lock, so it landed between the prune's validation and its delete.T558b.4.
--throughabove the head was decided byreceipt_chain. OneUPDATE receipt_chain SET seq = 99turned--through 8into a full-chain delete that both readers called clean. The bound now comes from the receipts themselves, which is the row §2.1 assumes is rewritten.T552.5. Rule 2's simulation was not the store. It dropped
UnreadableReceiptrows and re-derived the head, so a store with one unparseable row refused every prune.T553.6. The checkpoint could assert a
(seq, hash)pair that never existed and then anchor it: the seq came from--throughrather than from the boundary receipt.T554.7. A refused prune and a successful one left byte-identical receipts, and
--older-thanwas in neither. The prune now writes a request stagedproposedand an outcome stagedcompletedorrefused.T555.Mutation
22 of 22 caught, against a
git archive HEADcopy withPYTHONDONTWRITEBYTECODE=1.The last one is worth naming. R1 deletes rule 2's comparison outright,
if caused:toif False:, and every test in the file still passed. That is a real finding about the code, not only the test: once #6 makes the checkpoint name a pair that exists and #5 makes the simulation the store, no store state reaches that branch any more. Every construction is caught earlier, by the head bound, the forward-only checkpoint, or the missing-hash refusal.T556looked like it covered it and did not, because its assertion is anorthe earlier refusal satisfies.The comparison stays as a backstop and
T556ckeeps it honest, feeding it a simulation that reports one break more than the store does and asserting the refusal names theseq, deletes nothing, and leaves no checkpoint.Gate
Postgres tests ran against a real server.
T549races two prunes in separate OS processes.Pairing
Docs are
v0.11/3a-retention-review, which stacks on the still-open ctrlrun-docs#34.🤖 Generated with Claude Code