craft-media: Ogg review fixes: bounded recovery, lost pages, chained links, caps

From the review of the Ogg demuxer (storytold/craft-libs#440):

- The damage scan charges the positions it examines, not a whole 1 MiB read,
  reads 64 KiB at a time, and says when the 64 MiB budget runs out. A file
  with many damaged pages lost its tail without a warning.
- A page whose lacing claims more bytes than the file has left is skipped
  and the scan resumes, instead of ending the parse.
- A gap in a stream's page sequence numbers drops the packet the lost page
  continued; its two ends were joined into one corrupted packet.
- A beginning-of-stream page for a serial already seen starts a new stream,
  so a chained file whose links reuse a serial no longer hands the second
  link's headers to the decoder as audio.
- The packets kept from a file are capped by its size (as Matroska caps
  frames), so zero lacing values cannot make the packet lists many times the
  file. FLAC metadata under an unstated header count must be a block of the
  length its header states, and at most 1024 are kept.
- A packet over 4 GiB is dropped instead of overflowing its size.
- OpusTiming saturates, so hostile granule positions cannot overflow, and a
  clip whose only audio page is also the last is trimmed at the end
  (RFC 7845 §4.5) instead of at the start.
- Speex streams read extra_headers (offset 68) for their header count.
  codec_params gives Theora its Xiph-laced headers and Speex its header,
  rate and channels; the FLAC extradata flags exactly the last block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehel
2026-11-02 14:50:20 +01:00
co-authored by Claude Opus 5.5
parent 18a9c3f0e2
commit 23080711d4
3 changed files with 400 additions and 74 deletions
+8 -3
View File
@@ -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
+163 -49
View File
@@ -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<u32>,
}
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<usize> = 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<u8>,
}
/// 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<OggFile> {
let len = src.len();
@@ -266,33 +321,39 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
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::<u64>()
} 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<OggFile> {
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<OggFile> {
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<OggFile> {
}
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<OggFile> {
}
}
}
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<OggFile> {
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<OggFile> {
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<OggFile> {
Ok(OggFile { streams, warnings })
}
fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result<Option<u64>> {
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<Capture> {
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<Vec<u8>> {
@@ -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<u32> = stream.packets.iter().map(|p| opus_packet_samples(p.prefix, p.size).unwrap_or(0)).collect();
let n = durations.len();
let mut ends: Vec<Option<i64>> = 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<i64> = ends.iter().zip(&durations).map(|(e, &d)| e.unwrap_or(0) - d as i64).collect();
let raw_starts: Vec<i64> = 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<i64> = raw_starts.iter().map(|s| s - shift).collect();
let shift = base.saturating_add(i64::from(pre_skip));
let starts: Vec<i64> = 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 }
}
}
+229 -22
View File
@@ -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<Vec<&[u8]>> = (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<u8>, 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<usize> {
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<Vec<u8>> = (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));
}