crates/veilvoice-meta/tests/wav_fuzz.rs
what this file is for · veilvoice-meta · 299 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Randomised robustness testing for the RIFF chunk walker.
//!
//! `clean_wav_bytes` exists because `lofty` cannot remove ID3v2 from a WAV, so
//! VeilVoice walks the chunk list itself. That means it parses a container
//! somebody else produced, with every length field under their control, and the
//! textbook setting for an overrun or a loop that never ends.
//!
//! The properties asserted, for any input at all:
//!
//! 1. **It returns.** No panic, and no unbounded loop: every iteration must
//! advance `pos`, whatever the chunk sizes claim.
//! 2. **A success is a valid WAV.** If it hands back bytes, those bytes must
//! parse as RIFF/WAVE with a length field that matches what was written,
//! it is not permitted to emit something the next tool chokes on.
//! 3. **It never invents audio.** Output length is bounded by input length.
//!
//! Set `VEILVOICE_FUZZ_ROUNDS` to run it longer than the default.
//!
//! # In plain words
//!
//! Throws damaged and hostile WAV files at the metadata stripper.
//!
//! A WAV file is a series of labelled sections, and a section that lies about its
//! own size is the classic way to make a program read past the end of what it was
//! given. Every malformed file here has to be refused rather than trusted.
use veilvoice_meta::{clean_wav_bytes, is_wav, Policy};
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
Self(seed | 1)
}
fn next_u32(&mut self) -> u32 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 17;
self.0 ^= self.0 << 5;
self.0
}
fn below(&mut self, n: usize) -> usize {
if n == 0 {
0
} else {
self.next_u32() as usize % n
}
}
fn byte(&mut self) -> u8 {
(self.next_u32() >> 24) as u8
}
}
fn rounds() -> u32 {
std::env::var("VEILVOICE_FUZZ_ROUNDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20_000)
}
/// A minimal but genuinely valid WAV to mutate from.
fn seed_wav() -> Vec<u8> {
let samples: Vec<u8> = (0..400u32).flat_map(|i| (i as u16).to_le_bytes()).collect();
let mut fmt = Vec::new();
fmt.extend_from_slice(&1u16.to_le_bytes()); // PCM
fmt.extend_from_slice(&1u16.to_le_bytes()); // mono
fmt.extend_from_slice(&48_000u32.to_le_bytes());
fmt.extend_from_slice(&96_000u32.to_le_bytes());
fmt.extend_from_slice(&2u16.to_le_bytes());
fmt.extend_from_slice(&16u16.to_le_bytes());
let mut body = Vec::new();
body.extend_from_slice(b"fmt ");
body.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
body.extend_from_slice(&fmt);
// A tag chunk, so the cleaner has something to remove.
body.extend_from_slice(b"LIST");
body.extend_from_slice(&12u32.to_le_bytes());
body.extend_from_slice(b"INFOIART");
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(b"data");
body.extend_from_slice(&(samples.len() as u32).to_le_bytes());
body.extend_from_slice(&samples);
let mut wav = Vec::from(*b"RIFF");
wav.extend_from_slice(&((body.len() + 4) as u32).to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(&body);
wav
}
/// Mutations aimed at the chunk walker specifically: the interesting bytes are
/// the 32-bit sizes, so a fifth of the rounds corrupt one deliberately.
fn mutate(rng: &mut Rng, seed_bytes: &[u8]) -> Vec<u8> {
let mut out = seed_bytes.to_vec();
match rng.below(9) {
0 => {
if !out.is_empty() {
let i = rng.below(out.len());
out[i] ^= 1 << rng.below(8);
}
}
1 => {
let n = rng.below(out.len() + 1);
out.truncate(n);
}
2 => {
for _ in 0..rng.below(80) {
out.push(rng.byte());
}
}
// Lie about a chunk size, which is the whole point of the exercise.
3 | 4 => {
if out.len() >= 8 {
let i = rng.below(out.len() - 3);
let v: u32 = match rng.below(6) {
0 => u32::MAX,
1 => u32::MAX - 7,
2 => 0,
3 => 1,
4 => i32::MAX as u32,
_ => rng.next_u32(),
};
out[i..i + 4].copy_from_slice(&v.to_le_bytes());
}
}
// Lie about the RIFF size specifically.
5 => {
if out.len() >= 8 {
let v = if rng.below(2) == 0 {
u32::MAX
} else {
rng.next_u32()
};
out[4..8].copy_from_slice(&v.to_le_bytes());
}
}
// Rename a chunk, so unknown ids are exercised.
6 => {
if out.len() >= 16 {
let i = 12 + rng.below(out.len().saturating_sub(16));
for k in 0..4 {
out[i + k] = rng.byte();
}
}
}
7 => {
if !out.is_empty() {
let from = rng.below(out.len());
let to = (from + rng.below(48)).min(out.len());
for b in &mut out[from..to] {
*b = 0;
}
}
}
_ => {
let n = rng.below(300);
out = (0..n).map(|_| rng.byte()).collect();
if out.len() >= 12 {
out[..4].copy_from_slice(b"RIFF");
out[8..12].copy_from_slice(b"WAVE");
}
}
}
out
}
fn check_output(seed: u32, input: &[u8], out: &[u8]) {
assert!(
is_wav(out),
"seed {seed}: cleaner produced something that is not a WAV"
);
assert!(
out.len() >= 12,
"seed {seed}: output too short to be a container"
);
let declared = u32::from_le_bytes([out[4], out[5], out[6], out[7]]) as usize;
assert_eq!(
declared + 8,
out.len(),
"seed {seed}: the RIFF size field does not match the bytes written"
);
assert!(
out.len() <= input.len() + 256,
"seed {seed}: output grew from {} to {} bytes",
input.len(),
out.len()
);
}
#[test]
fn the_chunk_walker_survives_hostile_input() {
let valid = seed_wav();
for seed in 1..=rounds() {
let mut rng = Rng::new(seed);
let bytes = mutate(&mut rng, &valid);
// Reaching the next line is the no-panic, no-hang assertion.
if let Ok((out, _report)) = clean_wav_bytes(&bytes, Policy::Strip) {
check_output(seed, &bytes, &out);
}
}
}
/// `Policy::Realistic` appends a chunk of its own, which is the one path that
/// can make the output larger than the input.
#[test]
fn the_realistic_policy_survives_hostile_input() {
let valid = seed_wav();
for seed in 1..=rounds() {
let mut rng = Rng::new(seed ^ 0xC0FFEE);
let bytes = mutate(&mut rng, &valid);
if let Ok((out, report)) = clean_wav_bytes(&bytes, Policy::Realistic) {
check_output(seed, &bytes, &out);
assert!(
report.changed,
"seed {seed}: realistic policy always rewrites"
);
}
}
}
#[test]
fn pure_noise_is_rejected_or_handled() {
for seed in 1..=rounds() {
let mut rng = Rng::new(seed.wrapping_mul(2_246_822_519));
let n = rng.below(400);
let bytes: Vec<u8> = (0..n).map(|_| rng.byte()).collect();
if let Ok((out, _)) = clean_wav_bytes(&bytes, Policy::Strip) {
check_output(seed, &bytes, &out);
}
}
}
/// A cleaned file must clean again to itself. If a second pass changes
/// anything, the first pass left something behind.
#[test]
fn cleaning_is_idempotent() {
let valid = seed_wav();
let (once, first) = clean_wav_bytes(&valid, Policy::Strip).unwrap();
assert!(first.changed, "the seed file has a LIST chunk to remove");
let (twice, second) = clean_wav_bytes(&once, Policy::Strip).unwrap();
assert_eq!(once, twice, "a second pass changed the bytes");
assert!(!second.changed, "a second pass claimed to remove something");
}
/// Every truncation of a valid file, which is what a partial download or an
/// interrupted recording actually looks like.
#[test]
fn every_truncation_of_a_valid_file_is_handled() {
let valid = seed_wav();
for len in 0..=valid.len() {
if let Ok((out, _)) = clean_wav_bytes(&valid[..len], Policy::Strip) {
check_output(len as u32, &valid[..len], &out);
}
}
}
/// The RIFF size field is a `u32` widened to `usize` and then had 8 added to
/// it. On a 32-bit target, and VeilVoice ships an ARMv7 build, `u32::MAX + 8`
/// overflows `usize` and panics under overflow checks. A 64-bit host cannot
/// reach that, so no amount of fuzzing *here* would have found it; it is
/// asserted anyway so the saturating arithmetic is not quietly removed later.
#[test]
fn a_riff_size_of_u32_max_does_not_overflow_the_length_arithmetic() {
let mut wav = seed_wav();
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let (out, _) = clean_wav_bytes(&wav, Policy::Strip).expect("should still parse");
check_output(0, &wav, &out);
// And the smallest possible file, where the clamp is doing all the work.
let mut tiny = Vec::from(*b"RIFF");
tiny.extend_from_slice(&u32::MAX.to_le_bytes());
tiny.extend_from_slice(b"WAVE");
assert!(clean_wav_bytes(&tiny, Policy::Strip).is_err());
}
/// A chunk that declares a size of zero must still advance the walker. If it
/// did not, this would never return, which is why it is asserted rather than
/// assumed.
#[test]
fn zero_sized_chunks_do_not_stall_the_walker() {
let mut wav = Vec::from(*b"RIFF");
let mut body = Vec::new();
for _ in 0..500 {
body.extend_from_slice(b"junk");
body.extend_from_slice(&0u32.to_le_bytes());
}
body.extend_from_slice(b"data");
body.extend_from_slice(&4u32.to_le_bytes());
body.extend_from_slice(&[0u8; 4]);
wav.extend_from_slice(&((body.len() + 4) as u32).to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(&body);
let (out, report) = clean_wav_bytes(&wav, Policy::Strip).unwrap();
check_output(0, &wav, &out);
assert!(report.changed);
}