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()
|
.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 {
|
for _ in 0..iters {
|
||||||
// 1. Whole-image open. Must never panic; an Ok manifest must re-encode.
|
// 1. Whole-image open. Must never panic; an Ok manifest must re-encode.
|
||||||
let pick = (rng.next_u64() as usize) % images.len();
|
let pick = (rng.next_u64() as usize) % images.len();
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ mod store;
|
||||||
mod superblock;
|
mod superblock;
|
||||||
|
|
||||||
pub mod fuzz;
|
pub mod fuzz;
|
||||||
|
pub mod vectors;
|
||||||
|
|
||||||
pub use block::{
|
pub use block::{
|
||||||
decode_block, encode_block, envelope_offsets, pack_operation_blocks, BLOCK_SOFT_LIMIT,
|
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
|
tag: 7
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// Operation-kind tag 30 is one past the vocabulary (the Phase-3 ops
|
// Operation-kind tag 31 is one past the vocabulary (the Phase-3 ops
|
||||||
// tranche appended 24..=27, the repeat pair 28/29; encodings are
|
// tranche appended 24..=27, the repeat pair 28/29, `TransposeInterval`
|
||||||
// append-only).
|
// 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];
|
let mut bytes = vec![0u8];
|
||||||
bytes.extend(set_blob(&[]));
|
bytes.extend(set_blob(&[]));
|
||||||
bytes.extend(set_blob(&[vec![30u8]]));
|
bytes.extend(set_blob(&[vec![31u8]]));
|
||||||
bytes.push(0);
|
bytes.push(0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
EditBarrier::decode_canonical_bytes(&bytes),
|
EditBarrier::decode_canonical_bytes(&bytes),
|
||||||
Err(BarrierDecodeError::InvalidTag {
|
Err(BarrierDecodeError::InvalidTag {
|
||||||
kind: "OperationKindTag",
|
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]
|
#[test]
|
||||||
fn decode_rejects_non_nfc_pitch_space_text() {
|
fn decode_rejects_non_nfc_pitch_space_text() {
|
||||||
// scope: PitchSpace(tag 5) carrying a decomposed "café" — canonically
|
// 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
|
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
|
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.
|
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 corpus = build_decode_corpus(&mut rng);
|
||||||
let mut cov = DecodeFuzzCoverage::default();
|
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 {
|
for _ in 0..iters {
|
||||||
let bytes = gen_state_input(&mut rng, &corpus);
|
let bytes = gen_state_input(&mut rng, &corpus);
|
||||||
match crate::MaterializedState::decode_canonical(&bytes) {
|
match crate::MaterializedState::decode_canonical(&bytes) {
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ mod validate;
|
||||||
pub mod valuegen;
|
pub mod valuegen;
|
||||||
|
|
||||||
pub mod fuzz;
|
pub mod fuzz;
|
||||||
|
pub mod vectors;
|
||||||
|
|
||||||
pub use anomaly::{
|
pub use anomaly::{
|
||||||
AnomalousReplicaSegment, IntegrityAnomaly, IntegrityAnomalyKind, ReplicaAnomalyReason,
|
AnomalousReplicaSegment, IntegrityAnomaly, IntegrityAnomalyKind, ReplicaAnomalyReason,
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,11 @@ impl CanonicalDecode for OperationKindTag {
|
||||||
27 => OperationKindTag::SetStaffLayout,
|
27 => OperationKindTag::SetStaffLayout,
|
||||||
28 => OperationKindTag::CreateRepeatStructure,
|
28 => OperationKindTag::CreateRepeatStructure,
|
||||||
29 => OperationKindTag::DeleteRepeatStructure,
|
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),
|
_ => return Err(DecodeError::MalformedDomainTag),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1897,9 +1902,18 @@ mod tests {
|
||||||
assert_eq!(prim.tag(), OperationKindTag::RespellPitch);
|
assert_eq!(prim.tag(), OperationKindTag::RespellPitch);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// **Every** `OperationKindTag` variant, listed by name.
|
||||||
fn every_normative_operation_tag_has_a_distinct_canonical_discriminant() {
|
///
|
||||||
let tags = [
|
/// 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::InsertEvent,
|
||||||
OperationKindTag::DeleteEvent,
|
OperationKindTag::DeleteEvent,
|
||||||
OperationKindTag::ModifyEvent,
|
OperationKindTag::ModifyEvent,
|
||||||
|
|
@ -1929,7 +1943,35 @@ mod tests {
|
||||||
OperationKindTag::SetStaffLayout,
|
OperationKindTag::SetStaffLayout,
|
||||||
OperationKindTag::CreateRepeatStructure,
|
OperationKindTag::CreateRepeatStructure,
|
||||||
OperationKindTag::DeleteRepeatStructure,
|
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
|
let encoded: std::collections::BTreeSet<_> = tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(CanonicalEncode::to_canonical_bytes)
|
.map(CanonicalEncode::to_canonical_bytes)
|
||||||
|
|
@ -1939,17 +1981,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn operation_kind_tag_decode_mirrors_encode_exactly() {
|
fn operation_kind_tag_decode_mirrors_encode_exactly() {
|
||||||
// Every non-registered variant round-trips through its 1-byte form, and
|
// Every variant round-trips: the payload-less ones through their 1-byte
|
||||||
// the registered variant through its 17-byte (tag + registry id) form.
|
// form, `Registered` through its 17-byte (tag + registry id) form.
|
||||||
let mut tags: Vec<OperationKindTag> = (0u8..30)
|
//
|
||||||
.filter(|d| *d != 16)
|
// Driven from `all_tags()`, i.e. from the VARIANTS. Driving it from the
|
||||||
.map(|d| OperationKindTag::decode_canonical(&[d]).expect("known discriminant"))
|
// discriminants the decoder already knows is what let a variant the
|
||||||
.collect();
|
// decoder was missing pass unnoticed.
|
||||||
tags.push(OperationKindTag::Registered(OperationKindRegistryId(
|
for tag in all_tags() {
|
||||||
0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10,
|
|
||||||
)));
|
|
||||||
assert_eq!(tags.len(), 30, "the full tag vocabulary");
|
|
||||||
for tag in tags {
|
|
||||||
let bytes = tag.to_canonical_bytes();
|
let bytes = tag.to_canonical_bytes();
|
||||||
let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips");
|
let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips");
|
||||||
assert_eq!(decoded, tag);
|
assert_eq!(decoded, tag);
|
||||||
|
|
@ -1964,10 +2002,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn operation_kind_tag_decode_rejects_malformed_bytes() {
|
fn operation_kind_tag_decode_rejects_malformed_bytes() {
|
||||||
use epiphany_determinism::DecodeError;
|
use epiphany_determinism::DecodeError;
|
||||||
// Unknown discriminant (30 is one past the vocabulary): rejected,
|
// Unknown discriminant (31 is one past the vocabulary): rejected,
|
||||||
// never normalized.
|
// 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!(
|
assert_eq!(
|
||||||
OperationKindTag::decode_canonical(&[30]),
|
OperationKindTag::decode_canonical(&[31]),
|
||||||
Err(DecodeError::MalformedDomainTag)
|
Err(DecodeError::MalformedDomainTag)
|
||||||
);
|
);
|
||||||
// Empty input.
|
// 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})");
|
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.
|
//! materialization through a bundle snapshot.
|
||||||
|
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
pub mod vectors;
|
||||||
|
|
||||||
// Phase 2, Agent F (worklist F1): the Chapter 10 performance-budget gate the
|
// Phase 2, Agent F (worklist F1): the Chapter 10 performance-budget gate the
|
||||||
// `benches/` targets assert through (Pass / Xfail rows with thresholds written
|
// `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