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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion blueprint/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 16 additions & 35 deletions crates/desktop-seams/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
}
},
));
}

Expand Down
8 changes: 4 additions & 4 deletions crates/engine/kat/vectors/content/dag_capacity_accept.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
4 changes: 2 additions & 2 deletions crates/engine/kat/vectors/content/dag_capacity_reject.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
34 changes: 22 additions & 12 deletions crates/engine/src/content/dag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -546,34 +546,44 @@ 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:?}"),
}
}

#[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());
}

Expand Down Expand Up @@ -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")
Expand Down
54 changes: 47 additions & 7 deletions crates/engine/src/content/limits.rs
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
42 changes: 36 additions & 6 deletions crates/engine/src/content/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Self> {
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 })
Expand Down Expand Up @@ -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"
);
}
}
9 changes: 8 additions & 1 deletion crates/engine/src/testkit/conformance/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@
//! 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**;
//! calling it again must "reopen" the same logical backing (new handle,
//! 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.

Expand Down
Loading