diff --git a/crates/audio-io/src/caf.rs b/crates/audio-io/src/caf.rs new file mode 100644 index 00000000..fe4dda1e --- /dev/null +++ b/crates/audio-io/src/caf.rs @@ -0,0 +1,87 @@ +//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie, +//! a packet table or a packet claims, and a failed allocation aborts the process. + +use crate::pcm::{be_u16, be_u32, be_u64}; +use crate::{AudioError, Result}; + +/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes +/// than the file holds. +pub(crate) fn check(b: &[u8], at: usize) -> Result<()> { + if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) } +} + +fn malformed(what: &str) -> AudioError { + AudioError::Malformed(format!("CAF {what} is larger than the file")) +} + +/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by +/// the declared size, and after a `data` chunk of unknown size it reads on into the audio. +fn chunks(b: &[u8], mut pos: usize) -> Result<()> { + // Whether the packet table lists each packet's size and frame count (variable per packet). + let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false); + while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) { + let (body, size) = (pos + 12, size as i64); + let used = match kind { + b"desc" if size == 32 => { + lists_sizes = be_u32(b, body + 16) == Some(0); + lists_frames = be_u32(b, body + 20) == Some(0); + described = true; + Some(32) + } + b"data" if size == -1 => Some(4), + b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20), + b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?, + b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")), + b"desc" | b"pakt" | b"chan" => None, + _ => u64::try_from(size).ok(), + }; + match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) { + Some(next) if next < b.len() => pos = next, + _ => return Ok(()), + } + } + Ok(()) +} + +/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or +/// None where symphonia stops. It reserves room for every packet before reading any, so a table +/// it would refuse after that, or that runs past the file, is refused first. +fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result> { + let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0); + let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) }; + let mut at = body + 24; + if packets > b.len().saturating_sub(at) as u64 { + return Err(malformed("packet table")); + } + if !lists_sizes && !lists_frames { + return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) }; + } + for _ in 0..packets { + if lists_sizes { + let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) }; + if size > b.len() as u64 { + return Err(malformed("packet")); + } + at += len; + } + if lists_frames { + let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) }; + at += len; + } + } + Ok(Some((at - body) as u64)) +} + +/// A CAF variable-length integer at `at` and its length in bytes. +fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> { + let mut v = 0u64; + for i in 0..9 { + let x = *b.get(at + i)?; + v |= u64::from(x & 0x7f); + if x & 0x80 == 0 { + return Some((v, i + 1)); + } + v <<= 7; + } + None +} diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 2f5550b5..88b333e0 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -1,8 +1,8 @@ //! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per //! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with -//! partitioned Rice-coded residuals. +//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`]. -use crate::pcm::{Quantizer, validate_buffer}; +use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer}; use crate::{AudioBuffer, AudioError, BitDepth, Result}; const BLOCK_SIZE: usize = 4096; @@ -354,6 +354,54 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result Ok(out) } +/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its +/// metadata block holds: symphonia allocates each field's length before reading it. +pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> { + let mut pos = at + 4; + while let Some(head) = be_u32(b, pos) { + let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize); + let end = (body + len).min(b.len()); + let overruns = match (head >> 24) & 0x7f { + 4 => comments_overrun(b, body, end), + // Picture: type, MIME type, description, four size fields, data. + 6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]), + _ => false, + }; + if overruns { + return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into())); + } + if head >> 31 == 1 { + break; + } + pos = body + len; + } + Ok(()) +} + +/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds. +/// The vendor string is skipped unread. +fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool { + let Some(vendor) = le_u32(b, body) else { return false }; + let at = (body + 4).saturating_add(vendor as usize); + let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false }; + overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize)) +} + +/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are +/// the bytes skipped before each length, which `read` reads. +fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option, gaps: impl IntoIterator) -> bool { + for gap in gaps { + at = at.saturating_add(gap); + let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false }; + at += 4; + if n as usize > end - at { + return true; + } + at += n as usize; + } + false +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 0e3fdd9a..20f62133 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -14,7 +14,9 @@ mod aiff; mod buffer; +mod caf; mod flac; +mod mp4; mod pcm; pub mod peaks; mod symph; @@ -205,16 +207,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result { FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? { wav::WavParse::Native(l) => Ok(l.info), wav::WavParse::Foreign { bwf } => { - let mut info = symph::probe(bytes, Some("wav"), format)?; + let mut info = symph::probe(bytes, format)?; info.bwf = bwf; Ok(info) } }, FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? { aiff::AiffParse::Native(l) => Ok(l.info), - aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format), + aiff::AiffParse::Foreign => symph::probe(bytes, format), }, - _ => symph::probe(bytes, ext_hint, format), + _ => symph::probe(bytes, format), } } @@ -230,7 +232,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB Ok((info, buf)) } wav::WavParse::Foreign { bwf } => { - let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?; + let (mut info, buf) = symph::decode(bytes, format)?; info.bwf = bwf; Ok((info, buf)) } @@ -242,9 +244,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB info.frames = buf.frames() as u64; Ok((info, buf)) } - aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format), + aiff::AiffParse::Foreign => symph::decode(bytes, format), }, - _ => symph::decode(bytes, ext_hint, format), + _ => symph::decode(bytes, format), } } diff --git a/crates/audio-io/src/mp4.rs b/crates/audio-io/src/mp4.rs new file mode 100644 index 00000000..06e68ddc --- /dev/null +++ b/crates/audio-io/src/mp4.rs @@ -0,0 +1,106 @@ +//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample +//! table or a sample claims, and a failed allocation aborts the process. + +use crate::pcm::{be_u32, be_u64}; +use crate::{AudioError, Result}; + +#[derive(Clone, Copy)] +enum Level { + Top, + Moov, + Trak, + Mdia, + Minf, + Stbl, + Udta, + Meta, + Ilst, + Tag, +} + +/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a +/// sample table or a sample claims more bytes than the file holds. +pub(crate) fn check(b: &[u8], at: usize) -> Result<()> { + atoms(b, at, b.len(), Level::Top).map(|_| ()) +} + +fn malformed(what: &str) -> AudioError { + AudioError::Malformed(format!("MP4 {what} is larger than the file")) +} + +/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where +/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`. +fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result { + // A header that starts inside the container is read whole, even past its end. + while pos < end { + let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) }; + let head = if size == 1 { 16 } else { 8 }; + // Size 0: no data, and the next atom starts at the container's end. + let len = match size { + 0 => head, + 1 => be_u64(b, pos + 8).unwrap_or(0), + n => u64::from(n), + }; + if len < head { + return Ok(false); + } + let body = pos + head as usize; + let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n)); + let next = if size == 0 { Some(end) } else { data_end }; + let inner = data_end.map_or(b.len(), |n| n.min(b.len())); + let more = match (level, kind) { + (Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?, + (Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?, + (Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?, + (Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?, + (Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?, + (Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?, + (Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?, + (Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?, + (Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?, + (Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?, + (Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?, + (Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?, + (Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?, + (Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => { + return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind)))); + } + _ => true, + }; + match next { + Some(n) if more && n <= end => pos = n, + _ => return Ok(false), + } + } + Ok(true) +} + +/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for +/// all of them first. False if they run past the table's atom (`end`), where symphonia stops. +fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result { + let Some(n) = be_u32(b, count) else { return Ok(false) }; + let (first, bytes) = (count + 4, u64::from(n) * entry); + if bytes > b.len().saturating_sub(first) as u64 { + return Err(malformed("sample table")); + } + Ok(first as u64 + bytes <= end as u64) +} + +/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size. +/// Sizes past the atom are never used, as symphonia stops before reading a packet. +fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result { + let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) }; + let file = b.len() as u64; + if u64::from(size) > file { + return Err(malformed("sample")); + } + if size != 0 { + return Ok(true); + } + let fits = table(b, body + 8, 4, end)?; + let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>(); + if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) { + return Err(malformed("sample")); + } + Ok(fits) +} diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs index a1fe894f..a998fecf 100644 --- a/crates/audio-io/src/pcm.rs +++ b/crates/audio-io/src/pcm.rs @@ -36,6 +36,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option { Some(u32::from_be_bytes(s)) } +/// Big-endian `u64` at `pos`. +pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option { + let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?; + Some(u64::from_be_bytes(s)) +} + /// Four-byte tag at `pos`. pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> { b.get(pos..pos.checked_add(4)?)?.try_into().ok() diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 8fa19166..61691114 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -12,13 +12,17 @@ use symphonia::core::codecs::{ }; use symphonia::core::errors::Error as SymError; use symphonia::core::formats::{FormatOptions, FormatReader}; -use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions}; +use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes}; use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; +use symphonia::core::probe::Instantiate; -use crate::pcm::MAX_DECODE_SAMPLES; +use crate::pcm::{MAX_DECODE_SAMPLES, be_u32}; use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat}; +/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC +/// takes its frame length from the file (encoders use 4096). +const MAX_PACKET_FRAMES: u64 = 1 << 20; + fn map_err(e: SymError) -> AudioError { match e { SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()), @@ -32,15 +36,36 @@ fn guarded(f: impl FnOnce() -> Result) -> Result { catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into()))) } -fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result> { - let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); - let mut hint = Hint::new(); - if let Some(ext) = ext_hint { - hint.with_extension(ext.trim_start_matches('.')); - } +/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it +/// finds before its reader allocates whatever the file claims. +fn open(bytes: &[u8]) -> Result> { + let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; - let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?; - Ok(probed.format) + loop { + let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?; + let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); + let head = bytes.get(at..).unwrap_or_default(); + match (found, head.get(..10)) { + (Instantiate::Format(reader), _) => { + match head.get(..4) { + Some(b"caff") => crate::caf::check(bytes, at)?, + Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, + Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?, + Some(b"RIFF") => crate::wav::check_info(bytes, at)?, + _ => {} + } + return reader(mss, &fmt_opts).map_err(map_err); + } + // Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it. + (Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => { + let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f)); + mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?; + } + (Instantiate::Metadata(tag), _) => { + tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?; + } + } + } } fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat { @@ -115,8 +140,8 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi } /// Decode the whole first audio track. When `count_only`, samples are counted but not stored. -fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { - let mut reader = open(bytes, ext_hint)?; +fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { + let mut reader = open(bytes)?; let (track_id, mut info) = info_of(reader.as_ref(), detected)?; if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) { let total = n.saturating_mul(u64::from(info.channels.max(1))); @@ -130,6 +155,10 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b .find(|t| t.id == track_id) .map(|t| t.codec_params.clone()) .ok_or_else(|| AudioError::Malformed("track vanished".into()))?; + let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0)); + if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) { + return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); + } let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?; let mut planar: Vec> = Vec::new(); @@ -206,18 +235,18 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b Ok((info, AudioBuffer { sample_rate, channels: planar })) } -pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result { +pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { guarded(|| { - let reader = open(bytes, ext_hint)?; + let reader = open(bytes)?; let (_, info) = info_of(reader.as_ref(), detected)?; if info.frames > 0 && info.channels > 0 { return Ok(info); } // Length not in the header (e.g. MP3 without a Xing frame): count by decoding. - run(bytes, ext_hint, detected, true).map(|(i, _)| i) + run(bytes, detected, true).map(|(i, _)| i) }) } -pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { - guarded(|| run(bytes, ext_hint, detected, false)) +pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { + guarded(|| run(bytes, detected, false)) } diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index df560105..d6ce7cd3 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -83,6 +83,57 @@ fn parse_bext(c: &[u8]) -> Option { }) } +/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry +/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are +/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length. +pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> { + if tag(b, at + 8) != Some(*b"WAVE") { + return Ok(()); + } + let mut pos = at + 12; + while let Some(id) = tag(b, pos) { + let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break }; + let (body, pad) = (pos + 8, len & 1); + let used = match &id { + b"data" => break, + b"fmt " => fmt_used(b, body, len), + b"LIST" if len < 4 => break, + b"LIST" => { + let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 }; + // The list pads itself, then the chunk is padded again. + 4 + entries.max(len - 4) + pad + } + _ => len, + }; + pos = body.saturating_add(used).saturating_add(pad); + } + Ok(()) +} + +/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size. +fn fmt_used(b: &[u8], body: usize, len: usize) -> usize { + match le_u16(b, body) { + Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)), + Some(0xfffe) => 40, + _ => len, + } +} + +/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of +/// them, with the pad it reads after an odd entry. +fn info_entries(b: &[u8], start: usize, len: usize) -> Result { + let mut used = 0; + while used + 8 <= len { + let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break }; + if n > b.len().saturating_sub(start + used + 8) { + return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into())); + } + used += 8 + n; + used += used & 1; + } + Ok(used) +} + /// True when the bytes start like a RIFF/RF64/BW64 WAVE file. pub(crate) fn is_wav(b: &[u8]) -> bool { matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE") diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index 384e4bfa..ed0d6e31 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -153,6 +153,194 @@ fn absurd_chunk_sizes() { assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0); } +fn caf(chunks: &[(&[u8; 4], i64, Vec)]) -> Vec { + let mut out = b"caff\0\x01\0\0".to_vec(); + for (kind, size, body) in chunks { + out.extend_from_slice(*kind); + out.extend_from_slice(&size.to_be_bytes()); + out.extend_from_slice(body); + } + out +} + +fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec) { + let mut d = 48_000f64.to_be_bytes().to_vec(); + d.extend_from_slice(format); + for v in [0, bytes_per_packet, frames_per_packet, channels, bits] { + d.extend_from_slice(&v.to_be_bytes()); + } + (b"desc", 32, d) +} + +fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec { + [&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat() +} + +#[test] +fn caf_sizes_beyond_the_file_are_errors_not_aborts() { + let aac = || caf_desc(b"aac ", 0, 1024, 2, 0); + let huge = 1i64 << 50; + let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]); + let files = [ + cookie.clone(), + [vec![0; 100], cookie].concat(), + caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]), + caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]), + caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]), + caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]), + ]; + for f in files { + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } + let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]); + assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets"))); +} + +#[test] +fn well_formed_caf_still_reads() { + let pcm: Vec = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect(); + let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat(); + let data = [vec![0; 4], pcm].concat(); + let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]); + let (info, buf) = decode(&lpcm, None).unwrap(); + assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480)); + let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]); + let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]); + assert_eq!(probe(&aac, None).unwrap().frames, 3072); +} + +fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { + [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat() +} + +/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms. +fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec { + let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat()) +} + +/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom. +fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec) -> Vec) -> Vec { + let full = |kind: &[u8; 4], fields: &[u32]| { + let body: Vec = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect(); + swap(kind, atom(kind, &body)) + }; + let moov = |offset: u32| { + let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat()); + let stbl = + [stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])]; + let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat(); + let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)]; + let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat(); + atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat()) + }; + let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom"); + let offset = (ftyp.len() + moov(0).len() + 8) as u32; + let pcm: Vec = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect(); + [ftyp, moov(offset), atom(b"mdat", &pcm)].concat() +} + +#[test] +fn mp4_sizes_beyond_the_file_are_errors_not_aborts() { + let pcm = sample_entry(b"sowt", 0, &[], &[]); + let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap(); + assert_eq!(buf.frames(), 480); + let huge = |kind: &[u8; 4], a: Vec| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat(); + // The first field after version and flags: stts's entry count, stsz's sample size. + let first_field_max = |mut a: Vec| { + a[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + a + }; + let sizes: Vec = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect(); + let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat()); + // A header that starts inside its container is read whole, past the container's end. + let mdia = atom(b"mdia", &1u32.to_be_bytes()); + let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat(); + let files = [ + mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }), + [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(), + ]; + for f in files { + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } +} + +#[test] +fn atoms_running_past_their_parent_are_walked_once() { + // Each moov holds a trak that runs to the end of the file; symphonia stops after the first. + let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat()); + let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat(); + assert!(decode(&f, None).is_err()); +} + +#[test] +fn packets_longer_than_any_codec_uses_are_refused() { + let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::>(); + let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat(); + let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]); + let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat(); + let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames))); + let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap(); + assert_eq!(buf.frames(), 480); + for entry in [lpcm(1 << 21), alac(1 << 21)] { + assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets"))); + } +} + +#[test] +fn flac_metadata_fields_beyond_their_block_are_refused() { + let with_block = |kind: u8, body: &[u8]| { + let f = small(FileFormat::Flac, BitDepth::Int16, 1); + let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes(); + [&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat() + }; + let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat(); + assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300); + let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat(); + for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] { + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block"))); + } +} + +#[test] +fn an_id3_tag_before_the_audio_is_skipped_unread() { + let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat(); + let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat(); + let flac = small(FileFormat::Flac, BitDepth::Int16, 1); + assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300); +} + +#[test] +fn wav_info_entries_beyond_the_file_are_refused() { + let alaw = |len: u32| { + let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat(); + riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]) + }; + assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800); + let mut f = alaw(0x7fff_fff0); + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + let list = f.windows(4).position(|w| w == b"LIST").unwrap(); + f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); +} + +#[test] +fn wav_info_entries_are_found_where_symphonia_reads_them() { + let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); + let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat()); + // A-law's extra bytes are read past its chunk; an odd list is padded twice. + let files = + [riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()]; + for mut f in files { + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];