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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- **R-15 Gate 3 complete — crash-durable protected storage core (#445):** the protected-storage
core now covers everything Gate 3 requires — synced files, never-overwritten generations, synced
directories where the platform supports it (Windows reports them as not confirmed), startup
recovery and fault-injection tests on Linux, macOS and Windows — for ordinary
records, and refuses any other record type before writing. What remains is assigned to later gates
(asset pairs, power-loss qualification, record deletion, and switching the app over). Nothing
reads or writes user data through it yet. PR #949.
- **R-15 Gate 3, slice 3C part 3c-2b — protected writes through the root of trust (#445):** a
protected write now commits each step — the intent to write and the finished write — through a
new root of trust, and is reported as durably committed only after the second one. Reads serve
Expand Down
17 changes: 17 additions & 0 deletions crates/worldscript-secure-storage/src/protected.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use crate::commit::{
CommitError, RecordStore, Resolution, WriteRequest,
};
use crate::durable::{DirectoryDurability, DurableFs, WriteOperationId};
use crate::identity::has_ordinary_marker;
use crate::provider::{KeyProvider, RootKeyRefV1};
use crate::record::OpenedRecord;
use crate::root_store::{RootCommitted, RootLayout};
Expand Down Expand Up @@ -68,6 +69,10 @@ pub enum ProtectedError {
/// authority. In the protected path an uncatalogued record's chain can only be a rolled-back
/// first write, so a chain no committed root ever named is never published.
UnrootedChain,
/// Not an ordinary record: control-plane and retained-authority classes have no record-commit
/// marker, and an asset-pair member (`asset`, `asset-metadata`) is committed only through its
/// `asset-pair` marker (§8.4.1), which is Gate 5 — refused before anything is written.
NotAnOrdinaryRecord,
}

impl From<CommitError> for ProtectedError {
Expand Down Expand Up @@ -118,6 +123,7 @@ pub fn protected_write<F: DurableFs, P: KeyProvider>(
target: ProtectedTarget<'_>,
write: ProtectedWrite<'_>,
) -> Result<ProtectedCommitted, ProtectedError> {
ensure_ordinary(target.store)?;
let mut durability = reconcile_protected(fs, provider, target)?.durability;
let request = WriteRequest {
key_epoch: target.key_epoch,
Expand Down Expand Up @@ -161,6 +167,7 @@ pub fn reconcile_protected<F: DurableFs, P: KeyProvider>(
provider: &mut P,
target: ProtectedTarget<'_>,
) -> Result<ProtectedReconciled, ProtectedError> {
ensure_ordinary(target.store)?;
let catalog = load_catalog(fs, provider, target.layout)?;
let named = named_descriptor(catalog.as_ref(), target.store);
if let Some(named) = named {
Expand Down Expand Up @@ -196,6 +203,7 @@ pub fn read_protected<F: DurableFs, P: KeyProvider>(
layout: RootLayout<'_>,
store: RecordStore<'_>,
) -> Result<ProtectedRead, ProtectedError> {
ensure_ordinary(store)?;
let catalog = load_catalog(fs, provider, layout)?;
let Some(named) = named_descriptor(catalog.as_ref(), store) else {
return Ok(ProtectedRead::NotCatalogued);
Expand Down Expand Up @@ -261,6 +269,15 @@ fn commit_chain_state<F: DurableFs, P: KeyProvider>(
)?))
}

/// Only an ordinary record (§10.4.1 `MIGRATE_TO_R15`, not an asset-pair member) takes this path.
fn ensure_ordinary(store: RecordStore<'_>) -> Result<(), ProtectedError> {
if has_ordinary_marker(store.record.class()) {
Ok(())
} else {
Err(ProtectedError::NotAnOrdinaryRecord)
}
}

/// An uncatalogued record that is not a rolled-back first write must have no authority at all: a
/// chain no committed root ever named is never published.
fn refuse_unrooted_chain<F: DurableFs>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,21 @@ fn an_ahead_generation_that_does_not_verify_is_not_published() {
assert_eq!(fixture.listed_marker_states(), vec![(2, ACTIVE)]);
assert_eq!(fixture.payload().as_deref(), Some(&b"first"[..]));
}

#[test]
fn an_asset_pair_member_is_refused_before_anything_is_written() {
let mut fixture = Fixture::new();
fixture.record = RecordIdentity::new(RecordClass::Asset, &["p1", "a1"]).unwrap();
assert_eq!(
fixture.write_with(&mut StdFs, b"bytes"),
Err(ProtectedError::NotAnOrdinaryRecord)
);
assert_eq!(fixture.read(), Err(ProtectedError::NotAnOrdinaryRecord));
assert_eq!(
fixture.try_reconcile(),
Err(ProtectedError::NotAnOrdinaryRecord)
);
assert_eq!(fs::read_dir(fixture.marker_dir()).unwrap().count(), 0);
Comment thread
qnbs marked this conversation as resolved.
assert_eq!(fs::read_dir(fixture.record_dir()).unwrap().count(), 0);
assert!(!fixture.root_dir().join("catalog").exists());
}
Loading
Loading