1861 lines
77 KiB
Rust
1861 lines
77 KiB
Rust
//! The open/create/commit driver: the cold-open path and the atomic write
|
||
//! protocol (Chapter 8 §"The Atomic Write Protocol", §"Crash Recovery",
|
||
//! §"Streaming Reads").
|
||
//!
|
||
//! A [`Bundle`] wraps a [`BlockStore`] and the currently-active prelude state.
|
||
//! Its two load-bearing operations are:
|
||
//!
|
||
//! * [`Bundle::open`] — the cold-open procedure: header → superblock selection →
|
||
//! manifest read-and-verify, exposing the canonical roots for streaming.
|
||
//! * [`Bundle::commit`] — the seven-step atomic write protocol, whose final
|
||
//! durable flush is the commit point. A crash at any step leaves the bundle at
|
||
//! the previous generation (the active superblock and its reachable chunks are
|
||
//! never touched) or, only after the final flush, at the new generation.
|
||
|
||
use crate::block;
|
||
use crate::chunk::{
|
||
chunk_content_hash, chunk_id, content_hash_for, ChunkKind, ChunkRef, CompressionAlgorithm,
|
||
};
|
||
use crate::codec::DecodeError;
|
||
use crate::error::{BundleError, IntegrityAnomaly};
|
||
use crate::header::{FixedHeader, SLOT_A_OFFSET, SLOT_B_OFFSET};
|
||
use crate::ids::{BlobId, FileUuid, ReductionAlgorithmVersion, SchemaVersion, WallClockTime};
|
||
use crate::manifest::{BlobRef, Manifest, ProfileDeclaration};
|
||
use crate::store::{read_vec, BlockStore, MemStore};
|
||
use crate::superblock::{
|
||
select_active, CommitState, ProfileId, Slot, SlotParse, SlotReject, Superblock, SUPERBLOCK_LEN,
|
||
};
|
||
use epiphany_determinism::{ChunkId, ContentHash};
|
||
use std::collections::BTreeMap;
|
||
|
||
/// Offset where variable-length body content begins (after the header and both
|
||
/// superblock slots): 576 bytes.
|
||
pub const BODY_START: u64 = SLOT_B_OFFSET + SUPERBLOCK_LEN;
|
||
|
||
/// The schema major version this format version can parse (Chapter 8
|
||
/// §"Schema Versioning"): a canonical chunk or manifest at a higher major is
|
||
/// not interpretable by this reader.
|
||
pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0;
|
||
|
||
/// The conformance-profile major version this implementation understands
|
||
/// (Chapter 8 §"Format Profiles"). A profile declared at a higher major is a
|
||
/// future capability set this reader cannot honor.
|
||
pub const SUPPORTED_PROFILE_MAJOR: u32 = 0;
|
||
|
||
/// Reader resource limit on a manifest chunk. The spec calls manifests "a few
|
||
/// kilobytes at most"; this generous bound stops an untrusted superblock length
|
||
/// from driving an allocation before the bytes are read (finding: untrusted
|
||
/// length → OOM/truncation).
|
||
pub const MAX_MANIFEST_BYTES: u64 = 64 << 20;
|
||
|
||
/// Reader resource limit on a single chunk read, bounding allocation from an
|
||
/// untrusted length regardless of (possibly sparse) file size.
|
||
pub const MAX_CHUNK_BYTES: u64 = 256 << 20;
|
||
|
||
/// Default reader resource limit on a blob (Chapter 8 §"Blobs"). A `BlobRef`'s
|
||
/// own `declared_max_uncompressed_length`, if smaller, also applies.
|
||
pub const MAX_BLOB_BYTES: u64 = 1 << 30;
|
||
|
||
/// A chunk to be written by a commit: an opaque payload plus its kind and schema
|
||
/// version. The bundle assigns its offset, computes its content hash, and
|
||
/// returns a [`ChunkRef`] for the manifest builder to wire into roots.
|
||
#[derive(Clone, Debug)]
|
||
pub struct StagedChunk {
|
||
/// The chunk's kind (dispatches parsing on read).
|
||
pub kind: ChunkKind,
|
||
/// The schema version the payload is encoded against.
|
||
pub schema_version: SchemaVersion,
|
||
/// The opaque uncompressed payload bytes.
|
||
pub payload: Vec<u8>,
|
||
}
|
||
|
||
impl StagedChunk {
|
||
/// A staged operation-envelope block at the current schema version.
|
||
pub fn operation_block(payload: Vec<u8>) -> Self {
|
||
StagedChunk {
|
||
kind: ChunkKind::OperationEnvelopeBlock,
|
||
schema_version: SchemaVersion::V0,
|
||
payload,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Context handed to a commit's manifest builder: the previous manifest, the
|
||
/// [`ChunkRef`]s for the chunks this commit just wrote (in staging order), and
|
||
/// the generation the new manifest will carry.
|
||
pub struct CommitContext<'a> {
|
||
/// The manifest active before this commit.
|
||
pub previous_manifest: &'a Manifest,
|
||
/// References to the chunks written by this commit, in staging order.
|
||
pub new_chunks: &'a [ChunkRef],
|
||
/// The generation the new manifest must declare (active + 1).
|
||
pub generation: u64,
|
||
}
|
||
|
||
/// An open bundle over a block store.
|
||
pub struct Bundle<S: BlockStore> {
|
||
store: S,
|
||
header: FixedHeader,
|
||
active_slot: Slot,
|
||
superblock: Superblock,
|
||
manifest: Manifest,
|
||
/// Next append offset. Always at end-of-file, so appends never overwrite a
|
||
/// reachable chunk (Chapter 8 §"The Atomic Write Protocol", step 1).
|
||
write_cursor: u64,
|
||
read_only: bool,
|
||
anomalies: Vec<IntegrityAnomaly>,
|
||
}
|
||
|
||
impl<S: BlockStore> Bundle<S> {
|
||
/// Creates a brand-new bundle in `store` with the given file UUID and
|
||
/// initial manifest (its generation is forced to 0). Writes the manifest
|
||
/// chunk, then the header, the generation-0 superblock in slot A, and a
|
||
/// zeroed (invalid) slot B; flushes at the manifest and at the superblock.
|
||
///
|
||
/// A crash *during creation* may leave a half-formed file that [`Bundle::open`]
|
||
/// rejects as corrupt — acceptable, since the file is not yet a bundle. The
|
||
/// crash-safety guarantee is about *commits to an existing bundle*.
|
||
pub fn create(
|
||
mut store: S,
|
||
file_uuid: FileUuid,
|
||
mut manifest: Manifest,
|
||
) -> Result<Self, BundleError> {
|
||
manifest.generation = 0;
|
||
manifest.manifest_id = manifest.derive_id();
|
||
|
||
// The manifest must be emittable (it would otherwise be rejected by
|
||
// `open`): at least one declared profile.
|
||
let active_profile = validate_emittable_manifest(&manifest)?;
|
||
|
||
// A freshly created bundle writes only the manifest chunk, so it cannot
|
||
// have any canonical roots or blobs yet: reject an initial manifest that
|
||
// declares (necessarily dangling) operation roots, a canonical base, or
|
||
// blob roots.
|
||
if !manifest.operation_roots.is_empty()
|
||
|| manifest.canonical_base.is_some()
|
||
|| !manifest.blob_roots.is_empty()
|
||
{
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"create() requires a manifest with no canonical roots or blobs",
|
||
)));
|
||
}
|
||
|
||
// Encode the manifest and enforce the reader's manifest-size limit *here*
|
||
// (a writer must not emit a manifest its own `open` would reject as
|
||
// oversize). Then normalize the in-memory copy to the canonical
|
||
// (sorted/deduplicated) form by decoding the bytes we just produced, so
|
||
// `bundle.manifest()` matches what a reopen would yield.
|
||
let manifest_payload = manifest.encode();
|
||
enforce_limit(manifest_payload.len() as u64, MAX_MANIFEST_BYTES)?;
|
||
let manifest = Manifest::decode(&manifest_payload)?;
|
||
let manifest_hash =
|
||
chunk_content_hash(ChunkKind::Manifest, Manifest::SCHEMA, &manifest_payload);
|
||
store.write_at(BODY_START, &manifest_payload)?;
|
||
store.flush()?;
|
||
|
||
let superblock = Superblock {
|
||
generation: 0,
|
||
manifest_offset: BODY_START,
|
||
manifest_length: manifest_payload.len() as u64,
|
||
manifest_hash,
|
||
manifest_schema_version: Manifest::SCHEMA,
|
||
reduction_algorithm_version: reduction_version_for(&manifest),
|
||
profile_id: active_profile.profile_id,
|
||
commit_state: CommitState::Committed,
|
||
commit_timestamp: WallClockTime(0),
|
||
};
|
||
|
||
let header = FixedHeader::new(file_uuid);
|
||
store.write_at(0, &header.encode())?;
|
||
store.write_at(SLOT_A_OFFSET, &superblock.encode())?;
|
||
// Slot B is explicitly zeroed so it is invalid (bad magic) until the
|
||
// first commit writes a real superblock there.
|
||
store.write_at(SLOT_B_OFFSET, &[0u8; SUPERBLOCK_LEN as usize])?;
|
||
store.flush()?;
|
||
|
||
let write_cursor = store.len();
|
||
// A required unknown extension, or a non-editable (`ReadOnly`) active
|
||
// profile, makes even a freshly created bundle read-only.
|
||
let read_only =
|
||
manifest_forces_read_only(&manifest) || !profile_is_editable(&active_profile);
|
||
Ok(Bundle {
|
||
store,
|
||
header,
|
||
active_slot: Slot::A,
|
||
superblock,
|
||
manifest,
|
||
write_cursor,
|
||
read_only,
|
||
anomalies: Vec::new(),
|
||
})
|
||
}
|
||
|
||
/// Opens an existing bundle: the cold-open procedure (Chapter 8
|
||
/// §"Cold Open Procedure"). Verifies the header (magic, CRC), selects the
|
||
/// active superblock (validating each slot's magic, CRC, commit state, and
|
||
/// manifest hash), and reads + verifies the manifest. A structural anomaly
|
||
/// (generation gap, divergent same-generation slots, a non-committed slot)
|
||
/// opens the bundle **read-only** and is recorded in [`Bundle::anomalies`].
|
||
pub fn open(store: S) -> Result<Self, BundleError> {
|
||
// 1. Header.
|
||
let header_bytes = read_vec(&store, 0, crate::header::HEADER_LEN)?;
|
||
let header = FixedHeader::decode(&header_bytes)?;
|
||
|
||
// 2. Both slots.
|
||
let slot_a_bytes = read_vec(&store, SLOT_A_OFFSET, SUPERBLOCK_LEN)?;
|
||
let slot_b_bytes = read_vec(&store, SLOT_B_OFFSET, SUPERBLOCK_LEN)?;
|
||
let mut anomalies = Vec::new();
|
||
|
||
// 3. Parse + manifest-hash-verify each slot. A slot survives only if it
|
||
// is a valid, committed superblock whose manifest chunk verifies.
|
||
let a = verified_slot(&store, &slot_a_bytes, &mut anomalies);
|
||
let b = verified_slot(&store, &slot_b_bytes, &mut anomalies);
|
||
|
||
// 4–6. Apply the selection rule. A *selection-level* anomaly (a
|
||
// generation gap > 1, or divergent slots at the same generation) forces
|
||
// read-only recovery — these are the cases Chapter 8 says MUSTNOT be
|
||
// treated as normal. A non-committed slot (recorded by `verified_slot`)
|
||
// is ordinary fallback per the atomic-write protocol: it is surfaced but
|
||
// does not, on its own, block editing.
|
||
let selection = select_active(a, b)?;
|
||
let mut read_only = selection.anomaly.is_some();
|
||
if let Some(anomaly) = selection.anomaly.clone() {
|
||
anomalies.push(anomaly);
|
||
}
|
||
let superblock = selection.superblock;
|
||
|
||
// A manifest at an unsupported schema major cannot be interpreted as
|
||
// canonical state (Chapter 8 §"Schema Versioning").
|
||
if superblock.manifest_schema_version.major != SUPPORTED_SCHEMA_MAJOR {
|
||
return Err(BundleError::UnsupportedSchemaVersion {
|
||
version: superblock.manifest_schema_version,
|
||
});
|
||
}
|
||
|
||
// Read and decode the manifest the selected superblock points at. The
|
||
// placement (in-body, within the size limit) and hash were already
|
||
// checked by `verified_slot`; `Manifest::decode` additionally rejects
|
||
// non-canonical bytes.
|
||
let manifest_payload = read_manifest_payload(&store, &superblock)?;
|
||
let manifest = Manifest::decode(&manifest_payload)?;
|
||
|
||
// The manifest's self-declared generation must match the superblock that
|
||
// referenced it (a mismatch is structural corruption).
|
||
if manifest.generation != superblock.generation {
|
||
return Err(BundleError::GenerationMismatch {
|
||
superblock: superblock.generation,
|
||
manifest: manifest.generation,
|
||
});
|
||
}
|
||
// Every bundle MUST declare at least one profile, with distinct ids
|
||
// (Chapter 8 §"Format Profiles").
|
||
if manifest.profile_declarations.is_empty() {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"manifest declares no conformance profile",
|
||
)));
|
||
}
|
||
if !profile_ids_distinct(&manifest) {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"manifest declares the same profile id more than once",
|
||
)));
|
||
}
|
||
// The active superblock's profile must be one the manifest declares.
|
||
let active_profile = manifest
|
||
.profile_declarations
|
||
.iter()
|
||
.find(|p| p.profile_id == superblock.profile_id)
|
||
.copied();
|
||
let active_profile = match active_profile {
|
||
Some(p) => p,
|
||
None => {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"superblock profile is not declared by the manifest",
|
||
)))
|
||
}
|
||
};
|
||
// If the active profile is not understood (a `Custom` registry profile,
|
||
// a future major, or a block bound beyond the reader's limit), open
|
||
// read-only and surface it; if it is understood but not editable
|
||
// (`ReadOnly`), open read-only silently. (Chapter 8 §"Format Profiles":
|
||
// a reader edits only under a profile it supports.)
|
||
if !profile_is_understood(&active_profile) {
|
||
read_only = true;
|
||
anomalies.push(IntegrityAnomaly::UnsupportedProfile);
|
||
} else if !profile_is_editable(&active_profile) {
|
||
read_only = true;
|
||
}
|
||
// A canonical base is usable only if its reduction-algorithm version
|
||
// matches the active superblock's *and* its profile is one the manifest
|
||
// declares (Chapter 8 §"Canonical Document Identity").
|
||
if let Some(base) = &manifest.canonical_base {
|
||
if base.reduction_algorithm_version != superblock.reduction_algorithm_version {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"canonical base reduction-algorithm version disagrees with the superblock",
|
||
)));
|
||
}
|
||
if !manifest
|
||
.profile_declarations
|
||
.iter()
|
||
.any(|p| p.profile_id == base.profile_id)
|
||
{
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"canonical base profile is not declared by the manifest",
|
||
)));
|
||
}
|
||
}
|
||
// An unknown *required* extension forces read-only (Chapter 8 §"Behavior
|
||
// Under Unknown Extensions").
|
||
if manifest_forces_read_only(&manifest) {
|
||
read_only = true;
|
||
anomalies.push(IntegrityAnomaly::UnknownRequiredExtension);
|
||
}
|
||
|
||
let write_cursor = store.len();
|
||
Ok(Bundle {
|
||
store,
|
||
header,
|
||
active_slot: selection.slot,
|
||
superblock,
|
||
manifest,
|
||
write_cursor,
|
||
read_only,
|
||
anomalies,
|
||
})
|
||
}
|
||
|
||
/// The active manifest.
|
||
pub fn manifest(&self) -> &Manifest {
|
||
&self.manifest
|
||
}
|
||
|
||
/// The active generation.
|
||
pub fn generation(&self) -> u64 {
|
||
self.superblock.generation
|
||
}
|
||
|
||
/// The fixed header.
|
||
pub fn header(&self) -> &FixedHeader {
|
||
&self.header
|
||
}
|
||
|
||
/// The active superblock.
|
||
pub fn superblock(&self) -> &Superblock {
|
||
&self.superblock
|
||
}
|
||
|
||
/// Which slot is currently active.
|
||
pub fn active_slot(&self) -> Slot {
|
||
self.active_slot
|
||
}
|
||
|
||
/// The physical-bundle UUID.
|
||
pub fn file_uuid(&self) -> FileUuid {
|
||
self.header.file_uuid
|
||
}
|
||
|
||
/// Whether the bundle is open read-only (an integrity anomaly was detected,
|
||
/// or an unknown required extension). Commits are refused.
|
||
pub fn is_read_only(&self) -> bool {
|
||
self.read_only
|
||
}
|
||
|
||
/// Structural anomalies detected at open (empty for a normal bundle).
|
||
pub fn anomalies(&self) -> &[IntegrityAnomaly] {
|
||
&self.anomalies
|
||
}
|
||
|
||
/// The underlying store.
|
||
pub fn store(&self) -> &S {
|
||
&self.store
|
||
}
|
||
|
||
/// Consumes the bundle, returning its store (e.g. to inspect the bytes).
|
||
pub fn into_store(self) -> S {
|
||
self.store
|
||
}
|
||
|
||
/// Reads and fully verifies a chunk against its reference: that it lies in
|
||
/// the body (not the prelude), that its schema major is supported, its
|
||
/// declared length, its BLAKE3 content hash, and the `id == hash` redundancy
|
||
/// the spec keeps (Chapter 8 §"Chunks"). A canonical chunk whose hash fails
|
||
/// is hard corruption ([`BundleError::ChunkHashMismatch`]).
|
||
pub fn read_chunk(&self, r: &ChunkRef) -> Result<Vec<u8>, BundleError> {
|
||
read_and_verify_chunk(&self.store, r)
|
||
}
|
||
|
||
/// Reads and verifies a blob (Chapter 8 §"Blobs"): the same checks as
|
||
/// [`Bundle::read_chunk`], but addressed via the bare `MUSCBLOB` hash. A
|
||
/// resource-limit check honors the blob's `declared_max_uncompressed_length`.
|
||
pub fn read_blob(&self, b: &BlobRef) -> Result<Vec<u8>, BundleError> {
|
||
read_and_verify_blob(&self.store, b, MAX_BLOB_BYTES)
|
||
}
|
||
|
||
/// Reads a chunk and splits it into its opaque operation-envelope byte
|
||
/// strings (Chapter 8 §"Operation Envelope Blocks"). Rejects a reference of
|
||
/// the wrong kind, and a block whose uncompressed size exceeds the active
|
||
/// profile's maximum block size. The bundle does not interpret the envelopes.
|
||
pub fn read_operation_block(&self, r: &ChunkRef) -> Result<Vec<Vec<u8>>, BundleError> {
|
||
if r.kind != ChunkKind::OperationEnvelopeBlock {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"chunk reference is not an operation-envelope block",
|
||
)));
|
||
}
|
||
if r.uncompressed_length > self.max_block_size() {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"operation-envelope block exceeds the active profile's maximum size",
|
||
)));
|
||
}
|
||
let payload = self.read_chunk(r)?;
|
||
block::decode_block(&payload).map_err(BundleError::Decode)
|
||
}
|
||
|
||
/// The active profile's maximum uncompressed operation-block size
|
||
/// (Chapter 8 §"Operation Envelope Blocks"). The *active* profile is the one
|
||
/// the selected superblock names — a bundle opened under `Lite` must read
|
||
/// under `Lite`'s limits, not the canonical-first profile's. Falls back to
|
||
/// the canonical-first declaration, then the 64 MiB default.
|
||
fn max_block_size(&self) -> u64 {
|
||
self.active_profile()
|
||
.or_else(|| self.manifest.canonical_first_profile())
|
||
.map(|p| p.constraints.max_uncompressed_block_size)
|
||
.unwrap_or(block::MAX_BLOCK_DEFAULT)
|
||
}
|
||
|
||
/// The profile declaration the active superblock names, if the manifest
|
||
/// declares it.
|
||
fn active_profile(&self) -> Option<ProfileDeclaration> {
|
||
self.manifest
|
||
.profile_declarations
|
||
.iter()
|
||
.find(|p| p.profile_id == self.superblock.profile_id)
|
||
.copied()
|
||
}
|
||
|
||
/// Verifies every canonical chunk reachable from the manifest — the operation
|
||
/// blocks, the canonical base (its root chunk, with the snapshot's restated
|
||
/// hash cross-checked against the root), and every declared blob — is present
|
||
/// and intact. The crash-recovery and cold-open paths call this to honor the
|
||
/// spec rule that failed verification of a *canonical* chunk is hard
|
||
/// corruption. (The bundle cannot tell *which* blobs are canonical without
|
||
/// interpreting operations, so it conservatively verifies all of them.)
|
||
pub fn verify_canonical_chunks(&self) -> Result<(), BundleError> {
|
||
for r in &self.manifest.operation_roots {
|
||
read_and_verify_chunk(&self.store, r)?;
|
||
}
|
||
if let Some(base) = &self.manifest.canonical_base {
|
||
read_and_verify_chunk(&self.store, &base.root)?;
|
||
if base.hash != base.root.hash {
|
||
return Err(BundleError::ChunkHashMismatch {
|
||
expected: base.root.hash,
|
||
actual: base.hash,
|
||
});
|
||
}
|
||
}
|
||
for b in &self.manifest.blob_roots {
|
||
self.read_blob(b)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Commits a new generation via the seven-step atomic write protocol.
|
||
///
|
||
/// `new_chunks` are written (and flushed) first; the `build` closure then
|
||
/// assembles the new manifest from the resulting [`ChunkRef`]s and the
|
||
/// previous manifest; the manifest is written (and flushed); finally a new
|
||
/// superblock at `generation + 1` is written to the inactive slot and
|
||
/// flushed — the commit point. On success the in-memory state advances to
|
||
/// the new generation.
|
||
///
|
||
/// On a store error *before* the commit-point flush, the active superblock is
|
||
/// untouched, so the bundle is unchanged on disk except for unreachable
|
||
/// appended bytes. On an error *at* the commit-point flush the durable result
|
||
/// is indeterminate (the new superblock may or may not have landed); the
|
||
/// bundle is poisoned read-only and the caller must reopen from storage.
|
||
pub fn commit(
|
||
&mut self,
|
||
new_chunks: &[StagedChunk],
|
||
build: impl FnOnce(&CommitContext) -> Manifest,
|
||
) -> Result<(), BundleError> {
|
||
if self.read_only {
|
||
return Err(BundleError::ReadOnly);
|
||
}
|
||
|
||
let next_generation = self
|
||
.superblock
|
||
.generation
|
||
.checked_add(1)
|
||
.ok_or(BundleError::GenerationExhausted)?;
|
||
let inactive = self.active_slot.other();
|
||
let mut cursor = self.write_cursor;
|
||
|
||
// Step 1: write all new chunks outside the prelude and reachable chunks
|
||
// (we always append at EOF, so the "MUSTNOT overwrite a reachable chunk"
|
||
// rule holds by construction). Content-addressed dedup: a staged chunk
|
||
// whose content hash already exists — referenced by the current manifest
|
||
// or written earlier in this same commit — reuses that storage instead
|
||
// of re-appending (Chapter 8: duplicate content shares storage).
|
||
let mut known: BTreeMap<ChunkId, ChunkRef> = self
|
||
.manifest
|
||
.referenced_chunk_refs()
|
||
.into_iter()
|
||
.map(|r| (r.id, r))
|
||
.collect();
|
||
// Index blobs too (keyed by their bare content hash), so re-staging a
|
||
// payload already retained only as a `BlobRef` reuses its storage.
|
||
for b in &self.manifest.blob_roots {
|
||
known.entry(ChunkId(b.hash)).or_insert(ChunkRef {
|
||
id: ChunkId(b.hash),
|
||
kind: ChunkKind::Blob,
|
||
schema_version: SchemaVersion::V0,
|
||
offset: b.offset,
|
||
compressed_length: b.compressed_length,
|
||
uncompressed_length: b.uncompressed_length,
|
||
compression: b.compression,
|
||
hash: b.hash,
|
||
});
|
||
}
|
||
let mut new_refs = Vec::with_capacity(new_chunks.len());
|
||
for staged in new_chunks {
|
||
let id = chunk_id(staged.kind, staged.schema_version, &staged.payload);
|
||
let r = if let Some(existing) = known.get(&id) {
|
||
*existing
|
||
} else {
|
||
let r = append_chunk(
|
||
&mut self.store,
|
||
&mut cursor,
|
||
staged.kind,
|
||
staged.schema_version,
|
||
&staged.payload,
|
||
)?;
|
||
known.insert(id, r);
|
||
r
|
||
};
|
||
new_refs.push(r);
|
||
}
|
||
// Step 2: durable flush.
|
||
self.store.flush()?;
|
||
|
||
// Build the new manifest from the previous one and the new chunk refs.
|
||
let previous = self.manifest.clone();
|
||
let mut manifest = build(&CommitContext {
|
||
previous_manifest: &previous,
|
||
new_chunks: &new_refs,
|
||
generation: next_generation,
|
||
});
|
||
|
||
// Extension-root preservation (Chapter 8 §"Behavior Under Unknown
|
||
// Extensions"). The bundle's job is preservation: an extension-unaware
|
||
// commit closure must not silently drop unknown extensions and their
|
||
// `preserved_chunk_roots` (which would orphan those chunks). Carry
|
||
// forward every prior extension declaration the closure did not itself
|
||
// re-declare; an extension-*aware* writer that re-declares its own
|
||
// `extension_id` keeps full control of that declaration. (The manifest
|
||
// encoder sorts/dedups `extension_declarations`, so append order does not
|
||
// affect the canonical form.)
|
||
let redeclared: std::collections::BTreeSet<crate::ids::ExtensionId> = manifest
|
||
.extension_declarations
|
||
.iter()
|
||
.map(|e| e.extension_id)
|
||
.collect();
|
||
for prior in &previous.extension_declarations {
|
||
if !redeclared.contains(&prior.extension_id) {
|
||
manifest.extension_declarations.push(prior.clone());
|
||
}
|
||
}
|
||
|
||
manifest.generation = next_generation;
|
||
manifest.manifest_id = manifest.derive_id();
|
||
|
||
// The new manifest must be emittable (else `open` would reject it): at
|
||
// least one declared profile.
|
||
let active_profile = validate_emittable_manifest(&manifest)?;
|
||
|
||
// Before publishing, validate that every canonical root the new manifest
|
||
// declares actually resolves to a present, hash-intact chunk of the right
|
||
// kind and shape (the new chunks are written and flushed; retained roots
|
||
// are already in the body). This refuses a builder closure that produced
|
||
// dangling, wrong-kind, or mismatched roots — an abort here leaves the
|
||
// active superblock untouched, so the bundle stays at the old generation.
|
||
validate_canonical_roots(&self.store, &manifest, &active_profile)?;
|
||
|
||
// Step 3: write the new (uncompressed) manifest chunk. Enforce the
|
||
// reader's manifest-size limit before publishing (else the new slot would
|
||
// reopen as oversize), and normalize the in-memory copy to the canonical
|
||
// form by decoding the bytes we produce — so `bundle.manifest()` matches
|
||
// a reopen (e.g. duplicate roots are already collapsed).
|
||
let manifest_payload = manifest.encode();
|
||
enforce_limit(manifest_payload.len() as u64, MAX_MANIFEST_BYTES)?;
|
||
let manifest = Manifest::decode(&manifest_payload)?;
|
||
let manifest_hash =
|
||
chunk_content_hash(ChunkKind::Manifest, Manifest::SCHEMA, &manifest_payload);
|
||
let manifest_offset = cursor;
|
||
self.store.write_at(manifest_offset, &manifest_payload)?;
|
||
cursor += manifest_payload.len() as u64;
|
||
// Step 4: durable flush.
|
||
self.store.flush()?;
|
||
|
||
// Step 5: compute the new superblock.
|
||
let superblock = Superblock {
|
||
generation: next_generation,
|
||
manifest_offset,
|
||
manifest_length: manifest_payload.len() as u64,
|
||
manifest_hash,
|
||
manifest_schema_version: Manifest::SCHEMA,
|
||
reduction_algorithm_version: reduction_version_for(&manifest),
|
||
profile_id: active_profile.profile_id,
|
||
commit_state: CommitState::Committed,
|
||
commit_timestamp: WallClockTime(0),
|
||
};
|
||
// Step 6: write it to the currently-inactive slot.
|
||
self.store
|
||
.write_at(inactive.offset(), &superblock.encode())?;
|
||
// Step 7: durable flush — the commit point. An error here is
|
||
// *indeterminate*: the new superblock may or may not have reached durable
|
||
// storage, so the on-disk active generation is now unknown and the
|
||
// in-memory state cannot be trusted. Poison the bundle (read-only) so no
|
||
// further commit runs against stale state and risks overwriting the slot
|
||
// that may have just become active; the caller must reopen from storage.
|
||
if let Err(e) = self.store.flush() {
|
||
self.read_only = true;
|
||
return Err(e.into());
|
||
}
|
||
|
||
// The commit is durable; advance the in-memory state. If the new manifest
|
||
// introduces a required unknown extension or a non-editable active
|
||
// profile, the bundle becomes read-only immediately (not only on the next
|
||
// open).
|
||
self.active_slot = inactive;
|
||
self.superblock = superblock;
|
||
self.read_only =
|
||
manifest_forces_read_only(&manifest) || !profile_is_editable(&active_profile);
|
||
self.manifest = manifest;
|
||
self.write_cursor = cursor;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl Bundle<MemStore> {
|
||
/// The current in-memory bundle image (only available for the in-memory
|
||
/// store). Useful for snapshotting a bundle's bytes between commits.
|
||
pub fn image(&self) -> &[u8] {
|
||
self.store.as_bytes()
|
||
}
|
||
}
|
||
|
||
/// Whether a manifest is well-formed enough to *emit* — a writer must not emit a
|
||
/// manifest its own `open` would reject. Chapter 8 §"Format Profiles" requires at
|
||
/// least one declared profile, and §"Canonical Document Identity" requires a
|
||
/// canonical base's profile to be one the manifest declares.
|
||
fn validate_emittable_manifest(manifest: &Manifest) -> Result<ProfileDeclaration, BundleError> {
|
||
if manifest.profile_declarations.is_empty() {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"manifest declares no conformance profile",
|
||
)));
|
||
}
|
||
if !profile_ids_distinct(manifest) {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"manifest declares the same profile id more than once",
|
||
)));
|
||
}
|
||
// The active profile this manifest would be emitted under must be one this
|
||
// implementation *understands* (can interpret and honor). It need not be
|
||
// editable: a bundle may legitimately be produced under `ReadOnly` (it just
|
||
// opens read-only). This refuses emitting only under an unknown/`Custom`,
|
||
// wrong-major, or oversize-block profile — there is no understood profile to
|
||
// operate under.
|
||
let active_profile = active_profile_for_emit(manifest).ok_or(BundleError::Decode(
|
||
DecodeError::Malformed("no declared profile is supported by this implementation"),
|
||
))?;
|
||
if let Some(base) = &manifest.canonical_base {
|
||
if !manifest
|
||
.profile_declarations
|
||
.iter()
|
||
.any(|p| p.profile_id == base.profile_id)
|
||
{
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"canonical base profile is not declared by the manifest",
|
||
)));
|
||
}
|
||
}
|
||
Ok(active_profile)
|
||
}
|
||
|
||
/// Whether a manifest forces read-only mode: it declares a `required` extension
|
||
/// this implementation does not understand (v0 understands none, so any
|
||
/// `required` extension qualifies — Chapter 8 §"Behavior Under Unknown
|
||
/// Extensions").
|
||
fn manifest_forces_read_only(manifest: &Manifest) -> bool {
|
||
manifest.extension_declarations.iter().any(|e| e.required)
|
||
}
|
||
|
||
/// Whether this implementation *understands* a profile (can interpret and honor
|
||
/// its constraints): a built-in `ProfileId` (not a `Custom` registry profile),
|
||
/// a supported major version, and a block bound within the reader's hard chunk
|
||
/// limit (a profile demanding larger blocks than the reader can allocate is not
|
||
/// honorable — Chapter 8 §"Operation Envelope Blocks" / §"Format Profiles").
|
||
fn profile_is_understood(decl: &ProfileDeclaration) -> bool {
|
||
// A profile major change is non-backward-compatible (like a schema major), so
|
||
// the major must match exactly; minor/patch are accepted.
|
||
matches!(
|
||
decl.profile_id,
|
||
ProfileId::Full | ProfileId::ReadOnly | ProfileId::Lite
|
||
) && decl.version.major == SUPPORTED_PROFILE_MAJOR
|
||
&& decl.constraints.max_uncompressed_block_size <= MAX_CHUNK_BYTES
|
||
}
|
||
|
||
/// Whether a profile is *editable*: understood, and not the `ReadOnly` profile.
|
||
/// A `ReadOnly`-profile bundle opens read-only; v0 does not auto-upgrade it to a
|
||
/// writable profile (the spec's SHOULD-upgrade-on-edit is deferred).
|
||
fn profile_is_editable(decl: &ProfileDeclaration) -> bool {
|
||
profile_is_understood(decl) && matches!(decl.profile_id, ProfileId::Full | ProfileId::Lite)
|
||
}
|
||
|
||
/// Whether a manifest's profile declarations have distinct `ProfileId`s. A
|
||
/// duplicate id (even at a different version/constraints) is ambiguous about
|
||
/// which declaration governs and is rejected.
|
||
fn profile_ids_distinct(manifest: &Manifest) -> bool {
|
||
let mut ids: Vec<ProfileId> = manifest
|
||
.profile_declarations
|
||
.iter()
|
||
.map(|p| p.profile_id)
|
||
.collect();
|
||
ids.sort();
|
||
let len = ids.len();
|
||
ids.dedup();
|
||
ids.len() == len
|
||
}
|
||
|
||
/// The profile a freshly emitted superblock should name as active: prefer the
|
||
/// canonical-first *editable* profile, so the bundle is editable whenever it
|
||
/// declares an editable profile (e.g. `[ReadOnly, Lite]` is emitted under
|
||
/// `Lite`); otherwise the canonical-first merely *understood* profile (e.g. a
|
||
/// sole `ReadOnly` yields a read-only bundle). `None` if no declared profile is
|
||
/// understood — `validate_emittable_manifest` rejects that before this is used.
|
||
fn active_profile_for_emit(manifest: &Manifest) -> Option<ProfileDeclaration> {
|
||
let profiles = manifest.canonical_profiles();
|
||
profiles
|
||
.iter()
|
||
.find(|p| profile_is_editable(p))
|
||
.or_else(|| profiles.iter().find(|p| profile_is_understood(p)))
|
||
.copied()
|
||
}
|
||
|
||
/// The reduction-algorithm version a superblock should carry: the canonical
|
||
/// base's, if a base is present (only a base records a reduction); otherwise the
|
||
/// default (no base means no reduced base state at this generation).
|
||
fn reduction_version_for(manifest: &Manifest) -> ReductionAlgorithmVersion {
|
||
manifest
|
||
.canonical_base
|
||
.as_ref()
|
||
.map(|b| b.reduction_algorithm_version)
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Reads and fully verifies a chunk against its reference — the shared core of
|
||
/// [`Bundle::read_chunk`] and the commit-time canonical-root validation. Checks
|
||
/// body-placement, schema-major support, declared length, the content hash, and
|
||
/// the `id == hash` redundancy (Chapter 8 §"Chunks").
|
||
fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result<Vec<u8>, BundleError> {
|
||
if r.compression != CompressionAlgorithm::None {
|
||
return Err(BundleError::UnsupportedCompression);
|
||
}
|
||
// A chunk reference must point into the body, never the fixed prelude.
|
||
if r.offset < BODY_START {
|
||
return Err(BundleError::ChunkOutOfBounds {
|
||
offset: r.offset,
|
||
length: r.compressed_length,
|
||
file_len: store.len(),
|
||
});
|
||
}
|
||
// Canonical chunks must be parseable: v0 supports schema major 0 only.
|
||
if r.schema_version.major != SUPPORTED_SCHEMA_MAJOR {
|
||
return Err(BundleError::UnsupportedSchemaVersion {
|
||
version: r.schema_version,
|
||
});
|
||
}
|
||
// Bound the allocation by the reader's policy before touching the length.
|
||
enforce_limit(r.compressed_length, MAX_CHUNK_BYTES)?;
|
||
enforce_limit(r.uncompressed_length, MAX_CHUNK_BYTES)?;
|
||
let payload = read_chunk_bytes(store, r.offset, r.compressed_length)?;
|
||
if payload.len() as u64 != r.uncompressed_length {
|
||
return Err(BundleError::ChunkLengthMismatch {
|
||
expected: r.uncompressed_length,
|
||
actual: payload.len() as u64,
|
||
});
|
||
}
|
||
let actual = content_hash_for(r.kind, r.schema_version, &payload);
|
||
if actual != r.hash {
|
||
return Err(BundleError::ChunkHashMismatch {
|
||
expected: r.hash,
|
||
actual,
|
||
});
|
||
}
|
||
if r.id.content_hash() != r.hash {
|
||
return Err(BundleError::ChunkHashMismatch {
|
||
expected: r.hash,
|
||
actual: r.id.content_hash(),
|
||
});
|
||
}
|
||
Ok(payload)
|
||
}
|
||
|
||
/// Reads and fully verifies a blob against its reference (the shared core of
|
||
/// [`Bundle::read_blob`] and the commit-time blob validation): compression
|
||
/// support, the reader resource limit (`min(max_bytes, declared_max)`),
|
||
/// body-placement, declared length, and the bare-`MUSCBLOB` content hash with
|
||
/// the `blob_id == hash` redundancy.
|
||
fn read_and_verify_blob(
|
||
store: &dyn BlockStore,
|
||
b: &BlobRef,
|
||
max_bytes: u64,
|
||
) -> Result<Vec<u8>, BundleError> {
|
||
if b.compression != CompressionAlgorithm::None {
|
||
return Err(BundleError::UnsupportedCompression);
|
||
}
|
||
let limit = b
|
||
.declared_max_uncompressed_length
|
||
.unwrap_or(u64::MAX)
|
||
.min(max_bytes);
|
||
enforce_limit(b.uncompressed_length, limit)?;
|
||
enforce_limit(b.compressed_length, max_bytes)?;
|
||
if b.offset < BODY_START {
|
||
return Err(BundleError::ChunkOutOfBounds {
|
||
offset: b.offset,
|
||
length: b.compressed_length,
|
||
file_len: store.len(),
|
||
});
|
||
}
|
||
let payload = read_chunk_bytes(store, b.offset, b.compressed_length)?;
|
||
if payload.len() as u64 != b.uncompressed_length {
|
||
return Err(BundleError::ChunkLengthMismatch {
|
||
expected: b.uncompressed_length,
|
||
actual: payload.len() as u64,
|
||
});
|
||
}
|
||
let actual = BlobId::of_payload(&payload).0;
|
||
if actual != b.hash || b.blob_id.0 != b.hash {
|
||
return Err(BundleError::ChunkHashMismatch {
|
||
expected: b.hash,
|
||
actual,
|
||
});
|
||
}
|
||
Ok(payload)
|
||
}
|
||
|
||
/// Errors if `length` exceeds `limit`, before any allocation keyed on it.
|
||
fn enforce_limit(length: u64, limit: u64) -> Result<(), BundleError> {
|
||
if length > limit {
|
||
Err(BundleError::ResourceLimitExceeded { length, limit })
|
||
} else {
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Validates that every canonical root a manifest declares resolves to a
|
||
/// present, hash-intact chunk of the *right kind and shape* before a commit
|
||
/// publishes it — so a bundle that opens normally can never carry a dangling,
|
||
/// mis-roled, or malformed canonical root:
|
||
///
|
||
/// * each operation root is an `OperationEnvelopeBlock`, within the active
|
||
/// profile's maximum block size, whose payload actually decodes;
|
||
/// * the canonical base's root is a `Snapshot`, with its restated hash matching;
|
||
/// * every declared blob root resolves and verifies.
|
||
fn validate_canonical_roots(
|
||
store: &dyn BlockStore,
|
||
manifest: &Manifest,
|
||
active_profile: &ProfileDeclaration,
|
||
) -> Result<(), BundleError> {
|
||
let max_block = active_profile.constraints.max_uncompressed_block_size;
|
||
|
||
for r in &manifest.operation_roots {
|
||
if r.kind != ChunkKind::OperationEnvelopeBlock {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"operation root is not an operation-envelope block",
|
||
)));
|
||
}
|
||
if r.uncompressed_length > max_block {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"operation block exceeds the active profile's maximum size",
|
||
)));
|
||
}
|
||
let payload = read_and_verify_chunk(store, r)?;
|
||
// The block payload must be a well-formed envelope sequence.
|
||
block::decode_block(&payload).map_err(BundleError::Decode)?;
|
||
}
|
||
if let Some(base) = &manifest.canonical_base {
|
||
if base.root.kind != ChunkKind::Snapshot {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"canonical base root is not a snapshot chunk",
|
||
)));
|
||
}
|
||
read_and_verify_chunk(store, &base.root)?;
|
||
if base.hash != base.root.hash {
|
||
return Err(BundleError::ChunkHashMismatch {
|
||
expected: base.root.hash,
|
||
actual: base.hash,
|
||
});
|
||
}
|
||
}
|
||
for b in &manifest.blob_roots {
|
||
if !crate::manifest::valid_media_type(&b.media_type) {
|
||
return Err(BundleError::Decode(DecodeError::Malformed(
|
||
"blob media type is not a valid RFC 6838 type/subtype",
|
||
)));
|
||
}
|
||
read_and_verify_blob(store, b, MAX_BLOB_BYTES)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Reads the manifest payload a superblock points at, enforcing that it lies in
|
||
/// the body — Chapter 8 fixes the prelude layout, so a "manifest" overlapping a
|
||
/// header or superblock slot is foreign — and within the reader's manifest size
|
||
/// limit, before allocating.
|
||
fn read_manifest_payload(store: &dyn BlockStore, sb: &Superblock) -> Result<Vec<u8>, BundleError> {
|
||
if sb.manifest_offset < BODY_START {
|
||
return Err(BundleError::ChunkOutOfBounds {
|
||
offset: sb.manifest_offset,
|
||
length: sb.manifest_length,
|
||
file_len: store.len(),
|
||
});
|
||
}
|
||
enforce_limit(sb.manifest_length, MAX_MANIFEST_BYTES)?;
|
||
read_chunk_bytes(store, sb.manifest_offset, sb.manifest_length)
|
||
}
|
||
|
||
/// Reads `length` bytes at `offset`, bounds-checking against the store size and
|
||
/// reporting an out-of-range reference rather than an opaque I/O error.
|
||
fn read_chunk_bytes(
|
||
store: &dyn BlockStore,
|
||
offset: u64,
|
||
length: u64,
|
||
) -> Result<Vec<u8>, BundleError> {
|
||
let end = offset.checked_add(length);
|
||
match end {
|
||
Some(end) if end <= store.len() => Ok(read_vec(store, offset, length)?),
|
||
_ => Err(BundleError::ChunkOutOfBounds {
|
||
offset,
|
||
length,
|
||
file_len: store.len(),
|
||
}),
|
||
}
|
||
}
|
||
|
||
/// Writes a chunk payload at `*cursor`, advances the cursor, and returns the
|
||
/// resulting reference. v0 writes uncompressed, so compressed and uncompressed
|
||
/// lengths are equal.
|
||
fn append_chunk(
|
||
store: &mut dyn BlockStore,
|
||
cursor: &mut u64,
|
||
kind: ChunkKind,
|
||
schema: SchemaVersion,
|
||
payload: &[u8],
|
||
) -> Result<ChunkRef, BundleError> {
|
||
let id = chunk_id(kind, schema, payload);
|
||
let offset = *cursor;
|
||
store.write_at(offset, payload)?;
|
||
*cursor += payload.len() as u64;
|
||
Ok(ChunkRef {
|
||
id,
|
||
kind,
|
||
schema_version: schema,
|
||
offset,
|
||
compressed_length: payload.len() as u64,
|
||
uncompressed_length: payload.len() as u64,
|
||
compression: CompressionAlgorithm::None,
|
||
hash: id.content_hash(),
|
||
})
|
||
}
|
||
|
||
/// Parses one slot for ordinary selection and, if it is a valid committed
|
||
/// superblock, verifies its manifest chunk's hash against the store. Returns the
|
||
/// superblock only if every check passes; records a non-committed slot as an
|
||
/// anomaly.
|
||
fn verified_slot(
|
||
store: &dyn BlockStore,
|
||
slot_bytes: &[u8],
|
||
anomalies: &mut Vec<IntegrityAnomaly>,
|
||
) -> Option<Superblock> {
|
||
match Superblock::parse_slot(slot_bytes) {
|
||
SlotParse::Valid(sb) => {
|
||
// Verify the manifest chunk's hash (Chapter 8 §"Superblock
|
||
// Selection", step 2): a slot whose manifest is out of the body,
|
||
// oversize, or does not hash-verify is not valid for ordinary
|
||
// selection.
|
||
match read_manifest_payload(store, &sb) {
|
||
Ok(payload) => {
|
||
let actual = chunk_content_hash(
|
||
ChunkKind::Manifest,
|
||
sb.manifest_schema_version,
|
||
&payload,
|
||
);
|
||
if actual == sb.manifest_hash {
|
||
Some(sb)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
Err(_) => None,
|
||
}
|
||
}
|
||
SlotParse::Rejected(SlotReject::NotCommitted) => {
|
||
anomalies.push(IntegrityAnomaly::NonCommittedSlot);
|
||
None
|
||
}
|
||
SlotParse::Rejected(_) => None,
|
||
}
|
||
}
|
||
|
||
// Re-exported helper for harnesses that build raw images: the body start offset
|
||
// and the content hash of a manifest payload.
|
||
/// The content hash of a manifest chunk payload (Chapter 8 §"Content Hashing").
|
||
pub fn manifest_chunk_hash(payload: &[u8]) -> ContentHash {
|
||
chunk_content_hash(ChunkKind::Manifest, Manifest::SCHEMA, payload)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::ids::DocumentId;
|
||
|
||
fn fresh_bundle() -> Bundle<MemStore> {
|
||
Bundle::create(
|
||
MemStore::new(),
|
||
FileUuid([1; 16]),
|
||
Manifest::empty(DocumentId([2; 16])),
|
||
)
|
||
.unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn create_then_open_round_trips() {
|
||
let bundle = fresh_bundle();
|
||
let image = bundle.into_store().into_bytes();
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert_eq!(reopened.generation(), 0);
|
||
assert_eq!(reopened.active_slot(), Slot::A);
|
||
assert_eq!(reopened.file_uuid(), FileUuid([1; 16]));
|
||
assert!(!reopened.is_read_only());
|
||
assert!(reopened.anomalies().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn commit_advances_generation_and_flips_slot() {
|
||
let mut bundle = fresh_bundle();
|
||
let block = block::encode_block(&[b"env-1".to_vec(), b"env-2".to_vec()]);
|
||
bundle
|
||
.commit(&[StagedChunk::operation_block(block)], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots = ctx.new_chunks.to_vec();
|
||
m
|
||
})
|
||
.unwrap();
|
||
assert_eq!(bundle.generation(), 1);
|
||
assert_eq!(bundle.active_slot(), Slot::B);
|
||
|
||
// Reopen from the image; the committed state is visible and verifies.
|
||
let image = bundle.into_store().into_bytes();
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert_eq!(reopened.generation(), 1);
|
||
assert_eq!(reopened.manifest().operation_roots.len(), 1);
|
||
reopened.verify_canonical_chunks().unwrap();
|
||
let envs = reopened
|
||
.read_operation_block(&reopened.manifest().operation_roots[0])
|
||
.unwrap();
|
||
assert_eq!(envs, vec![b"env-1".to_vec(), b"env-2".to_vec()]);
|
||
}
|
||
|
||
#[test]
|
||
fn successive_commits_alternate_slots() {
|
||
let mut bundle = fresh_bundle();
|
||
for i in 1..=5u64 {
|
||
let payload = block::encode_block(&[vec![i as u8; 16]]);
|
||
bundle
|
||
.commit(&[StagedChunk::operation_block(payload)], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots.extend(ctx.new_chunks.iter().copied());
|
||
m
|
||
})
|
||
.unwrap();
|
||
assert_eq!(bundle.generation(), i);
|
||
assert_eq!(
|
||
bundle.active_slot(),
|
||
if i % 2 == 1 { Slot::B } else { Slot::A }
|
||
);
|
||
}
|
||
assert_eq!(bundle.manifest().operation_roots.len(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_corrupt_canonical_chunk_is_hard_corruption() {
|
||
let mut bundle = fresh_bundle();
|
||
let payload = block::encode_block(&[b"env".to_vec()]);
|
||
bundle
|
||
.commit(&[StagedChunk::operation_block(payload)], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots = ctx.new_chunks.to_vec();
|
||
m
|
||
})
|
||
.unwrap();
|
||
let op_offset = bundle.manifest().operation_roots[0].offset;
|
||
let mut image = bundle.into_store().into_bytes();
|
||
image[op_offset as usize] ^= 0xFF; // corrupt the operation block payload
|
||
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
// Opening still works (the manifest/superblock are intact)...
|
||
assert_eq!(reopened.generation(), 1);
|
||
// ...but verifying the canonical chunk surfaces hard corruption.
|
||
assert!(matches!(
|
||
reopened.verify_canonical_chunks(),
|
||
Err(BundleError::ChunkHashMismatch { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn commit_is_refused_on_a_read_only_bundle() {
|
||
let mut bundle = fresh_bundle();
|
||
bundle.read_only = true;
|
||
let err = bundle.commit(&[], |ctx| ctx.previous_manifest.clone());
|
||
assert!(matches!(err, Err(BundleError::ReadOnly)));
|
||
}
|
||
|
||
fn op_block(bundle: &mut Bundle<MemStore>, envelopes: &[&[u8]]) {
|
||
let payload =
|
||
block::encode_block(&envelopes.iter().map(|e| e.to_vec()).collect::<Vec<_>>());
|
||
bundle
|
||
.commit(&[StagedChunk::operation_block(payload)], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots.extend(ctx.new_chunks.iter().copied());
|
||
m
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn commit_rejects_a_dangling_canonical_root() {
|
||
// Finding 2: a builder closure that publishes a root pointing at a chunk
|
||
// that does not exist must be refused; the bundle stays at generation 0.
|
||
let mut bundle = fresh_bundle();
|
||
let bogus = ChunkRef {
|
||
id: ChunkId(ContentHash([9; 32])),
|
||
kind: ChunkKind::OperationEnvelopeBlock,
|
||
schema_version: SchemaVersion::V0,
|
||
offset: 1_000_000,
|
||
compressed_length: 10,
|
||
uncompressed_length: 10,
|
||
compression: CompressionAlgorithm::None,
|
||
hash: ContentHash([9; 32]),
|
||
};
|
||
let err = bundle.commit(&[], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots = vec![bogus];
|
||
m
|
||
});
|
||
assert!(matches!(err, Err(BundleError::ChunkOutOfBounds { .. })));
|
||
assert_eq!(bundle.generation(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn commit_rejects_a_root_with_a_mismatched_hash() {
|
||
// Finding 2: a root referencing a real chunk but with the wrong declared
|
||
// hash is rejected before commit.
|
||
let mut bundle = fresh_bundle();
|
||
op_block(&mut bundle, &[b"real"]);
|
||
let mut tampered = bundle.manifest().operation_roots[0];
|
||
tampered.hash = ContentHash([0; 32]);
|
||
let err = bundle.commit(&[], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots = vec![tampered];
|
||
m
|
||
});
|
||
assert!(matches!(err, Err(BundleError::ChunkHashMismatch { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn create_rejects_initial_canonical_roots() {
|
||
// Finding 2: create() writes no chunks, so any canonical root would be
|
||
// dangling; reject it.
|
||
let mut m = Manifest::empty(DocumentId([5; 16]));
|
||
m.operation_roots = vec![ChunkRef {
|
||
id: ChunkId(ContentHash([1; 32])),
|
||
kind: ChunkKind::OperationEnvelopeBlock,
|
||
schema_version: SchemaVersion::V0,
|
||
offset: BODY_START,
|
||
compressed_length: 1,
|
||
uncompressed_length: 1,
|
||
compression: CompressionAlgorithm::None,
|
||
hash: ContentHash([1; 32]),
|
||
}];
|
||
assert!(Bundle::create(MemStore::new(), FileUuid([1; 16]), m).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn forged_chunk_id_is_rejected_on_read() {
|
||
// Finding 12: a reference whose id disagrees with its (correct) hash
|
||
// fails verification.
|
||
let mut bundle = fresh_bundle();
|
||
op_block(&mut bundle, &[b"env"]);
|
||
let mut forged = bundle.manifest().operation_roots[0];
|
||
forged.id = ChunkId(ContentHash([0xAB; 32])); // hash still correct
|
||
assert!(matches!(
|
||
bundle.read_chunk(&forged),
|
||
Err(BundleError::ChunkHashMismatch { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_id_is_synced_after_create_and_commit() {
|
||
// Finding 13: the in-memory manifest id matches the derived id, with no
|
||
// reopen required.
|
||
let mut bundle = fresh_bundle();
|
||
assert_eq!(bundle.manifest().manifest_id, bundle.manifest().derive_id());
|
||
op_block(&mut bundle, &[b"env"]);
|
||
assert_eq!(bundle.manifest().manifest_id, bundle.manifest().derive_id());
|
||
}
|
||
|
||
fn commit_blob(bundle: &mut Bundle<MemStore>, payload: &[u8]) {
|
||
let staged = StagedChunk {
|
||
kind: ChunkKind::Blob,
|
||
schema_version: SchemaVersion::V0,
|
||
payload: payload.to_vec(),
|
||
};
|
||
bundle
|
||
.commit(&[staged], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
let r = ctx.new_chunks[0];
|
||
m.blob_roots.push(BlobRef {
|
||
blob_id: BlobId(r.hash),
|
||
media_type: "application/octet-stream".to_string(),
|
||
offset: r.offset,
|
||
compressed_length: r.compressed_length,
|
||
uncompressed_length: r.uncompressed_length,
|
||
compression: CompressionAlgorithm::None,
|
||
hash: r.hash,
|
||
declared_max_uncompressed_length: None,
|
||
});
|
||
m
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn staged_blob_reads_back() {
|
||
// Finding 5: a staged Blob chunk hashes the way it is verified, so it
|
||
// round-trips through read_blob (wired into blob_roots, not the
|
||
// operation roots — which would now be rejected for the wrong kind).
|
||
let mut bundle = fresh_bundle();
|
||
let payload = b"blob-bytes".to_vec();
|
||
commit_blob(&mut bundle, &payload);
|
||
let b = bundle.manifest().blob_roots[0].clone();
|
||
assert_eq!(bundle.read_blob(&b).unwrap(), payload);
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_content_is_deduplicated() {
|
||
// Committing the same payload twice reuses the existing chunk's storage
|
||
// (storage dedup) and collapses to a single canonical root (manifest
|
||
// dedup), with the in-memory manifest already normalized.
|
||
let mut bundle = fresh_bundle();
|
||
op_block(&mut bundle, &[b"same-content"]);
|
||
let cursor_before = bundle.write_cursor;
|
||
let manifest_len_before = bundle.superblock().manifest_length;
|
||
op_block(&mut bundle, &[b"same-content"]); // build extends roots to [r, r]
|
||
|
||
// Manifest dedup: the duplicate operation root collapsed to one, in
|
||
// memory (not only after a reopen).
|
||
assert_eq!(bundle.manifest().operation_roots.len(), 1);
|
||
|
||
// Storage dedup: the only body growth was the new manifest chunk, not a
|
||
// second copy of the block.
|
||
let grew = bundle.write_cursor - cursor_before;
|
||
assert!(
|
||
grew <= manifest_len_before + 256,
|
||
"duplicate content appears to have been re-stored (body grew by {grew})"
|
||
);
|
||
|
||
// And a reopen agrees with the in-memory (already normalized) manifest.
|
||
let image = bundle.into_store().into_bytes();
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert_eq!(reopened.manifest().operation_roots.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn required_extension_forces_read_only() {
|
||
// Finding 3: a declared required extension (unknown in v0) opens the
|
||
// bundle read-only.
|
||
let mut bundle = fresh_bundle();
|
||
bundle
|
||
.commit(&[], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.extension_declarations
|
||
.push(crate::manifest::ExtensionDeclaration {
|
||
extension_id: crate::ids::ExtensionId([3; 16]),
|
||
version: crate::ids::SemVer::new(1, 0, 0),
|
||
required: true,
|
||
preserved_chunk_roots: Vec::new(),
|
||
affected_object_kinds: Vec::new(),
|
||
edit_barriers: Vec::new(),
|
||
});
|
||
m
|
||
})
|
||
.unwrap();
|
||
let image = bundle.into_store().into_bytes();
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert!(reopened.is_read_only());
|
||
assert!(reopened
|
||
.anomalies()
|
||
.contains(&IntegrityAnomaly::UnknownRequiredExtension));
|
||
}
|
||
|
||
#[test]
|
||
fn commit_preserves_unknown_extension_roots_when_the_closure_drops_them() {
|
||
// The bundle's job is preservation: an extension-*unaware* writer that
|
||
// rebuilds the manifest from scratch must not orphan an unknown
|
||
// (optional) extension's preserved roots.
|
||
let mut bundle = fresh_bundle();
|
||
let ext_id = crate::ids::ExtensionId([7; 16]);
|
||
let ext_root = ChunkRef {
|
||
id: ChunkId(ContentHash([42; 32])),
|
||
kind: ChunkKind::ExtensionData,
|
||
schema_version: SchemaVersion::V0,
|
||
offset: 4096,
|
||
compressed_length: 8,
|
||
uncompressed_length: 8,
|
||
compression: CompressionAlgorithm::None,
|
||
hash: ContentHash([42; 32]),
|
||
};
|
||
|
||
// 1) An extension-aware commit declares the optional extension + a root.
|
||
bundle
|
||
.commit(&[], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.extension_declarations
|
||
.push(crate::manifest::ExtensionDeclaration {
|
||
extension_id: ext_id,
|
||
version: crate::ids::SemVer::new(1, 0, 0),
|
||
required: false,
|
||
preserved_chunk_roots: vec![ext_root],
|
||
affected_object_kinds: Vec::new(),
|
||
edit_barriers: Vec::new(),
|
||
});
|
||
m
|
||
})
|
||
.unwrap();
|
||
assert!(bundle
|
||
.manifest()
|
||
.extension_declarations
|
||
.iter()
|
||
.any(|e| e.extension_id == ext_id));
|
||
|
||
// 2) An extension-unaware commit rebuilds the manifest from empty,
|
||
// carrying only what it understands (operation roots). The bundle must
|
||
// still carry the extension and its root forward.
|
||
let doc = bundle.manifest().document_id;
|
||
bundle
|
||
.commit(&[], |ctx| {
|
||
let mut m = Manifest::empty(doc);
|
||
m.operation_roots = ctx.previous_manifest.operation_roots.clone();
|
||
m
|
||
})
|
||
.unwrap();
|
||
|
||
let survives = |m: &Manifest| {
|
||
m.extension_declarations.iter().any(|e| {
|
||
e.extension_id == ext_id
|
||
&& e.preserved_chunk_roots.iter().any(|r| r.id == ext_root.id)
|
||
})
|
||
};
|
||
assert!(
|
||
survives(bundle.manifest()),
|
||
"unknown extension + its root must survive an extension-unaware commit"
|
||
);
|
||
|
||
// 3) And it survives a reopen (durably preserved).
|
||
let image = bundle.into_store().into_bytes();
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert!(survives(reopened.manifest()));
|
||
}
|
||
|
||
#[test]
|
||
fn profile_selection_is_stable_across_reload() {
|
||
// Finding 9: the superblock's profile_id is the canonical-first profile,
|
||
// so a [Lite, Full] manifest does not flip its active profile on reload.
|
||
use crate::manifest::{ProfileConstraints, ProfileDeclaration};
|
||
let mut m = Manifest::empty(DocumentId([6; 16]));
|
||
m.profile_declarations = vec![
|
||
ProfileDeclaration {
|
||
profile_id: ProfileId::Lite,
|
||
version: crate::ids::SemVer::new(0, 1, 0),
|
||
constraints: ProfileConstraints::DEFAULT_FULL,
|
||
},
|
||
ProfileDeclaration {
|
||
profile_id: ProfileId::Full,
|
||
version: crate::ids::SemVer::new(0, 1, 0),
|
||
constraints: ProfileConstraints::DEFAULT_FULL,
|
||
},
|
||
];
|
||
let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap();
|
||
let in_memory_profile = bundle.superblock().profile_id;
|
||
let image = bundle.into_store().into_bytes();
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert_eq!(in_memory_profile, reopened.superblock().profile_id);
|
||
}
|
||
|
||
#[test]
|
||
fn corrupt_canonical_blob_is_surfaced() {
|
||
// Finding 4: a corrupt blob referenced by the manifest is caught by
|
||
// verify_canonical_chunks.
|
||
let mut bundle = fresh_bundle();
|
||
commit_blob(&mut bundle, b"audio-bytes");
|
||
let blob_offset = bundle.manifest().blob_roots[0].offset;
|
||
bundle.verify_canonical_chunks().unwrap(); // intact
|
||
let mut image = bundle.into_store().into_bytes();
|
||
image[blob_offset as usize] ^= 0xFF;
|
||
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert!(matches!(
|
||
reopened.verify_canonical_chunks(),
|
||
Err(BundleError::ChunkHashMismatch { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn empty_profile_list_is_rejected_at_create_and_commit() {
|
||
// Finding 1: a writer must not emit a manifest its own open would reject.
|
||
let mut m = Manifest::empty(DocumentId([8; 16]));
|
||
m.profile_declarations.clear();
|
||
assert!(Bundle::create(MemStore::new(), FileUuid([1; 16]), m).is_err());
|
||
|
||
let mut bundle = fresh_bundle();
|
||
let err = bundle.commit(&[], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.profile_declarations.clear();
|
||
m
|
||
});
|
||
assert!(err.is_err());
|
||
assert_eq!(bundle.generation(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn commit_rejects_a_dangling_blob_root() {
|
||
// Finding 3: a blob root pointing nowhere is refused before commit.
|
||
let mut bundle = fresh_bundle();
|
||
let err = bundle.commit(&[], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.blob_roots.push(BlobRef {
|
||
blob_id: BlobId(ContentHash([4; 32])),
|
||
media_type: "x/y".to_string(),
|
||
offset: 9_000_000,
|
||
compressed_length: 4,
|
||
uncompressed_length: 4,
|
||
compression: CompressionAlgorithm::None,
|
||
hash: ContentHash([4; 32]),
|
||
declared_max_uncompressed_length: None,
|
||
});
|
||
m
|
||
});
|
||
assert!(err.is_err());
|
||
assert_eq!(bundle.generation(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn commit_rejects_wrong_kind_operation_root() {
|
||
// Finding 2: an operation root must be an operation-envelope block.
|
||
let mut bundle = fresh_bundle();
|
||
let staged = StagedChunk {
|
||
kind: ChunkKind::LayoutCache,
|
||
schema_version: SchemaVersion::V0,
|
||
payload: b"not a block".to_vec(),
|
||
};
|
||
let err = bundle.commit(&[staged], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots = ctx.new_chunks.to_vec();
|
||
m
|
||
});
|
||
assert!(matches!(err, Err(BundleError::Decode(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn live_bundle_becomes_read_only_after_committing_a_required_extension() {
|
||
// Finding 4: read-only takes effect on the live object, not only on
|
||
// reopen.
|
||
let mut bundle = fresh_bundle();
|
||
bundle
|
||
.commit(&[], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.extension_declarations
|
||
.push(crate::manifest::ExtensionDeclaration {
|
||
extension_id: crate::ids::ExtensionId([3; 16]),
|
||
version: crate::ids::SemVer::new(1, 0, 0),
|
||
required: true,
|
||
preserved_chunk_roots: Vec::new(),
|
||
affected_object_kinds: Vec::new(),
|
||
edit_barriers: Vec::new(),
|
||
});
|
||
m
|
||
})
|
||
.unwrap();
|
||
assert!(bundle.is_read_only());
|
||
assert!(matches!(
|
||
bundle.commit(&[], |ctx| ctx.previous_manifest.clone()),
|
||
Err(BundleError::ReadOnly)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn indeterminate_final_flush_poisons_the_bundle() {
|
||
// Finding 5: if the commit-point flush persists G+1 but returns an error,
|
||
// the live bundle must not keep accepting commits against stale state.
|
||
use crate::store::{CrashPoint, FaultStore, Tear};
|
||
|
||
// Discover the commit's final-flush syscall index via a no-fault run.
|
||
let base = fresh_bundle().into_store().into_bytes();
|
||
let total = {
|
||
let mut b = Bundle::open(FaultStore::no_fault(base.clone())).unwrap();
|
||
b.commit(
|
||
&[StagedChunk::operation_block(block::encode_block(&[
|
||
b"e".to_vec()
|
||
]))],
|
||
|ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots.extend(ctx.new_chunks.iter().copied());
|
||
m
|
||
},
|
||
)
|
||
.unwrap();
|
||
b.into_store().syscalls_issued()
|
||
};
|
||
|
||
// Crash on the final flush but persist the whole superblock (full tear):
|
||
// durable is G+1, yet commit returns Err.
|
||
let crash = CrashPoint {
|
||
after_syscalls: total - 1,
|
||
tear: Tear::TornLastWrite { prefix: 256 },
|
||
};
|
||
let mut bundle = Bundle::open(FaultStore::new(base, crash)).unwrap();
|
||
let result = bundle.commit(
|
||
&[StagedChunk::operation_block(block::encode_block(&[
|
||
b"e".to_vec()
|
||
]))],
|
||
|ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots.extend(ctx.new_chunks.iter().copied());
|
||
m
|
||
},
|
||
);
|
||
assert!(result.is_err(), "the final flush errored");
|
||
// The bundle is poisoned: further commits are refused (the durable state
|
||
// is indeterminate and must be reloaded).
|
||
assert!(bundle.is_read_only());
|
||
}
|
||
|
||
#[test]
|
||
fn create_with_required_extension_is_read_only_immediately() {
|
||
// Finding 3: a freshly created bundle with a required extension is
|
||
// read-only without a reopen.
|
||
let mut m = Manifest::empty(DocumentId([9; 16]));
|
||
m.extension_declarations
|
||
.push(crate::manifest::ExtensionDeclaration {
|
||
extension_id: crate::ids::ExtensionId([1; 16]),
|
||
version: crate::ids::SemVer::new(1, 0, 0),
|
||
required: true,
|
||
preserved_chunk_roots: Vec::new(),
|
||
affected_object_kinds: Vec::new(),
|
||
edit_barriers: Vec::new(),
|
||
});
|
||
let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap();
|
||
assert!(bundle.is_read_only());
|
||
}
|
||
|
||
#[test]
|
||
fn commit_rejects_a_canonical_base_with_an_undeclared_profile() {
|
||
// Finding 1: a writer must not emit a canonical base whose profile its
|
||
// own open would reject.
|
||
let mut bundle = fresh_bundle(); // declares only Full
|
||
let snap_payload = b"snapshot".to_vec();
|
||
let staged = StagedChunk {
|
||
kind: ChunkKind::Snapshot,
|
||
schema_version: SchemaVersion::V0,
|
||
payload: snap_payload,
|
||
};
|
||
let err = bundle.commit(&[staged], |ctx| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
let root = ctx.new_chunks[0];
|
||
m.canonical_base = Some(crate::manifest::SnapshotRef {
|
||
snapshot_id: crate::ids::SnapshotId([1; 16]),
|
||
covers_causal_frontier: crate::ids::FrontierBytes::empty(),
|
||
reduction_algorithm_version: ReductionAlgorithmVersion(0),
|
||
profile_id: ProfileId::Lite, // NOT declared by the manifest
|
||
root,
|
||
hash: root.hash,
|
||
});
|
||
m
|
||
});
|
||
assert!(matches!(err, Err(BundleError::Decode(_))));
|
||
assert_eq!(bundle.generation(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn oversize_manifest_is_refused_by_the_writer() {
|
||
// Finding 2: a manifest exceeding the reader limit is rejected at
|
||
// create/commit, not only on reopen.
|
||
let mut m = Manifest::empty(DocumentId([2; 16]));
|
||
m.extension_declarations
|
||
.push(crate::manifest::ExtensionDeclaration {
|
||
extension_id: crate::ids::ExtensionId([1; 16]),
|
||
version: crate::ids::SemVer::new(1, 0, 0),
|
||
required: false,
|
||
preserved_chunk_roots: Vec::new(),
|
||
affected_object_kinds: vec![0u8; (MAX_MANIFEST_BYTES + 1) as usize],
|
||
edit_barriers: Vec::new(),
|
||
});
|
||
assert!(matches!(
|
||
Bundle::create(MemStore::new(), FileUuid([1; 16]), m),
|
||
Err(BundleError::ResourceLimitExceeded { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn block_size_limit_follows_the_active_superblock_profile() {
|
||
// Finding 4: a bundle selected under a smaller profile reads blocks under
|
||
// that profile's limit, not the canonical-first profile's.
|
||
use crate::manifest::{ProfileConstraints, ProfileDeclaration, RetentionPolicy};
|
||
let big = ProfileConstraints {
|
||
max_uncompressed_block_size: 64 << 20,
|
||
retention_policy: RetentionPolicy::DEFAULT_FULL,
|
||
};
|
||
let small = ProfileConstraints {
|
||
max_uncompressed_block_size: 2048,
|
||
retention_policy: RetentionPolicy::DEFAULT_FULL,
|
||
};
|
||
let mut m = Manifest::empty(DocumentId([3; 16]));
|
||
m.profile_declarations = vec![
|
||
ProfileDeclaration {
|
||
profile_id: ProfileId::Full,
|
||
version: crate::ids::SemVer::new(0, 1, 0),
|
||
constraints: big,
|
||
},
|
||
ProfileDeclaration {
|
||
profile_id: ProfileId::Lite,
|
||
version: crate::ids::SemVer::new(0, 1, 0),
|
||
constraints: small,
|
||
},
|
||
];
|
||
let mut bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap();
|
||
// Canonical-first profile is Full (discriminant 0): active = Full limit.
|
||
assert_eq!(bundle.superblock().profile_id, ProfileId::Full);
|
||
assert_eq!(bundle.max_block_size(), 64 << 20);
|
||
// Simulate a bundle selected under Lite (as a foreign writer might emit):
|
||
// the active limit must follow the superblock, not canonical-first.
|
||
bundle.superblock.profile_id = ProfileId::Lite;
|
||
assert_eq!(bundle.max_block_size(), 2048);
|
||
}
|
||
|
||
fn profile(
|
||
profile_id: ProfileId,
|
||
version: crate::ids::SemVer,
|
||
max_block: u64,
|
||
) -> ProfileDeclaration {
|
||
ProfileDeclaration {
|
||
profile_id,
|
||
version,
|
||
constraints: crate::manifest::ProfileConstraints {
|
||
max_uncompressed_block_size: max_block,
|
||
retention_policy: crate::manifest::RetentionPolicy::DEFAULT_FULL,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn profile_support_is_classified_correctly() {
|
||
// Findings 2/3/6: built-in editable, ReadOnly understood-but-not-editable,
|
||
// Custom/future-major/oversize-block not understood.
|
||
let v0 = crate::ids::SemVer::new(0, 1, 0);
|
||
let full = profile(ProfileId::Full, v0, 1 << 20);
|
||
assert!(profile_is_editable(&full));
|
||
|
||
let ro = profile(ProfileId::ReadOnly, v0, 1 << 20);
|
||
assert!(profile_is_understood(&ro) && !profile_is_editable(&ro));
|
||
|
||
let custom = profile(
|
||
ProfileId::Custom(crate::ids::ProfileRegistryId([1; 16])),
|
||
v0,
|
||
1 << 20,
|
||
);
|
||
assert!(!profile_is_understood(&custom));
|
||
|
||
let future = profile(ProfileId::Full, crate::ids::SemVer::new(1, 0, 0), 1 << 20);
|
||
assert!(!profile_is_understood(&future));
|
||
|
||
let huge = profile(ProfileId::Full, v0, MAX_CHUNK_BYTES + 1);
|
||
assert!(!profile_is_understood(&huge));
|
||
}
|
||
|
||
#[test]
|
||
fn create_rejects_unsupported_or_duplicate_active_profiles() {
|
||
let v0 = crate::ids::SemVer::new(0, 1, 0);
|
||
let make = |decls: Vec<ProfileDeclaration>| {
|
||
let mut m = Manifest::empty(DocumentId([1; 16]));
|
||
m.profile_declarations = decls;
|
||
Bundle::create(MemStore::new(), FileUuid([1; 16]), m)
|
||
};
|
||
// Custom-only (no understood profile to operate under).
|
||
assert!(make(vec![profile(
|
||
ProfileId::Custom(crate::ids::ProfileRegistryId([2; 16])),
|
||
v0,
|
||
1 << 20
|
||
)])
|
||
.is_err());
|
||
// Block bound beyond the reader's hard limit (unsupported).
|
||
assert!(make(vec![profile(ProfileId::Full, v0, MAX_CHUNK_BYTES + 1)]).is_err());
|
||
// Duplicate profile id.
|
||
assert!(make(vec![
|
||
profile(ProfileId::Full, v0, 1 << 20),
|
||
profile(ProfileId::Full, crate::ids::SemVer::new(0, 2, 0), 1 << 20),
|
||
])
|
||
.is_err());
|
||
// A plain Full profile is fine.
|
||
assert!(make(vec![profile(ProfileId::Full, v0, 1 << 20)]).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn read_only_profile_is_emittable_as_a_read_only_bundle() {
|
||
// Finding: a sole ReadOnly profile is a *valid* bundle to produce — it
|
||
// just opens read-only (the spec describes ReadOnly-produced bundles).
|
||
let v0 = crate::ids::SemVer::new(0, 1, 0);
|
||
let mut m = Manifest::empty(DocumentId([1; 16]));
|
||
m.profile_declarations = vec![profile(ProfileId::ReadOnly, v0, 1 << 20)];
|
||
let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap();
|
||
assert_eq!(bundle.superblock().profile_id, ProfileId::ReadOnly);
|
||
assert!(bundle.is_read_only());
|
||
// Round-trips: a reopen agrees it is read-only.
|
||
let image = bundle.into_store().into_bytes();
|
||
assert!(Bundle::open(MemStore::from_bytes(image))
|
||
.unwrap()
|
||
.is_read_only());
|
||
}
|
||
|
||
#[test]
|
||
fn editable_profile_is_preferred_for_the_active_superblock() {
|
||
// [ReadOnly, Lite]: ReadOnly sorts first, but the bundle is emitted under
|
||
// the editable Lite profile, so it is editable.
|
||
let v0 = crate::ids::SemVer::new(0, 1, 0);
|
||
let mut m = Manifest::empty(DocumentId([1; 16]));
|
||
m.profile_declarations = vec![
|
||
profile(ProfileId::ReadOnly, v0, 1 << 20),
|
||
profile(ProfileId::Lite, v0, 1 << 20),
|
||
];
|
||
let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap();
|
||
assert_eq!(bundle.superblock().profile_id, ProfileId::Lite);
|
||
assert!(!bundle.is_read_only());
|
||
}
|
||
|
||
#[test]
|
||
fn commit_validates_roots_under_the_profile_it_emits() {
|
||
let make_bundle = |read_only_max, lite_max| {
|
||
let v0 = crate::ids::SemVer::new(0, 1, 0);
|
||
let mut m = Manifest::empty(DocumentId([1; 16]));
|
||
m.profile_declarations = vec![
|
||
profile(ProfileId::ReadOnly, v0, read_only_max),
|
||
profile(ProfileId::Lite, v0, lite_max),
|
||
];
|
||
Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap()
|
||
};
|
||
let staged = || StagedChunk::operation_block(block::encode_block(&[vec![7; 16]]));
|
||
let append_root = |ctx: &CommitContext| {
|
||
let mut m = ctx.previous_manifest.clone();
|
||
m.operation_roots.push(ctx.new_chunks[0]);
|
||
m
|
||
};
|
||
|
||
// ReadOnly sorts first, but Lite is the emitted active profile. Its
|
||
// smaller bound must reject this 24-byte operation block.
|
||
let mut strict_lite = make_bundle(1024, 8);
|
||
assert_eq!(strict_lite.superblock().profile_id, ProfileId::Lite);
|
||
assert!(strict_lite.commit(&[staged()], append_root).is_err());
|
||
assert_eq!(strict_lite.generation(), 0);
|
||
|
||
// Conversely, the selected Lite profile's larger bound must admit the
|
||
// block even though canonical-first ReadOnly has a smaller bound.
|
||
let mut permissive_lite = make_bundle(8, 1024);
|
||
permissive_lite.commit(&[staged()], append_root).unwrap();
|
||
let root = permissive_lite.manifest().operation_roots[0];
|
||
assert!(permissive_lite.read_operation_block(&root).is_ok());
|
||
let reopened = Bundle::open(MemStore::from_bytes(
|
||
permissive_lite.into_store().into_bytes(),
|
||
))
|
||
.unwrap();
|
||
assert!(reopened.read_operation_block(&root).is_ok());
|
||
}
|
||
|
||
/// Builds a minimal valid bundle image at `generation`, with the given
|
||
/// profile declared and named by the superblock.
|
||
fn craft_image(generation: u64, profile_id: ProfileId) -> Vec<u8> {
|
||
let mut m = Manifest::empty(DocumentId([1; 16]));
|
||
m.generation = generation;
|
||
// Always declare Full (a distinct editable profile); add the named active
|
||
// profile only when it is something other than Full, to avoid a duplicate.
|
||
let mut decls = vec![ProfileDeclaration::full()];
|
||
if profile_id != ProfileId::Full {
|
||
decls.push(profile(
|
||
profile_id,
|
||
crate::ids::SemVer::new(0, 1, 0),
|
||
1 << 20,
|
||
));
|
||
}
|
||
m.profile_declarations = decls;
|
||
let payload = m.encode();
|
||
let mut image = vec![0u8; BODY_START as usize];
|
||
image.extend_from_slice(&payload);
|
||
let sb = Superblock {
|
||
generation,
|
||
manifest_offset: BODY_START,
|
||
manifest_length: payload.len() as u64,
|
||
manifest_hash: manifest_chunk_hash(&payload),
|
||
manifest_schema_version: SchemaVersion::V0,
|
||
reduction_algorithm_version: ReductionAlgorithmVersion(0),
|
||
profile_id,
|
||
commit_state: CommitState::Committed,
|
||
commit_timestamp: WallClockTime(0),
|
||
};
|
||
image[0..crate::header::HEADER_LEN as usize]
|
||
.copy_from_slice(&FixedHeader::new(FileUuid([1; 16])).encode());
|
||
image[SLOT_A_OFFSET as usize..SLOT_A_OFFSET as usize + SUPERBLOCK_LEN as usize]
|
||
.copy_from_slice(&sb.encode());
|
||
image
|
||
}
|
||
|
||
#[test]
|
||
fn read_only_profile_opens_read_only() {
|
||
// Finding 6: a bundle whose active profile is ReadOnly opens read-only
|
||
// (v0 does not auto-upgrade it).
|
||
let image = craft_image(0, ProfileId::ReadOnly);
|
||
let bundle = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert!(bundle.is_read_only());
|
||
assert!(bundle.anomalies().is_empty()); // a normal read-only bundle
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_custom_profile_opens_read_only_with_anomaly() {
|
||
// Finding 2: a Custom (registry-defined) active profile is unsupported in
|
||
// v0 — open read-only and surface it.
|
||
let image = craft_image(0, ProfileId::Custom(crate::ids::ProfileRegistryId([9; 16])));
|
||
let bundle = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert!(bundle.is_read_only());
|
||
assert!(bundle
|
||
.anomalies()
|
||
.contains(&IntegrityAnomaly::UnsupportedProfile));
|
||
}
|
||
|
||
#[test]
|
||
fn generation_exhaustion_is_an_error_not_a_panic() {
|
||
// Finding 5: committing a generation-u64::MAX bundle returns an error.
|
||
let image = craft_image(u64::MAX, ProfileId::Full);
|
||
let mut bundle = Bundle::open(MemStore::from_bytes(image)).unwrap();
|
||
assert_eq!(bundle.generation(), u64::MAX);
|
||
assert!(matches!(
|
||
bundle.commit(&[], |ctx| ctx.previous_manifest.clone()),
|
||
Err(BundleError::GenerationExhausted)
|
||
));
|
||
}
|
||
}
|