max / audiofiles
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]; |