From 4f74110e1d93aff66f1fb3078de3c550a769c966 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:08:52 +0200 Subject: [PATCH 1/4] fix(core): refuse bound lifecycle transitions before catalog reads --- CHANGELOG.md | 2 + .../src/operations.rs | 8 + .../tests/gate4b_operations_test.rs | 142 ++++++++++++++++-- docs/native/R15-SECURE-STORAGE-CONTRACT.md | 32 +++- 4 files changed, 168 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6e4e0e0..4105aafea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 E2 preparation:** lifecycle transitions authenticate the committed migration binding before catalog pages and refuse with `RecoveryPending` even when those pages are missing or corrupt. Existing root-error, locked-provider and unbound-catalog behavior is preserved. The contract records the owner-approved locator, phase-barrier and error-priority direction; PREPARE/DONE windows remain closed. PR reference pending. + - **R-15 Gate 4E evidence:** coordination-resource preservation checks inspect links without following them and reject nonempty files, FIFOs, sockets, and observation errors. Fixture-scoped regressions cover missing/empty resources and link variants. The ordinary-operation barrier is unchanged. Clean successor to PR #1021. PR #1026. - **Docs:** a release tag that is not an ancestor of the checked history now fails as one tag-boundary class instead of a list of disjoint subjects. Real undocumented subjects still fail after a correct ancestor tag. A history read that fails after that ancestor check fails closed instead of passing as empty history. PR #1023. diff --git a/crates/worldscript-secure-storage/src/operations.rs b/crates/worldscript-secure-storage/src/operations.rs index bb1656440..972b4e09b 100644 --- a/crates/worldscript-secure-storage/src/operations.rs +++ b/crates/worldscript-secure-storage/src/operations.rs @@ -587,6 +587,14 @@ impl ProtectedStorage

{ provider: &PublishedProvider<'_, P>, require_quiescent: bool, ) -> Result<(), OperationError> { + // A committed migration binding refuses lifecycle claims before any catalog observation. + // Keep the existing catalog/root error envelope for genuinely unusable root authority. + let committed = crate::root_store::load_committed_root(fs, provider, self.scope.layout()) + .map_err(AuthorityError::from)? + .ok_or(OperationError::MigrationRequired)?; + if committed.root.live_migration.is_some() { + return Err(OperationError::RecoveryPending); + } let catalog = crate::authority::load_catalog(fs, provider, self.scope.layout())? .ok_or(OperationError::MigrationRequired)?; if catalog.root.live_migration.is_some() diff --git a/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs b/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs index 218bf6eb5..4456b8f77 100644 --- a/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs @@ -1294,11 +1294,30 @@ fn barrier_transition( ) -> Result<(), OperationError> { let fixture = Fixture::with_setup(setup); damage(&fixture); - match which { - 0 => poll_admitted(|| fixture.storage().try_lock(&mut StdFs)).map(|_| ()), - 1 => poll_admitted(|| fixture.storage().try_unlock(&mut StdFs)).map(|_| ()), - _ => poll_admitted(|| fixture.storage().try_shutdown(&mut StdFs)), + let before = barrier_tree(&fixture); + let mut catalog_reads = 0; + let mut filesystem = HookFs(|path: &Path, event| { + if event == Event::Read && path.starts_with(fixture.root.join("catalog")) { + catalog_reads += 1; + return Err(io::Error::from(io::ErrorKind::PermissionDenied)); + } + Ok(()) + }); + let outcome = match which { + 0 => poll_admitted(|| fixture.storage().try_lock(&mut filesystem)).map(|_| ()), + 1 => poll_admitted(|| fixture.storage().try_unlock(&mut filesystem)).map(|_| ()), + _ => poll_admitted(|| fixture.storage().try_shutdown(&mut filesystem)), + }; + assert_eq!(catalog_reads, 0); + assert_eq!(barrier_tree(&fixture), before); + if which == 1 { + assert!(fixture.probe.lock_calls.load(Ordering::SeqCst) > 0); + assert!(matches!( + fixture.storage().try_authority_snapshot(&mut StdFs), + Err(OperationError::Provider(KeyProviderError::Locked)) + )); } + outcome } #[test] @@ -1310,10 +1329,7 @@ fn a_bound_root_with_an_intact_catalog_page_refuses_the_transitions_with_recover } #[test] -fn a_bound_root_that_lost_a_catalog_page_fails_the_transitions_with_the_set_mismatch() { - let mismatch = OperationError::Catalog(AuthorityError::RecoveryRequired( - CatalogRecoveryReason::CatalogSetMismatch, - )); +fn a_bound_root_that_lost_a_catalog_page_refuses_the_transitions_with_recovery_pending() { let outcomes: Vec<_> = (0..3) .map(|which| { barrier_transition(barrier_paged(), which, |fixture| { @@ -1324,9 +1340,113 @@ fn a_bound_root_that_lost_a_catalog_page_fails_the_transitions_with_the_set_mism }) }) .collect(); - // `load_catalog` returns `None` only when no root is committed: this is a committed, bound - // root, so a missing page is a set mismatch, never `MigrationRequired` and never `RecoveryPending`. - assert_eq!(outcomes, vec![Err(mismatch); 3]); + assert_eq!(outcomes, vec![Err(OperationError::RecoveryPending); 3]); +} + +#[test] +fn a_bound_root_with_corrupt_catalog_pages_refuses_before_observing_them() { + fn corrupt(dir: &Path) -> usize { + let mut changed = 0; + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + changed += corrupt(&path); + } else { + fs::write(&path, b"corrupt catalog fixture").unwrap(); + changed += 1; + } + } + changed + } + let outcomes: Vec<_> = (0..3) + .map(|which| { + barrier_transition(barrier_paged(), which, |fixture| { + assert!(corrupt(&fixture.root.join("catalog")) > 0); + }) + }) + .collect(); + assert_eq!(outcomes, vec![Err(OperationError::RecoveryPending); 3]); +} + +#[test] +fn lifecycle_root_read_errors_keep_their_catalog_root_envelope_without_page_reads() { + for bound in [false, true] { + for which in 0..3 { + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound] { + let fixture = Fixture::with_setup(Setup { + bound: bound.then(|| barrier_binding("barrier-operation", 1, 1, 0xAB)), + page: true, + ..Setup::default() + }); + let before = barrier_tree(&fixture); + let mut catalog_reads = 0; + let mut filesystem = HookFs(|path: &Path, event| { + if event == Event::Read { + if path.starts_with(fixture.root.join("catalog")) { + catalog_reads += 1; + } + if path.starts_with(fixture.root.join("slot-a")) + || path.starts_with(fixture.root.join("slot-b")) + { + return Err(io::Error::from(kind)); + } + } + Ok(()) + }); + let outcome = match which { + 0 => poll_admitted(|| fixture.storage().try_lock(&mut filesystem)).map(|_| ()), + 1 => { + poll_admitted(|| fixture.storage().try_unlock(&mut filesystem)).map(|_| ()) + } + _ => poll_admitted(|| fixture.storage().try_shutdown(&mut filesystem)), + }; + let root_error = if kind == io::ErrorKind::NotFound { + RootStoreError::RecoveryRequired(RootRecoveryReason::CommittedSlotMissing) + } else { + RootStoreError::Io { + step: RootStep::ReadSlot, + kind, + } + }; + assert_eq!( + outcome, + Err(OperationError::Catalog(AuthorityError::Root(root_error))) + ); + assert_eq!(catalog_reads, 0); + assert_eq!(barrier_tree(&fixture), before); + } + } + } +} + +#[test] +fn an_unbound_root_with_a_missing_catalog_page_keeps_the_lifecycle_set_mismatch() { + let outcomes: Vec<_> = (0..3) + .map(|which| { + let fixture = Fixture::with_setup(Setup { + page: true, + ..Setup::default() + }); + assert!(barrier_remove_catalog_pages(&fixture) > 0); + let before = barrier_tree(&fixture); + let outcome = match which { + 0 => poll_admitted(|| fixture.storage().try_lock(&mut StdFs)).map(|_| ()), + 1 => poll_admitted(|| fixture.storage().try_unlock(&mut StdFs)).map(|_| ()), + _ => poll_admitted(|| fixture.storage().try_shutdown(&mut StdFs)), + }; + assert_eq!(barrier_tree(&fixture), before); + outcome + }) + .collect(); + assert_eq!( + outcomes, + vec![ + Err(OperationError::Catalog(AuthorityError::RecoveryRequired( + CatalogRecoveryReason::CatalogSetMismatch, + ))); + 3 + ] + ); } #[test] diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 3e1baf376..7f1f3adec 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -3086,11 +3086,13 @@ named only where a test pins it. The ordinary data operations (write, reconcile, lets the root recover an interrupted root commit (`recover_prepared_root_admitted`), which completes or discards a preparation that root commit already made durable and is not an ordinary write; when the interrupted commit is an ordinary catalog commit, which copies the binding forward, the refusal that -follows is the same whether the recovery completed or discarded it. The transitions that claim something about the catalog -(lock from an unlocked key, unlock, shutdown) load and verify the catalog first and then refuse with -`RecoveryPending`; when the bound root's catalog cannot be used they fail with the error of that load -instead (a missing required page is `RecoveryRequired(CatalogSetMismatch)`; other load failures, such -as a tampered page, an unresolvable key or an I/O error, fail with their own error and are not pinned). +follows is the same whether the recovery completed or discarded it. The owner-admitted E2 preparation +changes the lifecycle error priority: transitions that claim something about the catalog (lock from an +unlocked key, unlock, shutdown) authenticate the committed root first. A live binding refuses with +`RecoveryPending` before catalog pages are observed, including missing or corrupt pages. This is a +refusal, not permission to ignore corruption. Genuine root-load errors retain their existing +`Catalog(AuthorityError::Root(...))` envelope; unbound roots still load and verify the catalog and retain +its errors. No journal lookup or phase-dependent ordinary-operation window is introduced. `load_catalog` returns `None`, and the transitions `MigrationRequired`, only when no root is committed at all, which is not a bound root. A provider that starts locked fails before the root is consulted (a write with the provider's `Locked`); locking it again is idempotent, since nothing is admitted, and unlocking it @@ -3105,6 +3107,26 @@ policy for reconciliation under the barrier, and no producer of a binding exists It also relies on a phase transition out of `PREPARE` happening under exclusive admission, which the conversion gate holds by type; making the lower-level transition require it is a separate step. +**Owner-approved E2 direction (2026-10-11), not general source admission:** a writer journal locator +must be explicit, immutable and scope-bound; the committed operation, key route, exact manifest digest, +fence and admitted filesystem identity authenticate its association. Missing, ambiguous or +contradictory locators preserve data and refuse without searching for another journal. Future +`PREPARE`/`DONE` windows require fresh authenticated phase/binding/fence under shared admission for the +whole ordinary operation, with authority revalidated at writes. Every relevant phase-changing writer +must mechanically hold exclusive admission before those windows can activate; until separately +admitted and proven, the E1 barrier remains. Cached journal snapshots never grant authority. This +direction does not admit E3, other migration phases, read/list policy, Gates 5–7 or release. + +| Lifecycle condition | Error priority / authority boundary | +|---|---| +| Locked provider / repeated lock | Existing provider refusal or idempotent lock; refused unlock clears runtime keys. | +| Root unreadable, malformed, incompatible or unresolved | Preserve the actual root error; no catalog or phase fallback. | +| Committed bound root, catalog intact/missing/corrupt | `RecoveryPending` before any catalog-page read; no ordinary write. | +| Bound root, missing/wrong journal or locator | Root-bound refusal remains; unauthenticated journal association never grants a future window. | +| Future admitted/authenticated window, catalog unusable | Preserve the real catalog error after authority checks; corruption never becomes success. | +| Interrupted root commit / recovery | Existing separately authorized root recovery or refusal only; no uncertain-phase permission. | +| Admission conflict, changed binding or stale fence | No admission or an authority refusal; stale snapshots cannot grant access. | + ### 10.4 Operations - **Enable:** discover known healthy legacy plaintext while unlocked, convert every admitted From 7270e7076c4769ef898dfbe250214e3ddaa04797 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:15:15 +0200 Subject: [PATCH 2/4] docs(core): link E2 preparation to PR 1027 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4105aafea..dc4875ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 E2 preparation:** lifecycle transitions authenticate the committed migration binding before catalog pages and refuse with `RecoveryPending` even when those pages are missing or corrupt. Existing root-error, locked-provider and unbound-catalog behavior is preserved. The contract records the owner-approved locator, phase-barrier and error-priority direction; PREPARE/DONE windows remain closed. PR reference pending. +- **R-15 E2 preparation:** lifecycle transitions authenticate the committed migration binding before catalog pages and refuse with `RecoveryPending` even when those pages are missing or corrupt. Existing root-error, locked-provider and unbound-catalog behavior is preserved. The contract records the owner-approved locator, phase-barrier and error-priority direction; PREPARE/DONE windows remain closed. PR #1027. - **R-15 Gate 4E evidence:** coordination-resource preservation checks inspect links without following them and reject nonempty files, FIFOs, sockets, and observation errors. Fixture-scoped regressions cover missing/empty resources and link variants. The ordinary-operation barrier is unchanged. Clean successor to PR #1021. PR #1026. From b567e36bbfcf9b94329a8977ec88393c340a8d52 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:22:47 +0200 Subject: [PATCH 3/4] test(core): flatten lifecycle root-error matrix --- .../tests/gate4b_operations_test.rs | 96 ++++++++++--------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs b/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs index 4456b8f77..abd2e63eb 100644 --- a/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs @@ -1368,53 +1368,59 @@ fn a_bound_root_with_corrupt_catalog_pages_refuses_before_observing_them() { assert_eq!(outcomes, vec![Err(OperationError::RecoveryPending); 3]); } +fn barrier_assert_lifecycle_root_error(bound: bool, which: usize, kind: io::ErrorKind) { + let fixture = Fixture::with_setup(Setup { + bound: bound.then(|| barrier_binding("barrier-operation", 1, 1, 0xAB)), + page: true, + ..Setup::default() + }); + let before = barrier_tree(&fixture); + let mut catalog_reads = 0; + let mut filesystem = HookFs(|path: &Path, event| { + if event == Event::Read && path.starts_with(fixture.root.join("catalog")) { + catalog_reads += 1; + } + if event == Event::Read + && (path.starts_with(fixture.root.join("slot-a")) + || path.starts_with(fixture.root.join("slot-b"))) + { + return Err(io::Error::from(kind)); + } + Ok(()) + }); + let outcome = match which { + 0 => poll_admitted(|| fixture.storage().try_lock(&mut filesystem)).map(|_| ()), + 1 => poll_admitted(|| fixture.storage().try_unlock(&mut filesystem)).map(|_| ()), + _ => poll_admitted(|| fixture.storage().try_shutdown(&mut filesystem)), + }; + let root_error = if kind == io::ErrorKind::NotFound { + RootStoreError::RecoveryRequired(RootRecoveryReason::CommittedSlotMissing) + } else { + RootStoreError::Io { + step: RootStep::ReadSlot, + kind, + } + }; + assert_eq!( + outcome, + Err(OperationError::Catalog(AuthorityError::Root(root_error))) + ); + assert_eq!(catalog_reads, 0); + assert_eq!(barrier_tree(&fixture), before); +} + #[test] fn lifecycle_root_read_errors_keep_their_catalog_root_envelope_without_page_reads() { - for bound in [false, true] { - for which in 0..3 { - for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound] { - let fixture = Fixture::with_setup(Setup { - bound: bound.then(|| barrier_binding("barrier-operation", 1, 1, 0xAB)), - page: true, - ..Setup::default() - }); - let before = barrier_tree(&fixture); - let mut catalog_reads = 0; - let mut filesystem = HookFs(|path: &Path, event| { - if event == Event::Read { - if path.starts_with(fixture.root.join("catalog")) { - catalog_reads += 1; - } - if path.starts_with(fixture.root.join("slot-a")) - || path.starts_with(fixture.root.join("slot-b")) - { - return Err(io::Error::from(kind)); - } - } - Ok(()) - }); - let outcome = match which { - 0 => poll_admitted(|| fixture.storage().try_lock(&mut filesystem)).map(|_| ()), - 1 => { - poll_admitted(|| fixture.storage().try_unlock(&mut filesystem)).map(|_| ()) - } - _ => poll_admitted(|| fixture.storage().try_shutdown(&mut filesystem)), - }; - let root_error = if kind == io::ErrorKind::NotFound { - RootStoreError::RecoveryRequired(RootRecoveryReason::CommittedSlotMissing) - } else { - RootStoreError::Io { - step: RootStep::ReadSlot, - kind, - } - }; - assert_eq!( - outcome, - Err(OperationError::Catalog(AuthorityError::Root(root_error))) - ); - assert_eq!(catalog_reads, 0); - assert_eq!(barrier_tree(&fixture), before); - } + for (bound, which) in [ + (false, 0), + (false, 1), + (false, 2), + (true, 0), + (true, 1), + (true, 2), + ] { + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound] { + barrier_assert_lifecycle_root_error(bound, which, kind); } } } From 03b20a60acc788ed6a77fef56ec285a1c2eb16c4 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:42:57 +0200 Subject: [PATCH 4/4] test(core): observe catalog directory access before lifecycle refusal --- .../tests/gate4b_operations_test.rs | 126 ++++++++++++++---- 1 file changed, 103 insertions(+), 23 deletions(-) diff --git a/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs b/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs index abd2e63eb..f9c22a296 100644 --- a/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4b_operations_test.rs @@ -1286,6 +1286,97 @@ fn barrier_remove_catalog_pages(fixture: &Fixture) -> usize { remove(&fixture.root.join("catalog")) } +struct BarrierObservationFs<'a> { + root: &'a Path, + catalog_observations: usize, + root_error: Option, +} + +impl BarrierObservationFs<'_> { + fn observe(&mut self, path: &Path) -> io::Result<()> { + if path.starts_with(self.root.join("catalog")) { + self.catalog_observations += 1; + return Err(io::Error::from(io::ErrorKind::PermissionDenied)); + } + match self.root_error { + Some(kind) + if path.starts_with(self.root.join("slot-a")) + || path.starts_with(self.root.join("slot-b")) => + { + Err(io::Error::from(kind)) + } + _ => Ok(()), + } + } +} + +impl DurableFs for BarrierObservationFs<'_> { + type File = fs::File; + fn create_new(&mut self, path: &Path) -> io::Result { + StdFs.create_new(path) + } + fn sync_file(&mut self, file: &mut fs::File) -> io::Result<()> { + StdFs.sync_file(file) + } + fn read(&mut self, path: &Path) -> io::Result> { + self.observe(path)?; + StdFs.read(path) + } + fn read_at_most(&mut self, path: &Path, limit: usize) -> io::Result>> { + self.observe(path)?; + StdFs.read_at_most(path, limit) + } + fn link_no_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + StdFs.link_no_replace(from, to) + } + fn remove_file(&mut self, path: &Path) -> io::Result<()> { + StdFs.remove_file(path) + } + fn sync_dir(&mut self, dir: &Path) -> io::Result { + StdFs.sync_dir(dir) + } + fn list_dir(&mut self, dir: &Path) -> io::Result> { + self.observe(dir)?; + StdFs.list_dir(dir) + } + fn list_dir_at_most( + &mut self, + dir: &Path, + limit: usize, + ) -> io::Result>> { + self.observe(dir)?; + StdFs.list_dir_at_most(dir, limit) + } + fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { + StdFs.rename_replace(from, to) + } + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + StdFs.create_dir_all(dir) + } +} + +#[test] +fn barrier_observer_intercepts_file_reads_and_directory_enumerations() { + let fixture = barrier_bound(); + let mut filesystem = BarrierObservationFs { + root: &fixture.root, + catalog_observations: 0, + root_error: None, + }; + let dir = fixture.root.join("catalog"); + let results = [ + filesystem.read(&dir).map(|_| ()), + filesystem.read_at_most(&dir, 1).map(|_| ()), + filesystem.list_dir(&dir).map(|_| ()), + filesystem.list_dir_at_most(&dir, 1).map(|_| ()), + ]; + assert_eq!( + results.map(|result| result.unwrap_err().kind()), + [io::ErrorKind::PermissionDenied; 4] + ); + assert_eq!(filesystem.catalog_observations, 4); +} + /// Lock (0), unlock (1) or shutdown (2) on a fresh storage built from `setup`, as its outcome. fn barrier_transition( setup: Setup, @@ -1295,20 +1386,17 @@ fn barrier_transition( let fixture = Fixture::with_setup(setup); damage(&fixture); let before = barrier_tree(&fixture); - let mut catalog_reads = 0; - let mut filesystem = HookFs(|path: &Path, event| { - if event == Event::Read && path.starts_with(fixture.root.join("catalog")) { - catalog_reads += 1; - return Err(io::Error::from(io::ErrorKind::PermissionDenied)); - } - Ok(()) - }); + let mut filesystem = BarrierObservationFs { + root: &fixture.root, + catalog_observations: 0, + root_error: None, + }; let outcome = match which { 0 => poll_admitted(|| fixture.storage().try_lock(&mut filesystem)).map(|_| ()), 1 => poll_admitted(|| fixture.storage().try_unlock(&mut filesystem)).map(|_| ()), _ => poll_admitted(|| fixture.storage().try_shutdown(&mut filesystem)), }; - assert_eq!(catalog_reads, 0); + assert_eq!(filesystem.catalog_observations, 0); assert_eq!(barrier_tree(&fixture), before); if which == 1 { assert!(fixture.probe.lock_calls.load(Ordering::SeqCst) > 0); @@ -1375,19 +1463,11 @@ fn barrier_assert_lifecycle_root_error(bound: bool, which: usize, kind: io::Erro ..Setup::default() }); let before = barrier_tree(&fixture); - let mut catalog_reads = 0; - let mut filesystem = HookFs(|path: &Path, event| { - if event == Event::Read && path.starts_with(fixture.root.join("catalog")) { - catalog_reads += 1; - } - if event == Event::Read - && (path.starts_with(fixture.root.join("slot-a")) - || path.starts_with(fixture.root.join("slot-b"))) - { - return Err(io::Error::from(kind)); - } - Ok(()) - }); + let mut filesystem = BarrierObservationFs { + root: &fixture.root, + catalog_observations: 0, + root_error: Some(kind), + }; let outcome = match which { 0 => poll_admitted(|| fixture.storage().try_lock(&mut filesystem)).map(|_| ()), 1 => poll_admitted(|| fixture.storage().try_unlock(&mut filesystem)).map(|_| ()), @@ -1405,7 +1485,7 @@ fn barrier_assert_lifecycle_root_error(bound: bool, which: usize, kind: io::Erro outcome, Err(OperationError::Catalog(AuthorityError::Root(root_error))) ); - assert_eq!(catalog_reads, 0); + assert_eq!(filesystem.catalog_observations, 0); assert_eq!(barrier_tree(&fixture), before); }