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
@@ -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());+ }+}