From 7e7a43b0508a2953ae8b15f5143e4623d5e417be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 09:42:04 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd --- crates/epiphany-core/src/codec.rs | 186 ++++++++++++------ crates/epiphany-core/src/fuzz.rs | 301 ++++++++++++++++++++++++++++++ crates/epiphany-core/src/lib.rs | 1 + 3 files changed, 433 insertions(+), 55 deletions(-) create mode 100644 crates/epiphany-core/src/fuzz.rs diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index f9a7d32..d2b3327 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -173,7 +173,21 @@ impl<'a> Reader<'a> { } fn count(&mut self) -> Result { - 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 { 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 { 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 { 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 { + fn enc_region_v0(reg: &Region, out: &mut Vec) { + 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 { - // 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) { - 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!( diff --git a/crates/epiphany-core/src/fuzz.rs b/crates/epiphany-core/src/fuzz.rs new file mode 100644 index 0000000..8a12c2d --- /dev/null +++ b/crates/epiphany-core/src/fuzz.rs @@ -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 { + 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>, + /// Valid single-`Region` encodings (the schema-major-1 type: it grew + /// `permits_spanning_slurs`). + regions: Vec>, + /// 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>, +} + +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 { + 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 { + 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 { + 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, 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()); + } +} diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 9af6dd5..465865d 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -55,6 +55,7 @@ mod pitch; mod tempo; mod time; +pub mod fuzz; pub mod generators; pub mod prepass;