Skip to main content

max / audiofiles

fix: guard decode against malformed WAV fmt chunks (G5) mix_to_mono(_, 0) called chunks_exact(0), which panics — reachable from any caller that hands it a 0-channel buffer (analysis + the LUFS-normalize edit path). Guard channels == 0 to return an empty mixdown instead. Also validate the hound fallback's fmt chunk explicitly: reject 0 channels and bit depths outside 1..=32 before the `1 << (bits - 1)` scale (a 0-bit chunk would underflow the u16). The current hound already rejects both at open, so this is defense-in-depth that keeps the path correct if that changes or the decoder is swapped — a clean error rather than a panicked worker. +3 tests: mix_to_mono(_, 0) is panic-free; crafted 0-channel and 0-bit WAVs decode to a clean error (verified end-to-end through decode_to_mono), never a panic. cargo test --workspace: 1095 pass; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 20:23 UTC
Commit: 3366b8a8d6958f1f3ce49470d67482fd80edebfb
Parent: 0a8d701
1 file changed, +75 insertions, -0 deletions
@@ -187,8 +187,25 @@ fn decode_wav_hound(path: &Path) -> Result<DecodedAudio, CoreError> {
187 187 let source_sample_rate = spec.sample_rate;
188 188 let source_channels = spec.channels;
189 189
190 + // Defensive validation of the fmt chunk a crafted file can declare: 0 channels
191 + // would panic `mix_to_mono` (`chunks_exact(0)`), and a 0 or absurd bit depth
192 + // would underflow/overflow the `1 << (bits - 1)` scale below. The current hound
193 + // rejects both at open, but validating explicitly keeps this path correct if
194 + // that ever changes or the decoder is swapped — and yields a clear error rather
195 + // than a panicked worker.
196 + if source_channels == 0 {
197 + return Err(AnalysisError::DecodeError("hound: WAV declares 0 channels".into()).into());
198 + }
199 +
190 200 let mut mono_samples: Vec<f32> = match spec.sample_format {
191 201 hound::SampleFormat::Int => {
202 + if !(1..=32).contains(&spec.bits_per_sample) {
203 + return Err(AnalysisError::DecodeError(format!(
204 + "hound: unsupported bit depth {}",
205 + spec.bits_per_sample
206 + ))
207 + .into());
208 + }
192 209 let max_val = (1i64 << (spec.bits_per_sample - 1)) as f32;
193 210 let all: Vec<f32> = reader
194 211 .into_samples::<i32>()
@@ -226,6 +243,11 @@ fn decode_wav_hound(path: &Path) -> Result<DecodedAudio, CoreError> {
226 243 /// Shared by the analysis decode path and the LUFS-normalize edit path so both
227 244 /// measure loudness on the same mono mixdown.
228 245 pub(crate) fn mix_to_mono(interleaved: &[f32], channels: usize) -> Vec<f32> {
246 + // `chunks_exact(0)` panics; a 0-channel buffer has no frames to mix. Guard it
247 + // here too so every caller (analysis + the LUFS-normalize edit path) is safe.
248 + if channels == 0 {
249 + return Vec::new();
250 + }
229 251 if channels == 1 {
230 252 return interleaved.to_vec();
231 253 }
@@ -281,6 +303,59 @@ mod tests {
281 303 }
282 304 }
283 305
306 + /// Build a raw PCM WAV byte stream with a caller-chosen (possibly malformed)
307 + /// channel count and bit depth, to exercise the crafted-file guards.
308 + fn raw_wav(channels: u16, bits: u16, data: &[u8]) -> Vec<u8> {
309 + let block_align = channels.max(1) * (bits.max(8) / 8);
310 + let byte_rate = 44100 * block_align as u32;
311 + let mut b = Vec::new();
312 + b.extend_from_slice(b"RIFF");
313 + b.extend_from_slice(&((36 + data.len()) as u32).to_le_bytes());
314 + b.extend_from_slice(b"WAVE");
315 + b.extend_from_slice(b"fmt ");
316 + b.extend_from_slice(&16u32.to_le_bytes());
317 + b.extend_from_slice(&1u16.to_le_bytes()); // PCM
318 + b.extend_from_slice(&channels.to_le_bytes());
319 + b.extend_from_slice(&44100u32.to_le_bytes());
320 + b.extend_from_slice(&byte_rate.to_le_bytes());
321 + b.extend_from_slice(&block_align.to_le_bytes());
322 + b.extend_from_slice(&bits.to_le_bytes());
323 + b.extend_from_slice(b"data");
324 + b.extend_from_slice(&(data.len() as u32).to_le_bytes());
325 + b.extend_from_slice(data);
326 + b
327 + }
328 +
329 + #[test]
330 + fn mix_to_mono_zero_channels_does_not_panic() {
331 + // chunks_exact(0) would panic; the guard must return empty instead.
332 + assert!(mix_to_mono(&[0.1, 0.2, 0.3], 0).is_empty());
333 + assert!(mix_to_mono(&[], 0).is_empty());
334 + }
335 +
336 + #[test]
337 + fn crafted_zero_channel_wav_errors_cleanly() {
338 + let dir = tempfile::tempdir().unwrap();
339 + let path = dir.path().join("zero_ch.wav");
340 + std::fs::write(&path, raw_wav(0, 16, &[0u8; 8])).unwrap();
341 + // Either hound rejects it on open or our guard does — never a panic.
342 + let r = decode_wav_hound(&path);
343 + assert!(r.is_err(), "0-channel WAV must be an error, not a panic");
344 + // And the full decode entry point is also panic-free on this input.
345 + assert!(decode_to_mono(&path).is_err());
346 + }
347 +
348 + #[test]
349 + fn crafted_zero_bit_depth_wav_errors_cleanly() {
350 + let dir = tempfile::tempdir().unwrap();
351 + let path = dir.path().join("zero_bits.wav");
352 + std::fs::write(&path, raw_wav(1, 0, &[0u8; 8])).unwrap();
353 + // The `1 << (bits - 1)` scale would underflow on bits = 0; guard returns Err.
354 + let r = decode_wav_hound(&path);
355 + assert!(r.is_err(), "0-bit-depth WAV must be an error, not a panic");
356 + assert!(decode_to_mono(&path).is_err());
357 + }
358 +
284 359 #[test]
285 360 fn sanitize_non_finite_replaces_with_silence() {
286 361 let mut s = vec![0.5f32, f32::NAN, -0.5, f32::INFINITY, f32::NEG_INFINITY, 0.25];