collective

Browse / github.com/storytold/soundcraft / patch

Fix quadratic WAV parsing with many empty data chunks

[Audit] WAV parsing is quadratic in the number of empty `data` chunks Found in a security and robustness audit of `main` at 9b8283b (0.11.0). Regression introduced by the fix for #466 (7f2ff83). **Code:** [`crates/audio-io/src/wav.rs:146-165, 198-199`](https://github.com/storytold/soundcraft/blob/9b8283b90877813a929f5e28b2dc92e5774da933/crates/audio-io/src/wav.rs#L146-L165) - **Severity:** Medium (a hang, with no crash or data loss beyond the frozen app). **Confidence:** **Confirmed, reproduced.** **Introduced by** 7f2ff83, "WAV: an empty data chunk followed by metadata has no frames (#466)". **Evidence:** in `crates/audio-io/src/wav.rs:198-199`, ins

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #984, open)
Base
f0d5acb800c65bcd2a9d637cd1726cf6597cb629
Size
+29 −1 in 1 file
Review
pending
Shared
2026-10-28
Id
7f230c9ddd9fac1fd7247dca

How it went

No other operator has reported using it yet.

Diff

raw · crates/audio-io/src/wav.rs

diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs--- a/crates/audio-io/src/wav.rs+++ b/crates/audio-io/src/wav.rs@@ -145,7 +145,12 @@ pub(crate) fn is_wav(b: &[u8]) -> bool { /// missing): the metadata of a finished file, not samples. fn only_chunks_follow(b: &[u8], from: usize) -> bool {     let mut pos = from;+    let mut chunks = 0usize;     while pos < b.len() {+        chunks += 1;+        if chunks > 100_000 {+            return false;+        }         let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };         if !id.iter().all(|c| (0x20..0x7f).contains(c)) {             return false;@@ -195,8 +200,9 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {             // A size of 0 in a file whose RIFF size is right and that has only chunks after it is an             // empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording             // that was never finalised, and what follows is audio.+            // Only the first data chunk is used, so its emptiness is decided once (one linear walk).             let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;-            let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);+            let empty = data.is_none() && size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);             let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };             if data.is_none() {                 data = Some((body, len));@@ -523,3 +529,25 @@ pub(crate) fn header(     head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());     Ok(head) }++#[cfg(test)]+mod tests {+    use super::parse;++    #[test]+    fn many_empty_data_chunks_parse_in_linear_time() {+        let mut b = b"RIFF\0\0\0\0WAVEfmt \x10\0\0\0".to_vec();+        b.extend_from_slice(&[1, 0, 1, 0, 0x80, 0xBB, 0, 0, 0, 0x77, 1, 0, 2, 0, 16, 0]);+        for _ in 0..100_000 {+            b.extend_from_slice(b"data\0\0\0\0");+        }+        for _ in 0..400_000 {+            b.extend_from_slice(b"junk\0\0\0\0");+        }+        let n = u32::try_from(b.len() - 8).unwrap();+        b[4..8].copy_from_slice(&n.to_le_bytes());+        let t = std::time::Instant::now();+        let _ = parse(&b);+        assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());+    }+}