387 lines
15 KiB
Rust
387 lines
15 KiB
Rust
//! Adversarial byte-decode fuzzing for the whole-`Score` canonical codec and
|
|
//! the per-value [`CanonicalValue`] decoders (the Binary Format companion's
|
|
//! "wire-format fuzzer" charter item).
|
|
//!
|
|
//! The canonical decoders are a **trust boundary**: they parse bytes that may
|
|
//! be truncated, corrupted, or wholly adversarial (a hostile bundle, a bit-rot
|
|
//! chunk, a mismatched implementation). The contract this harness enforces is
|
|
//! that *every* byte string decodes to a clean [`Err`] — the decoders **never
|
|
//! panic, never over-allocate, and never loop unboundedly** — and that any
|
|
//! string a decoder accepts re-encodes to itself, since canonical decoding is
|
|
//! injective (a value has exactly one canonical byte form; trailing or
|
|
//! non-canonical bytes are rejected). A panic here fails the run and names the
|
|
//! seed, so any counterexample is reproducible.
|
|
//!
|
|
//! This deliberately hammers the schema-major-1 migration surface added by the
|
|
//! schema-major track: [`Score::decode_canonical_versioned`] and its frozen
|
|
//! major-0 walk (`decode_v0_score` → `dec_canvas_v0` / `dec_region_v0` /
|
|
//! `dec_instruments_v0`), whose per-element `Vec` loops over attacker-supplied
|
|
//! counts are exactly the shape that, done naively, over-allocates or reads out
|
|
//! of bounds.
|
|
|
|
use epiphany_determinism::fuzz::SplitMix64;
|
|
|
|
use crate::generators::{valid_score, valid_score_rich};
|
|
use crate::{CanonicalValue, Region, Score, ScoreDecodeError};
|
|
|
|
/// `n` pseudo-random bytes.
|
|
fn random_bytes(rng: &mut SplitMix64, n: usize) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(n + 8);
|
|
while out.len() < n {
|
|
out.extend_from_slice(&rng.next_u64().to_le_bytes());
|
|
}
|
|
out.truncate(n);
|
|
out
|
|
}
|
|
|
|
/// A small pool of valid canonical encodings, built **once** per run — building
|
|
/// a fresh `Score` per iteration dominates the cost, so the corpus is generated
|
|
/// up front and every iteration mutates a clone of a pooled entry.
|
|
struct Corpus {
|
|
/// Valid whole-`Score` encodings (both the simple and rich/multi-region
|
|
/// shapes).
|
|
scores: Vec<Vec<u8>>,
|
|
/// Valid single-`Region` encodings (the schema-major-1 type: it grew
|
|
/// `permits_spanning_slurs`).
|
|
regions: Vec<Vec<u8>>,
|
|
/// Valid **frozen v0** whole-`Score` encodings — genuine major-0 wire bytes
|
|
/// (via [`crate::codec::encode_v0_score`]), to exercise the strict v0
|
|
/// migration path with real v0 inputs rather than only mutated current
|
|
/// bytes.
|
|
v0_scores: Vec<Vec<u8>>,
|
|
/// Valid **frozen v1** whole-`Score` encodings (via
|
|
/// [`crate::codec::encode_v1_score`]) — the schema-major-2 migration's
|
|
/// input form.
|
|
v1_scores: Vec<Vec<u8>>,
|
|
}
|
|
|
|
/// The rich fixture plus repeat structures carrying non-default v2 content
|
|
/// (a DalSegno kind, voltas), so the decode fuzzer exercises the
|
|
/// RepeatKind/Volta wire arms through a whole-`Score` form. Corpus-local —
|
|
/// the shared render fixtures deliberately stay repeat-free until the E1
|
|
/// rendering tranche (golden-churn discipline).
|
|
fn valid_score_rich_with_repeats(seed: u64) -> Score {
|
|
use crate::graph::{RepeatKind, RepeatStructure, Volta};
|
|
use crate::ids::RepeatStructureId;
|
|
use crate::time::{AnchorOffset, RegionEdge, TimeAnchor};
|
|
let mut score = valid_score_rich(seed);
|
|
let region = score.canvas.regions[0].id;
|
|
let span = |edge: RegionEdge| TimeAnchor::Region {
|
|
id: region,
|
|
edge,
|
|
offset: AnchorOffset::Zero,
|
|
};
|
|
let replica = crate::ids::ReplicaId(0xF0F0);
|
|
score.cross_cutting.repeats.push(RepeatStructure {
|
|
id: RepeatStructureId::new(replica, 1),
|
|
start: span(RegionEdge::Start),
|
|
end: span(RegionEdge::End),
|
|
kind: RepeatKind::DalSegno {
|
|
segno: span(RegionEdge::Start),
|
|
end_target: span(RegionEdge::End),
|
|
},
|
|
voltas: Vec::new(),
|
|
});
|
|
score.cross_cutting.repeats.push(RepeatStructure {
|
|
id: RepeatStructureId::new(replica, 2),
|
|
start: span(RegionEdge::Start),
|
|
end: span(RegionEdge::End),
|
|
kind: RepeatKind::Volta,
|
|
voltas: vec![
|
|
Volta {
|
|
endings: vec![1],
|
|
start: span(RegionEdge::Start),
|
|
end: span(RegionEdge::End),
|
|
},
|
|
Volta {
|
|
endings: vec![2, 3],
|
|
start: span(RegionEdge::Start),
|
|
end: span(RegionEdge::End),
|
|
},
|
|
],
|
|
});
|
|
score
|
|
}
|
|
|
|
fn build_corpus(rng: &mut SplitMix64) -> Corpus {
|
|
let mut scores = Vec::new();
|
|
let mut regions = Vec::new();
|
|
let mut v0_scores = Vec::new();
|
|
let mut v1_scores = Vec::new();
|
|
for i in 0..12u64 {
|
|
let seed = rng.next_u64();
|
|
let score = match i % 3 {
|
|
0 => valid_score(seed | 1),
|
|
1 => valid_score_rich(seed),
|
|
_ => valid_score_rich_with_repeats(seed),
|
|
};
|
|
if let Some(region) = score.canvas.regions.first() {
|
|
regions.push(region.canonical_bytes());
|
|
}
|
|
v0_scores.push(crate::codec::encode_v0_score(&score));
|
|
v1_scores.push(crate::codec::encode_v1_score(&score));
|
|
scores.push(score.canonical_bytes());
|
|
}
|
|
Corpus {
|
|
scores,
|
|
regions,
|
|
v0_scores,
|
|
v1_scores,
|
|
}
|
|
}
|
|
|
|
/// A clone of a random pooled valid `Score` encoding.
|
|
fn valid_score_bytes(rng: &mut SplitMix64, corpus: &Corpus) -> Vec<u8> {
|
|
corpus.scores[(rng.next_u64() as usize) % corpus.scores.len()].clone()
|
|
}
|
|
|
|
/// A clone of a random pooled valid `Region` encoding.
|
|
fn valid_region_bytes(rng: &mut SplitMix64, corpus: &Corpus) -> Vec<u8> {
|
|
corpus.regions[(rng.next_u64() as usize) % corpus.regions.len()].clone()
|
|
}
|
|
|
|
/// Overwrites up to `k` random single bytes.
|
|
fn substitute(rng: &mut SplitMix64, bytes: &mut [u8], k: usize) {
|
|
if bytes.is_empty() {
|
|
return;
|
|
}
|
|
for _ in 0..k {
|
|
let i = (rng.next_u64() as usize) % bytes.len();
|
|
bytes[i] = rng.next_u64() as u8;
|
|
}
|
|
}
|
|
|
|
/// Overwrites a random 4-byte window with a fresh `u32` (often large): the
|
|
/// length/count-prefix attack — the value a `Vec`/`String` decoder trusts for
|
|
/// its element count or byte length.
|
|
fn corrupt_length_prefix(rng: &mut SplitMix64, bytes: &mut [u8]) {
|
|
if bytes.len() < 4 {
|
|
return;
|
|
}
|
|
let i = (rng.next_u64() as usize) % (bytes.len() - 3);
|
|
// Bias toward extreme counts (all-ones / near-u32::MAX) alongside plain
|
|
// random draws, since those are what stress the allocation guards.
|
|
let v: u32 = match rng.next_u64() % 3 {
|
|
0 => u32::MAX,
|
|
1 => (rng.next_u64() as u32) | 0x8000_0000,
|
|
_ => rng.next_u64() as u32,
|
|
};
|
|
bytes[i..i + 4].copy_from_slice(&v.to_le_bytes());
|
|
}
|
|
|
|
/// Builds one adversarial input by a strategy chosen from `rng`. Strategy 1
|
|
/// returns *unmutated* valid `Score` bytes (a live sanity check that the
|
|
/// harness's own valid corpus round-trips).
|
|
fn gen_score_input(rng: &mut SplitMix64, corpus: &Corpus) -> Vec<u8> {
|
|
match rng.next_u64() % 7 {
|
|
0 => {
|
|
let n = (rng.next_u64() % 512) as usize;
|
|
random_bytes(rng, n)
|
|
}
|
|
1 => valid_score_bytes(rng, corpus),
|
|
2 => {
|
|
let mut b = valid_score_bytes(rng, corpus);
|
|
let k = 1 + (rng.next_u64() % 4) as usize;
|
|
substitute(rng, &mut b, k);
|
|
b
|
|
}
|
|
3 => {
|
|
let mut b = valid_score_bytes(rng, corpus);
|
|
let t = (rng.next_u64() as usize) % (b.len() + 1);
|
|
b.truncate(t);
|
|
b
|
|
}
|
|
4 => {
|
|
let mut b = valid_score_bytes(rng, corpus);
|
|
let n = 1 + (rng.next_u64() % 16) as usize;
|
|
let tail = random_bytes(rng, n);
|
|
b.extend_from_slice(&tail);
|
|
b
|
|
}
|
|
5 => {
|
|
let mut b = valid_score_bytes(rng, corpus);
|
|
corrupt_length_prefix(rng, &mut b);
|
|
b
|
|
}
|
|
_ => {
|
|
// A valid Region's bytes, standing where a Score is expected (a
|
|
// structurally plausible but wrong-type payload).
|
|
valid_region_bytes(rng, corpus)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Asserts a whole-`Score` decode result is well-behaved: an accepted string
|
|
/// re-encodes to itself (canonical decode is injective). A panic in the decoder
|
|
/// would already have aborted the run.
|
|
fn check_score(result: Result<Score, ScoreDecodeError>, bytes: &[u8]) {
|
|
if let Ok(score) = result {
|
|
// Strictly canonical decode is injective: an accepted string re-encodes
|
|
// to itself (enforced by `Score::decode_canonical`; this is the fuzzer's
|
|
// independent safety net over 20K+ adversarial inputs).
|
|
assert_eq!(
|
|
score.canonical_bytes(),
|
|
bytes,
|
|
"the whole-Score decoder accepted a non-canonical byte string"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Runs `iters` adversarial byte-decode iterations from `seed` against the
|
|
/// whole-`Score` codec (current layout and the versioned seam, including the
|
|
/// frozen major-0 migration) and a per-value decoder. Panics — a decoder crash
|
|
/// or a non-canonical acceptance — fail the run; the `seed` reproduces it.
|
|
pub fn run_decode_fuzz(iters: u64, seed: u64) {
|
|
let mut rng = SplitMix64::new(seed);
|
|
let corpus = build_corpus(&mut rng);
|
|
for _ in 0..iters {
|
|
let bytes = gen_score_input(&mut rng, &corpus);
|
|
|
|
// The current-layout decoder: must not panic; an Ok must round-trip.
|
|
check_score(Score::decode_canonical(&bytes), &bytes);
|
|
|
|
// The schema-version dispatch seam. Major 3 is the current layout;
|
|
// majors 2, 1, and 0 run the frozen migrations; an arbitrary major
|
|
// exercises the defensive out-of-accept-set path. Each migration
|
|
// default-fills the appended fields, so it does not round-trip to the
|
|
// *current* form — but each is strictly canonical over its OWN wire
|
|
// form: an accepted input re-encodes to itself via the frozen encoder.
|
|
// This proves non-canonical rejection on every versioned path, not
|
|
// just the absence of a panic.
|
|
let _ = Score::decode_canonical_versioned(&bytes, 2);
|
|
if let Ok(v1_score) = Score::decode_canonical_versioned(&bytes, 1) {
|
|
assert_eq!(
|
|
crate::codec::encode_v1_score(&v1_score),
|
|
bytes,
|
|
"the v1 migration accepted a non-canonical v1 byte string"
|
|
);
|
|
}
|
|
if let Ok(v0_score) = Score::decode_canonical_versioned(&bytes, 0) {
|
|
assert_eq!(
|
|
crate::codec::encode_v0_score(&v0_score),
|
|
bytes,
|
|
"the v0 migration accepted a non-canonical v0 byte string"
|
|
);
|
|
}
|
|
let _ = Score::decode_canonical_versioned(&bytes, rng.next_u64() as u16);
|
|
|
|
// A per-value decoder over the same adversarial bytes.
|
|
let _ = Region::decode_canonical(&bytes);
|
|
|
|
// Every ~8th iteration, target the Region decoder with bytes grown from
|
|
// a *valid Region* (mutated), so the value codec is hit past its early
|
|
// tags, not just rejected at byte 0.
|
|
if rng.next_u64() % 8 == 0 {
|
|
let mut rb = valid_region_bytes(&mut rng, &corpus);
|
|
match rng.next_u64() % 3 {
|
|
0 => {
|
|
let k = 1 + (rng.next_u64() % 3) as usize;
|
|
substitute(&mut rng, &mut rb, k);
|
|
}
|
|
1 => {
|
|
let t = (rng.next_u64() as usize) % (rb.len() + 1);
|
|
rb.truncate(t);
|
|
}
|
|
_ => corrupt_length_prefix(&mut rng, &mut rb),
|
|
}
|
|
if let Ok(region) = Region::decode_canonical(&rb) {
|
|
assert_eq!(
|
|
region.canonical_bytes(),
|
|
rb,
|
|
"the Region decoder accepted a non-canonical byte string"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Every ~4th iteration, feed a *genuine* frozen-form encoding
|
|
// (mutated) to its migration path — both the v0 and v1 wire forms —
|
|
// so each strict canonicality guard is hit with real bytes of its own
|
|
// major. An UNMUTATED frozen encoding MUST decode Ok (enforced, not
|
|
// just commented); an accepted input must re-encode to itself.
|
|
if rng.next_u64() % 4 == 0 {
|
|
type Reenc = fn(&Score) -> Vec<u8>;
|
|
let forms: [(&[Vec<u8>], u16, Reenc, &str); 2] = [
|
|
(
|
|
&corpus.v0_scores,
|
|
0,
|
|
crate::codec::encode_v0_score as Reenc,
|
|
"v0",
|
|
),
|
|
(
|
|
&corpus.v1_scores,
|
|
1,
|
|
crate::codec::encode_v1_score as Reenc,
|
|
"v1",
|
|
),
|
|
];
|
|
for (pool, major, reenc, label) in forms {
|
|
let mut bytes = pool[(rng.next_u64() as usize) % pool.len()].clone();
|
|
let mutation = rng.next_u64() % 4;
|
|
match mutation {
|
|
0 => {} // unmutated: must decode Ok (asserted below).
|
|
1 => {
|
|
let k = 1 + (rng.next_u64() % 4) as usize;
|
|
substitute(&mut rng, &mut bytes, k);
|
|
}
|
|
2 => {
|
|
let t = (rng.next_u64() as usize) % (bytes.len() + 1);
|
|
bytes.truncate(t);
|
|
}
|
|
_ => corrupt_length_prefix(&mut rng, &mut bytes),
|
|
}
|
|
match Score::decode_canonical_versioned(&bytes, major) {
|
|
Ok(score) => assert_eq!(
|
|
reenc(&score),
|
|
bytes,
|
|
"the {label} migration accepted a non-canonical {label} byte string"
|
|
),
|
|
Err(_) => assert_ne!(
|
|
mutation, 0,
|
|
"an unmutated genuine {label} encoding must decode Ok"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A fast smoke run in the ordinary test suite: enough iterations to catch a
|
|
/// gross regression, cheap enough for every `cargo test` (each iteration
|
|
/// decodes *and* re-encodes several times for the strict-canonical check, so
|
|
/// the count is kept modest; a deeper sweep runs via [`run_decode_fuzz`] with
|
|
/// a large `iters` in a dedicated gate).
|
|
#[test]
|
|
fn decode_fuzz_smoke() {
|
|
run_decode_fuzz(20_000, 0x0DEC_0DE0_F022_1234);
|
|
}
|
|
|
|
/// A second seed, so a determinism-sensitive bug does not hide behind one
|
|
/// generator stream.
|
|
#[test]
|
|
fn decode_fuzz_smoke_alt_seed() {
|
|
run_decode_fuzz(20_000, 0xF0FA_11BA_C0DE_5EED);
|
|
}
|
|
|
|
/// Directly confirm the harness's core invariants on hand-built inputs — so
|
|
/// a change that made the fuzzer vacuous (e.g. always generating rejected
|
|
/// bytes) is caught.
|
|
#[test]
|
|
fn valid_bytes_round_trip_and_truncations_reject() {
|
|
let score = valid_score(0xA11CE);
|
|
let bytes = score.canonical_bytes();
|
|
assert_eq!(Score::decode_canonical(&bytes).unwrap(), score);
|
|
// Every proper prefix is rejected (never accepted, never a panic).
|
|
for t in 0..bytes.len() {
|
|
assert!(Score::decode_canonical(&bytes[..t]).is_err());
|
|
}
|
|
// Trailing garbage is rejected.
|
|
let mut extended = bytes.clone();
|
|
extended.push(0);
|
|
assert!(Score::decode_canonical(&extended).is_err());
|
|
}
|
|
}
|