wav-codec 0.1.0

WAV (RIFF/WAVE) reader and writer over std I/O. PCM and IEEE-float WAV only. Compressed WAV (ADPCM, mu-law, A-law) not decoded.
Documentation
//! Gate (e), CI smoke half: the fuzz corpus seeds are the conformance fixtures,
//! and this test drives the same drain the `read_drain` fuzz target runs
//! over every seed plus a deterministic mutation set: every truncation
//! length, every single-byte flip, and a handful of hostile header values.
//! Nothing here may panic, and no accepted input may yield more samples
//! than its bytes can hold.

mod common;

use std::io::Cursor;
use std::panic::{catch_unwind, AssertUnwindSafe};

use common::{fixture_bytes, open, vector, vectors};
use wav_codec::{Error, SampleFormat, WavReader, WavWriter};

/// A truncated stream can end mid-frame, so the writer may report
/// `UnfinishedFrame`. Any other finalize error is a finding.
fn finish<W: std::io::Write + std::io::Seek>(w: WavWriter<W>) {
    match w.finalize() {
        Ok(()) | Err(Error::UnfinishedFrame) => {}
        Err(e) => panic!("finalize failed: {}", e),
    }
}

/// Open, drain, and round-trip through the writer, mirroring the fuzz
/// target. Returns the count of samples yielded before any error.
fn exercise(data: &[u8]) -> usize {
    let mut reader = match WavReader::new(Cursor::new(data)) {
        Ok(r) => r,
        Err(_) => return 0,
    };
    let spec_ex = reader.spec_ex();
    let width = usize::from(spec_ex.bytes_per_sample);
    assert!(width >= 1);
    let n = match spec_ex.spec.sample_format {
        SampleFormat::Int => {
            let mut got = Vec::new();
            for s in reader.samples::<i32>() {
                match s {
                    Ok(v) => got.push(v),
                    Err(_) => break,
                }
            }
            if let Ok(mut w) = WavWriter::new_with_spec_ex(Cursor::new(Vec::new()), spec_ex) {
                w.write_samples(&got).unwrap();
                finish(w);
            }
            got.len()
        }
        SampleFormat::Float => {
            let mut got = Vec::new();
            for s in reader.samples::<f64>() {
                match s {
                    Ok(v) => got.push(v),
                    Err(_) => break,
                }
            }
            if let Ok(mut w) = WavWriter::new_with_spec_ex(Cursor::new(Vec::new()), spec_ex) {
                if spec_ex.spec.bits_per_sample == 64 {
                    w.write_samples(&got).unwrap();
                } else {
                    let narrow: Vec<f32> = got.iter().map(|v| *v as f32).collect();
                    w.write_samples(&narrow).unwrap();
                }
                finish(w);
            }
            got.len()
        }
    };
    assert!(n * width <= data.len(), "more samples than bytes");

    // The bulk path over the same input must agree on the count.
    if let Ok(mut reader) = WavReader::new(Cursor::new(data)) {
        let mut bulk = 0usize;
        let mut buf = [0i32; 7];
        let mut fbuf = [0f64; 7];
        loop {
            let r = match reader.spec().sample_format {
                SampleFormat::Int => reader.read_samples(&mut buf),
                SampleFormat::Float => reader.read_samples(&mut fbuf),
            };
            match r {
                Ok(0) => break,
                Ok(k) => bulk += k,
                Err(_) => break,
            }
        }
        // A truncated stream may fail the bulk call holding the last
        // whole samples, so bulk can fall short by one buffer, never over.
        assert!(bulk <= n);
        assert!(n - bulk < 7);
    }
    n
}

fn must_not_panic(label: &str, data: &[u8]) -> usize {
    catch_unwind(AssertUnwindSafe(|| exercise(data)))
        .unwrap_or_else(|_| panic!("{} panicked", label))
}

fn seeds() -> Vec<(String, Vec<u8>)> {
    let mut out = Vec::new();
    for v in vectors() {
        if v.field("kind").as_str() == "perf" {
            continue;
        }
        out.push((v.field("id").as_str().to_string(), fixture_bytes(&v)));
    }
    for name in ["ref-ffmpeg-f64.wav", "ref-ffmpeg-s24.wav"] {
        let bytes = std::fs::read(common::fixture_path(&format!("fixtures/{}", name))).unwrap();
        out.push((name.to_string(), bytes));
    }
    out
}

#[test]
fn fuzz_corpus_smoke() {
    let mut runs = 0usize;
    for (id, bytes) in seeds() {
        must_not_panic(&id, &bytes);
        runs += 1;
        for cut in 0..bytes.len() {
            must_not_panic(&format!("{} cut at {}", id, cut), &bytes[..cut]);
            runs += 1;
        }
        let mut flipped = bytes.clone();
        for i in 0..bytes.len() {
            flipped[i] ^= 0xFF;
            must_not_panic(&format!("{} flip at {}", id, i), &flipped);
            flipped[i] = bytes[i];
            runs += 1;
            for b in [0x00u8, 0x01, 0x7F, 0x80] {
                flipped[i] = b;
                must_not_panic(&format!("{} set {} at {}", id, b, i), &flipped);
                flipped[i] = bytes[i];
                runs += 1;
            }
        }
    }
    assert!(runs > 10_000, "{} runs", runs);
}

/// Hostile header fields: sizes at the u32 ceiling, channel and block
/// align values at the u16 ceiling, and bit depths that do not fit.
#[test]
fn hostile_header_values_do_not_panic() {
    let base = fixture_bytes(&vectors()[1]); // V02: fmt 16, stereo i16
    let mut cases: Vec<(&str, Vec<u8>)> = Vec::new();

    let mut b = base.clone();
    b[16..20].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
    cases.push(("fmt size u32::MAX", b));

    let mut b = base.clone();
    b[16..20].copy_from_slice(&17u32.to_le_bytes());
    cases.push(("fmt size 17", b));

    let mut b = base.clone();
    b[22..24].copy_from_slice(&0xFFFFu16.to_le_bytes());
    b[32..34].copy_from_slice(&0xFFFFu16.to_le_bytes());
    cases.push(("65535 channels, block align 65535", b));

    let mut b = base.clone();
    b[32..34].copy_from_slice(&0xFFFFu16.to_le_bytes());
    cases.push(("block align 65535 with 2 channels", b));

    let mut b = base.clone();
    b[34..36].copy_from_slice(&0xFFFFu16.to_le_bytes());
    cases.push(("bits 65535", b));

    let mut b = base.clone();
    b[34..36].copy_from_slice(&0u16.to_le_bytes());
    cases.push(("bits 0", b));

    let mut b = base.clone();
    b[32..34].copy_from_slice(&2u16.to_le_bytes());
    b[34..36].copy_from_slice(&1u16.to_le_bytes());
    cases.push(("1 valid bit in a 1-byte container", b));

    let mut b = base.clone();
    b[40..44].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
    cases.push(("data size u32::MAX (odd)", b));

    let mut b = base.clone();
    b[40..44].copy_from_slice(&0xFFFF_FFFCu32.to_le_bytes());
    cases.push(("data size u32::MAX - 3", b));

    let mut b = base.clone();
    b[36..40].copy_from_slice(b"JUNK");
    b[40..44].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
    cases.push(("unknown chunk size u32::MAX", b));

    let mut b = base.clone();
    b[36..40].copy_from_slice(b"fmt ");
    cases.push(("duplicate fmt", b));

    let mut b = base.clone();
    b[36..40].copy_from_slice(b"\x00\x01\x02\x03");
    cases.push(("unprintable chunk id", b));

    let mut b = base.clone();
    b[24..28].copy_from_slice(&0u32.to_le_bytes());
    cases.push(("zero sample rate", b));

    let mut b = base.clone();
    b[20..22].copy_from_slice(&0xFFFEu16.to_le_bytes());
    cases.push(("extensible tag in a 16-byte fmt", b));

    let mut b = base.clone();
    b[20..22].copy_from_slice(&0x0002u16.to_le_bytes());
    cases.push(("ADPCM tag", b));

    for (label, bytes) in cases {
        let n = must_not_panic(label, &bytes);
        assert!(n <= 8, "{}: {} samples", label, n);
    }

    // An empty input and inputs shorter than every header.
    for len in 0..13 {
        must_not_panic("short RIFF", &base[..len]);
    }
}

/// The drain is bounded by the actual bytes, not the declared `data` size:
/// opening X08 (declared ~4 GiB) yields exactly two samples and then one
/// error, well within the wall-clock bound, so it never walks the declared
/// size.
#[test]
fn declared_size_does_not_drive_allocation() {
    let x08 = fixture_bytes(
        &vectors()
            .into_iter()
            .find(|v| v.field("id").as_str() == "X08")
            .unwrap(),
    );
    let start = std::time::Instant::now();
    let n = must_not_panic("X08", &x08);
    assert_eq!(n, 2);
    assert!(
        start.elapsed().as_secs() < 5,
        "the drain must not walk the declared size"
    );
}

/// Regression: a one-channel `fmt ` with `block_align` 0xFF01 made the
/// container width times 8 overflow `u16`, a panic in debug builds and a
/// wrong width check in release builds. The file now fails as
/// `Unsupported` (a PCM container wider than 4 bytes).
#[test]
fn wide_block_align_does_not_overflow() {
    let mut bytes = fixture_bytes(&vector("V01"));
    bytes[32..34].copy_from_slice(&0xFF01u16.to_le_bytes());
    let outcome = catch_unwind(AssertUnwindSafe(|| open(&bytes).map(|_| ())));
    let outcome = outcome.expect("parsing must not panic");
    assert!(matches!(outcome, Err(Error::Unsupported(_))));

    // The same width with 8 valid bits in 8 channels: container 8160,
    // container bits 65280, still a clean rejection.
    bytes[22..24].copy_from_slice(&8u16.to_le_bytes());
    bytes[32..34].copy_from_slice(&0xFF00u16.to_le_bytes());
    let outcome = catch_unwind(AssertUnwindSafe(|| open(&bytes).map(|_| ())));
    assert!(matches!(
        outcome.expect("no panic"),
        Err(Error::Unsupported(_))
    ));
}