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

345 lines
12 KiB
Rust

//! Decode conformance vectors for the bundle wire (P4 of the decode-hardening
//! track). See `epiphany_ops::vectors` for the corpus's purpose and columns.
//!
//! These live inside the crate because several rejection classes can only be
//! *constructed* with internal access: a manifest whose vectors are out of order
//! but whose `manifest_id` is correctly derived over the reordered body isolates
//! the ordering rule from the id rule, and `ManifestId::derive` is crate-private.
use crate::chunk::{ChunkKind, ChunkRef, CompressionAlgorithm};
use crate::ids::{DocumentId, ManifestId, SchemaVersion};
use crate::manifest::Manifest;
use crate::opindex::OperationIndex;
use crate::{block, DecodeError};
use epiphany_determinism::{ChunkId, ContentHash};
/// `(surface, verdict, class, name, bytes)` — see `epiphany_ops::vectors`.
pub type DecodeVector = (&'static str, &'static str, &'static str, String, Vec<u8>);
fn row(
surface: &'static str,
verdict: &'static str,
class: &'static str,
name: impl Into<String>,
bytes: Vec<u8>,
) -> DecodeVector {
(surface, verdict, class, name.into(), bytes)
}
/// A `ChunkRef` encodes as 95 bytes: id 32, kind 1, schema 4, offset 8,
/// compressed_length 8, uncompressed_length 8, compression 2, hash 32.
const CHUNK_REF_LEN: usize = 32 + 1 + 4 + 8 + 8 + 8 + 2 + 32;
/// Offset of the compression *parameter* byte within a `ChunkRef`.
const CHUNK_REF_COMPRESSION_PARAM: usize = 32 + 1 + 4 + 8 + 8 + 8 + 1;
fn block_ref(hash_byte: u8, offset: u64) -> ChunkRef {
ChunkRef {
id: ChunkId(ContentHash([hash_byte; 32])),
kind: ChunkKind::OperationEnvelopeBlock,
schema_version: SchemaVersion::V0,
offset,
compressed_length: 64,
uncompressed_length: 64,
compression: CompressionAlgorithm::None,
hash: ContentHash([hash_byte; 32]),
}
}
fn swap(bytes: &[u8], first: usize, width: usize) -> Vec<u8> {
let second = first + width;
let mut out = bytes.to_vec();
out[first..second].copy_from_slice(&bytes[second..second + width]);
out[second..second + width].copy_from_slice(&bytes[first..second]);
out
}
/// Every bundle-wire vector.
pub fn decode_vectors() -> Vec<DecodeVector> {
let mut v: Vec<DecodeVector> = Vec::new();
// --- Manifest ----------------------------------------------------------
const MAN: &str = "bundle.manifest";
let doc = DocumentId([5; 16]);
let empty = Manifest::empty(doc);
v.push(row(MAN, "accept", "-", "empty_manifest", empty.encode()));
let mut one = Manifest::empty(doc);
one.operation_roots.push(block_ref(0x11, 576));
let one_bytes = one.encode();
v.push(row(
MAN,
"accept",
"-",
"one_operation_root",
one_bytes.clone(),
));
// Body edit: `manifest_id` is derived from the body, so it no longer matches.
let mut id_mismatch = one_bytes.clone();
let last = id_mismatch.len() - 1;
id_mismatch[last] ^= 0xFF;
v.push(row(
MAN,
"reject",
"manifest-id-mismatch",
"one_root_body_edited",
id_mismatch,
));
// Two roots, encoded in canonical (sorted) order. Swapping them and then
// *re-deriving the id over the reordered body* isolates the ordering rule:
// the id is right, the order is wrong, and only the re-encode guard rejects
// it — `encode_body` sorts, so the round trip cannot reproduce these bytes.
let mut two = Manifest::empty(doc);
two.operation_roots.push(block_ref(0x11, 576));
two.operation_roots.push(block_ref(0x22, 1024));
let two_bytes = two.encode();
v.push(row(
MAN,
"accept",
"-",
"two_operation_roots",
two_bytes.clone(),
));
// Locate the first root: id(16) + document_id(16) + lineage option tag(1)
// + generation(8) + roots count(4).
const ROOTS_AT: usize = 16 + 16 + 1 + 8 + 4;
let mut reordered = swap(&two_bytes, ROOTS_AT, CHUNK_REF_LEN);
let body = &reordered[16..];
let redone = ManifestId::derive(doc, 0, body);
reordered[0..16].copy_from_slice(&redone.0.to_be_bytes());
v.push(row(
MAN,
"reject",
"non-canonical-vec-order",
"two_roots_out_of_order_valid_id",
reordered,
));
let mut trailing = one_bytes.clone();
trailing.push(0);
v.push(row(
MAN,
"reject",
"trailing-bytes",
"one_root_trailing",
trailing,
));
// --- OperationIndex ----------------------------------------------------
const IDX: &str = "bundle.operation_index";
let empty_index = OperationIndex::build(&[]).expect("empty").encode();
v.push(row(IDX, "accept", "-", "empty_index", empty_index.clone()));
let one_index = OperationIndex::build(&[(block_ref(0x11, 576), vec![([2; 16], 8)])])
.expect("one block")
.encode();
v.push(row(
IDX,
"accept",
"-",
"one_block_one_entry",
one_index.clone(),
));
let two_index = OperationIndex::build(&[
(block_ref(0x11, 576), vec![([1; 16], 8), ([3; 16], 40)]),
(block_ref(0x22, 1024), vec![([2; 16], 8)]),
])
.expect("two blocks")
.encode();
v.push(row(IDX, "accept", "-", "two_blocks", two_index.clone()));
// The lenient-compression class. `CompressionAlgorithm::None` used to ignore
// this byte, so `[0, 0xFF]` and `[0, 0]` decoded alike: two byte strings, one
// value. The index has no whole-value re-encode guard, so it accepted both.
// (Push 5 / P3; `req:binfmt:compression-none-parameter`.)
let mut lenient = one_index.clone();
lenient[4 + CHUNK_REF_COMPRESSION_PARAM] = 0xFF;
v.push(row(
IDX,
"reject",
"lenient-sub-codec",
"compression_none_non_zero_parameter",
lenient,
));
// Strict ascent, checked per-site (there is no guard here to catch it).
v.push(row(
IDX,
"reject",
"non-canonical-vec-order",
"blocks_out_of_order",
swap(&two_index, 4, CHUNK_REF_LEN),
));
// Entries are 24 bytes: id(16) + block(4) + offset(4). They begin after the
// blocks and their count.
let entries_at = 4 + 2 * CHUNK_REF_LEN + 4;
v.push(row(
IDX,
"reject",
"non-canonical-vec-order",
"entries_out_of_order",
swap(&two_index, entries_at, 24),
));
// An entry naming a block ordinal that does not exist.
let mut bad_ordinal = one_index.clone();
let ordinal_at = 4 + CHUNK_REF_LEN + 4 + 16;
bad_ordinal[ordinal_at..ordinal_at + 4].copy_from_slice(&7u32.to_le_bytes());
v.push(row(
IDX,
"reject",
"block-ordinal-out-of-range",
"entry_names_missing_block",
bad_ordinal,
));
// A block reference whose kind is not an operation-envelope block.
let mut wrong_kind = one_index.clone();
wrong_kind[4 + 32] = ChunkKind::Snapshot.discriminant();
v.push(row(
IDX,
"reject",
"wrong-chunk-kind",
"block_is_not_an_envelope_block",
wrong_kind,
));
let mut idx_trailing = empty_index.clone();
idx_trailing.push(0);
v.push(row(
IDX,
"reject",
"trailing-bytes",
"empty_index_trailing",
idx_trailing,
));
// --- Block payload framing ---------------------------------------------
const BLK: &str = "bundle.block";
let payloads: Vec<Vec<u8>> = vec![vec![0xAA; 8], vec![0xBB; 13]];
let packed = block::pack_operation_blocks(&payloads);
let good = packed.first().expect("one block").clone();
v.push(row(BLK, "accept", "-", "two_envelopes", good.clone()));
let mut blk_trailing = good.clone();
blk_trailing.push(0);
v.push(row(
BLK,
"reject",
"trailing-bytes",
"two_envelopes_trailing",
blk_trailing,
));
let mut blk_truncated = good.clone();
blk_truncated.pop();
v.push(row(
BLK,
"reject",
"truncated",
"two_envelopes_truncated",
blk_truncated,
));
// A declared envelope count far past the bytes remaining: the decoder must
// reject on the count, not pre-allocate for it.
let mut huge = good.clone();
huge[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
v.push(row(
BLK,
"reject",
"count-exceeds-remaining",
"envelope_count_u32_max",
huge,
));
v
}
/// Applies `surface`'s decoder to `bytes`.
///
/// `Ok(injective)` means the decoder **accepted**, and `injective` says whether
/// the value re-encodes to exactly these bytes; `Err` means it **rejected**. See
/// `epiphany_ops::vectors::check` for why those must not be collapsed.
///
/// `None` for surfaces this crate does not own.
pub fn check(surface: &str, bytes: &[u8]) -> Option<Result<bool, String>> {
fn report<T>(r: Result<T, DecodeError>) -> Result<T, String> {
r.map_err(|e| format!("{e:?}"))
}
match surface {
"bundle.manifest" => Some(report(Manifest::decode(bytes)).map(|m| m.encode() == bytes)),
"bundle.operation_index" => {
Some(report(OperationIndex::decode(bytes)).map(|i| i.encode() == bytes))
}
// A block payload is a framing, not a canonical value: `decode_block`
// returns the envelopes, and re-framing them reproduces the payload.
"bundle.block" => Some(report(block::decode_block(bytes)).map(|envelopes| {
block::pack_operation_blocks(&envelopes)
.first()
.is_some_and(|packed| packed == bytes)
})),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_vector_gets_its_declared_verdict() {
for (surface, verdict, class, name, bytes) in decode_vectors() {
let result = check(surface, &bytes).expect("a surface this crate owns");
match (verdict, &result) {
("accept", Ok(true)) => {}
("accept", Ok(false)) => {
panic!("{surface}/{name}: accepted but does not re-encode to its bytes")
}
("reject", Err(_)) => {}
_ => panic!("{surface}/{name} ({class}): declared {verdict}, got {result:?}"),
}
}
}
#[test]
fn every_surface_carries_both_verdicts() {
for surface in ["bundle.manifest", "bundle.operation_index", "bundle.block"] {
let rows: Vec<_> = decode_vectors()
.into_iter()
.filter(|(s, ..)| *s == surface)
.collect();
assert!(
rows.iter().any(|(_, v, ..)| *v == "accept"),
"{surface} has no accept vector"
);
assert!(
rows.iter().any(|(_, v, ..)| *v == "reject"),
"{surface} has no reject vector"
);
}
}
/// The reordered-manifest vector must isolate the *ordering* rule: its
/// `manifest_id` is correctly derived over the reordered body, so it is not
/// rejected merely for a stale id. If this stopped holding, the vector would
/// be pinning the wrong rejection.
#[test]
fn the_reordered_manifest_vector_carries_a_valid_id() {
let (.., bytes) = decode_vectors()
.into_iter()
.find(|(.., name, _)| *name == "two_roots_out_of_order_valid_id")
.expect("the vector exists");
let derived = ManifestId::derive(DocumentId([5; 16]), 0, &bytes[16..]);
let stored = ManifestId(u128::from_be_bytes(
bytes[0..16].try_into().expect("16 bytes"),
));
assert_eq!(stored, derived, "the id matches its (reordered) body");
assert!(Manifest::decode(&bytes).is_err(), "the order still rejects");
}
}