diff --git a/blueprint/core.md b/blueprint/core.md index 544875eb3..e4d198f73 100644 --- a/blueprint/core.md +++ b/blueprint/core.md @@ -190,7 +190,7 @@ ownerPseudonymPk, [(tag, permission, pseudonymPk)]}`), owner blob, the optional A **rotation** holds the one key that starts that walk — the previous epoch's seed — so it keeps the newest 64 links (`MAX_RETAINED_HISTORY_LINKS`) that actually walk and drops the rest. Order is therefore proven, not assumed, and - the chain is bounded by design rather than by the 4 MiB block ceiling; the two + the chain is bounded by design rather than by the 2 MiB block ceiling; the two constants are coupled, retention staying under the decode bound so that bound remains a malformed-input guard an honest rotator never approaches. An unwalkable remainder is **truncated, never refused**: the carried set is diff --git a/crates/desktop-seams/tests/conformance.rs b/crates/desktop-seams/tests/conformance.rs index a1577edf3..94e62c5ce 100644 --- a/crates/desktop-seams/tests/conformance.rs +++ b/crates/desktop-seams/tests/conformance.rs @@ -19,6 +19,7 @@ use cipherbox_engine::StagingRetireLedger; use cipherbox_engine::seams::{ CappedFetchError, CredentialStore, Http, HttpCredentials, HttpMethod, HttpRequest, StagingStore, }; +use cipherbox_engine::testkit::conformance::staging_store::Backing; use cipherbox_engine::testkit::{block_on, conformance}; mod mock_http; @@ -38,44 +39,24 @@ fn file_floor_store_passes_the_floor_store_kit() { })); } +/// The desktop `StagingStore` kit. The fault lever denies the write target for +/// a replacement put, and for a first put — where Windows honours no denial on +/// a path that does not exist yet — removes the still-empty `staged/` +/// directory, which `FileStagingStore::open` recreates for the read-back. #[test] fn file_staging_store_passes_the_staging_store_kit() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("staging"); - block_on(conformance::staging_store::check(async || { - FileStagingStore::open(&path).unwrap() - })); -} - -/// The failed-put kit case. The lever is a permission denial `atomic_write` -/// hits on its way to the sidecar: on Unix the `staged/` directory is made -/// unwritable, so the temp file cannot be created; on Windows the sidecar -/// itself is made read-only, which `MoveFileEx` refuses to replace. Either way -/// the failure lands before the sidecar's bytes can change. -#[test] -fn file_staging_store_passes_the_failed_put_kit() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("staging"); - let denial = WriteDenial::for_store(&path); - block_on(conformance::staging_store::check_failed_put( - async || FileStagingStore::open(&path).unwrap(), - async || denial.arm(), - )); -} - -/// The failed-put kit's fresh-backing case: a first put that fails must land -/// nothing at the key. Unix only — the lever has to be armed before the key -/// exists, and Windows honours no denial on a path that is not there yet, so -/// its leg runs the replacement case above. -#[cfg(unix)] -#[test] -fn file_staging_store_passes_the_failed_first_put_kit() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("staging"); - let denial = WriteDenial::for_store(&path); - block_on(conformance::staging_store::check_failed_first_put( - async || FileStagingStore::open(&path).unwrap(), - async || denial.arm(), + let root = dir.path(); + let denial = WriteDenial::for_store(&root.join(Backing::FailedReplacement.label())); + block_on(conformance::staging_store::check( + async |backing: Backing| FileStagingStore::open(root.join(backing.label())).unwrap(), + async |backing: Backing| match backing { + Backing::Ordering | Backing::FailedReplacement => denial.arm(), + Backing::FailedFirstPut => { + std::fs::remove_dir(root.join(backing.label()).join("staged")) + .expect("the kit's lever must be armed, or it proves nothing"); + } + }, )); } diff --git a/crates/engine/kat/vectors/content/dag_capacity_accept.json b/crates/engine/kat/vectors/content/dag_capacity_accept.json index 74f36d452..4de2cc4fb 100644 --- a/crates/engine/kat/vectors/content/dag_capacity_accept.json +++ b/crates/engine/kat/vectors/content/dag_capacity_accept.json @@ -2,9 +2,9 @@ { "name": "flat-dag-ceiling-max-links", "chunkSize": 1048536, - "leafCount": 110375, - "size": 115732161000, - "rootBlockLen": 4194294, - "contentCid": "01711e20cce429fca648b0d71f89a985f66aa5cb4cb89652b99314f00444aca30652329a" + "leafCount": 55187, + "size": 57865556232, + "rootBlockLen": 2097148, + "contentCid": "01711e20638f9d84bcfcc3db635030e97753b8136ad1497fef650118ada1c3c9efc34c3c" } ] diff --git a/crates/engine/kat/vectors/content/dag_capacity_reject.json b/crates/engine/kat/vectors/content/dag_capacity_reject.json index cbfba921c..b0e113e92 100644 --- a/crates/engine/kat/vectors/content/dag_capacity_reject.json +++ b/crates/engine/kat/vectors/content/dag_capacity_reject.json @@ -2,8 +2,8 @@ { "name": "flat-dag-ceiling-one-link-past", "chunkSize": 1048536, - "leafCount": 110376, - "size": 115733209536, + "leafCount": 55188, + "size": 57866604768, "check": "dag-root-too-large", "class": "over-cap" } diff --git a/crates/engine/src/content/dag.rs b/crates/engine/src/content/dag.rs index 00dc58d85..55d5ea3dd 100644 --- a/crates/engine/src/content/dag.rs +++ b/crates/engine/src/content/dag.rs @@ -73,7 +73,7 @@ pub enum DagError { LinkCountMismatch, /// The assembled root manifest exceeded [`MAX_RESOLVED_RECORD_BYTES`]: the /// flat root inlines every leaf CID, so a file past the flat-DAG ceiling - /// (~108 GiB) produces a root [`read_block`](super::read::read_block) would + /// (~54 GiB) produces a root [`read_block`](super::read::read_block) would /// reject on fetch. Fails closed here so the encoder never emits an /// unreadable root (AGENTS.md rule 8). RootTooLarge { @@ -546,19 +546,26 @@ mod tests { #[test] fn assemble_fails_closed_when_the_root_exceeds_the_block_cap() { - // Each inlined 36-byte CID costs ~38 CBOR bytes, so ~120k links push the - // flat root over the 4 MiB cap; `assemble` must fail closed in every + // Each inlined 36-byte CID costs ~38 CBOR bytes, so ~60k links push the + // flat root over the 2 MiB cap; `assemble` must fail closed in every // build (not a release-stripped assert) rather than emit an unreadable // root (AGENTS.md rule 8). `plaintext_len = count * chunkSize` keeps the // leaf-count invariant satisfied so the size guard is what fires. let profile = ContentProfile::CI; let chunk_size = profile.chunk_size() as u64; - let count = 120_000; + let count = 60_000; let leaves = dummy_leaves(count); match assemble(&leaves, count as u64 * chunk_size, &profile) { Err(DagError::RootTooLarge { size, limit }) => { assert!(size > limit, "reported size exceeds the cap"); - assert_eq!(limit, 4 * 1024 * 1024); + assert_eq!(limit, 2 * 1024 * 1024); + // The reservation arithmetic must refuse the same root, at the + // same size, or a version the encoder will not emit still books + // staging budget. + assert_eq!( + root_block_len(count as u64 * chunk_size, &profile), + Err(DagError::RootTooLarge { size, limit }) + ); } other => panic!("expected RootTooLarge, got {other:?}"), } @@ -566,14 +573,17 @@ mod tests { #[test] fn assemble_accepts_a_root_just_under_the_block_cap() { - // ~100k links keep the root comfortably under 4 MiB; it assembles Ok and - // content-addresses, proving the guard rejects only over-cap roots. let profile = ContentProfile::CI; let chunk_size = profile.chunk_size() as u64; - let count = 100_000; + let count = 54_000; let leaves = dummy_leaves(count); let dag = assemble(&leaves, count as u64 * chunk_size, &profile).unwrap(); - assert!(dag.root_block.len() <= 4 * 1024 * 1024); + assert!(dag.root_block.len() <= MAX_RESOLVED_RECORD_BYTES); + assert!( + dag.root_block.len() > MAX_RESOLVED_RECORD_BYTES * 9 / 10, + "a root {} bytes under the cap does not exercise the boundary", + MAX_RESOLVED_RECORD_BYTES - dag.root_block.len() + ); assert!(verify_cid(&dag.content_cid, &dag.root_block).is_ok()); } @@ -615,9 +625,9 @@ mod tests { fn root_block_len_matches_the_assembled_root() { for profile in [ContentProfile::CI, ContentProfile::PRODUCTION] { let chunk = profile.chunk_size() as u64; - // Every CBOR head width the links array and the `size` uint cross: - // 1, 2, 3 and 5 bytes. - for leaves in [0u64, 1, 23, 24, 255, 256, 300, 65_535, 65_536] { + // Every CBOR head width the links array and the `size` uint cross + // below the cap, up to the widest reachable one. + for leaves in [0u64, 1, 23, 24, 255, 256, 300, 54_000] { let size = leaves * chunk; let assembled = assemble(&dummy_leaves(leaves.max(1) as usize), size, &profile) .expect("assembles") diff --git a/crates/engine/src/content/limits.rs b/crates/engine/src/content/limits.rs index 73ee733a3..6fd393b63 100644 --- a/crates/engine/src/content/limits.rs +++ b/crates/engine/src/content/limits.rs @@ -1,17 +1,57 @@ //! Shared content-plane size limits. +use super::chunk::SEALED_LEAF_OVERHEAD; +use super::profile::ContentProfile; + /// Hard ceiling on a resolved content block, the single source of truth for both /// the decode side ([`super::read::read_block`], which rejects any fetched block /// over this before it is hashed, decoded, or gated — gate work is linear in the /// fetched byte count) and the encode side ([`super::dag::assemble`], which fails -/// closed rather than emit a root manifest over this cap). A resolved record's +/// closed rather than emit a root manifest over this cap; and the reassembly +/// buffer's preallocation budget). A resolved record's /// envelope-content rides in an IPFS block fetched by CID; capping it here bounds /// gate work to a fixed budget and fails closed on anything larger /// (blueprint/engine.md "Content plane"). /// -/// Must exceed the 1 MiB content chunk size. A legitimate flat-DAG root inlines -/// every leaf CID, so it fits only up to the flat-DAG ceiling (~108 GiB at a -/// 1 MiB chunk size); `assemble` enforces that ceiling as a release-active -/// `Err`, so this crate never publishes a root its own `read_block` rejects -/// (the encode/decode fail-closed symmetry of AGENTS.md rule 8). -pub(crate) const MAX_RESOLVED_RECORD_BYTES: usize = 4 * 1024 * 1024; +/// The value is the IPFS single-block ceiling: `block/put` refuses anything over +/// 2 MiB (blueprint/api.md), so a larger record is authorable but unpinnable — +/// signed by this engine and then refused by its own ingress. +/// +/// Must exceed the 1 MiB sealed leaf, which [`ContentProfile::new`] enforces +/// for every injected profile. A legitimate flat-DAG root inlines every +/// leaf CID, so it fits only up to the flat-DAG ceiling (~54 GiB at a 1 MiB chunk +/// size); `assemble` enforces that ceiling as a release-active `Err`, so this +/// crate never publishes a root its own `read_block` rejects (the encode/decode +/// fail-closed symmetry of AGENTS.md rule 8). +pub(crate) const MAX_RESOLVED_RECORD_BYTES: usize = 2 * 1024 * 1024; + +/// The shipped framing's sealed leaf must fit the block ceiling, or every +/// content block this engine authors is refused by the ingress it publishes +/// through. Compile-time, so a framing edit cannot reach a release build +/// (AGENTS.md rule 8). +const _: () = assert!( + ContentProfile::PRODUCTION.chunk_size() as u64 + SEALED_LEAF_OVERHEAD + <= MAX_RESOLVED_RECORD_BYTES as u64, + "a production sealed leaf must fit the IPFS block ceiling" +); + +#[cfg(test)] +mod tests { + use super::*; + use cipherbox_core::content::seal_chunk; + use cipherbox_core::suite::aead::{KEY_LEN, NONCE_LEN}; + + /// The const assertion above computes the leaf size from + /// `SEALED_LEAF_OVERHEAD`; this measures a real sealed leaf, so a seal + /// layout that outgrows that constant still fails. + #[test] + fn a_production_sealed_leaf_fits_the_ceiling() { + let plaintext = vec![0u8; ContentProfile::PRODUCTION.chunk_size()]; + let sealed = seal_chunk(&[0u8; KEY_LEN], &[0u8; NONCE_LEN], &plaintext); + assert!( + sealed.len() <= MAX_RESOLVED_RECORD_BYTES, + "a {}-byte sealed leaf is unpinnable at a {MAX_RESOLVED_RECORD_BYTES}-byte ceiling", + sealed.len() + ); + } +} diff --git a/crates/engine/src/content/profile.rs b/crates/engine/src/content/profile.rs index 16acac3ca..7ea93df8d 100644 --- a/crates/engine/src/content/profile.rs +++ b/crates/engine/src/content/profile.rs @@ -5,14 +5,19 @@ //! profile, is injected rather than hardcoded at a call site: framing reads the //! size from the profile handed in. +use super::chunk::SEALED_LEAF_OVERHEAD; +use super::limits::MAX_RESOLVED_RECORD_BYTES; + /// The content-plane framing profile. Fixed-size chunking over a flat DAG is /// the whole of the frozen shape. /// /// There is deliberately **no `Default`** (mirrors [`crate::profile`]): every /// construction site names its profile, and the chunk size is always a real, -/// nonzero value — the field is private and every constructor rejects zero, so -/// a zero chunk size (which would panic framing at `chunks(0)`) is -/// unrepresentable rather than a fail-late panic. +/// nonzero value that seals to a block the ingress accepts — the field is +/// private and every constructor rejects both, so a zero chunk size (which +/// would panic framing at `chunks(0)`) and one whose leaves this engine's own +/// [`read_block`](super::read::read_block) would reject are unrepresentable +/// rather than a fail-late panic or an unpinnable version. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ContentProfile { /// Fixed content chunk size in bytes. Every leaf but the last carries @@ -40,10 +45,13 @@ impl ContentProfile { /// reachable from tiny fixtures (blueprint/testing.md "The DX hook"). pub const CI: Self = Self { chunk_size: 16 }; - /// A custom profile with the given chunk size, or `None` for a zero size — - /// the construction site that enforces the nonzero invariant. + /// A custom profile with the given chunk size, or `None` for a size that is + /// zero or seals past [`MAX_RESOLVED_RECORD_BYTES`] — the construction site + /// that fails closed on both, so no injected profile can frame a leaf this + /// crate's own reader rejects (AGENTS.md rule 8). pub const fn new(chunk_size: usize) -> Option { - if chunk_size == 0 { + let sealed = chunk_size.saturating_add(SEALED_LEAF_OVERHEAD as usize); + if chunk_size == 0 || sealed > MAX_RESOLVED_RECORD_BYTES { None } else { Some(Self { chunk_size }) @@ -99,4 +107,26 @@ mod tests { assert_eq!(ContentProfile::new(0), None, "zero is unrepresentable"); assert_eq!(ContentProfile::new(4096).unwrap().chunk_size(), 4096); } + + /// The release-active half of the sealed-leaf ceiling: the const assertion + /// in `limits` covers the shipped profile, this covers every injected one. + #[test] + fn new_rejects_a_chunk_size_that_seals_past_the_block_ceiling() { + let largest = MAX_RESOLVED_RECORD_BYTES - SEALED_LEAF_OVERHEAD as usize; + assert_eq!( + ContentProfile::new(largest).unwrap().chunk_size(), + largest, + "the largest leaf the ingress accepts is still framable" + ); + assert_eq!( + ContentProfile::new(largest + 1), + None, + "a profile whose leaves this engine's own reader rejects is unrepresentable" + ); + assert_eq!( + ContentProfile::new(usize::MAX), + None, + "no wrap past the cap" + ); + } } diff --git a/crates/engine/src/testkit/conformance/mod.rs b/crates/engine/src/testkit/conformance/mod.rs index 97381815c..2be2ab531 100644 --- a/crates/engine/src/testkit/conformance/mod.rs +++ b/crates/engine/src/testkit/conformance/mod.rs @@ -8,7 +8,7 @@ //! themselves). One contract, every platform: the v1 per-platform //! store-drift class has no home. //! -//! Shape: each kit is one `check` async function that panics (via +//! Shape: each kit is **one** `check` async function that panics (via //! `assert!`) on the first contract violation, so it drops into any test //! harness — `#[test]` + `block_on` natively, `wasm_bindgen_test` in the //! browser. Kits for durable stores take an `AsyncFnMut() -> S` **factory**; @@ -16,6 +16,13 @@ //! same durable state) — that is how durability is asserted without a //! process restart. Kits for transports take a live instance. //! +//! One entry point, always: a case a host reaches through a second `check_*` +//! function is a case a host can omit, and nothing in the type system, CI, or +//! review says it did. Where a case needs a fault the seam cannot produce on +//! its own, the lever is a **parameter** of `check` — the completeness argument +//! is made by the signature, the same way `SeamSet` makes it by field +//! construction. +//! //! One seam ships no kit, deliberately: `Http` is a pure passthrough, so //! its behavior is the live contract suite's job. diff --git a/crates/engine/src/testkit/conformance/staging_store.rs b/crates/engine/src/testkit/conformance/staging_store.rs index ef20fe73a..564241d14 100644 --- a/crates/engine/src/testkit/conformance/staging_store.rs +++ b/crates/engine/src/testkit/conformance/staging_store.rs @@ -1,30 +1,79 @@ -//! Conformance kit: [`StagingStore`] FIFO ordering, durability, and -//! orphan-GC support. +//! Conformance kit: [`StagingStore`] FIFO ordering, durability, orphan-GC +//! support, and `put_staged_bytes` failure atomicity. use crate::seams::StagingStore; -/// The staging key the failed-put cases write, so a host whose fault injector -/// is scoped to one key can arm exactly the put the kit makes fail. +/// The staging key the failure-atomicity phases write, so a host whose fault +/// injector is scoped to one key can arm exactly the put the kit makes fail. pub const FAILED_PUT_KEY: &[u8] = b"failed-put-key"; -/// Runs the `StagingStore` contract against an implementation. +/// Which of the kit's backings the host is being asked for. /// -/// `open` must return a handle over the same durable backing on every call -/// (reopen semantics); the backing must start empty. +/// Each names a **distinct** durable backing that starts empty; repeat `open` +/// calls for the same one must reopen it (new handle, same durable state), which +/// is how the kit asserts durability without a process restart. The failure +/// phases cannot share a backing with the ordering phase or with each other: +/// one needs an established record to defend, the next needs a key that has +/// never been written. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Backing { + /// FIFO ordering, durability, and orphan-GC support. + Ordering, + /// Failure atomicity of a put that replaces an existing record. + FailedReplacement, + /// Failure atomicity of a put at a key that held nothing. + FailedFirstPut, +} + +impl Backing { + /// Every backing the kit asks for, so a host that has to enumerate them + /// (one behind a string boundary, say) reads the set off the kit rather + /// than transcribing it. + pub const ALL: [Self; 3] = [ + Self::Ordering, + Self::FailedReplacement, + Self::FailedFirstPut, + ]; + + /// A stable label a host can key a directory, database name, or map entry + /// off to keep its backings apart. + pub fn label(self) -> &'static str { + match self { + Self::Ordering => "ordering", + Self::FailedReplacement => "failed-replacement", + Self::FailedFirstPut => "failed-first-put", + } + } +} + +/// Runs the whole `StagingStore` contract against an implementation. /// -/// The failure-atomicity half of `put_staged_bytes` needs a fault lever this -/// function cannot supply, so it lives in [`check_failed_put`] and -/// [`check_failed_first_put`]; a host passes all three or it is only part-held -/// to the contract. +/// `arm_failed_put` is the fault lever the failure-atomicity phases need and the +/// kit cannot supply: it must make the named backing's next `put_staged_bytes` +/// at [`FAILED_PUT_KEY`] fail (exhausted quota, a short write, a denied +/// directory; the host picks its own). /// /// # Panics /// Panics on the first contract violation. -pub async fn check(mut open: F) +pub async fn check(mut open: F, mut arm_failed_put: G) where S: StagingStore, - F: AsyncFnMut() -> S, + F: AsyncFnMut(Backing) -> S, + G: AsyncFnMut(Backing), { - let store = open().await; + ordering_and_durability(&mut open).await; + failed_replacement_put(&mut open, &mut arm_failed_put).await; + failed_first_put(&mut open, &mut arm_failed_put).await; +} + +/// FIFO ordering, id progression, staged-byte accounting, orphan-GC support, +/// and the durability of all four across reopen. +async fn ordering_and_durability(open: &mut F) +where + S: StagingStore, + F: AsyncFnMut(Backing) -> S, +{ + let store = open(Backing::Ordering).await; // Fresh backing. assert!(store.queued_ops().await.unwrap().is_empty()); @@ -90,7 +139,7 @@ where // Durability: queue order, staged bytes, and id progression survive // reopen. - let reopened = open().await; + let reopened = open(Backing::Ordering).await; assert_eq!( reopened.queued_ops().await.unwrap(), vec![(id_a, b"op-a".to_vec()), (id_c, b"op-c".to_vec())], @@ -125,7 +174,7 @@ where } assert!(reopened.queued_ops().await.unwrap().is_empty()); - let drained = open().await; + let drained = open(Backing::Ordering).await; let id_f = drained.enqueue_op(b"op-f").await.unwrap(); assert!( id_f > id_e, @@ -133,27 +182,21 @@ where ); } -/// Runs the replacement case of [`StagingStore::put_staged_bytes`]'s failure -/// atomicity against an implementation: a put that returns `Err` over an -/// existing record must leave the previous bytes exactly as it found them. -/// [`check_failed_first_put`] covers the same put into fresh backing. -/// -/// Separate from [`check`] because it needs a lever [`check`] cannot supply — -/// `arm_failed_put` must make the next `put_staged_bytes` at -/// [`FAILED_PUT_KEY`] fail (exhausted quota, a short write, a denied -/// directory; the host picks its own). Everything the kit does after arming is -/// a read, so the lever may stay armed. `open` carries [`check`]'s reopen -/// semantics, and the backing must start empty. -/// -/// # Panics -/// Panics on the first contract violation. -pub async fn check_failed_put(mut open: F, arm_failed_put: G) +/// A put that returns `Err` over an existing record must leave the previous +/// bytes exactly as it found them. +async fn failed_replacement_put(open: &mut F, arm_failed_put: &mut G) where S: StagingStore, - F: AsyncFnMut() -> S, - G: AsyncFnOnce(), + F: AsyncFnMut(Backing) -> S, + G: AsyncFnMut(Backing), { - let store = open().await; + let backing = Backing::FailedReplacement; + let store = open(backing).await; + assert!( + store.staged_keys().await.unwrap().is_empty(), + "every kit backing is its own, and starts empty" + ); + let previous = b"the-previously-staged-record"; store .put_staged_bytes(FAILED_PUT_KEY, previous) @@ -161,7 +204,7 @@ where .unwrap(); let total = store.staged_bytes_total().await.unwrap(); - arm_failed_put().await; + arm_failed_put(backing).await; assert!( store .put_staged_bytes(FAILED_PUT_KEY, b"a-longer-replacement-that-must-not-land") @@ -172,7 +215,7 @@ where // Read back through a fresh handle: what must survive is the durable // record, not what the handle that failed still remembers. - let reopened = open().await; + let reopened = open(backing).await; assert_eq!( reopened .staged_bytes(FAILED_PUT_KEY) @@ -197,33 +240,22 @@ where ); } -/// Runs the fresh-backing case of [`StagingStore::put_staged_bytes`]'s failure -/// atomicity: a put that returns `Err` for a key that held nothing must leave -/// that key absent — never an empty or half-written record. -/// -/// A separate entry point rather than a phase of [`check_failed_put`] because -/// the two cases cannot share one backing: this one needs the lever armed -/// before the key's first put, while [`check_failed_put`] needs an unarmed put -/// to establish the bytes it then defends. -/// -/// `arm_failed_put` and `open` carry [`check_failed_put`]'s contracts, and the -/// backing must start empty. -/// -/// # Panics -/// Panics on the first contract violation. -pub async fn check_failed_first_put(mut open: F, arm_failed_put: G) +/// A put that returns `Err` for a key that held nothing must leave that key +/// absent — never an empty or half-written record. +async fn failed_first_put(open: &mut F, arm_failed_put: &mut G) where S: StagingStore, - F: AsyncFnMut() -> S, - G: AsyncFnOnce(), + F: AsyncFnMut(Backing) -> S, + G: AsyncFnMut(Backing), { - let store = open().await; + let backing = Backing::FailedFirstPut; + let store = open(backing).await; assert!( store.staged_keys().await.unwrap().is_empty(), - "this case reads the key's absence as the result, so the backing must start empty" + "this phase reads the key's absence as the result, so the backing must start empty" ); - arm_failed_put().await; + arm_failed_put(backing).await; assert!( store .put_staged_bytes(FAILED_PUT_KEY, b"a-first-record-that-must-not-land") @@ -234,7 +266,7 @@ where // Read back through a fresh handle: what must be absent is the durable // record, not what the handle that failed still remembers. - let reopened = open().await; + let reopened = open(backing).await; assert_eq!( reopened.staged_bytes(FAILED_PUT_KEY).await.unwrap(), None, diff --git a/crates/engine/src/testkit/fakes/mod.rs b/crates/engine/src/testkit/fakes/mod.rs index 1cac49fda..37ddf3ccd 100644 --- a/crates/engine/src/testkit/fakes/mod.rs +++ b/crates/engine/src/testkit/fakes/mod.rs @@ -23,4 +23,4 @@ pub use received_share_store::InMemoryReceivedShareStore; pub use record_store::InMemoryRecordStore; pub use scheduler::VirtualScheduler; pub use snapshot_cache::InMemorySnapshotCache; -pub use staging_store::InMemoryStagingStore; +pub use staging_store::{InMemoryStagingBackings, InMemoryStagingStore}; diff --git a/crates/engine/src/testkit/fakes/staging_store.rs b/crates/engine/src/testkit/fakes/staging_store.rs index 07b8f1ae1..f5cc2ac26 100644 --- a/crates/engine/src/testkit/fakes/staging_store.rs +++ b/crates/engine/src/testkit/fakes/staging_store.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use crate::seams::{OpId, SeamError, SeamResult, StagingStore}; +use crate::testkit::conformance::staging_store::Backing; struct Inner { next_op_id: u64, @@ -79,9 +80,10 @@ impl InMemoryStagingStore { Some((staging_key.to_vec(), budget)); } - /// Fails the next write at `staging_key` past `budget` **after** landing - /// half of the new bytes — the write-in-place shape of a host whose failed - /// put strands a partial record where the key held none. + /// Fails the next write at `staging_key` past `budget`, landing half of the + /// new bytes when the key held none — the write-in-place shape of a host + /// that is failure-atomic on a replacement but strands a partial record on + /// a create. pub fn strand_staged_write_after(&self, staging_key: &[u8], budget: u64) { self.inner.lock().expect("lock").partial_write_budget = Some((staging_key.to_vec(), budget)); @@ -163,8 +165,10 @@ impl StagingStore for InMemoryStagingStore { return Err(SeamError::new("put_staged_bytes unavailable")); } if interrupts(&mut inner.partial_write_budget, staging_key) { - let half = bytes[..bytes.len() / 2].to_vec(); - inner.staged.insert(staging_key.to_vec(), half); + if !inner.staged.contains_key(staging_key) { + let half = bytes[..bytes.len() / 2].to_vec(); + inner.staged.insert(staging_key.to_vec(), half); + } return Err(SeamError::new("put_staged_bytes unavailable")); } inner.staged.insert(staging_key.to_vec(), bytes.to_vec()); @@ -215,3 +219,23 @@ impl StagingStore for InMemoryStagingStore { .sum()) } } + +/// One [`InMemoryStagingStore`] per conformance-kit [`Backing`]: what a kit +/// caller's `open` factory hands back, so every phase gets its own initially +/// empty backing while a repeat call for the same one reopens it. +#[derive(Clone, Default)] +pub struct InMemoryStagingBackings { + stores: Arc>>, +} + +impl InMemoryStagingBackings { + /// The store backing `backing`, minted empty on first ask. + pub fn open(&self, backing: Backing) -> InMemoryStagingStore { + self.stores + .lock() + .expect("lock") + .entry(backing) + .or_default() + .clone() + } +} diff --git a/crates/engine/tests/conformance_fakes.rs b/crates/engine/tests/conformance_fakes.rs index a253691ba..69708cf0c 100644 --- a/crates/engine/tests/conformance_fakes.rs +++ b/crates/engine/tests/conformance_fakes.rs @@ -3,9 +3,10 @@ //! by the kits. use cipherbox_engine::seams::EndpointId; +use cipherbox_engine::testkit::conformance::staging_store::FAILED_PUT_KEY; use cipherbox_engine::testkit::fakes::{ InMemoryCredentialStore, InMemoryFloorStore, InMemoryMailboxHub, InMemoryReceivedShareStore, - InMemoryRecordStore, InMemorySnapshotCache, InMemoryStagingStore, VirtualScheduler, + InMemoryRecordStore, InMemorySnapshotCache, InMemoryStagingBackings, VirtualScheduler, }; use cipherbox_engine::testkit::{block_on, conformance}; @@ -17,8 +18,15 @@ fn in_memory_floor_store_passes_the_floor_store_kit() { #[test] fn in_memory_staging_store_passes_the_staging_store_kit() { - let store = InMemoryStagingStore::default(); - block_on(conformance::staging_store::check(async || store.clone())); + let backings = InMemoryStagingBackings::default(); + block_on(conformance::staging_store::check( + async |backing| backings.open(backing), + async |backing| { + backings + .open(backing) + .interrupt_staged_write_after(FAILED_PUT_KEY, 0) + }, + )); } #[test] diff --git a/crates/engine/tests/content_wipe.rs b/crates/engine/tests/content_wipe.rs index 7ec60f847..5f572dfb8 100644 --- a/crates/engine/tests/content_wipe.rs +++ b/crates/engine/tests/content_wipe.rs @@ -312,10 +312,10 @@ fn a_mid_read_trust_reject_wipes_what_the_assembly_buffer_already_holds() { #[test] fn outgrowing_the_assembly_buffer_wipes_the_allocation_it_leaves_behind() { const MARKER: u8 = 0xC5; - // The assembly buffer preallocates a 4 MiB budget, so growth is only - // reachable from a window wider than that: two 2 MiB leaves fill the budget + // The assembly buffer preallocates the block-cap budget, so growth is only + // reachable from a window wider than that: two 1 MiB leaves fill the budget // exactly and a 16-byte tail leaf forces the grow. - const BIG_CHUNK: usize = 2 * 1024 * 1024; + const BIG_CHUNK: usize = 1024 * 1024; let profile = ContentProfile::new(BIG_CHUNK).expect("nonzero chunk size"); let mut plaintext = vec![MARKER; BIG_CHUNK]; plaintext.extend_from_slice(&vec![0x11u8; BIG_CHUNK]); diff --git a/crates/engine/tests/staging_atomic_put.rs b/crates/engine/tests/staging_atomic_put.rs index 0043c1e0f..55ddc953f 100644 --- a/crates/engine/tests/staging_atomic_put.rs +++ b/crates/engine/tests/staging_atomic_put.rs @@ -1,48 +1,30 @@ -//! The failed-put kit cases run against the in-memory fake — the replacement -//! case and the fresh-backing case, each paired with the fault a host that is -//! not failure-atomic would show (the previous bytes destroyed; a partial -//! record stranded where the key held none). The paired `should_panic` tests -//! are the negative controls that prove each case actually holds a host to -//! `put_staged_bytes`'s failure-atomicity. +//! The negative controls for the staging-store kit's failure-atomicity phases: +//! each pairs the kit with the fault a host that is not failure-atomic would +//! show (the previous bytes destroyed; a partial record stranded where the key +//! held none), and proves the kit refuses to pass it. The positive leg — the +//! in-memory fake passing the whole kit — lives in `conformance_fakes.rs`. use cipherbox_engine::testkit::conformance::staging_store::FAILED_PUT_KEY; -use cipherbox_engine::testkit::fakes::InMemoryStagingStore; +use cipherbox_engine::testkit::fakes::{InMemoryStagingBackings, InMemoryStagingStore}; use cipherbox_engine::testkit::{block_on, conformance}; -#[test] -fn the_in_memory_staging_store_passes_the_failed_put_kit() { - let store = InMemoryStagingStore::default(); - block_on(conformance::staging_store::check_failed_put( - async || store.clone(), - async || store.interrupt_staged_write_after(FAILED_PUT_KEY, 0), +/// Runs the whole kit against the in-memory fake, with `arm` as its lever. +fn run_kit(arm: fn(&InMemoryStagingStore)) { + let backings = InMemoryStagingBackings::default(); + block_on(conformance::staging_store::check( + async |backing| backings.open(backing), + async |backing| arm(&backings.open(backing)), )); } #[test] #[should_panic(expected = "must leave the previous bytes readable and unchanged")] -fn the_failed_put_kit_catches_a_host_that_destroys_the_previous_bytes() { - let store = InMemoryStagingStore::default(); - block_on(conformance::staging_store::check_failed_put( - async || store.clone(), - async || store.destroy_staged_write_after(FAILED_PUT_KEY, 0), - )); -} - -#[test] -fn the_in_memory_staging_store_passes_the_failed_first_put_kit() { - let store = InMemoryStagingStore::default(); - block_on(conformance::staging_store::check_failed_first_put( - async || store.clone(), - async || store.interrupt_staged_write_after(FAILED_PUT_KEY, 0), - )); +fn the_kit_catches_a_host_that_destroys_the_previous_bytes() { + run_kit(|store| store.destroy_staged_write_after(FAILED_PUT_KEY, 0)); } #[test] #[should_panic(expected = "must leave no record at the key")] -fn the_failed_first_put_kit_catches_a_host_that_strands_a_partial_record() { - let store = InMemoryStagingStore::default(); - block_on(conformance::staging_store::check_failed_first_put( - async || store.clone(), - async || store.strand_staged_write_after(FAILED_PUT_KEY, 0), - )); +fn the_kit_catches_a_host_that_strands_a_partial_record() { + run_kit(|store| store.strand_staged_write_after(FAILED_PUT_KEY, 0)); } diff --git a/crates/wasm/src/conformance.rs b/crates/wasm/src/conformance.rs index cb04a92c7..a8c7b29cf 100644 --- a/crates/wasm/src/conformance.rs +++ b/crates/wasm/src/conformance.rs @@ -30,11 +30,11 @@ use crate::seams_bridge::{ SnapshotCacheAdapter, StagingStoreAdapter, }; -/// Calls a JS `() => Promise` and awaits its resolution, labelling any +/// Calls a JS `(arg) => Promise` and awaits its resolution, labelling any /// misuse with `what`. -async fn call_async(f: &Function, what: &str) -> JsValue { +async fn call_async_with(f: &Function, arg: &JsValue, what: &str) -> JsValue { let result = f - .call0(&JsValue::UNDEFINED) + .call1(&JsValue::UNDEFINED, arg) .unwrap_or_else(|_| panic!("{what} must not throw")); let promise: Promise = result .dyn_into() @@ -47,7 +47,7 @@ async fn call_async(f: &Function, what: &str) -> JsValue { /// Calls a JS `() => Promise` factory and awaits the fresh seam handle /// (the conformance kits' "reopen" contract). async fn open_seam(factory: &Function) -> JsValue { - call_async(factory, "seam factory").await + call_async_with(factory, &JsValue::UNDEFINED, "seam factory").await } /// Runs the `FloorStore` conformance kit against a JS `FloorStoreSeam`, @@ -72,47 +72,43 @@ pub async fn run_snapshot_cache_conformance(factory: Function) { } /// Runs the `StagingStore` conformance kit against a JS `StagingStoreSeam`. -#[wasm_bindgen(js_name = runStagingStoreConformance)] -pub async fn run_staging_store_conformance(factory: Function) { - console_error_panic_hook::set_once(); - conformance::staging_store::check(async || StagingStoreAdapter { - js: open_seam(&factory).await.unchecked_into(), - }) - .await; -} - -/// Runs the `StagingStore` failed-put kit case against a JS -/// `StagingStoreSeam`. `armFailedPut` is the host's fault lever: it must make -/// the seam's next `putStagedBytes` at the kit's key fail. -#[wasm_bindgen(js_name = runStagingStoreFailedPutConformance)] -pub async fn run_staging_store_failed_put_conformance(factory: Function, arm_failed_put: Function) { - console_error_panic_hook::set_once(); - conformance::staging_store::check_failed_put( - async || StagingStoreAdapter { - js: open_seam(&factory).await.unchecked_into(), - }, - async || { - call_async(&arm_failed_put, "failed-put arm").await; - }, - ) - .await; +/// +/// `openBacking` is called with the kit's backing label and must resolve a +/// handle over that backing — distinct durable state per label, the same state +/// on a repeat call. `armFailedPut` is the host's fault lever for the same +/// backing: it must make that backing's next `putStagedBytes` at the kit's key +/// fail. +/// The staging-store kit's backing labels, in the order the kit asks for them, +/// so a JS host prepares and asserts over what the kit declares rather than a +/// transcribed copy of it. +#[wasm_bindgen(js_name = stagingStoreBackings)] +pub fn staging_store_backings() -> Vec { + conformance::staging_store::Backing::ALL + .iter() + .map(|backing| backing.label().to_string()) + .collect() } -/// Runs the `StagingStore` failed-first-put kit case against a JS -/// `StagingStoreSeam`: the same lever, armed before the key's first put, so the -/// backing this runner is handed must start empty. -#[wasm_bindgen(js_name = runStagingStoreFailedFirstPutConformance)] -pub async fn run_staging_store_failed_first_put_conformance( - factory: Function, - arm_failed_put: Function, -) { +#[wasm_bindgen(js_name = runStagingStoreConformance)] +pub async fn run_staging_store_conformance(open_backing: Function, arm_failed_put: Function) { console_error_panic_hook::set_once(); - conformance::staging_store::check_failed_first_put( - async || StagingStoreAdapter { - js: open_seam(&factory).await.unchecked_into(), + conformance::staging_store::check( + async |backing: conformance::staging_store::Backing| StagingStoreAdapter { + js: call_async_with( + &open_backing, + &JsValue::from_str(backing.label()), + "staging backing factory", + ) + .await + .unchecked_into(), }, - async || { - call_async(&arm_failed_put, "failed-put arm").await; + async |backing: conformance::staging_store::Backing| { + call_async_with( + &arm_failed_put, + &JsValue::from_str(backing.label()), + "failed-put arm", + ) + .await; }, ) .await; diff --git a/packages/client/test/browser/conformance.spec.ts b/packages/client/test/browser/conformance.spec.ts index 4a987cbb7..bb67149cf 100644 --- a/packages/client/test/browser/conformance.spec.ts +++ b/packages/client/test/browser/conformance.spec.ts @@ -15,8 +15,6 @@ const kitSeams = [ 'floorStore', 'snapshotCache', 'stagingStore', - 'stagingStoreFailedPut', - 'stagingStoreFailedFirstPut', 'credentialStore', 'scheduler', 'recordTransport', diff --git a/packages/client/test/browser/conformance.worker.ts b/packages/client/test/browser/conformance.worker.ts index 8c9c8abb6..b5d1714cf 100644 --- a/packages/client/test/browser/conformance.worker.ts +++ b/packages/client/test/browser/conformance.worker.ts @@ -16,8 +16,7 @@ import init, { runSchedulerConformance, runSnapshotCacheConformance, runStagingStoreConformance, - runStagingStoreFailedFirstPutConformance, - runStagingStoreFailedPutConformance, + stagingStoreBackings, } from './pkg/cipherbox_wasm.js'; import wasmUrl from './pkg/cipherbox_wasm_bg.wasm?url'; @@ -166,6 +165,41 @@ async function runStagingDebrisBehavioral(): Promise { await assertStagedEntryCount(dirName, 1); } +/** + * The staged-record count each kit backing is left holding, keyed by the kit's + * own backing labels. Anything above these counts is in-flight write debris. + */ +const STAGED_AFTER_KIT: Record = { + ordering: 1, + 'failed-replacement': 1, + 'failed-first-put': 0, +}; + +/** Stores are per-fault: the kit asserts on leftover staged counts, so another + * fault's debris would read as this pass's. */ +async function runStagingConformance(fault: string, arm: () => void): Promise { + const storeName = (backing: string): string => `conf-staging-${fault}-${backing}`; + const backings = stagingStoreBackings(); + + for (const backing of backings) { + await deleteDatabase(storeName(backing)); + await clearOpfsDir(`${storeName(backing)}-staged`); + } + + await runStagingStoreConformance( + (backing: string) => Promise.resolve(new OpfsStagingStore(storeName(backing))), + () => Promise.resolve(arm()) + ); + + for (const backing of backings) { + const staged = STAGED_AFTER_KIT[backing]; + if (staged === undefined) { + throw new Error(`no staged-record count declared for kit backing "${backing}"`); + } + await assertStagedEntryCount(`${storeName(backing)}-staged`, staged); + } +} + async function runHttpBehavioral(): Promise { const http = new FetchHttp(); const { origin } = scope.location; @@ -246,33 +280,16 @@ async function run(seam: string): Promise { return; } case 'stagingStore': { - const name = 'conf-staging'; - await deleteDatabase(name); - await clearOpfsDir(`${name}-staged`); - await runStagingStoreConformance(() => Promise.resolve(new OpfsStagingStore(name))); - return; - } - case 'stagingStoreFailedPut': { - const name = 'conf-staging-failed-put'; - for (const arm of [armShortWrite, armThrowingWrite]) { - await clearOpfsDir(`${name}-staged`); - await runStagingStoreFailedPutConformance( - () => Promise.resolve(new OpfsStagingStore(name)), - () => Promise.resolve(arm()) - ); - await assertStagedEntryCount(`${name}-staged`, 1); - } - return; - } - case 'stagingStoreFailedFirstPut': { - const name = 'conf-staging-failed-first-put'; - for (const arm of [armShortWrite, armThrowingWrite]) { - await clearOpfsDir(`${name}-staged`); - await runStagingStoreFailedFirstPutConformance( - () => Promise.resolve(new OpfsStagingStore(name)), - () => Promise.resolve(arm()) - ); - await assertStagedEntryCount(`${name}-staged`, 0); + // Both OPFS write faults the seam can hit, each against the whole kit. + // Distinct store names per fault: the kit's handles stay open, and an + // IndexedDB delete under an open connection blocks rather than clears. + // The staged-directory counts afterwards are the debris check no kit + // assertion can make. + for (const [fault, arm] of [ + ['short-write', armShortWrite], + ['throwing-write', armThrowingWrite], + ] as const) { + await runStagingConformance(fault, arm); } return; }