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:
parent
7045a13a18
commit
7e7a43b050
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ mod pitch;
|
|||
mod tempo;
|
||||
mod time;
|
||||
|
||||
pub mod fuzz;
|
||||
pub mod generators;
|
||||
pub mod prepass;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue