275 lines
9.6 KiB
Rust
275 lines
9.6 KiB
Rust
//! 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 accept/reject verdict, ratified by the Binary Format
|
|
//! companion's `req:binfmt:decode-vectors` (§"The Decode Vector Corpus"). 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>
|
|
#
|
|
# NORMATIVE: Binary Format companion, `req:binfmt:decode-vectors`
|
|
# (section: The Decode Vector Corpus). A conforming decoder MUST accept every `accept`
|
|
# vector for a surface it implements and MUST reject every `reject` vector, and
|
|
# a value decoded from an `accept` vector MUST re-encode to exactly its bytes.
|
|
#
|
|
# Accepting a `reject` vector and then normalizing it IS accepting it, and does
|
|
# not satisfy the requirement. Canonical decode is injective: distinct byte
|
|
# strings denote distinct values, which is what content-addressing rests on.
|
|
#
|
|
# `verdict` and the bytes are the only normative columns.
|
|
#
|
|
# `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");
|
|
}
|
|
}
|
|
}
|