From f75a7ee301ee0659d9255f7a4445e2af9e75e1b3 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 19 Aug 2026 18:04:35 +0200 Subject: [PATCH 1/5] fix: reconcile the resolved-record ceiling with the 2 MiB IPFS block limit MAX_RESOLVED_RECORD_BYTES was 4 MiB while Kubo block/put refuses anything over 2 MiB, so the engine would author, sign and hand off a head or DAG root between the two that its own ingress then rejects. Lower the ceiling to the block limit, so HeadTooLarge/RootTooLarge fire client-side as permanent, actionable errors. Add a const assertion tying the shipped sealed-leaf size to the ceiling: one extra framing byte would make every content block the engine authors unpinnable, and a compile-time check cannot be stripped in release. The flat-DAG ceiling halves with the cap, so the KAT capacity vectors are regenerated through the committed generator. Closes #915 --- blueprint/core.md | 2 +- .../vectors/content/dag_capacity_accept.json | 8 +-- .../vectors/content/dag_capacity_reject.json | 4 +- crates/engine/src/content/dag.rs | 54 ++++++++++------- crates/engine/src/content/limits.rs | 59 +++++++++++++++++-- crates/engine/tests/content_wipe.rs | 6 +- 6 files changed, 96 insertions(+), 37 deletions(-) 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/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..9b19f9b81 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,19 @@ 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); } other => panic!("expected RootTooLarge, got {other:?}"), } @@ -566,14 +566,20 @@ 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. + // A link count chosen to land inside the 2 MiB cap but within a tenth of + // it, so the accepting side of the guard is exercised at the boundary + // rather than far below it. 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() <= 2 * 1024 * 1024); + assert!( + dag.root_block.len() > 2 * 1024 * 1024 * 9 / 10, + "a root {} bytes under the cap does not exercise its boundary", + 2 * 1024 * 1024 - dag.root_block.len() + ); assert!(verify_cid(&dag.content_cid, &dag.root_block).is_ok()); } @@ -610,24 +616,30 @@ mod tests { /// The arithmetic sizing and the real encoder must never drift: the staging /// reservation is exact only if this holds at every leaf count where a CBOR - /// head width changes. + /// head width changes, and only if the two refuse the same over-cap roots. #[test] 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] { + // 1, 2, 3 and 5 bytes, plus counts either side of the block cap. + for leaves in [0u64, 1, 23, 24, 255, 256, 300, 54_000, 65_535, 65_536] { let size = leaves * chunk; - let assembled = assemble(&dummy_leaves(leaves.max(1) as usize), size, &profile) - .expect("assembles") - .root_block - .len() as u64; - assert_eq!( - root_block_len(size, &profile).unwrap(), - assembled, - "{leaves} leaves at chunk {chunk}" - ); + let predicted = root_block_len(size, &profile); + let assembled = assemble(&dummy_leaves(leaves.max(1) as usize), size, &profile); + match (predicted, assembled) { + (Ok(predicted), Ok(dag)) => assert_eq!( + predicted, + dag.root_block.len() as u64, + "{leaves} leaves at chunk {chunk}" + ), + (Err(DagError::RootTooLarge { .. }), Err(DagError::RootTooLarge { .. })) => {} + (predicted, assembled) => panic!( + "{leaves} leaves at chunk {chunk}: sizing said {predicted:?}, \ + the encoder said {:?}", + assembled.map(|dag| dag.root_block.len()) + ), + } } // A short tail exercises a `size` that is not a chunk multiple. let size = 2 * chunk + 1; diff --git a/crates/engine/src/content/limits.rs b/crates/engine/src/content/limits.rs index 73ee733a3..f45ccf319 100644 --- a/crates/engine/src/content/limits.rs +++ b/crates/engine/src/content/limits.rs @@ -1,5 +1,8 @@ //! 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 @@ -9,9 +12,53 @@ /// 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. 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. Enforced at compile time — the one form of rule 8's release-active +/// check that a framing edit cannot outrun, since there is no encode path left +/// to reach. +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 ceiling is the ingress's, not a number of the engine's own choosing: + /// `block/put` refuses anything over 2 MiB, so authoring past it signs a + /// pointer to a block that can never be pinned. + #[test] + fn the_ceiling_is_the_ipfs_single_block_limit() { + assert_eq!(MAX_RESOLVED_RECORD_BYTES, 2 * 1024 * 1024); + } + + /// The const assertion above pins the same relationship at build time; this + /// measures it against a real sealed leaf in whatever build runs the suite, + /// so a seal-layout change that the overhead constant misses 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/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]); From 755672674390c813f2d9b88ba6311890c210e75e Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 19 Aug 2026 18:14:12 +0200 Subject: [PATCH 2/5] fix: fold the staging-store kit's failed-put cases into one mandatory check check_failed_put and check_failed_first_put were entry points beside check, so a host that called check and stopped was held to half the contract with nothing at compile time, in CI, or in review to say a case was skipped - the same shape as the seam drift the failed-put case was written to catch. The fault lever is now a parameter of check, and the three phases each take their own named backing, so omitting the lever is a compile error rather than an omission. Every other kit already had a single entry point, so the shape is uniform again. The desktop first-put lever is now portable: it removes the still-empty staged directory rather than denying writes to it, which Windows honours where it honours no denial on a path that does not exist yet, so the case no longer skips on Windows. Closes #1195 --- crates/desktop-seams/tests/conformance.rs | 60 ++++---- crates/engine/src/testkit/conformance/mod.rs | 9 +- .../src/testkit/conformance/staging_store.rs | 139 +++++++++++------- crates/engine/src/testkit/fakes/mod.rs | 2 +- .../engine/src/testkit/fakes/staging_store.rs | 34 ++++- crates/engine/tests/conformance_fakes.rs | 14 +- crates/engine/tests/staging_atomic_put.rs | 59 +++----- crates/wasm/src/conformance.rs | 68 ++++----- .../client/test/browser/conformance.spec.ts | 2 - .../client/test/browser/conformance.worker.ts | 69 +++++---- 10 files changed, 249 insertions(+), 207 deletions(-) diff --git a/crates/desktop-seams/tests/conformance.rs b/crates/desktop-seams/tests/conformance.rs index a1577edf3..2fa0d928f 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,33 @@ fn file_floor_store_passes_the_floor_store_kit() { })); } +/// The desktop `StagingStore` kit, fault lever included. Both failure-atomicity +/// phases fail `atomic_write` on its way to the sidecar, by whichever denial the +/// platform honours: +/// +/// - a **replacement** put denies the write target — on Unix the `staged/` +/// directory, so the temp file cannot be created; on Windows the sidecar +/// itself, which `MoveFileEx` refuses to replace when it is read-only; +/// - a **first** put removes the still-empty `staged/` directory, so the temp +/// has nowhere to land. Windows honours no denial on a path that does not +/// exist yet, and there are no staged bytes to lose; +/// `FileStagingStore::open` recreates the directory for the kit's read-back. +/// +/// Either way the failure lands before the key's bytes can change. #[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::FailedFirstPut => { + std::fs::remove_dir(root.join(backing.label()).join("staged")) + .expect("the kit's lever must be armed, or it proves nothing"); + } + _ => denial.arm(), + }, )); } 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..7cb2e2e76 100644 --- a/crates/engine/src/testkit/conformance/staging_store.rs +++ b/crates/engine/src/testkit/conformance/staging_store.rs @@ -1,30 +1,72 @@ -//! 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, Hash)] +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 { + /// 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). It is a parameter rather than a second +/// entry point so a host cannot be held to only the half of the contract it +/// remembered to ask for. /// /// # 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(Backing) -> S, + G: AsyncFnMut(Backing), +{ + 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() -> S, + F: AsyncFnMut(Backing) -> S, { - let store = open().await; + let store = open(Backing::Ordering).await; // Fresh backing. assert!(store.queued_ops().await.unwrap().is_empty()); @@ -90,7 +132,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 +167,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 +175,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 +197,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 +208,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 +233,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 +259,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..7061a4fd3 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.label()) + .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/staging_atomic_put.rs b/crates/engine/tests/staging_atomic_put.rs index 0043c1e0f..e498c4bc8 100644 --- a/crates/engine/tests/staging_atomic_put.rs +++ b/crates/engine/tests/staging_atomic_put.rs @@ -1,48 +1,37 @@ -//! 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; 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), - )); -} - #[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() { + let backings = InMemoryStagingBackings::default(); + block_on(conformance::staging_store::check( + async |backing| backings.open(backing), + async |backing| { + backings + .open(backing) + .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() { + let backings = InMemoryStagingBackings::default(); + block_on(conformance::staging_store::check( + async |backing| backings.open(backing), + async |backing| { + backings + .open(backing) + .strand_staged_write_after(FAILED_PUT_KEY, 0) + }, )); } diff --git a/crates/wasm/src/conformance.rs b/crates/wasm/src/conformance.rs index cb04a92c7..2ff82dbe9 100644 --- a/crates/wasm/src/conformance.rs +++ b/crates/wasm/src/conformance.rs @@ -33,8 +33,13 @@ use crate::seams_bridge::{ /// Calls a JS `() => Promise` and awaits its resolution, labelling any /// misuse with `what`. async fn call_async(f: &Function, what: &str) -> JsValue { + call_async_with(f, &JsValue::UNDEFINED, what).await +} + +/// [`call_async`] for a JS `(arg) => Promise`. +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() @@ -72,47 +77,32 @@ pub async fn run_snapshot_cache_conformance(factory: Function) { } /// Runs the `StagingStore` conformance kit against a JS `StagingStoreSeam`. +/// +/// `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. #[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; -} - -/// 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, -) { +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..8d0816697 100644 --- a/packages/client/test/browser/conformance.worker.ts +++ b/packages/client/test/browser/conformance.worker.ts @@ -16,8 +16,6 @@ import init, { runSchedulerConformance, runSnapshotCacheConformance, runStagingStoreConformance, - runStagingStoreFailedFirstPutConformance, - runStagingStoreFailedPutConformance, } from './pkg/cipherbox_wasm.js'; import wasmUrl from './pkg/cipherbox_wasm_bg.wasm?url'; @@ -166,6 +164,38 @@ async function runStagingDebrisBehavioral(): Promise { await assertStagedEntryCount(dirName, 1); } +/** + * One pass of the staging-store kit under `arm`. Each kit backing gets its own + * store, emptied on the kit's first ask for it and reopened thereafter (the + * kit's reopen contract). The staged-directory counts afterwards are what no + * kit assertion can see: an in-flight temp abandoned by a failed put is + * invisible to `stagedKeys` and `stagedBytesTotal`, so orphan GC could never + * reclaim it. + */ +async function runStagingConformance(fault: string, arm: () => void): Promise { + const storeName = (backing: string): string => `conf-staging-${fault}-${backing}`; + const emptied = new Set(); + await runStagingStoreConformance( + async (backing: string) => { + if (!emptied.has(backing)) { + emptied.add(backing); + await deleteDatabase(storeName(backing)); + await clearOpfsDir(`${storeName(backing)}-staged`); + } + return new OpfsStagingStore(storeName(backing)); + }, + () => Promise.resolve(arm()) + ); + + for (const [backing, staged] of [ + ['ordering', 1], + ['failed-replacement', 1], + ['failed-first-put', 0], + ] as const) { + await assertStagedEntryCount(`${storeName(backing)}-staged`, staged); + } +} + async function runHttpBehavioral(): Promise { const http = new FetchHttp(); const { origin } = scope.location; @@ -246,33 +276,14 @@ 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. + for (const [fault, arm] of [ + ['short-write', armShortWrite], + ['throwing-write', armThrowingWrite], + ] as const) { + await runStagingConformance(fault, arm); } return; } From d090dfea8dfb2c949fb00dced767b0df567c585d Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 19 Aug 2026 18:29:15 +0200 Subject: [PATCH 3/5] fix: refuse a content profile whose sealed leaf outgrows the block ceiling The const assertion covers only the shipped profile, but ContentProfile is injected and ContentProfile::new accepts any nonzero chunk size, so an oversized profile would frame leaves this engine's own read_block rejects. ContentProfile::new now fails closed on that the same way it fails closed on zero, which is the release-active half of the rule 8 pair. Also fold the sizing-vs-encoder refusal agreement into the existing over-cap test rather than a second pass over 65k-link DAGs. --- crates/engine/src/content/dag.rs | 50 +++++++++++++--------------- crates/engine/src/content/limits.rs | 25 +++++--------- crates/engine/src/content/profile.rs | 42 +++++++++++++++++++---- 3 files changed, 69 insertions(+), 48 deletions(-) diff --git a/crates/engine/src/content/dag.rs b/crates/engine/src/content/dag.rs index 9b19f9b81..55d5ea3dd 100644 --- a/crates/engine/src/content/dag.rs +++ b/crates/engine/src/content/dag.rs @@ -559,6 +559,13 @@ mod tests { Err(DagError::RootTooLarge { size, limit }) => { assert!(size > limit, "reported size exceeds the cap"); 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,19 +573,16 @@ mod tests { #[test] fn assemble_accepts_a_root_just_under_the_block_cap() { - // A link count chosen to land inside the 2 MiB cap but within a tenth of - // it, so the accepting side of the guard is exercised at the boundary - // rather than far below it. let profile = ContentProfile::CI; let chunk_size = profile.chunk_size() as u64; 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() <= 2 * 1024 * 1024); + assert!(dag.root_block.len() <= MAX_RESOLVED_RECORD_BYTES); assert!( - dag.root_block.len() > 2 * 1024 * 1024 * 9 / 10, - "a root {} bytes under the cap does not exercise its boundary", - 2 * 1024 * 1024 - dag.root_block.len() + 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()); } @@ -616,30 +620,24 @@ mod tests { /// The arithmetic sizing and the real encoder must never drift: the staging /// reservation is exact only if this holds at every leaf count where a CBOR - /// head width changes, and only if the two refuse the same over-cap roots. + /// head width changes. #[test] 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, plus counts either side of the block cap. - for leaves in [0u64, 1, 23, 24, 255, 256, 300, 54_000, 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 predicted = root_block_len(size, &profile); - let assembled = assemble(&dummy_leaves(leaves.max(1) as usize), size, &profile); - match (predicted, assembled) { - (Ok(predicted), Ok(dag)) => assert_eq!( - predicted, - dag.root_block.len() as u64, - "{leaves} leaves at chunk {chunk}" - ), - (Err(DagError::RootTooLarge { .. }), Err(DagError::RootTooLarge { .. })) => {} - (predicted, assembled) => panic!( - "{leaves} leaves at chunk {chunk}: sizing said {predicted:?}, \ - the encoder said {:?}", - assembled.map(|dag| dag.root_block.len()) - ), - } + let assembled = assemble(&dummy_leaves(leaves.max(1) as usize), size, &profile) + .expect("assembles") + .root_block + .len() as u64; + assert_eq!( + root_block_len(size, &profile).unwrap(), + assembled, + "{leaves} leaves at chunk {chunk}" + ); } // A short tail exercises a `size` that is not a chunk multiple. let size = 2 * chunk + 1; diff --git a/crates/engine/src/content/limits.rs b/crates/engine/src/content/limits.rs index f45ccf319..6fd393b63 100644 --- a/crates/engine/src/content/limits.rs +++ b/crates/engine/src/content/limits.rs @@ -7,7 +7,8 @@ use super::profile::ContentProfile; /// 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"). @@ -16,7 +17,8 @@ use super::profile::ContentProfile; /// 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. A legitimate flat-DAG root inlines every +/// 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 @@ -25,9 +27,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. Enforced at compile time — the one form of rule 8's release-active -/// check that a framing edit cannot outrun, since there is no encode path left -/// to reach. +/// 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, @@ -40,17 +41,9 @@ mod tests { use cipherbox_core::content::seal_chunk; use cipherbox_core::suite::aead::{KEY_LEN, NONCE_LEN}; - /// The ceiling is the ingress's, not a number of the engine's own choosing: - /// `block/put` refuses anything over 2 MiB, so authoring past it signs a - /// pointer to a block that can never be pinned. - #[test] - fn the_ceiling_is_the_ipfs_single_block_limit() { - assert_eq!(MAX_RESOLVED_RECORD_BYTES, 2 * 1024 * 1024); - } - - /// The const assertion above pins the same relationship at build time; this - /// measures it against a real sealed leaf in whatever build runs the suite, - /// so a seal-layout change that the overhead constant misses still fails. + /// 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()]; 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" + ); + } } From 6d2e371479cf74b90fda8cb6a8b40cfacefeef34 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 19 Aug 2026 18:29:20 +0200 Subject: [PATCH 4/5] test: tie the staging-store kit's host boundary to what the kit declares Backing::ALL is exported through the wasm bridge so the browser host prepares and asserts over the kit's own backing set instead of a transcribed copy, and the desktop host's lever match is exhaustive so a new backing breaks the build rather than silently arming the wrong store. --- crates/desktop-seams/tests/conformance.rs | 19 +++----- .../src/testkit/conformance/staging_store.rs | 15 +++++-- .../engine/src/testkit/fakes/staging_store.rs | 4 +- crates/engine/tests/staging_atomic_put.rs | 29 +++++------- crates/wasm/src/conformance.rs | 20 ++++++--- .../client/test/browser/conformance.worker.ts | 45 ++++++++++--------- 6 files changed, 67 insertions(+), 65 deletions(-) diff --git a/crates/desktop-seams/tests/conformance.rs b/crates/desktop-seams/tests/conformance.rs index 2fa0d928f..94e62c5ce 100644 --- a/crates/desktop-seams/tests/conformance.rs +++ b/crates/desktop-seams/tests/conformance.rs @@ -39,19 +39,10 @@ fn file_floor_store_passes_the_floor_store_kit() { })); } -/// The desktop `StagingStore` kit, fault lever included. Both failure-atomicity -/// phases fail `atomic_write` on its way to the sidecar, by whichever denial the -/// platform honours: -/// -/// - a **replacement** put denies the write target — on Unix the `staged/` -/// directory, so the temp file cannot be created; on Windows the sidecar -/// itself, which `MoveFileEx` refuses to replace when it is read-only; -/// - a **first** put removes the still-empty `staged/` directory, so the temp -/// has nowhere to land. Windows honours no denial on a path that does not -/// exist yet, and there are no staged bytes to lose; -/// `FileStagingStore::open` recreates the directory for the kit's read-back. -/// -/// Either way the failure lands before the key's bytes can change. +/// 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(); @@ -60,11 +51,11 @@ fn file_staging_store_passes_the_staging_store_kit() { 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"); } - _ => denial.arm(), }, )); } diff --git a/crates/engine/src/testkit/conformance/staging_store.rs b/crates/engine/src/testkit/conformance/staging_store.rs index 7cb2e2e76..564241d14 100644 --- a/crates/engine/src/testkit/conformance/staging_store.rs +++ b/crates/engine/src/testkit/conformance/staging_store.rs @@ -15,7 +15,7 @@ pub const FAILED_PUT_KEY: &[u8] = b"failed-put-key"; /// 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, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Backing { /// FIFO ordering, durability, and orphan-GC support. Ordering, @@ -26,6 +26,15 @@ pub enum Backing { } 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 { @@ -42,9 +51,7 @@ impl Backing { /// `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). It is a parameter rather than a second -/// entry point so a host cannot be held to only the half of the contract it -/// remembered to ask for. +/// directory; the host picks its own). /// /// # Panics /// Panics on the first contract violation. diff --git a/crates/engine/src/testkit/fakes/staging_store.rs b/crates/engine/src/testkit/fakes/staging_store.rs index 7061a4fd3..f5cc2ac26 100644 --- a/crates/engine/src/testkit/fakes/staging_store.rs +++ b/crates/engine/src/testkit/fakes/staging_store.rs @@ -225,7 +225,7 @@ impl StagingStore for InMemoryStagingStore { /// empty backing while a repeat call for the same one reopens it. #[derive(Clone, Default)] pub struct InMemoryStagingBackings { - stores: Arc>>, + stores: Arc>>, } impl InMemoryStagingBackings { @@ -234,7 +234,7 @@ impl InMemoryStagingBackings { self.stores .lock() .expect("lock") - .entry(backing.label()) + .entry(backing) .or_default() .clone() } diff --git a/crates/engine/tests/staging_atomic_put.rs b/crates/engine/tests/staging_atomic_put.rs index e498c4bc8..55ddc953f 100644 --- a/crates/engine/tests/staging_atomic_put.rs +++ b/crates/engine/tests/staging_atomic_put.rs @@ -5,33 +5,26 @@ //! 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::InMemoryStagingBackings; +use cipherbox_engine::testkit::fakes::{InMemoryStagingBackings, InMemoryStagingStore}; use cipherbox_engine::testkit::{block_on, conformance}; -#[test] -#[should_panic(expected = "must leave the previous bytes readable and unchanged")] -fn the_kit_catches_a_host_that_destroys_the_previous_bytes() { +/// 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| { - backings - .open(backing) - .destroy_staged_write_after(FAILED_PUT_KEY, 0) - }, + async |backing| arm(&backings.open(backing)), )); } +#[test] +#[should_panic(expected = "must leave the previous bytes readable and unchanged")] +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_kit_catches_a_host_that_strands_a_partial_record() { - let backings = InMemoryStagingBackings::default(); - block_on(conformance::staging_store::check( - async |backing| backings.open(backing), - async |backing| { - backings - .open(backing) - .strand_staged_write_after(FAILED_PUT_KEY, 0) - }, - )); + 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 2ff82dbe9..a8c7b29cf 100644 --- a/crates/wasm/src/conformance.rs +++ b/crates/wasm/src/conformance.rs @@ -30,13 +30,8 @@ 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 { - call_async_with(f, &JsValue::UNDEFINED, what).await -} - -/// [`call_async`] for a JS `(arg) => Promise`. async fn call_async_with(f: &Function, arg: &JsValue, what: &str) -> JsValue { let result = f .call1(&JsValue::UNDEFINED, arg) @@ -52,7 +47,7 @@ async fn call_async_with(f: &Function, arg: &JsValue, 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`, @@ -83,6 +78,17 @@ pub async fn run_snapshot_cache_conformance(factory: Function) { /// 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() +} + #[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(); diff --git a/packages/client/test/browser/conformance.worker.ts b/packages/client/test/browser/conformance.worker.ts index 8d0816697..098f98011 100644 --- a/packages/client/test/browser/conformance.worker.ts +++ b/packages/client/test/browser/conformance.worker.ts @@ -16,6 +16,7 @@ import init, { runSchedulerConformance, runSnapshotCacheConformance, runStagingStoreConformance, + stagingStoreBackings, } from './pkg/cipherbox_wasm.js'; import wasmUrl from './pkg/cipherbox_wasm_bg.wasm?url'; @@ -165,33 +166,35 @@ async function runStagingDebrisBehavioral(): Promise { } /** - * One pass of the staging-store kit under `arm`. Each kit backing gets its own - * store, emptied on the kit's first ask for it and reopened thereafter (the - * kit's reopen contract). The staged-directory counts afterwards are what no - * kit assertion can see: an in-flight temp abandoned by a failed put is - * invisible to `stagedKeys` and `stagedBytesTotal`, so orphan GC could never - * reclaim it. + * 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, +}; + +/** One pass of the staging-store kit under `arm`, over its own set of stores. */ async function runStagingConformance(fault: string, arm: () => void): Promise { const storeName = (backing: string): string => `conf-staging-${fault}-${backing}`; - const emptied = new Set(); + const backings = stagingStoreBackings(); + + for (const backing of backings) { + await deleteDatabase(storeName(backing)); + await clearOpfsDir(`${storeName(backing)}-staged`); + } + await runStagingStoreConformance( - async (backing: string) => { - if (!emptied.has(backing)) { - emptied.add(backing); - await deleteDatabase(storeName(backing)); - await clearOpfsDir(`${storeName(backing)}-staged`); - } - return new OpfsStagingStore(storeName(backing)); - }, + (backing: string) => Promise.resolve(new OpfsStagingStore(storeName(backing))), () => Promise.resolve(arm()) ); - for (const [backing, staged] of [ - ['ordering', 1], - ['failed-replacement', 1], - ['failed-first-put', 0], - ] as const) { + 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); } } @@ -279,6 +282,8 @@ async function run(seam: string): Promise { // 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], From 845b06d3ac62d80cf777bdef174884672a793e6e Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 19 Aug 2026 22:25:29 +0200 Subject: [PATCH 5/5] test: say why the staging kit's stores are per-fault --- packages/client/test/browser/conformance.worker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/test/browser/conformance.worker.ts b/packages/client/test/browser/conformance.worker.ts index 098f98011..b5d1714cf 100644 --- a/packages/client/test/browser/conformance.worker.ts +++ b/packages/client/test/browser/conformance.worker.ts @@ -175,7 +175,8 @@ const STAGED_AFTER_KIT: Record = { 'failed-first-put': 0, }; -/** One pass of the staging-store kit under `arm`, over its own set of stores. */ +/** 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();