@@ -57,17 +57,13 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
5757 // Note: span is not entered via `.entered()` to keep this async fn's
5858 // future `Send`. Use `tracing::instrument` or enter in sync sections only.
5959 let new_commit_id = self . guard . latest_commit_id + 1 ;
60- let precommit_history_state =
61- std:: env:: var_os ( "PAGEDB_INVARIANT_CHECKS" )
62- . is_some ( )
63- . then ( || {
64- (
65- self . guard . next_page_id ,
66- self . guard . commit_history_root_page_id ,
67- self . guard . commit_history_root_version ,
68- self . guard . commit_history_count ,
69- )
70- } ) ;
60+ // `Some` is both the enablement signal for the opt-in invariant and the
61+ // means of undoing what materialization is about to advance — the check
62+ // is never reachable without the rollback it needs. Presence of the
63+ // variable enables it, including a non-Unicode value.
64+ let invariant_rollback = std:: env:: var_os ( "PAGEDB_INVARIANT_CHECKS" )
65+ . is_some ( )
66+ . then ( || InvariantRollback :: capture ( & self . guard ) ) ;
7167
7268 // ── Materialize all trees first ──────────────────────────────────────
7369 // Done before accounting freed pages so every copy-on-write spine free
@@ -180,27 +176,27 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
180176 // caller observes as failed. Check every live tree, including commit
181177 // history, and keep the strict dangling-pointer walk that detects pages
182178 // freed in an earlier commit.
183- if let Some ( precommit_history_state ) = precommit_history_state {
179+ if let Some ( rollback ) = invariant_rollback {
184180 let freed = entries. iter ( ) . map ( |& ( _, page_id) | page_id) . collect ( ) ;
181+ let roots = [
182+ ( "data" , new_root) ,
183+ ( "catalog" , new_catalog_root) ,
184+ ( "commit-history" , self . guard . commit_history_root_page_id ) ,
185+ ] ;
185186 if let Err ( violation) = assert_freed_pages_unreachable (
186187 self . db ,
187- [
188- new_root,
189- new_catalog_root,
190- self . guard . commit_history_root_page_id ,
191- ] ,
188+ roots,
192189 self . guard . next_page_id ,
193190 & freed,
194191 new_commit_id,
195192 )
196193 . await
197194 {
198- (
199- self . guard . next_page_id ,
200- self . guard . commit_history_root_page_id ,
201- self . guard . commit_history_root_version ,
202- self . guard . commit_history_count ,
203- ) = precommit_history_state;
195+ // Unwind to exactly the state the still-durable header
196+ // describes, so the next writer bump-allocates over the pages
197+ // this candidate abandoned instead of leaking their ids.
198+ // `Drop` discards the dirty pages themselves.
199+ rollback. restore ( & mut self . guard ) ;
204200 panic ! ( "{violation}" ) ;
205201 }
206202 }
@@ -330,6 +326,41 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
330326 }
331327}
332328
329+ /// The writer fields a candidate commit advances before the opt-in invariant
330+ /// runs, captured so a rejected candidate can be unwound.
331+ ///
332+ /// These four are exactly what tree materialization and
333+ /// `write_commit_history_entry` assign. Everything else the pre-invariant
334+ /// stretch of `commit` touches is either transaction-local (dies with the
335+ /// `WriteTxn`) or rebuilt from the durable free-list by the next
336+ /// `begin_write` — the shared allocator caches among them — so it needs no
337+ /// rollback of its own.
338+ #[ derive( Clone , Copy ) ]
339+ struct InvariantRollback {
340+ next_page_id : u64 ,
341+ commit_history_root_page_id : u64 ,
342+ commit_history_root_version : u64 ,
343+ commit_history_count : Option < u64 > ,
344+ }
345+
346+ impl InvariantRollback {
347+ fn capture ( state : & super :: super :: db:: WriterState ) -> Self {
348+ Self {
349+ next_page_id : state. next_page_id ,
350+ commit_history_root_page_id : state. commit_history_root_page_id ,
351+ commit_history_root_version : state. commit_history_root_version ,
352+ commit_history_count : state. commit_history_count ,
353+ }
354+ }
355+
356+ fn restore ( self , state : & mut super :: super :: db:: WriterState ) {
357+ state. next_page_id = self . next_page_id ;
358+ state. commit_history_root_page_id = self . commit_history_root_page_id ;
359+ state. commit_history_root_version = self . commit_history_root_version ;
360+ state. commit_history_count = self . commit_history_count ;
361+ }
362+ }
363+
333364#[ derive( Debug ) ]
334365enum FreedPageInvariantViolation {
335366 Dangling {
@@ -382,19 +413,26 @@ impl std::fmt::Display for FreedPageInvariantViolation {
382413 }
383414}
384415
416+ /// Reject a candidate commit that would free a page still reachable from any
417+ /// of its live trees, or whose live trees cannot be authenticated and decoded.
418+ ///
419+ /// Each root is walked twice, and the two walks are not redundant.
420+ /// `find_dangling` selects a node's decoder from the node body's own header
421+ /// byte and reports the first bad *pointer* with its parent, which is what
422+ /// localizes a use-after-free. `collect_all_page_ids` selects the decoder from
423+ /// the *authenticated* envelope kind, so it also catches a page whose envelope
424+ /// and body disagree — invisible to the first walk — and its error must be
425+ /// propagated rather than dropped, or the freed-set check below would pass
426+ /// merely because the reachable set came back short.
385427async fn assert_freed_pages_unreachable < V : Vfs + Clone > (
386428 db : & super :: super :: db:: Db < V > ,
387- roots : [ u64 ; 3 ] ,
429+ roots : [ ( & ' static str , u64 ) ; 3 ] ,
388430 next_page_id : u64 ,
389431 freed : & HashSet < u64 > ,
390432 commit_id : u64 ,
391433) -> std:: result:: Result < ( ) , FreedPageInvariantViolation > {
392434 let mut reachable = std:: collections:: BTreeSet :: new ( ) ;
393- for ( tree_name, root) in [ "data" , "catalog" , "commit-history" ]
394- . into_iter ( )
395- . zip ( roots)
396- . filter ( |( _, root) | * root != 0 )
397- {
435+ for ( tree_name, root) in roots. into_iter ( ) . filter ( |( _, root) | * root != 0 ) {
398436 let tree = crate :: btree:: BTree :: open (
399437 db. pager . clone ( ) ,
400438 db. realm_id ,
@@ -457,14 +495,17 @@ mod tests {
457495
458496 use super :: * ;
459497 use crate :: OpenOptions ;
498+ use crate :: pager:: PageKind ;
460499 use crate :: vfs:: memory:: MemVfs ;
461500
501+ const REALM : crate :: RealmId = crate :: RealmId :: new ( [ 3u8 ; 16 ] ) ;
502+
462503 async fn populated_db ( ) -> crate :: Db < MemVfs > {
463504 let db = crate :: Db :: open_internal_with_options (
464505 MemVfs :: new ( ) ,
465506 [ 7u8 ; 32 ] ,
466507 4096 ,
467- crate :: RealmId :: new ( [ 3u8 ; 16 ] ) ,
508+ REALM ,
468509 OpenOptions :: default ( ) ,
469510 )
470511 . await
@@ -486,7 +527,11 @@ mod tests {
486527
487528 let violation = assert_freed_pages_unreachable (
488529 & db,
489- [ 0 , 0 , history_root] ,
530+ [
531+ ( "data" , 0 ) ,
532+ ( "catalog" , 0 ) ,
533+ ( "commit-history" , history_root) ,
534+ ] ,
490535 next_page_id,
491536 & HashSet :: from ( [ history_root] ) ,
492537 2 ,
@@ -508,7 +553,11 @@ mod tests {
508553
509554 let violation = assert_freed_pages_unreachable (
510555 & db,
511- [ unreadable_root, 0 , 0 ] ,
556+ [
557+ ( "data" , unreadable_root) ,
558+ ( "catalog" , 0 ) ,
559+ ( "commit-history" , 0 ) ,
560+ ] ,
512561 unreadable_root + 1 ,
513562 & HashSet :: new ( ) ,
514563 2 ,
@@ -525,19 +574,94 @@ mod tests {
525574 ) ) ;
526575 }
527576
577+ /// The two walks are complementary, not redundant, and the invariant has to
578+ /// fail closed when only the second one objects.
579+ ///
580+ /// `find_dangling` picks a node's decoder from the body's own header byte;
581+ /// `collect_all_page_ids` picks it from the authenticated envelope kind. A
582+ /// page whose envelope says internal while its body is a valid leaf
583+ /// therefore satisfies the first walk and fails the second. Dropping that
584+ /// error would leave a short reachable set that the freed-page check would
585+ /// then clear vacuously.
586+ #[ tokio:: test( flavor = "current_thread" ) ]
587+ async fn invariant_rejects_a_root_whose_authenticated_kind_contradicts_its_body ( ) {
588+ let db = populated_db ( ) . await ;
589+ let ( data_root, next_page_id) = {
590+ let state = db. writer . lock ( ) . await ;
591+ ( state. root_page_id , state. next_page_id )
592+ } ;
593+
594+ // Re-persist the real leaf root's bytes under the internal-node
595+ // envelope kind. Copying a live page keeps the body structurally valid,
596+ // so the disagreement is purely the authenticated kind.
597+ let ( guard, kind) = db. pager . read_main_node ( data_root, REALM ) . await . unwrap ( ) ;
598+ assert_eq ! ( kind, PageKind :: BTreeLeaf , "the fixture root must be a leaf" ) ;
599+ let body = guard. body_ref ( ) . to_vec ( ) ;
600+ drop ( guard) ;
601+ let forged = next_page_id;
602+ db. pager
603+ . write_main_page ( forged, REALM , PageKind :: BTreeInternal , & body)
604+ . await
605+ . unwrap ( ) ;
606+ db. pager . flush_main ( REALM ) . await . unwrap ( ) ;
607+ db. pager . reset_main_pages ( ) ;
608+
609+ let tree =
610+ crate :: btree:: BTree :: open ( db. pager . clone ( ) , REALM , forged, forged + 1 , db. page_size ) ;
611+ assert ! (
612+ tree. find_dangling( ) . await . is_none( ) ,
613+ "the strict pointer walk is expected to miss a kind disagreement; \
614+ if it now catches it, this test no longer covers the traversal path"
615+ ) ;
616+
617+ let violation = assert_freed_pages_unreachable (
618+ & db,
619+ [ ( "data" , forged) , ( "catalog" , 0 ) , ( "commit-history" , 0 ) ] ,
620+ forged + 1 ,
621+ & HashSet :: new ( ) ,
622+ 2 ,
623+ )
624+ . await
625+ . unwrap_err ( ) ;
626+ assert ! (
627+ matches!(
628+ violation,
629+ FreedPageInvariantViolation :: Traversal {
630+ tree_name: "data" ,
631+ root,
632+ ..
633+ } if root == forged
634+ ) ,
635+ "expected a traversal violation, got {violation:?}"
636+ ) ;
637+ }
638+
528639 #[ test]
529640 fn invariant_failure_precedes_durable_publication ( ) {
530- let status = Command :: new ( std:: env:: current_exe ( ) . unwrap ( ) )
641+ let output = Command :: new ( std:: env:: current_exe ( ) . unwrap ( ) )
531642 . args ( [
532643 "--ignored" ,
533644 "--exact" ,
534645 "txn::write::commit::tests::invariant_failure_precedes_durable_publication_child" ,
535646 "--nocapture" ,
536647 ] )
537648 . env ( "PAGEDB_INVARIANT_CHECKS" , "1" )
538- . status ( )
649+ . output ( )
539650 . unwrap ( ) ;
540- assert ! ( status. success( ) , "invariant subprocess failed: {status}" ) ;
651+ let report = String :: from_utf8_lossy ( & output. stdout ) . into_owned ( )
652+ + & String :: from_utf8_lossy ( & output. stderr ) ;
653+ // libtest exits 0 when a filter matches nothing, so a successful status
654+ // on its own would keep reporting green if this path ever drifted —
655+ // renaming the child, the module, or the file would silently delete the
656+ // regression. Require evidence that the child actually ran.
657+ assert ! (
658+ report. contains( "1 passed" ) ,
659+ "the child helper did not run; the filter no longer names it:\n {report}"
660+ ) ;
661+ assert ! (
662+ output. status. success( ) ,
663+ "invariant subprocess failed:\n {report}"
664+ ) ;
541665 }
542666
543667 #[ tokio:: test( flavor = "current_thread" ) ]
0 commit comments