Fuzzer P1: adversarial decode fuzz + strict-canonical decode + bounded counts

Stands up the Binary Format companion's wire-format-fuzzer charter item as a
core adversarial byte-decode harness, and lands the three robustness fixes it
drove out. The canonical decoders are a trust boundary (a hostile bundle, a
bit-rot chunk, a mismatched implementation), so every byte string must decode
to a clean Err -- never panic, over-allocate, or loop unboundedly -- and any
accepted string must re-encode to itself (canonical decode is injective).

- epiphany-core/src/fuzz.rs (new): run_decode_fuzz mutates a once-built corpus
  (random, substitution, truncation, trailing garbage, length-prefix
  corruption, wrong-type payload, genuine-v0-form) against Score::decode_canonical,
  the versioned seam (v1 + the frozen v0 migration), and a per-value decoder;
  asserts no-panic + injective decode over ~40K inputs/run. Two seeds, plus
  deterministic prefix-rejection sanity tests.

- Strict-canonical decode (the fuzzer's first finding): decode reconstructed via
  normalizing constructors (RationalTime reduces, BTreeSet/BTreeMap re-sort, a
  CanonicalF64/ReferencePitch/Tempo normalizes via new), so distinct byte
  strings could map to one value. Fixed complete-by-construction: Score::
  decode_canonical and the CanonicalValue macro re-encode and reject any input
  not already its canonical form; decode_v0_score does the same against the
  frozen v0 wire form (encode_v0_score promoted to production), so major-0
  snapshots are injective too.

- Bounded collection count (Reader::count): reject any count/length exceeding
  the bytes remaining. A garbage u32 count was a soft-DoS -- decoders looped
  element-by-element toward EOF (e.g. misparsing v1 bytes as v0), ~100ms per
  adversarial input; the bound also caps Vec/set allocation and gives a ~2600x
  fuzz speedup (422s -> 0.16s). Codec round-trips confirm no valid data has
  zero-byte-element collections, so the bound never rejects a real encoding.

Full gate green (workspace tests, clippy -D warnings, fmt, rustdoc -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
Levi Neuwirth 2026-07-07 09:42:04 -04:00
parent 7045a13a18
commit 7e7a43b050
3 changed files with 433 additions and 55 deletions

View File

@ -173,7 +173,21 @@ impl<'a> Reader<'a> {
}
fn count(&mut self) -> Result<usize> {
usize::try_from(self.u32()?).map_err(|_| ScoreDecodeError::LengthOverflow)
let n = usize::try_from(self.u32()?).map_err(|_| ScoreDecodeError::LengthOverflow)?;
// A count/length prefix can never exceed the bytes remaining, in both of
// its uses: a length-prefixed leaf needs exactly `n` further bytes
// (`lp`), and a collection prefix counts elements, each of which is at
// least one byte in this codec, so `n` elements need at least `n` bytes.
// (An empty leaf or collection has `n == 0`, which passes.) A larger `n`
// is corrupt or adversarial: reject it up front rather than looping
// element-by-element toward EOF — that bounds decode time on hostile
// input (a garbage `u32` count is otherwise a soft-DoS: e.g. misparsing
// v1 bytes as v0 reads a huge count and iterates ~remaining times) and
// caps the `Vec`/set allocation to a real size.
if n > self.bytes.len() - self.pos {
return Err(ScoreDecodeError::UnexpectedEof);
}
Ok(n)
}
/// A `u32`-length-prefixed byte slice.
@ -2058,10 +2072,27 @@ impl Score {
/// This is the **current (schema major 1)** layout. To decode bytes whose
/// schema major is not known to be current, use
/// [`Score::decode_canonical_versioned`].
///
/// Decoding is **strictly canonical**: an accepted byte string is its own
/// canonical form (`req:format:codec-conventions`). Several leaf and
/// collection decoders are individually *lenient* — they normalize on decode
/// (an unreduced [`RationalTime`](crate::RationalTime) reduces to lowest
/// terms; a `BTreeSet`/`BTreeMap` re-sorts and de-duplicates; a
/// [`CanonicalF64`] / `ReferencePitch` / `Tempo` normalizes via its
/// constructor) — so decode alone is not injective. This entry point closes
/// that gap uniformly: it re-encodes the decoded score and rejects any input
/// that is not already canonical, so two byte strings can never map to one
/// score (which would break content-addressing). A valid encoder never emits
/// a non-canonical score, so this only rejects corrupted or adversarial bytes.
pub fn decode_canonical(bytes: &[u8]) -> Result<Score> {
let mut r = Reader::new(bytes);
let score = Score::dec(&mut r)?;
r.finish()?;
if score.canonical_bytes() != bytes {
return Err(ScoreDecodeError::InvalidValue(
"non-canonical Score encoding",
));
}
Ok(score)
}
@ -2140,7 +2171,7 @@ fn decode_v0_score(bytes: &[u8]) -> Result<Score> {
let tombstoned_pitches = Codec::dec(&mut r)?;
let tombstoned_events = Codec::dec(&mut r)?;
r.finish()?;
Ok(Score {
let score = Score {
metadata,
canvas,
instruments,
@ -2160,7 +2191,71 @@ fn decode_v0_score(bytes: &[u8]) -> Result<Score> {
identity,
tombstoned_pitches,
tombstoned_events,
})
};
// Strictly canonical on the **v0 wire form**, exactly as
// `Score::decode_canonical` is for v1: the frozen field walk above decodes
// leniently (the unchanged fields normalize on decode — an unreduced
// RationalTime, an unsorted set, …), so re-encode the migrated score to the
// frozen v0 form and reject any input that is not already its canonical v0
// encoding. This keeps the migration injective over v0 snapshots too (a
// major-0 chunk is content-addressed just like a major-1 one). A valid v0
// writer never emitted a non-canonical snapshot.
if encode_v0_score(&score) != bytes {
return Err(ScoreDecodeError::InvalidValue(
"non-canonical v0 Score encoding",
));
}
Ok(score)
}
/// The **frozen schema-major-0** encoding of a score — the byte-exact inverse of
/// [`decode_v0_score`]'s field walk: `Canvas` without `layout_defaults`,
/// `Instrument` without `range`, `Region` without `permits_spanning_slurs`;
/// every other field through the current [`Codec`]. Used by `decode_v0_score`
/// to enforce strict v0 canonicality (and by the migration tests to synthesize
/// genuine v0 bytes). A migrated score's schema-major-1 fields hold their
/// defaults, so this simply omits them.
pub(crate) fn encode_v0_score(s: &Score) -> Vec<u8> {
fn enc_region_v0(reg: &Region, out: &mut Vec<u8>) {
reg.id.enc(out);
reg.time_model.enc(out);
reg.content.enc(out);
reg.time_extent.enc(out);
reg.staff_extent.enc(out);
reg.local_tempo_map.enc(out);
// v0: no permits_spanning_slurs.
}
let mut out = Vec::new();
s.metadata.enc(&mut out);
// Canvas v0: `regions` only (no `layout_defaults`).
put_len(&mut out, s.canvas.regions.len());
for reg in &s.canvas.regions {
enc_region_v0(reg, &mut out);
}
// Instruments v0: `{ id, name }` only (no `range`).
put_len(&mut out, s.instruments.len());
for inst in &s.instruments {
inst.id.enc(&mut out);
inst.name.enc(&mut out);
}
// Fields 4..19 are unchanged between v0 and v1.
s.staves.enc(&mut out);
s.staff_groups.enc(&mut out);
s.parts.enc(&mut out);
s.cross_cutting.enc(&mut out);
s.time_signatures.enc(&mut out);
s.tuning_context.enc(&mut out);
s.tempo_map.enc(&mut out);
s.events.enc(&mut out);
s.spelling_attachments.enc(&mut out);
s.decomposition_attachments.enc(&mut out);
s.spelling_precedence.enc(&mut out);
s.analysis_layers.enc(&mut out);
s.views.enc(&mut out);
s.identity.enc(&mut out);
s.tombstoned_pitches.enc(&mut out);
s.tombstoned_events.enc(&mut out);
out
}
/// Frozen v0 decoder for `Canvas`: the v0 layout was **just `regions`** (a
@ -2272,6 +2367,15 @@ macro_rules! canonical_value {
let mut r = Reader::new(bytes);
let v = <$ty as Codec>::dec(&mut r)?;
r.finish()?;
// Strictly canonical, exactly as `Score::decode_canonical`:
// reject a non-canonical encoding so per-value decode is
// injective (these bytes are content-addressed inside
// operation payloads).
if v.canonical_bytes() != bytes {
return Err(ScoreDecodeError::InvalidValue(
"non-canonical value encoding",
));
}
Ok(v)
}
}
@ -2478,58 +2582,6 @@ mod tests {
}
}
/// A frozen **v0 encoder** — the byte-exact inverse of the [`decode_v0_score`]
/// field walk, used only by the migration tests to synthesize *genuine* v0
/// bytes (a score whose three schema-major-1 fields are absent). It mirrors
/// the v0 layout exactly: `Canvas` without `layout_defaults`, `Instrument`
/// without `range`, `Region` without `permits_spanning_slurs`; every other
/// field through the current `Codec`. A decoder/encoder that agreed on a
/// *wrong* layout would still round-trip, so the callers also anchor the v0
/// byte length against the production v1 encoder (which this does not touch).
fn encode_v0_score(s: &Score) -> Vec<u8> {
// The inverse of dec_region_v0: the six fields before the schema-major-1
// permits_spanning_slurs (a full-Score snapshot's v0 region form).
fn enc_region_v0(reg: &Region, out: &mut Vec<u8>) {
reg.id.enc(out);
reg.time_model.enc(out);
reg.content.enc(out);
reg.time_extent.enc(out);
reg.staff_extent.enc(out);
reg.local_tempo_map.enc(out);
}
let mut out = Vec::new();
s.metadata.enc(&mut out);
// Canvas v0: `regions` only (no `layout_defaults`).
put_len(&mut out, s.canvas.regions.len());
for reg in &s.canvas.regions {
enc_region_v0(reg, &mut out);
}
// Instruments v0: `{ id, name }` only (no `range`).
put_len(&mut out, s.instruments.len());
for inst in &s.instruments {
inst.id.enc(&mut out);
inst.name.enc(&mut out);
}
// Fields 4..19 are unchanged between v0 and v1.
s.staves.enc(&mut out);
s.staff_groups.enc(&mut out);
s.parts.enc(&mut out);
s.cross_cutting.enc(&mut out);
s.time_signatures.enc(&mut out);
s.tuning_context.enc(&mut out);
s.tempo_map.enc(&mut out);
s.events.enc(&mut out);
s.spelling_attachments.enc(&mut out);
s.decomposition_attachments.enc(&mut out);
s.spelling_precedence.enc(&mut out);
s.analysis_layers.enc(&mut out);
s.views.enc(&mut out);
s.identity.enc(&mut out);
s.tombstoned_pitches.enc(&mut out);
s.tombstoned_events.enc(&mut out);
out
}
/// A C-in-`cmn-12` pitch at the given octave, for a non-default
/// [`PitchRange`] (the generators never populate `Instrument.range`).
fn cmn_c(octave: i8) -> Pitch {
@ -2664,6 +2716,30 @@ mod tests {
.all(|r| !r.permits_spanning_slurs));
}
#[test]
fn v0_decode_is_strictly_canonical_over_the_v0_wire_form() {
// The major-0 migration is strict, like the major-1 decoder: a canonical
// v0 encoding is accepted and re-encodes to itself in the frozen v0 form
// (`decode_v0_score` compares against `encode_v0_score`), while trailing
// or truncated bytes are rejected. This closes the injectivity gap for
// major-0 snapshots (content-addressed like major-1 ones).
for seed in 0..32u64 {
let score = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1));
let v0 = encode_v0_score(&score);
// Canonical v0 is accepted and re-encodes to the same v0 bytes.
let migrated = Score::decode_canonical_versioned(&v0, 0).unwrap();
assert_eq!(encode_v0_score(&migrated), v0);
// Trailing garbage is rejected.
let mut trailed = v0.clone();
trailed.push(0);
assert!(Score::decode_canonical_versioned(&trailed, 0).is_err());
// A truncation is rejected.
if !v0.is_empty() {
assert!(Score::decode_canonical_versioned(&v0[..v0.len() - 1], 0).is_err());
}
}
}
#[test]
fn distinct_scores_serialize_differently() {
assert_ne!(

View File

@ -0,0 +1,301 @@
//! 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 v1 bytes.
v0_scores: Vec<Vec<u8>>,
}
fn build_corpus(rng: &mut SplitMix64) -> Corpus {
let mut scores = Vec::new();
let mut regions = Vec::new();
let mut v0_scores = Vec::new();
for i in 0..12u64 {
let seed = rng.next_u64();
let score = if i % 2 == 0 {
valid_score(seed | 1)
} else {
valid_score_rich(seed)
};
if let Some(region) = score.canvas.regions.first() {
regions.push(region.canonical_bytes());
}
v0_scores.push(crate::codec::encode_v0_score(&score));
scores.push(score.canonical_bytes());
}
Corpus {
scores,
regions,
v0_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 1 is the current layout; major
// 0 runs the frozen `decode_v0_score` migration; an arbitrary major
// exercises the defensive out-of-accept-set path.
let _ = Score::decode_canonical_versioned(&bytes, 1);
// The v0 migration default-fills the schema-major-1 fields, so it does
// not round-trip to the *v1* form — but it is strictly canonical over the
// **v0 wire form**: an accepted input re-encodes to itself via the frozen
// v0 encoder. This proves non-canonical rejection on the v0 path, not
// just the absence of a panic.
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* v0-form encoding (mutated) to
// the frozen major-0 migration, so its strict v0 canonicality is hit
// with real v0 bytes — an accepted input must re-encode to itself in the
// v0 wire form, and an unmutated v0 encoding must always be accepted.
if rng.next_u64() % 4 == 0 {
let mut v0 =
corpus.v0_scores[(rng.next_u64() as usize) % corpus.v0_scores.len()].clone();
match rng.next_u64() % 4 {
0 => {} // unmutated: must decode Ok and round-trip the v0 form.
1 => {
let k = 1 + (rng.next_u64() % 4) as usize;
substitute(&mut rng, &mut v0, k);
}
2 => {
let t = (rng.next_u64() as usize) % (v0.len() + 1);
v0.truncate(t);
}
_ => corrupt_length_prefix(&mut rng, &mut v0),
}
if let Ok(score) = Score::decode_canonical_versioned(&v0, 0) {
assert_eq!(
crate::codec::encode_v0_score(&score),
v0,
"the v0 migration accepted a non-canonical v0 byte string"
);
}
}
}
}
#[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());
}
}

View File

@ -55,6 +55,7 @@ mod pitch;
mod tempo;
mod time;
pub mod fuzz;
pub mod generators;
pub mod prepass;