Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Destructive writes stay closed in code and in the workflow token until the owner admits a
writer-fence protocol, so even an enabled schedule only reports. Issue #1028. PR #1029.

- **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.

- **Security:** update quinn-proto to the OSV-fixed release 0.11.18 (RUSTSEC-2026-0338 to -0342). The crate is a transitive dependency through `tauri-plugin-http`, `reqwest` 0.12 and `quinn`; the lockfile-only update changes no manifest, feature, or toolchain pin and unblocks the required OSV scan. PR #1030.

- **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.
Expand Down
8 changes: 8 additions & 0 deletions crates/worldscript-secure-storage/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,14 @@ impl<P: KeyProvider> ProtectedStorage<P> {
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()
Expand Down
228 changes: 217 additions & 11 deletions crates/worldscript-secure-storage/tests/gate4b_operations_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<io::ErrorKind>,
}

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<fs::File> {
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<Vec<u8>> {
self.observe(path)?;
StdFs.read(path)
}
fn read_at_most(&mut self, path: &Path, limit: usize) -> io::Result<Option<Vec<u8>>> {
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<DirectoryDurability> {
StdFs.sync_dir(dir)
}
fn list_dir(&mut self, dir: &Path) -> io::Result<Vec<std::ffi::OsString>> {
self.observe(dir)?;
StdFs.list_dir(dir)
}
fn list_dir_at_most(
&mut self,
dir: &Path,
limit: usize,
) -> io::Result<Option<Vec<std::ffi::OsString>>> {
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,
Expand All @@ -1294,11 +1385,27 @@ 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 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!(filesystem.catalog_observations, 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]
Expand All @@ -1310,10 +1417,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| {
Expand All @@ -1324,9 +1428,111 @@ 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]);
}

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 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(|_| ()),
_ => 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!(filesystem.catalog_observations, 0);
assert_eq!(barrier_tree(&fixture), before);
}

#[test]
fn lifecycle_root_read_errors_keep_their_catalog_root_envelope_without_page_reads() {
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);
}
}
}

#[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]
Expand Down
32 changes: 27 additions & 5 deletions docs/native/R15-SECURE-STORAGE-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading