Push 5 / P4: the decode conformance corpus, and the tag it could not read back
spec/vectors/decode_vectors.txt -- 37 committed byte strings across five surfaces, each with its normative accept/reject verdict. The reference implementation's fuzzers prove its own decoders self-consistent, which says nothing about whether a foreign decoder agrees with the format. This is what one is checked against. Gated in the conformance suite as [7d], and drift-locked: the committed file must equal vectors::render(), so a wire-format change lands in the diff. It found a real defect on its first run. OperationKindTag::TransposeInterval encoded to [30] and its own decoder REJECTED it -- Push 4a added the variant to discriminant() and never to decode_canonical. OperationKindTag is what edit barriers persist, so a barrier prohibiting TransposeInterval could be written and never read back. Silent data loss on reopen. Four things should have caught it. None did, and two made it worse: The round-trip test enumerated DISCRIMINANTS -- (0u8..30).map(decode_canonical) -- starting from bytes the decoder already knew, so it structurally could not notice a variant the decoder was missing. It now enumerates VARIANTS from one all_tags() list, with a completeness check in both directions. The distinctness test's hand-written variant list omitted it too. Same list now. operation_kind_tag_decode_rejects_malformed_bytes asserted that tag 30 is REJECTED, and layout-ir's decode_rejects_unknown_discriminants asserted the same at the barrier surface. Both were locking the bug in place and made it look deliberate. Both now name 31, and a new barrier test round-trips a barrier prohibiting every tag -- the persistence surface where this actually bites. The P2 decode fuzzer fed valid corpus bytes to the tag decoder and tallied the failure as a REJECTION, like any garbage input. It never asserted that an unmutated corpus entry decodes. Both fuzzers now do, as a pre-pass. The harness had the same disease as the code. `check` collapsed "rejected" with "accepted but does not re-encode", so a decoder that silently normalizes non-canonical bytes PASSED the reject vectors it was written to catch. Verified: removing the whole-state guard, and restoring the lenient compression codec, both left the corpus green. `check` now returns Ok(injective) for accept and Err for reject and never conflates them -- silently normalizing non-canonical bytes IS accepting them. With that fixed, all four defect mutations fail the corpus, each naming its class. The corpus pins one vector per class this repo has shipped a bug in: non-canonical-map-order (a guard catches it; no per-site check exists), non-canonical-vec-order (only a per-site check catches it; a guard is blind), lenient-sub-codec (a guard masked it in the manifest; the index had none), plus trailing-bytes, truncated, unknown-discriminant, count-exceeds-remaining. A test fails if one goes missing. Gate: fmt clean, clippy 0, 30 targets / 1024 passed / 0 failed, docs 0 under -D warnings, conformance 8/8 (now including [7d]), zero golden churn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
09a7f62802
commit
a41596d329
|
|
@ -327,6 +327,22 @@ pub fn run_wire_decode_fuzz(iters: u64, seed: u64) -> WireFuzzCoverage {
|
|||
.collect()
|
||||
};
|
||||
|
||||
// The corpus must decode. An *unmutated* valid byte string that a decoder
|
||||
// rejects would otherwise be tallied as a rejection, like any garbage input
|
||||
// — which is exactly how a broken `OperationKindTag` decoder hid inside the
|
||||
// ops fuzzer for two commits (Push 5 / P4).
|
||||
for bytes in &manifests {
|
||||
let m = Manifest::decode(bytes).expect("a valid manifest must decode");
|
||||
assert_eq!(m.encode(), *bytes);
|
||||
}
|
||||
for bytes in &valid_indices {
|
||||
let i = OperationIndex::decode(bytes).expect("a valid operation index must decode");
|
||||
assert_eq!(i.encode(), *bytes);
|
||||
}
|
||||
for bytes in &valid_blocks {
|
||||
block::decode_block(bytes).expect("a valid block payload must decode");
|
||||
}
|
||||
|
||||
for _ in 0..iters {
|
||||
// 1. Whole-image open. Must never panic; an Ok manifest must re-encode.
|
||||
let pick = (rng.next_u64() as usize) % images.len();
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ mod store;
|
|||
mod superblock;
|
||||
|
||||
pub mod fuzz;
|
||||
pub mod vectors;
|
||||
|
||||
pub use block::{
|
||||
decode_block, encode_block, envelope_offsets, pack_operation_blocks, BLOCK_SOFT_LIMIT,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,322 @@
|
|||
//! 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,
|
||||
&'static str,
|
||||
Vec<u8>,
|
||||
);
|
||||
|
||||
/// 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((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((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((
|
||||
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((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((
|
||||
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((
|
||||
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((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((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((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((
|
||||
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((
|
||||
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((
|
||||
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((
|
||||
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((
|
||||
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((
|
||||
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((BLK, "accept", "-", "two_envelopes", good.clone()));
|
||||
|
||||
let mut blk_trailing = good.clone();
|
||||
blk_trailing.push(0);
|
||||
v.push((
|
||||
BLK,
|
||||
"reject",
|
||||
"trailing-bytes",
|
||||
"two_envelopes_trailing",
|
||||
blk_trailing,
|
||||
));
|
||||
|
||||
let mut blk_truncated = good.clone();
|
||||
blk_truncated.pop();
|
||||
v.push((
|
||||
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((
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1100,22 +1100,61 @@ mod tests {
|
|||
tag: 7
|
||||
})
|
||||
);
|
||||
// Operation-kind tag 30 is one past the vocabulary (the Phase-3 ops
|
||||
// tranche appended 24..=27, the repeat pair 28/29; encodings are
|
||||
// append-only).
|
||||
// Operation-kind tag 31 is one past the vocabulary (the Phase-3 ops
|
||||
// tranche appended 24..=27, the repeat pair 28/29, `TransposeInterval`
|
||||
// 30; encodings are append-only).
|
||||
//
|
||||
// This assertion named 30 until Push 5 / P4 — by which time 30 was
|
||||
// `TransposeInterval`, so the test was pinning a bug: a barrier that
|
||||
// prohibited the new operation encoded fine and would not read back.
|
||||
let mut bytes = vec![0u8];
|
||||
bytes.extend(set_blob(&[]));
|
||||
bytes.extend(set_blob(&[vec![30u8]]));
|
||||
bytes.extend(set_blob(&[vec![31u8]]));
|
||||
bytes.push(0);
|
||||
assert_eq!(
|
||||
EditBarrier::decode_canonical_bytes(&bytes),
|
||||
Err(BarrierDecodeError::InvalidTag {
|
||||
kind: "OperationKindTag",
|
||||
tag: 30
|
||||
tag: 31
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A barrier naming *every* operation tag must survive a round trip. Edit
|
||||
/// barriers are where `OperationKindTag` is persisted, so a tag that encodes
|
||||
/// and will not decode is silent data loss on reopen — which is exactly what
|
||||
/// `TransposeInterval` did between Push 4a and Push 5 / P4.
|
||||
#[test]
|
||||
fn a_barrier_prohibiting_every_operation_tag_round_trips() {
|
||||
use epiphany_ops::OperationKindRegistryId;
|
||||
let mut tags: Vec<OperationKindTag> = (0u8..=30)
|
||||
.filter(|d| *d != 16)
|
||||
.map(|d| {
|
||||
epiphany_determinism::CanonicalDecode::decode_canonical(&[d][..])
|
||||
.unwrap_or_else(|e| panic!("tag {d} must decode: {e:?}"))
|
||||
})
|
||||
.collect();
|
||||
tags.push(OperationKindTag::Registered(OperationKindRegistryId(7)));
|
||||
|
||||
let barrier = EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: Vec::new(),
|
||||
prohibited_operation_kinds: tags,
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
let bytes = barrier.to_canonical_bytes();
|
||||
let decoded = EditBarrier::decode_canonical_bytes(&bytes).expect("round-trips");
|
||||
// `prohibited_operation_kinds` is encoded as a canonical *set*, so the
|
||||
// decoded order is the canonical one, not the order it was built in.
|
||||
// Compare as sets, and pin injectivity on the bytes.
|
||||
let mut want = barrier.prohibited_operation_kinds.clone();
|
||||
let mut got = decoded.prohibited_operation_kinds.clone();
|
||||
want.sort_by_key(epiphany_determinism::CanonicalEncode::to_canonical_bytes);
|
||||
got.sort_by_key(epiphany_determinism::CanonicalEncode::to_canonical_bytes);
|
||||
assert_eq!(got, want, "every tag survives the round trip");
|
||||
assert_eq!(decoded.to_canonical_bytes(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_non_nfc_pitch_space_text() {
|
||||
// scope: PitchSpace(tag 5) carrying a decomposed "café" — canonically
|
||||
|
|
|
|||
|
|
@ -1517,3 +1517,55 @@ Each was mutation-verified against the exact check it locks.
|
|||
reduces to states with no conflicts, anomalies, pending, or spellings — the very
|
||||
branches that hold every canonical-order check. Measured: 6 of 12 seeds failed to
|
||||
produce all four. `build_decode_corpus` now draws until covered and asserts it.
|
||||
|
||||
## Push 5 / P4 — the cross-implementation decode corpus, and what it caught
|
||||
|
||||
`spec/vectors/decode_vectors.txt`: 37 committed byte strings across five
|
||||
surfaces (`ops.materialized_state`, `ops.operation_kind_tag`, `bundle.manifest`,
|
||||
`bundle.operation_index`, `bundle.block`), each with its normative accept/reject
|
||||
verdict. The reference implementation's fuzzers prove its own decoders
|
||||
self-consistent, which says nothing about whether a *foreign* decoder agrees
|
||||
with the format. This is what one is checked against.
|
||||
|
||||
**It found a real defect on its first run.** `OperationKindTag::TransposeInterval`
|
||||
encoded to `[30]` and **its own decoder rejected it**: Push 4a added the variant
|
||||
to `discriminant()` and never to `decode_canonical`. `OperationKindTag` is what
|
||||
edit barriers persist, so a barrier prohibiting `TransposeInterval` could be
|
||||
written and never read back — silent data loss on reopen.
|
||||
|
||||
**Four things should have caught it and did not.**
|
||||
|
||||
1. `operation_kind_tag_decode_mirrors_encode_exactly` enumerated *discriminants*
|
||||
(`(0u8..30).map(decode_canonical)`), so it started from bytes the decoder
|
||||
already knew and structurally could not notice a variant the decoder was
|
||||
missing. It now enumerates **variants**, from a single `all_tags()` list, with
|
||||
a completeness check in both directions.
|
||||
2. `every_normative_operation_tag_has_a_distinct_canonical_discriminant`'s
|
||||
hand-written variant list also omitted it. Same list now.
|
||||
3. `operation_kind_tag_decode_rejects_malformed_bytes` asserted **tag 30 is
|
||||
rejected**, and `epiphany-layout-ir`'s `decode_rejects_unknown_discriminants`
|
||||
asserted the same at the barrier surface. Both were *locking the bug in
|
||||
place*, and made it look deliberate. Both now name 31.
|
||||
4. The P2 decode fuzzer fed valid corpus bytes to the tag decoder and tallied the
|
||||
failure as a **rejection**, like any garbage input. It never asserted that an
|
||||
*unmutated* corpus entry decodes. Both fuzzers now do, as a pre-pass.
|
||||
|
||||
**The harness had the same disease as the code.** `check` originally collapsed
|
||||
"rejected" with "accepted but does not re-encode", so a decoder that silently
|
||||
normalizes non-canonical bytes *passed* the `reject` vectors it was written to
|
||||
catch — verified: removing the whole-state guard and restoring the lenient
|
||||
compression codec both left the corpus green. `check` now returns
|
||||
`Ok(injective)` for accept and `Err` for reject, and the two are never conflated:
|
||||
**silently normalizing non-canonical bytes IS accepting them.** With that fixed,
|
||||
all four defect mutations fail the corpus, each naming its class.
|
||||
|
||||
The corpus pins one vector per class this repository has shipped a bug in:
|
||||
`non-canonical-map-order` (a re-encode guard catches it; no per-site check
|
||||
exists), `non-canonical-vec-order` (only a per-site check catches it; a guard is
|
||||
blind), `lenient-sub-codec` (a guard *masked* it in the manifest; the index had
|
||||
none), plus `trailing-bytes`, `truncated`, `unknown-discriminant`,
|
||||
`count-exceeds-remaining`. `the_corpus_pins_every_class_we_have_shipped_a_bug_in`
|
||||
fails if one goes missing.
|
||||
|
||||
Gated in the conformance suite as `[7d]`, and drift-locked: the committed file
|
||||
must equal `vectors::render()`, so a wire-format change lands in the diff.
|
||||
|
|
|
|||
|
|
@ -830,6 +830,22 @@ pub fn run_decode_fuzz(iters: u64, seed: u64) -> DecodeFuzzCoverage {
|
|||
let corpus = build_decode_corpus(&mut rng);
|
||||
let mut cov = DecodeFuzzCoverage::default();
|
||||
|
||||
// The corpus must decode. Obvious, and it was not checked: an *unmutated*
|
||||
// valid byte string that a decoder rejects was silently tallied as a
|
||||
// rejection, like any garbage input. `OperationKindTag::TransposeInterval`
|
||||
// encoded to `[30]` and would not read back for two commits, right here in
|
||||
// the corpus, and this fuzzer reported green (Push 5 / P4).
|
||||
for bytes in &corpus.states {
|
||||
let state = crate::MaterializedState::decode_canonical(bytes)
|
||||
.expect("a valid materialized state must decode");
|
||||
assert_eq!(state.canonical_bytes(), *bytes);
|
||||
}
|
||||
for bytes in &corpus.tags {
|
||||
let tag = OperationKindTag::decode_canonical(bytes)
|
||||
.expect("a valid operation-kind tag must decode");
|
||||
assert_eq!(tag.to_canonical_bytes(), *bytes);
|
||||
}
|
||||
|
||||
for _ in 0..iters {
|
||||
let bytes = gen_state_input(&mut rng, &corpus);
|
||||
match crate::MaterializedState::decode_canonical(&bytes) {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ mod validate;
|
|||
pub mod valuegen;
|
||||
|
||||
pub mod fuzz;
|
||||
pub mod vectors;
|
||||
|
||||
pub use anomaly::{
|
||||
AnomalousReplicaSegment, IntegrityAnomaly, IntegrityAnomalyKind, ReplicaAnomalyReason,
|
||||
|
|
|
|||
|
|
@ -526,6 +526,11 @@ impl CanonicalDecode for OperationKindTag {
|
|||
27 => OperationKindTag::SetStaffLayout,
|
||||
28 => OperationKindTag::CreateRepeatStructure,
|
||||
29 => OperationKindTag::DeleteRepeatStructure,
|
||||
// Push 4a. Omitting this made `to_canonical_bytes` and
|
||||
// `decode_canonical` asymmetric: the tag encoded to `[30]` and
|
||||
// would not read back, so an edit barrier that named
|
||||
// `TransposeInterval` could be persisted and never reopened.
|
||||
30 => OperationKindTag::TransposeInterval,
|
||||
_ => return Err(DecodeError::MalformedDomainTag),
|
||||
})
|
||||
}
|
||||
|
|
@ -1897,9 +1902,18 @@ mod tests {
|
|||
assert_eq!(prim.tag(), OperationKindTag::RespellPitch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_normative_operation_tag_has_a_distinct_canonical_discriminant() {
|
||||
let tags = [
|
||||
/// **Every** `OperationKindTag` variant, listed by name.
|
||||
///
|
||||
/// Enumerating *variants* is the point. The round-trip test used to
|
||||
/// enumerate *discriminants* — `(0u8..30).map(decode_canonical)` — which
|
||||
/// starts from bytes the decoder already knows and therefore cannot notice a
|
||||
/// variant the decoder is missing. Push 4a added `TransposeInterval` to
|
||||
/// `discriminant()` and not to `decode_canonical`, and that test stayed
|
||||
/// green while the tag encoded to `[30]` and would not read back.
|
||||
///
|
||||
/// Adding a variant without adding it here fails `the_tag_vocabulary_is_complete`.
|
||||
fn all_tags() -> Vec<OperationKindTag> {
|
||||
let mut tags = vec![
|
||||
OperationKindTag::InsertEvent,
|
||||
OperationKindTag::DeleteEvent,
|
||||
OperationKindTag::ModifyEvent,
|
||||
|
|
@ -1929,7 +1943,35 @@ mod tests {
|
|||
OperationKindTag::SetStaffLayout,
|
||||
OperationKindTag::CreateRepeatStructure,
|
||||
OperationKindTag::DeleteRepeatStructure,
|
||||
OperationKindTag::TransposeInterval,
|
||||
];
|
||||
tags.push(OperationKindTag::Registered(OperationKindRegistryId(
|
||||
0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10,
|
||||
)));
|
||||
tags
|
||||
}
|
||||
|
||||
/// A compile-time-ish completeness check: `all_tags` must match the
|
||||
/// vocabulary the decoder accepts, in both directions.
|
||||
#[test]
|
||||
fn the_tag_vocabulary_is_complete() {
|
||||
let tags = all_tags();
|
||||
assert_eq!(tags.len(), 31, "30 payload-less tags plus `Registered`");
|
||||
|
||||
// Every payload-less discriminant 0..=30 except 16 decodes to a variant
|
||||
// that is in the list.
|
||||
for d in (0u8..=30).filter(|d| *d != 16) {
|
||||
let decoded = OperationKindTag::decode_canonical(&[d])
|
||||
.unwrap_or_else(|e| panic!("discriminant {d} must decode: {e:?}"));
|
||||
assert!(tags.contains(&decoded), "{decoded:?} missing from all_tags");
|
||||
}
|
||||
// And 31 is one past the vocabulary.
|
||||
assert!(OperationKindTag::decode_canonical(&[31]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_normative_operation_tag_has_a_distinct_canonical_discriminant() {
|
||||
let tags = all_tags();
|
||||
let encoded: std::collections::BTreeSet<_> = tags
|
||||
.iter()
|
||||
.map(CanonicalEncode::to_canonical_bytes)
|
||||
|
|
@ -1939,17 +1981,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn operation_kind_tag_decode_mirrors_encode_exactly() {
|
||||
// Every non-registered variant round-trips through its 1-byte form, and
|
||||
// the registered variant through its 17-byte (tag + registry id) form.
|
||||
let mut tags: Vec<OperationKindTag> = (0u8..30)
|
||||
.filter(|d| *d != 16)
|
||||
.map(|d| OperationKindTag::decode_canonical(&[d]).expect("known discriminant"))
|
||||
.collect();
|
||||
tags.push(OperationKindTag::Registered(OperationKindRegistryId(
|
||||
0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10,
|
||||
)));
|
||||
assert_eq!(tags.len(), 30, "the full tag vocabulary");
|
||||
for tag in tags {
|
||||
// Every variant round-trips: the payload-less ones through their 1-byte
|
||||
// form, `Registered` through its 17-byte (tag + registry id) form.
|
||||
//
|
||||
// Driven from `all_tags()`, i.e. from the VARIANTS. Driving it from the
|
||||
// discriminants the decoder already knows is what let a variant the
|
||||
// decoder was missing pass unnoticed.
|
||||
for tag in all_tags() {
|
||||
let bytes = tag.to_canonical_bytes();
|
||||
let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips");
|
||||
assert_eq!(decoded, tag);
|
||||
|
|
@ -1964,10 +2002,12 @@ mod tests {
|
|||
#[test]
|
||||
fn operation_kind_tag_decode_rejects_malformed_bytes() {
|
||||
use epiphany_determinism::DecodeError;
|
||||
// Unknown discriminant (30 is one past the vocabulary): rejected,
|
||||
// never normalized.
|
||||
// Unknown discriminant (31 is one past the vocabulary): rejected,
|
||||
// never normalized. This assertion named 30 until Push 5 / P4, by which
|
||||
// time 30 was `TransposeInterval` — so the test was actively locking a
|
||||
// bug in place rather than guarding against one.
|
||||
assert_eq!(
|
||||
OperationKindTag::decode_canonical(&[30]),
|
||||
OperationKindTag::decode_canonical(&[31]),
|
||||
Err(DecodeError::MalformedDomainTag)
|
||||
);
|
||||
// Empty input.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,304 @@
|
|||
//! Decode conformance vectors for the operation layer (P4 of the
|
||||
//! decode-hardening track).
|
||||
//!
|
||||
//! A curated, committed corpus of byte strings with their normative accept /
|
||||
//! reject verdict. The reference implementation's own fuzzer proves *its*
|
||||
//! decoders self-consistent; these vectors say what any decoder must do, so a
|
||||
//! second implementation can be checked against the format rather than against
|
||||
//! this code.
|
||||
//!
|
||||
//! Each rejection class here is one this repository actually shipped a bug in,
|
||||
//! or one whose check is invisible to an injectivity fuzzer (see
|
||||
//! `DECISIONS.md` §"Push 5 / P2"): the whole-state re-encode guard catches
|
||||
//! fields the decoder *normalizes*, and is blind to order-preserving `Vec`
|
||||
//! fields, which need per-site order checks. A conforming decoder needs both.
|
||||
//!
|
||||
//! The `class` string is informative, not normative: implementations need not
|
||||
//! agree on error taxonomy, only on the accept/reject verdict.
|
||||
|
||||
use epiphany_core::{EventId, OperationId, ReplicaId, TypedObjectId};
|
||||
use epiphany_determinism::CanonicalEncode;
|
||||
|
||||
use crate::{
|
||||
IntegrityAnomaly, IntegrityAnomalyKind, MaterializedState, ObjectState,
|
||||
OperationKindRegistryId, OperationKindTag, PendingReason,
|
||||
};
|
||||
|
||||
/// One vector: `(surface, verdict, class, name, bytes)`.
|
||||
///
|
||||
/// `verdict` is `"accept"` or `"reject"`. An `accept` vector additionally
|
||||
/// asserts **injectivity**: the decoded value must re-encode to exactly these
|
||||
/// bytes.
|
||||
pub type DecodeVector = (
|
||||
&'static str,
|
||||
&'static str,
|
||||
&'static str,
|
||||
&'static str,
|
||||
Vec<u8>,
|
||||
);
|
||||
|
||||
/// Swaps the two equal-length records of `entry` bytes that begin at `first`.
|
||||
fn swap_records(bytes: &[u8], first: usize, entry: usize) -> Vec<u8> {
|
||||
let second = first + entry;
|
||||
let mut out = bytes.to_vec();
|
||||
out[first..second].copy_from_slice(&bytes[second..second + entry]);
|
||||
out[second..second + entry].copy_from_slice(&bytes[first..second]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The offset of the count that first differs between an empty encoding and a
|
||||
/// two-element one, and the per-record width. Both encodings agree up to the
|
||||
/// count, and differ in total length by exactly the two records.
|
||||
fn count_and_entry(empty: &[u8], two: &[u8]) -> (usize, usize) {
|
||||
let count_at = empty
|
||||
.iter()
|
||||
.zip(two.iter())
|
||||
.position(|(a, b)| a != b)
|
||||
.expect("the counts differ");
|
||||
(count_at, (two.len() - empty.len()) / 2)
|
||||
}
|
||||
|
||||
fn object(counter: u64) -> TypedObjectId {
|
||||
TypedObjectId::Event(EventId::new(ReplicaId(1), counter))
|
||||
}
|
||||
|
||||
fn anomaly(counter: u64) -> IntegrityAnomaly {
|
||||
IntegrityAnomaly::new(IntegrityAnomalyKind::OperationSlotEquivocated {
|
||||
operation_id: OperationId::new(ReplicaId(1), counter),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every operation-layer vector.
|
||||
pub fn decode_vectors() -> Vec<DecodeVector> {
|
||||
let mut v: Vec<DecodeVector> = Vec::new();
|
||||
|
||||
// --- MaterializedState -------------------------------------------------
|
||||
const MS: &str = "ops.materialized_state";
|
||||
let empty = MaterializedState::default().canonical_bytes();
|
||||
v.push((MS, "accept", "-", "empty_state", empty.clone()));
|
||||
|
||||
// Two objects, canonically ordered. Swapping them is caught only by the
|
||||
// whole-state re-encode guard: `objects` is a BTreeMap, so the decoder
|
||||
// silently re-sorts it and no per-site check exists.
|
||||
let two_objects = MaterializedState {
|
||||
objects: [
|
||||
(object(1), ObjectState::Live),
|
||||
(object(2), ObjectState::Live),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
.canonical_bytes();
|
||||
let (at, entry) = count_and_entry(&empty, &two_objects);
|
||||
v.push((MS, "accept", "-", "two_objects", two_objects.clone()));
|
||||
v.push((
|
||||
MS,
|
||||
"reject",
|
||||
"non-canonical-map-order",
|
||||
"objects_out_of_order",
|
||||
swap_records(&two_objects, at + 4, entry),
|
||||
));
|
||||
|
||||
// Two anomalies, canonically ordered. `anomalies` is a Vec whose order the
|
||||
// decoder PRESERVES, so a swap re-encodes to itself and the whole-state
|
||||
// guard is blind: only a per-site order check rejects it.
|
||||
let (lo, hi) = {
|
||||
let (a, b) = (anomaly(1), anomaly(2));
|
||||
if a.id < b.id {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
}
|
||||
};
|
||||
let two_anomalies = MaterializedState {
|
||||
anomalies: vec![lo, hi],
|
||||
..Default::default()
|
||||
}
|
||||
.canonical_bytes();
|
||||
let (at, entry) = count_and_entry(&empty, &two_anomalies);
|
||||
v.push((MS, "accept", "-", "two_anomalies", two_anomalies.clone()));
|
||||
v.push((
|
||||
MS,
|
||||
"reject",
|
||||
"non-canonical-vec-order",
|
||||
"anomalies_out_of_order",
|
||||
swap_records(&two_anomalies, at + 4, entry),
|
||||
));
|
||||
|
||||
// Same for `pending`, whose entries are (OperationId, PendingReason) pairs.
|
||||
let (p1, p2) = (
|
||||
OperationId::new(ReplicaId(1), 1),
|
||||
OperationId::new(ReplicaId(1), 2),
|
||||
);
|
||||
let two_pending = MaterializedState {
|
||||
pending: vec![
|
||||
(p1, PendingReason::MissingCausalPredecessor { missing: p1 }),
|
||||
(p2, PendingReason::MissingCausalPredecessor { missing: p1 }),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
.canonical_bytes();
|
||||
let (at, entry) = count_and_entry(&empty, &two_pending);
|
||||
v.push((MS, "accept", "-", "two_pending", two_pending.clone()));
|
||||
v.push((
|
||||
MS,
|
||||
"reject",
|
||||
"non-canonical-vec-order",
|
||||
"pending_out_of_order",
|
||||
swap_records(&two_pending, at + 4, entry),
|
||||
));
|
||||
|
||||
let mut trailing = empty.clone();
|
||||
trailing.push(0);
|
||||
v.push((
|
||||
MS,
|
||||
"reject",
|
||||
"trailing-bytes",
|
||||
"empty_state_trailing",
|
||||
trailing,
|
||||
));
|
||||
|
||||
let mut truncated = empty.clone();
|
||||
truncated.pop();
|
||||
v.push((
|
||||
MS,
|
||||
"reject",
|
||||
"truncated",
|
||||
"empty_state_truncated",
|
||||
truncated,
|
||||
));
|
||||
|
||||
// A count prefix far past the bytes remaining. The decoder must not
|
||||
// pre-allocate on it, and must not loop toward EOF for a measurable time.
|
||||
let mut huge_count = empty.clone();
|
||||
huge_count[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
v.push((
|
||||
MS,
|
||||
"reject",
|
||||
"count-exceeds-remaining",
|
||||
"effects_count_u32_max",
|
||||
huge_count,
|
||||
));
|
||||
|
||||
// --- OperationKindTag --------------------------------------------------
|
||||
const TAG: &str = "ops.operation_kind_tag";
|
||||
for (name, tag) in [
|
||||
("insert_event", OperationKindTag::InsertEvent),
|
||||
("transpose_frozen", OperationKindTag::Transpose),
|
||||
("transpose_interval", OperationKindTag::TransposeInterval),
|
||||
(
|
||||
"registered",
|
||||
OperationKindTag::Registered(OperationKindRegistryId(0x0123_4567_89AB_CDEF)),
|
||||
),
|
||||
] {
|
||||
v.push((TAG, "accept", "-", name, tag.to_canonical_bytes()));
|
||||
}
|
||||
|
||||
v.push((TAG, "reject", "unknown-discriminant", "tag_200", vec![200]));
|
||||
v.push((TAG, "reject", "truncated", "tag_empty", Vec::new()));
|
||||
v.push((
|
||||
TAG,
|
||||
"reject",
|
||||
"trailing-bytes",
|
||||
"insert_event_trailing",
|
||||
vec![0, 0],
|
||||
));
|
||||
// `Registered` is 1 + 16 bytes; one short must not read past the end.
|
||||
let mut short_registered =
|
||||
OperationKindTag::Registered(OperationKindRegistryId(1)).to_canonical_bytes();
|
||||
short_registered.pop();
|
||||
v.push((
|
||||
TAG,
|
||||
"reject",
|
||||
"truncated",
|
||||
"registered_one_byte_short",
|
||||
short_registered,
|
||||
));
|
||||
|
||||
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**.
|
||||
///
|
||||
/// The two are deliberately not collapsed. A decoder that accepts non-canonical
|
||||
/// bytes and silently normalizes them is *not* rejecting them — that is the
|
||||
/// whole defect class (`non-canonical-map-order`, `lenient-sub-codec`), and an
|
||||
/// earlier version of this function reported it as a rejection, so the corpus
|
||||
/// passed against decoders it was written to catch.
|
||||
///
|
||||
/// `None` when the surface is not owned by this crate.
|
||||
pub fn check(surface: &str, bytes: &[u8]) -> Option<Result<bool, String>> {
|
||||
match surface {
|
||||
"ops.materialized_state" => Some(match MaterializedState::decode_canonical(bytes) {
|
||||
Ok(state) => Ok(state.canonical_bytes() == bytes),
|
||||
Err(e) => Err(format!("{e}")),
|
||||
}),
|
||||
"ops.operation_kind_tag" => Some(decode_tag(bytes)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_tag(bytes: &[u8]) -> Result<bool, String> {
|
||||
use epiphany_determinism::CanonicalDecode;
|
||||
match OperationKindTag::decode_canonical(bytes) {
|
||||
Ok(tag) => Ok(tag.to_canonical_bytes() == bytes),
|
||||
Err(e) => Err(format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each vector must get the verdict it declares. This is the property a
|
||||
/// second implementation is being asked to satisfy; if the reference cannot,
|
||||
/// the corpus is wrong.
|
||||
#[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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The corpus must actually contain both verdicts on every surface, or it is
|
||||
/// pinning half a contract.
|
||||
#[test]
|
||||
fn every_surface_carries_both_verdicts() {
|
||||
for surface in ["ops.materialized_state", "ops.operation_kind_tag"] {
|
||||
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 two rejection classes that need *different* machinery: a map order a
|
||||
/// re-encode guard catches, and a `Vec` order only a per-site check catches.
|
||||
/// If either vector went missing the corpus would stop pinning the lesson.
|
||||
#[test]
|
||||
fn the_corpus_pins_both_non_canonical_classes() {
|
||||
let classes: Vec<&str> = decode_vectors().iter().map(|(_, _, c, ..)| *c).collect();
|
||||
assert!(classes.contains(&"non-canonical-map-order"));
|
||||
assert!(classes.contains(&"non-canonical-vec-order"));
|
||||
}
|
||||
}
|
||||
|
|
@ -153,5 +153,25 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
eprintln!("[7d ] cross-implementation decode conformance vectors");
|
||||
{
|
||||
use epiphany_testkit::vectors;
|
||||
assert_eq!(
|
||||
vectors::COMMITTED,
|
||||
vectors::render(),
|
||||
"{} is stale; regenerate with `cargo run -q -p epiphany-testkit \
|
||||
--example generate_vectors`",
|
||||
vectors::PATH
|
||||
);
|
||||
match vectors::verify(vectors::COMMITTED) {
|
||||
Ok(n) => eprintln!(" {n} vectors, every verdict agreed"),
|
||||
Err(failures) => panic!(
|
||||
"{} decode-vector disagreement(s):\n{}",
|
||||
failures.len(),
|
||||
failures.join("\n")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[8/8] ok: full conformance suite passed (scale {scale})");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
//! Regenerates `spec/vectors/decode_vectors.txt`, the cross-implementation
|
||||
//! decode conformance corpus. Run from the workspace root.
|
||||
fn main() {
|
||||
let text = epiphany_testkit::vectors::render();
|
||||
let path = epiphany_testkit::vectors::PATH;
|
||||
std::fs::write(path, &text).unwrap_or_else(|e| panic!("writing {path}: {e}"));
|
||||
let rows = epiphany_testkit::vectors::parse(&text).expect("parses");
|
||||
eprintln!("wrote {} vectors to {path}", rows.len());
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@
|
|||
//! materialization through a bundle snapshot.
|
||||
|
||||
pub mod rng;
|
||||
pub mod vectors;
|
||||
|
||||
// Phase 2, Agent F (worklist F1): the Chapter 10 performance-budget gate the
|
||||
// `benches/` targets assert through (Pass / Xfail rows with thresholds written
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
//! The cross-implementation decode conformance corpus (P4 of the
|
||||
//! decode-hardening track).
|
||||
//!
|
||||
//! `spec/vectors/decode_vectors.txt` is a committed, human-diffable list of byte
|
||||
//! strings and their normative accept/reject verdict. It is what a *second*
|
||||
//! implementation is checked against — the reference implementation's fuzzers
|
||||
//! prove its own decoders self-consistent, which says nothing about whether a
|
||||
//! foreign decoder agrees with the format.
|
||||
//!
|
||||
//! Two properties are enforced here:
|
||||
//!
|
||||
//! 1. **The committed file is what the generator produces.** A wire-format
|
||||
//! change that moves a vector's bytes must move the file too, deliberately,
|
||||
//! in the diff.
|
||||
//! 2. **Every vector gets its declared verdict** from the owning crate's
|
||||
//! decoder. An `accept` vector additionally must re-encode to its own bytes.
|
||||
//!
|
||||
//! The `class` column is informative: implementations need not agree on error
|
||||
//! taxonomy, only on accept versus reject.
|
||||
|
||||
/// The committed corpus.
|
||||
pub const COMMITTED: &str = include_str!("../../../spec/vectors/decode_vectors.txt");
|
||||
|
||||
/// The path a regeneration writes to, relative to the workspace root.
|
||||
pub const PATH: &str = "spec/vectors/decode_vectors.txt";
|
||||
|
||||
const HEADER: &str = "\
|
||||
# Epiphany decode conformance vectors — format version 1
|
||||
#
|
||||
# Generated. Regenerate with:
|
||||
# cargo run -q -p epiphany-testkit --example generate_vectors
|
||||
# `epiphany_testkit::vectors::the_committed_corpus_matches_the_generator` fails
|
||||
# on drift, so a wire-format change must land here deliberately.
|
||||
#
|
||||
# One vector per line, space-separated:
|
||||
#
|
||||
# <surface> <verdict> <class> <name> <hex>
|
||||
#
|
||||
# `verdict` is `accept` or `reject`, and is the ONLY normative column besides
|
||||
# the bytes. A conforming decoder must accept every `accept` vector and reject
|
||||
# every `reject` vector. An accepted value must additionally re-encode to
|
||||
# exactly the vector's bytes: canonical decode is injective, which is what
|
||||
# content-addressing rests on.
|
||||
#
|
||||
# `class` names why a `reject` vector is rejected. It is informative only —
|
||||
# implementations need not agree on error taxonomy. `-` where not applicable.
|
||||
#
|
||||
# `<hex>` is lowercase, no separators; `-` denotes the empty byte string.
|
||||
";
|
||||
|
||||
fn to_hex(bytes: &[u8]) -> String {
|
||||
if bytes.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn from_hex(s: &str) -> Option<Vec<u8>> {
|
||||
if s == "-" {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every vector, in a stable order: the operation layer, then the bundle wire.
|
||||
fn all() -> Vec<(String, String, String, String, Vec<u8>)> {
|
||||
let ops = epiphany_ops::vectors::decode_vectors()
|
||||
.into_iter()
|
||||
.map(|(s, v, c, n, b)| {
|
||||
(
|
||||
s.to_string(),
|
||||
v.to_string(),
|
||||
c.to_string(),
|
||||
n.to_string(),
|
||||
b,
|
||||
)
|
||||
});
|
||||
let bundle = epiphany_bundle::vectors::decode_vectors()
|
||||
.into_iter()
|
||||
.map(|(s, v, c, n, b)| {
|
||||
(
|
||||
s.to_string(),
|
||||
v.to_string(),
|
||||
c.to_string(),
|
||||
n.to_string(),
|
||||
b,
|
||||
)
|
||||
});
|
||||
ops.chain(bundle).collect()
|
||||
}
|
||||
|
||||
/// Renders the corpus file.
|
||||
pub fn render() -> String {
|
||||
let mut out = String::from(HEADER);
|
||||
let mut surface = String::new();
|
||||
for (s, v, c, n, b) in all() {
|
||||
if s != surface {
|
||||
out.push_str("\n# ");
|
||||
out.push_str(&s);
|
||||
out.push('\n');
|
||||
surface = s.clone();
|
||||
}
|
||||
out.push_str(&format!("{s} {v} {c} {n} {}\n", to_hex(&b)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One parsed row.
|
||||
pub struct Row {
|
||||
pub surface: String,
|
||||
pub verdict: String,
|
||||
pub class: String,
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parses the corpus file, skipping comments and blank lines.
|
||||
pub fn parse(text: &str) -> Result<Vec<Row>, String> {
|
||||
let mut rows = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.len() != 5 {
|
||||
return Err(format!(
|
||||
"line {}: expected 5 columns, got {}",
|
||||
i + 1,
|
||||
f.len()
|
||||
));
|
||||
}
|
||||
rows.push(Row {
|
||||
surface: f[0].to_string(),
|
||||
verdict: f[1].to_string(),
|
||||
class: f[2].to_string(),
|
||||
name: f[3].to_string(),
|
||||
bytes: from_hex(f[4]).ok_or_else(|| format!("line {}: bad hex", i + 1))?,
|
||||
});
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Runs every vector against the owning crate's decoder, returning the number
|
||||
/// checked or the disagreements.
|
||||
pub fn verify(text: &str) -> Result<usize, Vec<String>> {
|
||||
let rows = match parse(text) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(vec![e]),
|
||||
};
|
||||
let mut failures = Vec::new();
|
||||
for row in &rows {
|
||||
let result = epiphany_ops::vectors::check(&row.surface, &row.bytes)
|
||||
.or_else(|| epiphany_bundle::vectors::check(&row.surface, &row.bytes));
|
||||
let Some(result) = result else {
|
||||
failures.push(format!("{}: no decoder owns this surface", row.surface));
|
||||
continue;
|
||||
};
|
||||
// `Ok(injective)` = accepted; `Err` = rejected. A decoder that accepts
|
||||
// a `reject` vector fails even if it then re-encodes it faithfully, and
|
||||
// one that accepts an `accept` vector non-injectively fails too. The two
|
||||
// must not be collapsed: silently normalizing non-canonical bytes IS
|
||||
// accepting them, and is the defect the corpus exists to catch.
|
||||
match (row.verdict.as_str(), &result) {
|
||||
("accept", Ok(true)) | ("reject", Err(_)) => {}
|
||||
("accept", Ok(false)) => failures.push(format!(
|
||||
"{}/{}: accepted, but the value does not re-encode to its bytes",
|
||||
row.surface, row.name
|
||||
)),
|
||||
("reject", Ok(injective)) => failures.push(format!(
|
||||
"{}/{} ({}): declared reject, but was ACCEPTED (injective={injective})",
|
||||
row.surface, row.name, row.class
|
||||
)),
|
||||
_ => failures.push(format!(
|
||||
"{}/{} ({}): declared {}, got {:?}",
|
||||
row.surface, row.name, row.class, row.verdict, result
|
||||
)),
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(rows.len())
|
||||
} else {
|
||||
Err(failures)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The file in the tree is exactly what the generator emits. A wire-format
|
||||
/// change must land in this diff.
|
||||
#[test]
|
||||
fn the_committed_corpus_matches_the_generator() {
|
||||
assert_eq!(
|
||||
COMMITTED,
|
||||
render(),
|
||||
"\n{PATH} is stale. Regenerate:\n \
|
||||
cargo run -q -p epiphany-testkit --example generate_vectors\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference implementation satisfies the contract it is publishing. If
|
||||
/// it cannot, the corpus is wrong, not the decoder.
|
||||
#[test]
|
||||
fn the_reference_implementation_agrees_with_every_vector() {
|
||||
match verify(COMMITTED) {
|
||||
Ok(n) => assert!(n >= 25, "only {n} vectors — the corpus has thinned"),
|
||||
Err(failures) => panic!(
|
||||
"{} disagreement(s):\n{}",
|
||||
failures.len(),
|
||||
failures.join("\n")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The corpus must pin the rejection classes this repository learned the
|
||||
/// hard way. Losing one would quietly stop testing it.
|
||||
#[test]
|
||||
fn the_corpus_pins_every_class_we_have_shipped_a_bug_in() {
|
||||
let rows = parse(COMMITTED).expect("parses");
|
||||
let classes: Vec<&str> = rows.iter().map(|r| r.class.as_str()).collect();
|
||||
for required in [
|
||||
// A guard catches this; no per-site check exists (P2).
|
||||
"non-canonical-map-order",
|
||||
// Only a per-site check catches this; a guard is blind (P2).
|
||||
"non-canonical-vec-order",
|
||||
// A guard *masked* this in the manifest; the index had none (P3).
|
||||
"lenient-sub-codec",
|
||||
"trailing-bytes",
|
||||
"truncated",
|
||||
"unknown-discriminant",
|
||||
"count-exceeds-remaining",
|
||||
] {
|
||||
assert!(
|
||||
classes.contains(&required),
|
||||
"the corpus no longer pins `{required}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Both verdicts on every surface, or the corpus pins half a contract.
|
||||
#[test]
|
||||
fn every_surface_carries_both_verdicts() {
|
||||
use std::collections::BTreeMap;
|
||||
let rows = parse(COMMITTED).expect("parses");
|
||||
let mut seen: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
|
||||
for r in &rows {
|
||||
let e = seen.entry(r.surface.as_str()).or_default();
|
||||
match r.verdict.as_str() {
|
||||
"accept" => e.0 = true,
|
||||
"reject" => e.1 = true,
|
||||
other => panic!("unknown verdict {other}"),
|
||||
}
|
||||
}
|
||||
assert!(seen.len() >= 5, "surfaces: {:?}", seen.keys());
|
||||
for (surface, (accept, reject)) in seen {
|
||||
assert!(accept, "{surface} has no accept vector");
|
||||
assert!(reject, "{surface} has no reject vector");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Epiphany decode conformance vectors — format version 1
|
||||
#
|
||||
# Generated. Regenerate with:
|
||||
# cargo run -q -p epiphany-testkit --example generate_vectors
|
||||
# `epiphany_testkit::vectors::the_committed_corpus_matches_the_generator` fails
|
||||
# on drift, so a wire-format change must land here deliberately.
|
||||
#
|
||||
# One vector per line, space-separated:
|
||||
#
|
||||
# <surface> <verdict> <class> <name> <hex>
|
||||
#
|
||||
# `verdict` is `accept` or `reject`, and is the ONLY normative column besides
|
||||
# the bytes. A conforming decoder must accept every `accept` vector and reject
|
||||
# every `reject` vector. An accepted value must additionally re-encode to
|
||||
# exactly the vector's bytes: canonical decode is injective, which is what
|
||||
# content-addressing rests on.
|
||||
#
|
||||
# `class` names why a `reject` vector is rejected. It is informative only —
|
||||
# implementations need not agree on error taxonomy. `-` where not applicable.
|
||||
#
|
||||
# `<hex>` is lowercase, no separators; `-` denotes the empty byte string.
|
||||
|
||||
# ops.materialized_state
|
||||
ops.materialized_state accept - empty_state 0000000000000000000000000000000000000000000000000000000000000000
|
||||
ops.materialized_state accept - two_objects 00000000000000000000000002000000000000000000000000010000000000000001000000000000000000000100000000000000020000000000000000000000000000000000
|
||||
ops.materialized_state reject non-canonical-map-order objects_out_of_order 00000000000000000000000002000000000000000000000000010000000000000002000000000000000000000100000000000000010000000000000000000000000000000000
|
||||
ops.materialized_state accept - two_anomalies 00000000000000000200000021000000ffffffffffffffff68eefa3a8f8dfa49010000000000000001000000000000000121000000ffffffffffffffffbf0e1228971141c501000000000000000100000000000000020000000000000000000000000000000000000000
|
||||
ops.materialized_state reject non-canonical-vec-order anomalies_out_of_order 00000000000000000200000021000000ffffffffffffffffbf0e1228971141c5010000000000000001000000000000000221000000ffffffffffffffff68eefa3a8f8dfa4901000000000000000100000000000000010000000000000000000000000000000000000000
|
||||
ops.materialized_state accept - two_pending 0000000000000000000000000000000000000000000000000000000002000000000000000000000100000000000000010000000000000000010000000000000001000000000000000100000000000000020000000000000000010000000000000001
|
||||
ops.materialized_state reject non-canonical-vec-order pending_out_of_order 0000000000000000000000000000000000000000000000000000000002000000000000000000000100000000000000020000000000000000010000000000000001000000000000000100000000000000010000000000000000010000000000000001
|
||||
ops.materialized_state reject trailing-bytes empty_state_trailing 000000000000000000000000000000000000000000000000000000000000000000
|
||||
ops.materialized_state reject truncated empty_state_truncated 00000000000000000000000000000000000000000000000000000000000000
|
||||
ops.materialized_state reject count-exceeds-remaining effects_count_u32_max ffffffff00000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# ops.operation_kind_tag
|
||||
ops.operation_kind_tag accept - insert_event 00
|
||||
ops.operation_kind_tag accept - transpose_frozen 04
|
||||
ops.operation_kind_tag accept - transpose_interval 1e
|
||||
ops.operation_kind_tag accept - registered 1000000000000000000123456789abcdef
|
||||
ops.operation_kind_tag reject unknown-discriminant tag_200 c8
|
||||
ops.operation_kind_tag reject truncated tag_empty -
|
||||
ops.operation_kind_tag reject trailing-bytes insert_event_trailing 0000
|
||||
ops.operation_kind_tag reject truncated registered_one_byte_short 10000000000000000000000000000000
|
||||
|
||||
# bundle.manifest
|
||||
bundle.manifest accept - empty_manifest 6f9e7d11689ab113c4a1f05faf60fe60050505050505050505050505050505050000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000
|
||||
bundle.manifest accept - one_operation_root ae38d9cd408df9b59917c43a82c9d1880505050505050505050505050505050500000000000000000001000000111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000
|
||||
bundle.manifest reject manifest-id-mismatch one_root_body_edited ae38d9cd408df9b59917c43a82c9d18805050505050505050505050505050505000000000000000000010000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111000000000000000000000000000001000000000000000000000000000000000000000000000000000000010000000000000000000004000000000100000000010000000000ff
|
||||
bundle.manifest accept - two_operation_roots 423da9a59202adc10cdd0985146bb1d805050505050505050505050505050505000000000000000000020000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200000001000004000000000000400000000000000040000000000000000000222222222222222222222222222222222222222222222222222222222222222200000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000
|
||||
bundle.manifest reject non-canonical-vec-order two_roots_out_of_order_valid_id c643c490d7705661b435a5e3efe0c5fa05050505050505050505050505050505000000000000000000020000002222222222222222222222222222222222222222222222222222222222222222000000010000040000000000004000000000000000400000000000000000002222222222222222222222222222222222222222222222222222222222222222111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000
|
||||
bundle.manifest reject trailing-bytes one_root_trailing ae38d9cd408df9b59917c43a82c9d188050505050505050505050505050505050000000000000000000100000011111111111111111111111111111111111111111111111111111111111111110000000100400200000000000040000000000000004000000000000000000011111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000010000000000000000000000000000000000000000000000000000000100000000000000000000040000000001000000000100000000000000
|
||||
|
||||
# bundle.operation_index
|
||||
bundle.operation_index accept - empty_index 0000000000000000
|
||||
bundle.operation_index accept - one_block_one_entry 01000000111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020000000008000000
|
||||
bundle.operation_index accept - two_blocks 020000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200000001000004000000000000400000000000000040000000000000000000222222222222222222222222222222222222222222222222222222222222222203000000010101010101010101010101010101010000000008000000020202020202020202020202020202020100000008000000030303030303030303030303030303030000000028000000
|
||||
bundle.operation_index reject lenient-sub-codec compression_none_non_zero_parameter 010000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000ff111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020000000008000000
|
||||
bundle.operation_index reject non-canonical-vec-order blocks_out_of_order 020000002222222222222222222222222222222222222222222222222222222222222222000000010000040000000000004000000000000000400000000000000000002222222222222222222222222222222222222222222222222222222222222222111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111103000000010101010101010101010101010101010000000008000000020202020202020202020202020202020100000008000000030303030303030303030303030303030000000028000000
|
||||
bundle.operation_index reject non-canonical-vec-order entries_out_of_order 020000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200000001000004000000000000400000000000000040000000000000000000222222222222222222222222222222222222222222222222222222222222222203000000020202020202020202020202020202020100000008000000010101010101010101010101010101010000000008000000030303030303030303030303030303030000000028000000
|
||||
bundle.operation_index reject block-ordinal-out-of-range entry_names_missing_block 01000000111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020700000008000000
|
||||
bundle.operation_index reject wrong-chunk-kind block_is_not_an_envelope_block 01000000111111111111111111111111111111111111111111111111111111111111111102000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020000000008000000
|
||||
bundle.operation_index reject trailing-bytes empty_index_trailing 000000000000000000
|
||||
|
||||
# bundle.block
|
||||
bundle.block accept - two_envelopes 0200000008000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
bundle.block reject trailing-bytes two_envelopes_trailing 0200000008000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbbbb00
|
||||
bundle.block reject truncated two_envelopes_truncated 0200000008000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbb
|
||||
bundle.block reject count-exceeds-remaining envelope_count_u32_max ffffffff08000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
Loading…
Reference in New Issue