epiphany/crates/epiphany-bundle/src/error.rs

290 lines
13 KiB
Rust

//! Bundle error and integrity-anomaly types.
//!
//! The spec draws a sharp line (Chapter 8 §"Superblock Selection",
//! §"Crash Recovery", §"Canonical and Non-Canonical Manifest Roots") between
//! two failure categories, and so does this module:
//!
//! * **Hard errors** ([`BundleError`]) — the bundle cannot be opened or a
//! *canonical* chunk failed verification. *"if neither superblock validates,
//! the file is corrupt and MUST be reported as such"*; failed verification of
//! a canonical chunk *"is corruption and MUST be surfaced as a hard error."*
//! * **Integrity anomalies** ([`IntegrityAnomaly`]) — the file is openable but
//! structurally suspicious (e.g. a generation gap > 1). The reader *"MAY
//! continue in read-only recovery mode … it MUSTNOT silently treat the file
//! as normal."* These are returned alongside a successfully opened (read-only)
//! bundle, not as errors.
use crate::codec::DecodeError;
use crate::ids::SchemaVersion;
use epiphany_determinism::ContentHash;
/// A hard bundle failure: the file is unopenable, or a canonical chunk is
/// corrupt. Recoverable conditions (a torn inactive slot, unreachable garbage)
/// are *not* errors — they are handled silently by superblock selection.
#[derive(Debug)]
pub enum BundleError {
/// An underlying storage operation failed.
Io(std::io::Error),
/// The fixed header's magic bytes were not `"MUSCBND\0"`. Not an Epiphany
/// bundle (Chapter 8 §"The Fixed Header": readers verify magic first).
BadHeaderMagic,
/// The fixed header's CRC did not match its contents (the header is torn or
/// corrupt). Readers verify the header CRC before consulting anything else.
HeaderCrcMismatch,
/// The header declared a length this format version cannot interpret.
UnsupportedHeaderLength { declared: u32 },
/// The header declared a major format version this reader does not support.
UnsupportedFormatVersion { major: u16, minor: u16 },
/// Neither superblock slot was valid for ordinary selection: the file is
/// corrupt (Chapter 8 §"Superblock Selection", step 6). Readers MUST surface
/// this and MUSTNOT synthesize state.
NoValidSuperblock,
/// The active superblock's manifest chunk failed BLAKE3 verification: a
/// canonical chunk is corrupt, which is a hard error.
ManifestHashMismatch {
expected: ContentHash,
actual: ContentHash,
},
/// A canonical chunk's recomputed hash did not match its declared hash
/// (Chapter 8 §"Chunks": canonical-chunk hash failure is hard corruption).
ChunkHashMismatch {
expected: ContentHash,
actual: ContentHash,
},
/// A chunk's payload length did not match its declared `uncompressed_length`
/// (Chapter 8: length mismatch is corruption).
ChunkLengthMismatch { expected: u64, actual: u64 },
/// A chunk reference pointed outside the bundle's bytes (or into the fixed
/// prelude, which holds no chunks).
ChunkOutOfBounds {
offset: u64,
length: u64,
file_len: u64,
},
/// The selected manifest's self-declared generation did not match the
/// superblock that referenced it: structural corruption / a non-conforming
/// writer (Chapter 8 §"The Manifest": the manifest's `generation` matches the
/// referencing superblock).
GenerationMismatch { superblock: u64, manifest: u64 },
/// A declared length exceeded the reader's resource-limit policy (Chapter 8
/// §"Blobs": *"uncompressed length MUST be checked against the reader's
/// policy before decompression begins"*). Checked before any allocation, so
/// an untrusted length in a (possibly sparse) file cannot drive an OOM.
ResourceLimitExceeded { length: u64, limit: u64 },
/// A chunk declared a `Reserved` compression algorithm. This format version
/// defines only `None` and `Zstd`; further algorithms require a new format
/// major version (Chapter 8 §"Compression").
UnsupportedCompression,
/// A zstd-compressed chunk's stream failed to decompress: malformed,
/// truncated, or producing more bytes than the declared
/// `uncompressed_length` (Chapter 8 §"Compression": decompression MUST
/// verify the output length against the declared length). Surfaced as
/// typed corruption — never a panic or an unbounded allocation.
Decompression(std::io::Error),
/// A manifest chunk reference declared compression. The manifest chunk
/// MUST be stored uncompressed in this format version (Chapter 8
/// §"Manifest Encoding"): it is the bootstrap entry into the chunk graph,
/// decodable from header + superblock information alone.
CompressedManifest,
/// A chunk declared a schema major version this reader cannot parse
/// (Chapter 8 §"Schema Versioning").
UnsupportedSchemaVersion { version: SchemaVersion },
/// The generation counter is exhausted (`u64::MAX`): no further commit can
/// allocate a higher generation. Surfaced instead of overflow-panicking.
GenerationExhausted,
/// A structured payload (manifest, superblock, etc.) failed to decode.
Decode(DecodeError),
/// An edit was attempted on a bundle opened read-only (an unknown required
/// extension, or a recovery/anomaly open). Chapter 8 §"Behavior Under
/// Unknown Extensions" / §"Superblock Selection".
ReadOnly,
}
impl core::fmt::Display for BundleError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
BundleError::Io(e) => write!(f, "bundle I/O error: {e}"),
BundleError::BadHeaderMagic => f.write_str("not an Epiphany bundle (bad header magic)"),
BundleError::HeaderCrcMismatch => {
f.write_str("fixed header CRC mismatch (corrupt header)")
}
BundleError::UnsupportedHeaderLength { declared } => {
write!(f, "unsupported header length {declared}")
}
BundleError::UnsupportedFormatVersion { major, minor } => {
write!(f, "unsupported format version {major}.{minor}")
}
BundleError::NoValidSuperblock => f.write_str("no valid superblock: bundle is corrupt"),
BundleError::ManifestHashMismatch { expected, actual } => write!(
f,
"manifest hash mismatch: superblock declared {expected:?}, computed {actual:?}"
),
BundleError::ChunkHashMismatch { expected, actual } => write!(
f,
"canonical chunk hash mismatch: declared {expected:?}, computed {actual:?}"
),
BundleError::ChunkLengthMismatch { expected, actual } => {
write!(
f,
"chunk length mismatch: declared {expected}, got {actual}"
)
}
BundleError::GenerationMismatch {
superblock,
manifest,
} => write!(
f,
"manifest generation {manifest} does not match superblock generation {superblock}"
),
BundleError::ChunkOutOfBounds {
offset,
length,
file_len,
} => write!(
f,
"chunk ref [{offset}, {offset}+{length}) lies outside the {file_len}-byte file"
),
BundleError::ResourceLimitExceeded { length, limit } => {
write!(
f,
"declared length {length} exceeds the reader limit {limit}"
)
}
BundleError::UnsupportedCompression => f.write_str(
"reserved compression algorithm; this format version defines None and Zstd",
),
BundleError::Decompression(e) => write!(f, "zstd decompression failed: {e}"),
BundleError::CompressedManifest => f.write_str(
"manifest chunk is compressed; the manifest MUST be stored uncompressed in this format version",
),
BundleError::UnsupportedSchemaVersion { version } => {
write!(
f,
"unsupported schema version {}.{}",
version.major, version.minor
)
}
BundleError::GenerationExhausted => {
f.write_str("generation counter exhausted (u64::MAX); cannot commit")
}
BundleError::Decode(e) => write!(f, "decode error: {e}"),
BundleError::ReadOnly => f.write_str("bundle is open read-only; edits are refused"),
}
}
}
impl std::error::Error for BundleError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
BundleError::Io(e) => Some(e),
BundleError::Decompression(e) => Some(e),
BundleError::Decode(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for BundleError {
fn from(e: std::io::Error) -> Self {
BundleError::Io(e)
}
}
impl From<DecodeError> for BundleError {
fn from(e: DecodeError) -> Self {
BundleError::Decode(e)
}
}
/// A structural anomaly that does *not* prevent opening but does forbid treating
/// the bundle as normal (Chapter 8 §"Superblock Selection"). Surfaced alongside
/// a read-only open, never thrown as an error. Kept distinct from semantic
/// `ConflictKind`/`IntegrityAnomaly` types in `epiphany-ops`: those are facts
/// about canonical *state*; these are facts about the physical *bundle*.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum IntegrityAnomaly {
/// Both slots are valid but their generations differ by more than one
/// (Chapter 8 §"Superblock Selection", step 4): possible tampering, an
/// accidental merge, or a non-conforming writer. Recovery continues
/// read-only at the highest-generation valid slot.
GenerationGap { active: u64, other: u64 },
/// Both slots are valid at the *same* generation but reference *different*
/// manifests. The spec's selection rule has no tie-break for equal
/// generations with divergent content; this crate treats it as an anomaly
/// and opens read-only at slot A (see `DECISIONS.md`, Pass 11 candidate).
DivergentSameGeneration { generation: u64 },
/// A slot carried a non-`Committed` commit state (Chapter 8
/// §"Superblock Selection", step 3): a crashed or non-conforming writer.
/// The slot is excluded from ordinary selection; its presence is reported.
NonCommittedSlot,
/// The manifest declares a `required` extension this implementation does not
/// understand (Chapter 8 §"Behavior Under Unknown Extensions"). v0 supports
/// no extensions, so any required extension is unknown; the bundle opens
/// strictly read-only.
UnknownRequiredExtension,
/// The active superblock names a profile this implementation does not
/// understand — a `Custom` registry profile, an unsupported profile major
/// version, or one demanding a block bound beyond the reader's limit
/// (Chapter 8 §"Format Profiles"). The bundle opens read-only.
UnsupportedProfile,
/// A declared **canonical** root — an operation-envelope block — is stamped
/// at a schema major above this reader's accept-set for that role
/// (Binary Format companion §"Schema Major 1", "Canonical chunks — parse or
/// open read-only"). The reader cannot interpret the newer op bytes, so it
/// opens the bundle read-only: it still reads the canonical base and
/// manifest (both stay major 0) but refuses to author against op history it
/// cannot parse. Carries the offending major.
UnsupportedCanonicalChunkMajor { schema_major: u16 },
}
impl core::fmt::Display for IntegrityAnomaly {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
IntegrityAnomaly::GenerationGap { active, other } => write!(
f,
"superblock generation gap > 1 (active {active}, other {other}); read-only recovery"
),
IntegrityAnomaly::DivergentSameGeneration { generation } => write!(
f,
"both slots valid at generation {generation} with divergent manifests; read-only recovery"
),
IntegrityAnomaly::NonCommittedSlot => {
f.write_str("a superblock slot was not in the Committed state")
}
IntegrityAnomaly::UnknownRequiredExtension => {
f.write_str("an unknown required extension forces read-only mode")
}
IntegrityAnomaly::UnsupportedProfile => {
f.write_str("the active profile is unsupported; opened read-only")
}
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major } => write!(
f,
"a canonical operation block is at schema major {schema_major}, above this reader's accept-set; opened read-only"
),
}
}
}