diff --git a/crates/media/README.md b/crates/media/README.md index a6016276..f7c6e554 100644 --- a/crates/media/README.md +++ b/crates/media/README.md @@ -405,8 +405,12 @@ first frame (AV1 from `rav1e`) creates the writer after that frame. A clean-room Ogg demuxer from FilmCraft's `filmcraft-ogg` (RFC 3533, RFC 7845 Ogg Opus, the Vorbis I Ogg mapping). `open` reads every page (CRC-32 checked; damage is skipped by searching for the next `OggS`), groups pages by logical stream and assembles packets across pages; codec headers are kept -apart from audio packets. `OpusTiming` gives each Opus packet's start, the pre-skip and the end -trimming of the final page. No muxer. +apart from audio packets. A gap in a stream's page sequence numbers drops the packet the lost page +continued, and a beginning-of-stream page for a serial already seen starts a new stream (the next +link of a chained file). The damage scan, the packets kept and the FLAC metadata of a stream +that does not state its header count are bounded, with a warning when a bound is reached. +`OpusTiming` gives each Opus packet's start, the pre-skip and the end trimming of the final page. +No muxer. ## Containers, codecs and codec parameters @@ -448,7 +452,8 @@ custom source (a web `Blob`, a network range reader) to use every container with hand-built file (unknown-size Segment and Clusters, header stripping, BlockGroups, block additions, color and mastering metadata, Chapters, Tags, Attachments, CRC-32, Cue-less seek, hostile cluster timestamps) and mux-demux round trips. For Ogg (`src/ogg`): page parsing, CRC, - packets across pages, damage resync and Opus timing. + packets across pages, damage resync, lost pages, chained links, the packet and FLAC metadata + caps, Opus timing (end trimming, hostile granules) and the codec parameters of each codec. - `tests/roundtrip.rs`: property tests that write random multi-track files (progressive and fragmented) and read them back. - `tests/oracle_demux.rs` and `tests/oracle_mux.rs` compare the demuxer and muxer with ffprobe and diff --git a/crates/media/src/ogg/mod.rs b/crates/media/src/ogg/mod.rs index 353cafae..03a1c680 100644 --- a/crates/media/src/ogg/mod.rs +++ b/crates/media/src/ogg/mod.rs @@ -120,7 +120,7 @@ impl Codec { match self { Codec::Opus => 2, Codec::Vorbis | Codec::Theora => 3, - Codec::Speex => 2, + Codec::Speex => 2, // see `speex_header_count` Codec::Flac | Codec::Unknown => 1, } } @@ -164,6 +164,13 @@ fn flac_header_count(first: &[u8]) -> usize { } } +/// Header packets of a Speex stream: the Speex header, the comment header and the +/// `extra_headers` the Speex header states (a 32-bit little-endian field at offset 68). +fn speex_header_count(first: &[u8]) -> usize { + let extra = first.get(68..72).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + 2 + usize::try_from(extra).unwrap_or(usize::MAX).min(usize::from(u16::MAX)) +} + /// A packet: its byte ranges (a packet may span pages) and the granule position of the page it /// completes on when it is that page's last completed packet. #[non_exhaustive] @@ -205,11 +212,14 @@ pub struct Stream { header_count: usize, /// The bytes of `headers`, kept as they are added. header_bytes: usize, + /// The page sequence number the next page should carry. + next_seq: Option, } impl Stream { /// What a decoder needs for this stream, in the container-neutral form the codec crates take: - /// the `OpusHead`, the Vorbis headers Xiph-laced, or `fLaC` + the FLAC metadata blocks. + /// the `OpusHead`, the Vorbis or Theora headers Xiph-laced, `fLaC` + the FLAC metadata blocks + /// (the last one flagged last), or the Speex header. pub fn codec_params(&self) -> crate::CodecParams { use crate::CodecId as Id; let first = self.headers.first().map(Vec::as_slice).unwrap_or_default(); @@ -227,16 +237,35 @@ impl Stream { // the other header packets are metadata blocks. Codec::Flac => { let mut extradata = first.get(9..).unwrap_or_default().to_vec(); + // block headers: STREAMINFO's after `fLaC`, then one per metadata packet + let mut blocks = vec![4]; for h in self.headers.iter().skip(1) { + blocks.push(extradata.len()); extradata.extend_from_slice(h); } + // exactly the last block is flagged last, also when an audio frame ended the + // metadata of a stream that does not state its header count + let blocks: Vec = blocks.into_iter().filter(|&at| at < extradata.len()).collect(); + for (k, &at) in blocks.iter().enumerate() { + if k + 1 == blocks.len() { + extradata[at] |= 0x80; + } else { + extradata[at] &= 0x7F; + } + } // STREAMINFO follows `fLaC` and its 4-byte block header: 10 bytes of block and frame // sizes, then a 20-bit sample rate and 3 bits of channels less one. let info = first.get(27..30).map_or(0, |b| (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2])); let channels = if info == 0 { 0 } else { ((info >> 1) & 7) as u16 + 1 }; crate::CodecParams::new(Id::Flac).with_extradata(extradata).with_sample_rate(info >> 4).with_channels(channels) } - c => crate::CodecParams::new(c.codec_id()), + Codec::Theora => crate::CodecParams::new(Id::Theora).with_extradata(crate::xiph_lace(&self.headers).unwrap_or_default()), + // the Speex header: rate at offset 36 and channels at 48, 32-bit little-endian + Codec::Speex => crate::CodecParams::new(Id::Speex) + .with_extradata(first) + .with_sample_rate(le_u32(36)) + .with_channels(u16::try_from(le_u32(48)).unwrap_or(0)), + Codec::Unknown => crate::CodecParams::new(Id::Unknown), } } } @@ -257,6 +286,32 @@ struct Partial { prefix: Vec, } +/// Bytes the damage-recovery scan may read in one call. +const SCAN_CHUNK: usize = 64 << 10; +/// Bytes the damage-recovery scans of one file may examine in all. +const RECOVERY_BUDGET: usize = 64 << 20; +/// Cap on the packets kept from one file, all streams and headers together. +const MAX_PACKETS: usize = 1 << 26; +/// Bytes of file each kept packet needs beyond the first `MIN_PACKET_ALLOWANCE`. A kept packet +/// takes about 60 bytes of memory and a zero lacing value describes one in a single byte, so +/// without this a file could make the packet lists about 60 times its own size. +const FILE_BYTES_PER_PACKET: u64 = 8; +/// Packets any file may keep, however short. +const MIN_PACKET_ALLOWANCE: usize = 1 << 20; +/// The most metadata packets kept for an Ogg FLAC stream that does not state its header count. +const MAX_UNSTATED_HEADERS: usize = 1024; + +/// Where a damage-recovery scan stopped. +#[derive(Debug, PartialEq, Eq)] +enum Capture { + /// The next capture pattern starts here. + At(u64), + /// The source ended first. + End, + /// The file's recovery budget ran out first. + BudgetSpent, +} + /// Read every page and assemble the packets of every logical bitstream. pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { let len = src.len(); @@ -266,33 +321,39 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { let mut warnings = Vec::new(); let mut pos = 0u64; let mut found_any = false; - let mut recovery_budget = 64 << 20; + let mut recovery_budget = RECOVERY_BUDGET; + let mut packet_budget = usize::try_from(len / FILE_BYTES_PER_PACKET).unwrap_or(usize::MAX).clamp(MIN_PACKET_ALLOWANCE, MAX_PACKETS); let mut hdr = [0u8; 27 + 255]; while pos + 27 <= len { src.read_at(pos, &mut hdr[..27])?; if &hdr[..4] != b"OggS" || hdr[4] != 0 { match find_capture(src, pos + 1, len, &mut recovery_budget)? { - Some(p) => { + Capture::At(p) => { if found_any { warnings.push(format!("lost sync at byte {pos}; resumed at {p}")); } pos = p; continue; } - None => break, + Capture::End => break, + Capture::BudgetSpent => { + warnings.push(format!("lost sync at byte {pos}; the rest of the file is past the recovery budget")); + break; + } } } let nseg = hdr[26] as usize; - if pos + 27 + nseg as u64 > len { - warnings.push(format!("truncated page header at byte {pos}")); - break; - } - src.read_at(pos + 27, &mut hdr[27..27 + nseg])?; - let body_len: u64 = hdr[27..27 + nseg].iter().map(|&b| b as u64).sum(); - let page_len = 27 + nseg as u64 + body_len; - if pos + page_len > len { - warnings.push(format!("truncated page at byte {pos}")); - break; + let page_len = if pos + 27 + nseg as u64 <= len { + src.read_at(pos + 27, &mut hdr[27..27 + nseg])?; + 27 + nseg as u64 + hdr[27..27 + nseg].iter().map(|&b| b as u64).sum::() + } else { + u64::MAX + }; + if pos.saturating_add(page_len) > len { + // truncated, or a damaged lacing value: look for a page further on + warnings.push(format!("page at byte {pos} runs past the end of the file")); + pos += 1; + continue; } let mut page = vec![0u8; page_len as usize]; src.read_at(pos, &mut page)?; @@ -307,8 +368,17 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { let flags = page[5]; let granule = i64::from_le_bytes(page[6..14].try_into().unwrap_or([0; 8])); let serial = u32::from_le_bytes([page[14], page[15], page[16], page[17]]); + let seq = u32::from_le_bytes([page[18], page[19], page[20], page[21]]); let lacing = page[27..27 + nseg].to_vec(); let body_off = pos + 27 + nseg as u64; + // a beginning-of-stream page for a serial already seen starts the next link of a chained + // file (RFC 3533 §4), which may reuse the serial + if flags & 0x02 != 0 && by_serial.contains_key(&serial) { + by_serial.remove(&serial); + if partial.remove(&serial).is_some() { + warnings.push(format!("incomplete packet before page at byte {pos}")); + } + } let si = *by_serial.entry(serial).or_insert_with(|| { streams.push(Stream { serial, @@ -320,10 +390,17 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { saw_eos: false, header_count: 1, header_bytes: 0, + next_seq: None, }); streams.len() - 1 }); let st = &mut streams[si]; + // a gap in the page sequence numbers is a lost page: a packet it continued is incomplete + if st.next_seq.is_some_and(|n| n != seq) { + warnings.push(format!("pages missing before page at byte {pos}")); + partial.remove(&serial); + } + st.next_seq = Some(seq.wrapping_add(1)); let page_index = st.pages; st.pages += 1; if flags & 0x04 != 0 { @@ -351,8 +428,14 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { } let p = partial.entry(serial).or_insert_with(|| Partial { parts: Vec::new(), size: 0, prefix: Vec::new() }); if n > 0 { + let Some(size) = p.size.checked_add(n as u32) else { + warnings.push(format!("packet over 4 GiB dropped at byte {pos}")); + partial.remove(&serial); + skip_fragment = l == 255; + continue; + }; p.parts.push((body_off + a, n as u32)); - p.size += n as u32; + p.size = size; let s = (body_off - pos + a) as usize; let need = 2usize.saturating_sub(p.prefix.len()).min(n as usize); p.prefix.extend_from_slice(&page[s..s + need]); @@ -366,6 +449,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { } } } + let Some(left) = packet_budget.checked_sub(completed.len()) else { + warnings.push(format!("stopped at byte {pos}: more packets than a file of this size can hold")); + break; + }; + packet_budget = left; if granule != -1 { st.last_granule = Some(granule); if let Some(last) = completed.last_mut() { @@ -378,6 +466,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { st.codec = Codec::of(&data); st.header_count = match st.codec { Codec::Flac => flac_header_count(&data), + Codec::Speex => speex_header_count(&data), c => c.header_count(), }; } @@ -386,17 +475,22 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { if st.header_count == usize::MAX && !st.headers.is_empty() { // FLAC with an unstated count: metadata packets run to the one flagged last. A // packet that is no metadata block (an audio frame starts with 0xFF) ends them, - // and so does `MAX_UNSTATED_HEADER_BYTES`, so audio is never held as headers. - let is_block = data.first().is_some_and(|b| b & 0x7F <= 6); - if !is_block || st.header_bytes.saturating_add(data.len()) > MAX_UNSTATED_HEADER_BYTES { - if is_block { - warnings.push(format!("stream {}: FLAC metadata beyond {} bytes read as audio", st.serial, MAX_UNSTATED_HEADER_BYTES)); - } + // and so do the caps, so audio is never held as headers. + if !is_flac_metadata_block(&data) { st.header_count = st.headers.len(); st.packets.push(pk); continue; } - if data.first().is_some_and(|b| b & 0x80 != 0) { + if st.headers.len() >= MAX_UNSTATED_HEADERS || st.header_bytes.saturating_add(data.len()) > MAX_UNSTATED_HEADER_BYTES { + warnings.push(format!( + "stream {}: FLAC metadata beyond {MAX_UNSTATED_HEADERS} blocks or {MAX_UNSTATED_HEADER_BYTES} bytes read as audio", + st.serial + )); + st.header_count = st.headers.len(); + st.packets.push(pk); + continue; + } + if data[0] & 0x80 != 0 { st.header_count = st.headers.len() + 1; } } @@ -417,22 +511,34 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { Ok(OggFile { streams, warnings }) } -fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result> { - let mut at = from; - while at + 4 <= len && *budget > 0 { - let n = ((len - at) as usize).min(1 << 20).min(*budget); - let mut buf = vec![0u8; n]; - src.read_at(at, &mut buf)?; - *budget = budget.saturating_sub(n); - if let Some(i) = buf.windows(4).position(|w| w == b"OggS") { - return Ok(Some(at + i as u64)); - } - if n < 4 { - break; - } - at += (n - 3) as u64; +/// A FLAC metadata block: a defined block type (0–6) and as many bytes as its 4-byte header says. +fn is_flac_metadata_block(data: &[u8]) -> bool { + match data { + [kind, a, b, c, body @ ..] => kind & 0x7F <= 6 && body.len() == (usize::from(*a) << 16 | usize::from(*b) << 8 | usize::from(*c)), + _ => false, } - Ok(None) +} + +/// Scan for the next capture pattern from `from`, examining at most `budget` positions and +/// charging the positions examined. +fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result { + let mut at = from; + let mut buf = vec![0u8; (len.saturating_sub(from) as usize).min(SCAN_CHUNK + 3)]; + while at + 4 <= len { + if *budget == 0 { + return Ok(Capture::BudgetSpent); + } + let positions = ((len - at - 3) as usize).min(SCAN_CHUNK).min(*budget); + let n = positions + 3; + src.read_at(at, &mut buf[..n])?; + if let Some(i) = buf[..n].windows(4).position(|w| w == b"OggS") { + *budget -= i + 1; + return Ok(Capture::At(at + i as u64)); + } + *budget -= positions; + at += positions as u64; + } + Ok(Capture::End) } fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result> { @@ -501,6 +607,8 @@ pub struct OpusTiming { impl OpusTiming { /// Timing of the Opus stream `s` (`pre_skip` from its `OpusHead`). pub fn of(stream: &Stream, pre_skip: u32) -> OpusTiming { + // Granule positions come straight from the file, so the arithmetic saturates: a hostile + // value gives wrong positions, never an overflow. let durations: Vec = stream.packets.iter().map(|p| opus_packet_samples(p.prefix, p.size).unwrap_or(0)).collect(); let n = durations.len(); let mut ends: Vec> = vec![None; n]; @@ -517,38 +625,44 @@ impl OpusTiming { // trailing packets without a granule (truncated file): continue from the cursor let mut c = cursor.unwrap_or(0); for k in i..n { - c += durations[k] as i64; + c = c.saturating_add(i64::from(durations[k])); ends[k] = Some(c); } break; } let Some(g) = stream.packets[j].granule else { break }; - let sum: i64 = durations[i..=j].iter().map(|&d| d as i64).sum(); - let forward = cursor.map(|c| c + sum); - let end = match forward { + let sum = durations[i..=j].iter().fold(0i64, |a, &d| a.saturating_add(i64::from(d))); + let eos = stream.packets[j].eos; + let end = match cursor.map(|c| c.saturating_add(sum)) { // end trimming on the last page (RFC 7845 §4.5): keep the forward positions - Some(f) if stream.packets[j].eos && g < f => { + Some(f) if eos && g < f => { trimmed_end = Some(g); f } Some(f) if f == g => f, + // the only page with audio is also the last: its packets start at 0 and the + // shortfall is trimmed at the end (RFC 7845 §4.5) + None if eos && g < sum => { + trimmed_end = Some(g); + sum + } // first page, or a discontinuity: trust the granule _ => g, }; let mut e = end; for k in (i..=j).rev() { ends[k] = Some(e); - e -= durations[k] as i64; + e = e.saturating_sub(i64::from(durations[k])); } cursor = Some(end); i = j + 1; } - let raw_starts: Vec = ends.iter().zip(&durations).map(|(e, &d)| e.unwrap_or(0) - d as i64).collect(); + let raw_starts: Vec = ends.iter().zip(&durations).map(|(e, &d)| e.unwrap_or(0).saturating_sub(i64::from(d))).collect(); let base = raw_starts.first().copied().unwrap_or(0).max(0); - let shift = base + pre_skip as i64; - let starts: Vec = raw_starts.iter().map(|s| s - shift).collect(); + let shift = base.saturating_add(i64::from(pre_skip)); + let starts: Vec = raw_starts.iter().map(|s| s.saturating_sub(shift)).collect(); let end_all = trimmed_end.or(cursor).unwrap_or(0); - let total = (end_all - shift).max(0); + let total = end_all.saturating_sub(shift).max(0); OpusTiming { pre_skip, starts, durations, total } } } diff --git a/crates/media/src/ogg/tests.rs b/crates/media/src/ogg/tests.rs index 3245db86..7af32c70 100644 --- a/crates/media/src/ogg/tests.rs +++ b/crates/media/src/ogg/tests.rs @@ -3,12 +3,22 @@ use super::*; #[test] -fn recovery_scan_stops_when_its_shared_budget_is_spent() { +fn recovery_scan_charges_what_it_examines_and_says_why_it_stopped() { let source = b"junkOggS".to_vec(); + let len = source.len() as u64; let mut budget = 3; - assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), None); - let mut budget = source.len(); - assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), Some(4)); + assert_eq!(find_capture(&source, 0, len, &mut budget).unwrap(), Capture::BudgetSpent); + assert_eq!(budget, 0); + // found on the last position the budget allows: a capture, not a spent budget + let mut budget = 5; + assert_eq!(find_capture(&source, 0, len, &mut budget).unwrap(), Capture::At(4)); + assert_eq!(budget, 0); + // a nearby capture costs the positions up to it, not a whole read + let mut budget = 1000; + assert_eq!(find_capture(&source, 0, len, &mut budget).unwrap(), Capture::At(4)); + assert_eq!(budget, 995); + let mut budget = 1000; + assert_eq!(find_capture(&b"junk, no pattern".to_vec(), 0, 16, &mut budget).unwrap(), Capture::End); } /// Write one page holding `segments` (lacing already computed by `lace`). @@ -230,26 +240,223 @@ fn flac_codec_params_carry_rate_channels_and_every_metadata_block() { // stream's audio is never held as header packets. let ogg = open(&ogg_flac(0, false, 0x04)[..]).unwrap(); assert_eq!((ogg.streams[0].headers.len(), ogg.streams[0].packets.len()), (2, 1)); - // Many tiny metadata packets under an unstated count: the size cap is kept as a running total, - // so opening stays linear in the packets (summing the kept headers per packet would not). - let mut file = Vec::new(); - let head = { - let f = ogg_flac(0, false, 0x04); - open(&f[..]).unwrap().streams[0].headers[0].clone() + // Under an unstated count, a packet is a metadata block only when it has a 4-byte block header + // and the length that header states: a 1-byte packet ends the metadata. + let head = open(&ogg_flac(0, false, 0x04)[..]).unwrap().streams[0].headers[0].clone(); + let flac_file = |pages: &[Vec<&[u8]>]| { + let mut file = Vec::new(); + let (lacing, body) = lace(&[&head[..]], false); + page(&mut file, 2, 0, 9, 0, &lacing, &body); + for (seq, packets) in (1..).zip(pages) { + let (lacing, body) = lace(packets, false); + page(&mut file, 0, 0, 9, seq, &lacing, &body); + } + file }; - let (lacing, body) = lace(&[&head[..]], false); - page(&mut file, 2, 0, 9, 0, &lacing, &body); - let tiny = [0x04u8]; - for seq in 1..=200u32 { - let packets: Vec<&[u8]> = vec![&tiny[..]; 250]; - let (lacing, body) = lace(&packets, false); - page(&mut file, 0, 0, 9, seq, &lacing, &body); - } - let (lacing, body) = lace(&[&[0xFF, 0xF8, 0x69, 0x08, 0x00][..]], false); - page(&mut file, 4, 0, 9, 201, &lacing, &body); - let ogg = open(&file[..]).unwrap(); - assert_eq!((ogg.streams[0].headers.len(), ogg.streams[0].packets.len()), (1 + 200 * 250, 1)); + let ogg = open(&flac_file(&[vec![&[0x04][..], &[0x04, 0, 0, 0][..]]])[..]).unwrap(); + assert_eq!((ogg.streams[0].headers.len(), ogg.streams[0].packets.len()), (1, 2)); + // Many small valid blocks: the first MAX_UNSTATED_HEADERS are kept, the rest read as audio. + let empty_padding = [0x01u8, 0, 0, 0]; + let pages: Vec> = (0..200).map(|_| vec![&empty_padding[..]; 250]).collect(); + let ogg = open(&flac_file(&pages)[..]).unwrap(); + assert_eq!((ogg.streams[0].headers.len(), ogg.streams[0].packets.len()), (MAX_UNSTATED_HEADERS, 200 * 250 + 1 - MAX_UNSTATED_HEADERS)); + assert!(ogg.warnings.iter().any(|w| w.contains("FLAC metadata beyond")), "{:?}", ogg.warnings); // STREAMINFO alone, flagged last: the next packet is audio. let ogg = open(&ogg_flac(0, true, 0xFF)[..]).unwrap(); assert_eq!((ogg.streams[0].headers.len(), ogg.streams[0].packets.len()), (1, 2)); } + +/// Opus header pages for `serial` (with the beginning-of-stream flag), then data pages of one +/// 960-sample packet of `pk_len` bytes each; sequence numbers continue from the header pages. +fn opus_pages(f: &mut Vec, serial: u32, npk: usize, pk_len: usize, eos: bool) { + let head = opus_head(0); + let (l, b) = lace(&[&head], false); + page(f, 0x02, 0, serial, 0, &l, &b); + let (l, b) = lace(&[b"OpusTags\x00\x00\x00\x00\x00\x00\x00\x00"], false); + page(f, 0, 0, serial, 1, &l, &b); + for i in 0..npk { + let p = pkt(i, pk_len); + let (l, b) = lace(&[&p], false); + let last = eos && i + 1 == npk; + page(f, if last { 0x04 } else { 0 }, (i as i64 + 1) * 960, serial, i as u32 + 2, &l, &b); + } +} + +fn page_starts(f: &[u8]) -> Vec { + f.windows(4).enumerate().filter(|(_, w)| *w == b"OggS").map(|(i, _)| i).collect() +} + +#[test] +fn many_damaged_pages_do_not_exhaust_the_recovery_budget() { + // 6 KB packets: a 2.4 MB file, so a scan that charged a whole 1 MiB read per damaged page + // would spend the 64 MiB budget long before the end + let mut f = Vec::new(); + opus_pages(&mut f, 3, 400, 6000, true); + let starts = page_starts(&f); + // damage every fourth data page's body: 100 bad pages + for k in (2..402).step_by(4) { + f[starts[k] + 30] ^= 0xFF; + } + let o = open(&f).unwrap(); + assert_eq!(o.streams[0].packets.len(), 300); + assert_eq!(o.warnings.iter().filter(|w| w.contains("bad CRC")).count(), 100); + assert!(!o.warnings.iter().any(|w| w.contains("recovery budget")), "{:?}", o.warnings); +} + +#[test] +fn a_page_running_past_the_end_is_skipped_not_the_end_of_the_file() { + let mut f = Vec::new(); + opus_pages(&mut f, 3, 2, 40, false); + // a damaged page whose lacing values claim far more body than the file has left + page(&mut f, 0, 2880, 3, 4, &[255; 5], &[0; 10]); + let p = pkt(9, 40); + let (l, b) = lace(&[&p], false); + page(&mut f, 0x04, 3840, 3, 5, &l, &b); + let o = open(&f).unwrap(); + assert!(o.warnings.iter().any(|w| w.contains("runs past the end")), "{:?}", o.warnings); + assert_eq!(o.read_packet(&f, 0, o.streams[0].packets.len() - 1).unwrap(), p); +} + +#[test] +fn a_lost_middle_page_drops_the_packet_it_continued() { + let mut f = Vec::new(); + let head = opus_head(0); + let (l, b) = lace(&[&head], false); + page(&mut f, 0x02, 0, 1, 0, &l, &b); + let (l, b) = lace(&[b"OpusTags"], false); + page(&mut f, 0, 0, 1, 1, &l, &b); + // one packet over three pages: 255 bytes, 255 bytes, then the last 90 with a whole packet + let big = pkt(0, 600); + page(&mut f, 0, -1, 1, 2, &[255], &big[..255]); + page(&mut f, 0x01, -1, 1, 3, &[255], &big[255..510]); + let small = pkt(1, 30); + let (l, b) = lace(&[&big[510..], &small], false); + page(&mut f, 0x01 | 0x04, 1920, 1, 4, &l, &b); + let mut g = f.clone(); + g[page_starts(&f)[3] + 40] ^= 0xFF; // the middle page fails its CRC + let o = open(&g).unwrap(); + assert!(o.warnings.iter().any(|w| w.contains("pages missing")), "{:?}", o.warnings); + assert_eq!(o.streams[0].packets.len(), 1); + assert_eq!(o.read_packet(&g, 0, 0).unwrap(), small); +} + +#[test] +fn chained_links_that_reuse_a_serial_are_separate_streams() { + let mut f = Vec::new(); + opus_pages(&mut f, 0, 3, 40, true); + opus_pages(&mut f, 0, 3, 40, true); + let o = open(&f).unwrap(); + assert!(o.warnings.is_empty(), "{:?}", o.warnings); + assert_eq!(o.streams.len(), 2); + for st in &o.streams { + assert_eq!((st.codec, st.headers.len(), st.packets.len()), (Codec::Opus, 2, 3)); + assert!(st.headers[0].starts_with(b"OpusHead")); + } +} + +#[test] +fn opus_single_page_clip_is_trimmed_at_the_end() { + let mut f = Vec::new(); + let head = opus_head(312); + let (l, b) = lace(&[&head], false); + page(&mut f, 0x02, 0, 5, 0, &l, &b); + let (l, b) = lace(&[b"OpusTags"], false); + page(&mut f, 0, 0, 5, 1, &l, &b); + let pkts: Vec> = (0..3).map(|i| pkt(i, 40)).collect(); + let refs: Vec<&[u8]> = pkts.iter().map(Vec::as_slice).collect(); + let (l, b) = lace(&refs, false); + page(&mut f, 0x04, 2000, 5, 2, &l, &b); + let t = OpusTiming::of(&open(&f).unwrap().streams[0], 312); + assert_eq!(t.starts, vec![-312, 648, 1608]); + assert_eq!(t.total, 2000 - 312); // 3 × 960 − 2000 = 880 samples trimmed at the end +} + +#[test] +fn hostile_granules_do_not_overflow_opus_timing() { + for g in [i64::MIN, i64::MIN + 1, i64::MAX, -2] { + for eos in [false, true] { + let mut f = Vec::new(); + let head = opus_head(u16::MAX); + let (l, b) = lace(&[&head], false); + page(&mut f, 0x02, 0, 5, 0, &l, &b); + let (l, b) = lace(&[b"OpusTags"], false); + page(&mut f, 0, 0, 5, 1, &l, &b); + let (l, b) = lace(&[&pkt(0, 40), &pkt(1, 40)], false); + page(&mut f, 0, g, 5, 2, &l, &b); + let (l, b) = lace(&[&pkt(2, 40)], false); + page(&mut f, if eos { 0x04 } else { 0 }, g, 5, 3, &l, &b); + let st = &open(&f).unwrap().streams[0]; + let t = OpusTiming::of(st, u32::MAX); + assert_eq!(t.starts.len(), 3); + assert!(t.total >= 0); + } + } +} + +#[test] +fn empty_packets_are_capped_by_the_file_size() { + // pages of 255 zero lacing values: 255 empty packets in 282 bytes, enough pages to pass the + // packet allowance of a file this size + let mut f = Vec::new(); + let head = opus_head(0); + let (l, b) = lace(&[&head], false); + page(&mut f, 0x02, 0, 1, 0, &l, &b); + let pages = MIN_PACKET_ALLOWANCE / 255 + 10; + for seq in 1..=pages as u32 { + page(&mut f, 0, -1, 1, seq, &[0; 255], &[]); + } + let o = open(&f).unwrap(); + let kept = o.streams[0].headers.len() + o.streams[0].packets.len(); + assert!(kept <= MIN_PACKET_ALLOWANCE.max(f.len() / 8), "{kept}"); + assert!(o.warnings.iter().any(|w| w.contains("more packets than")), "{:?}", o.warnings); +} + +#[test] +fn speex_extra_headers_and_codec_params() { + let mut first = b"Speex 1.2".to_vec(); + first.resize(80, 0); + first[36..40].copy_from_slice(&16_000u32.to_le_bytes()); + first[48..52].copy_from_slice(&1u32.to_le_bytes()); + first[68..72].copy_from_slice(&1u32.to_le_bytes()); // one extra header after the comments + let mut f = Vec::new(); + for (seq, (flags, p)) in [(0x02, &first[..]), (0, b"comments"), (0, b"extra"), (0x04, b"audio")].into_iter().enumerate() { + let (l, b) = lace(&[p], false); + page(&mut f, flags, 0, 4, seq as u32, &l, &b); + } + let st = &open(&f).unwrap().streams[0]; + assert_eq!((st.codec, st.headers.len(), st.packets.len()), (Codec::Speex, 3, 1)); + let p = st.codec_params(); + assert_eq!((p.codec, p.sample_rate, p.channels), (crate::CodecId::Speex, Some(16_000), Some(1))); + assert_eq!(p.extradata, first); +} + +#[test] +fn theora_codec_params_carry_the_laced_headers() { + let headers: [&[u8]; 3] = [b"\x80theora-id", b"\x81theora-comments", b"\x82theora-setup"]; + let mut f = Vec::new(); + for (seq, h) in headers.iter().enumerate() { + let (l, b) = lace(&[h], false); + page(&mut f, if seq == 0 { 0x02 } else { 0 }, 0, 6, seq as u32, &l, &b); + } + let (l, b) = lace(&[b"frame"], false); + page(&mut f, 0x04, 1, 6, 3, &l, &b); + let st = &open(&f).unwrap().streams[0]; + let p = st.codec_params(); + assert_eq!(p.codec, crate::CodecId::Theora); + assert_eq!(Some(p.extradata), crate::xiph_lace(&st.headers)); +} + +#[test] +fn flac_extradata_flags_exactly_the_last_block() { + // comment block not flagged last (an audio frame ended the metadata), and flagged last + for comment_flags in [0x04, 0x84] { + for count in [0, 1] { + let p = open(&ogg_flac(count, false, comment_flags)[..]).unwrap().streams[0].codec_params(); + assert_eq!(p.extradata[4] & 0x80, 0, "STREAMINFO is not last"); + assert_eq!(p.extradata[4 + 4 + 34], 0x84, "the comment block is last"); + } + } + // STREAMINFO alone is the last block + let p = open(&ogg_flac(0, true, 0xFF)[..]).unwrap().streams[0].codec_params(); + assert_eq!((p.extradata.len(), p.extradata[4] & 0x80), (4 + 4 + 34, 0x80)); +}