diff --git a/src/ifo.rs b/src/ifo.rs index 88e6923..ddbc7b9 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -356,32 +356,7 @@ pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result return Err(Error::IfoParse); } - let num_titles = be_u16(&vmg_data, tt_srpt_offset)?; - - // Parse title entries — each is 12 bytes, starting at tt_srpt_offset + 8 - let entries_start = tt_srpt_offset + 8; - let mut title_set_map: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - - for i in 0..num_titles as usize { - let base = entries_start + i * 12; - if base + 12 > vmg_data.len() { - break; // truncated — parse what we can - } - - let num_chapters = be_u16(&vmg_data, base + 2)?; - let vts_number = byte_at(&vmg_data, base + 6)?; - let vts_title_num = byte_at(&vmg_data, base + 7)?; - - if vts_number == 0 { - continue; // invalid - } - - title_set_map - .entry(vts_number) - .or_default() - .push((num_chapters, vts_title_num)); - } + let title_set_map = parse_tt_srpt(&vmg_data, tt_srpt_offset)?; // Parse each VTS IFO let mut title_sets = Vec::new(); @@ -398,6 +373,71 @@ pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result Ok(DvdInfo { title_sets }) } +/// Maximum TT_SRPT entries honoured. DVD-Video caps a disc at 99 titles +/// (VMGI TT_SRPT `TT_Ns`, and the 99-title / 99-title-set structure the format +/// is built around), but the on-disc count is an untrusted `u16`: a ~800 KB +/// crafted IFO can declare 65535 entries, each re-parsing a PGC into a full +/// `DvdTitle` (~540 MB of `DvdInfo`). +/// +/// Headroom: this IS the format maximum, so it clips no conformant disc — a +/// real DVD cannot address a 100th title through TT_SRPT. +const MAX_TT_SRPT_TITLES: usize = 99; + +/// Parse the VMG TT_SRPT into a per-title-set map of +/// `(chapter_count, vts_title_number)`. +/// +/// Two bounds on untrusted input: the declared entry count is clamped to +/// [`MAX_TT_SRPT_TITLES`], and entries naming a `(vts_number, vts_title_num)` +/// pair already seen are dropped. De-duplication is a correctness fix as well +/// as a bound: two TT_SRPT entries pointing at the same VTS title are the same +/// title, and `parse_pgcit` would otherwise re-parse that one PGC into a +/// separate `DvdTitle` per entry. +fn parse_tt_srpt( + vmg_data: &[u8], + tt_srpt_offset: usize, +) -> Result>> { + let num_titles = be_u16(vmg_data, tt_srpt_offset)? as usize; + if num_titles > MAX_TT_SRPT_TITLES { + tracing::warn!( + declared = num_titles, + cap = MAX_TT_SRPT_TITLES, + "TT_SRPT title count exceeds the DVD-Video maximum, clamping" + ); + } + let num_titles = num_titles.min(MAX_TT_SRPT_TITLES); + + // Title entries are 12 bytes each, starting at tt_srpt_offset + 8. + let entries_start = tt_srpt_offset + 8; + let mut title_set_map: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + let mut seen: std::collections::HashSet<(u8, u8)> = std::collections::HashSet::new(); + + for i in 0..num_titles { + let base = entries_start + i * 12; + if base + 12 > vmg_data.len() { + break; // truncated — parse what we can + } + + let num_chapters = be_u16(vmg_data, base + 2)?; + let vts_number = byte_at(vmg_data, base + 6)?; + let vts_title_num = byte_at(vmg_data, base + 7)?; + + if vts_number == 0 { + continue; // invalid + } + if !seen.insert((vts_number, vts_title_num)) { + continue; // duplicate entry for the same VTS title + } + + title_set_map + .entry(vts_number) + .or_default() + .push((num_chapters, vts_title_num)); + } + + Ok(title_set_map) +} + // ── VTS parser ────────────────────────────────────────────────────────────── /// Parse VTS_XX_0.IFO for one title set. @@ -858,6 +898,79 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result mod tests { use super::*; + /// Build a synthetic TT_SRPT at offset 0: a declared `u16` title count + /// followed by `entries` 12-byte records of + /// `(num_chapters, vts_number, vts_title_num)`. + fn tt_srpt_bytes(declared: u16, entries: &[(u16, u8, u8)]) -> Vec { + let mut data = vec![0u8; 8 + entries.len() * 12]; + data[0..2].copy_from_slice(&declared.to_be_bytes()); + for (i, &(chapters, vts, title)) in entries.iter().enumerate() { + let base = 8 + i * 12; + data[base + 2..base + 4].copy_from_slice(&chapters.to_be_bytes()); + data[base + 6] = vts; + data[base + 7] = title; + } + data + } + + #[test] + fn tt_srpt_title_count_is_capped_and_deduplicated() { + // The TT_SRPT count is an untrusted u16. A ~800 KB crafted IFO declares + // 65535 entries, and each one re-parses a PGC into another full DvdTitle. + // + // The entries are DISTINCT, which matters: with 65535 identical entries + // the de-duplication collapses them to one title on its own and the count + // cap is never what bounds the result — the test would then pass with the + // cap removed entirely, proving only that dedup works. Distinct entries + // defeat dedup, so the cap is the only thing left holding the line. + // De-duplication gets its own assertion below, on its own fixture. + // Both title fields are u8, so vary each within its own range to get + // ~65025 distinct pairs out of the 65535 entries. + let entries: Vec<(u16, u8, u8)> = (0..u16::MAX) + .map(|i| (5u16, (i % 255) as u8 + 1, ((i / 255) % 255) as u8 + 1)) + .collect(); + let data = tt_srpt_bytes(u16::MAX, &entries); + let map = parse_tt_srpt(&data, 0).expect("well-formed TT_SRPT"); + let total: usize = map.values().map(|v| v.len()).sum(); + // Asserted against the DVD-Video format maximum as a LITERAL, not against + // MAX_TT_SRPT_TITLES. Comparing a result to the very constant under test + // passes vacuously the moment someone raises that constant, which is the + // most likely regression here — and is the tautology class this audit has + // now found six times. 99 is the format's own ceiling, so this pins the + // cap to the spec rather than to whatever the code currently says. + assert!( + total <= 99, + "TT_SRPT produced {total} titles from a crafted 65535-entry table; \ + DVD-Video allows at most 99" + ); + // De-duplication, on its own fixture: the same VTS title named 65535 + // times is one title, not many. Kept separate from the cap assertion + // above so neither guard can mask the other. + let dupes = vec![(5u16, 1u8, 1u8); u16::MAX as usize]; + let dup_map = + parse_tt_srpt(&tt_srpt_bytes(u16::MAX, &dupes), 0).expect("well-formed TT_SRPT"); + assert_eq!( + dup_map.get(&1).map(Vec::len), + Some(1), + "duplicate (vts_number, vts_title_num) entries must collapse" + ); + } + + #[test] + fn tt_srpt_admits_a_full_99_title_disc() { + // The DVD-Video maximum must still parse in full: 99 distinct titles + // spread over two title sets. + let entries: Vec<(u16, u8, u8)> = (0..99u8) + .map(|i| (3u16, if i < 50 { 1 } else { 2 }, i + 1)) + .collect(); + let data = tt_srpt_bytes(99, &entries); + let map = parse_tt_srpt(&data, 0).expect("well-formed TT_SRPT"); + let total: usize = map.values().map(|v| v.len()).sum(); + assert_eq!(total, 99, "a full 99-title disc must survive the cap"); + assert_eq!(map.get(&1).map(Vec::len), Some(50)); + assert_eq!(map.get(&2).map(Vec::len), Some(49)); + } + #[test] fn bcd_to_secs_basic() { // 1 hour, 23 minutes, 45 seconds, 0 frames at 25fps diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index 015e544..4763d05 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -208,6 +208,31 @@ const FINGERPRINTS: &[Fingerprint] = &[ /// prefix, so a count mismatch within tolerance is informative-but-OK. const LDC_COUNT_TOLERANCE: usize = 4; +/// Cap on the `ldc` operands retained per class by [`clinit_ldc_strings`]. +/// +/// Unlike every other count cap in this crate, the paired byte cap here cannot +/// be the disc-file size: a `.class` entry gated only by a `com/bydeluxe/` path +/// prefix deflates from ~100 KB up to the 64 MiB `MAX_CLASS_BYTES` read ceiling, +/// so ~33M two-byte `ldc` instructions — one retained `String` each — are +/// reachable from a small crafted disc. The bound has to be on the decompressed +/// work, so it is applied here. +/// +/// Headroom: the largest framework-stable enum is `Language` at 70 values +/// (`FINGERPRINTS`), and no fingerprint matches a count more than +/// `LDC_COUNT_TOLERANCE` away from its expected size, so anything past ~74 can +/// never identify a master enum. 4096 leaves ~55x headroom over the largest +/// real enum for framework drift. +const MAX_CLINIT_LDC_STRINGS: usize = 4096; + +/// Companion byte cap for [`MAX_CLINIT_LDC_STRINGS`]: the count cap alone still +/// admits 4096 x 64 KiB of `Utf8` (a JVMS `CONSTANT_Utf8_info` length is a u16), +/// i.e. ~268 MB per class from repeated `ldc` of one huge constant. +/// +/// Headroom: master-enum values are short display names ("English", +/// "HDR10 Plus", "USA_D1") well under 32 bytes, so a real Language enum retains +/// ~1 KB. 256 KiB admits 4096 values averaging 64 bytes each. +const MAX_CLINIT_LDC_BYTES: usize = 256 * 1024; + /// Phase A. Walk every `.class` in `archive`, identify the master /// enums by `` ldc-sequence fingerprint. Returns a vector of /// `(label, MasterEnum)` — at most one match per fingerprint label. @@ -258,9 +283,18 @@ pub(crate) fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str /// Walk `` and collect every `ldc` / `ldc_w` operand that /// resolves to either a `String` constant or a `Utf8` constant, in /// declaration order. Returns `None` if the class has no ``. +/// +/// Collection stops at [`MAX_CLINIT_LDC_STRINGS`] operands or +/// [`MAX_CLINIT_LDC_BYTES`] of retained text, whichever comes first: the walk +/// is driven by the DECOMPRESSED class, so it is not bounded by the disc-file +/// size cap the way the rest of this module's counts are. Truncation cannot +/// lose a real match — a truncated sequence is far longer than any +/// `FINGERPRINTS` entry's `expected_count + LDC_COUNT_TOLERANCE`, so it would +/// have been rejected on count anyway. fn clinit_ldc_strings(class: &super::class_reader::ClassFile) -> Option> { let mut found = false; - let mut out = Vec::new(); + let mut out: Vec = Vec::new(); + let mut out_bytes = 0usize; for m in &class.methods { let Some(name) = class.member_name(m) else { continue; @@ -287,6 +321,18 @@ fn clinit_ldc_strings(class: &super::class_reader::ClassFile) -> Option None, }; if let Some(s) = resolved { + if out.len() >= MAX_CLINIT_LDC_STRINGS + || out_bytes.saturating_add(s.len()) > MAX_CLINIT_LDC_BYTES + { + tracing::debug!( + class = class.this_class_name().unwrap_or(""), + strings = out.len(), + bytes = out_bytes, + "deluxe: clinit ldc collection hit cap, truncating" + ); + return Some(out); + } + out_bytes += s.len(); out.push(s); } } @@ -475,6 +521,9 @@ struct BindingDecoder<'a> { pool: &'a ConstantPool, master: &'a MasterEnumTable, stack: Vec, + /// Depth limit for `stack`, taken from the Code attribute's own `max_stack` + /// in [`run`](Self::run). Zero until then. + max_stack: usize, constructions: Vec, } @@ -484,6 +533,7 @@ impl<'a> BindingDecoder<'a> { pool, master, stack: Vec::new(), + max_stack: 0, constructions: Vec::new(), } } @@ -491,33 +541,56 @@ impl<'a> BindingDecoder<'a> { /// Run the walker over the given Code attribute. On exit the /// `constructions` field holds the result. pub(crate) fn run(&mut self, code: &CodeAttribute<'_>) { + self.max_stack = code.max_stack as usize; for insn in code.instructions() { self.step(insn); } } + /// Push onto the symbolic stack, honouring the Code attribute's declared + /// `max_stack`. + /// + /// JVMS 4.7.3 requires that a method's operand stack never exceed + /// `max_stack` at any point, so a push past it can only come from bytecode + /// that would fail JVM verification. Dropping it costs nothing on real + /// bytecode and bounds the decoder: a `.class` gated only by a + /// `com/bydeluxe/` path prefix deflates from ~100 KB to the 64 MiB + /// `MAX_CLASS_BYTES` ceiling, i.e. ~67M single-byte `iconst_0` (~2 GiB of + /// `StackVal`) on an unbounded `Vec`. + /// + /// Headroom: `max_stack` is exactly what javac computed for the real + /// binding ``, so no real construction can be clipped. Our symbolic + /// stack counts `long`/`double` as one slot where the JVM counts two, so + /// our depth is never greater than the verified depth. + fn push(&mut self, val: StackVal) { + if self.stack.len() >= self.max_stack { + return; + } + self.stack.push(val); + } + fn step(&mut self, insn: super::class_reader::Instruction<'_>) { match insn.opcode { // Push small int constants. - ICONST_M1 => self.stack.push(StackVal::Int(-1)), - ICONST_0 => self.stack.push(StackVal::Int(0)), - ICONST_1 => self.stack.push(StackVal::Int(1)), - ICONST_2 => self.stack.push(StackVal::Int(2)), - ICONST_3 => self.stack.push(StackVal::Int(3)), - ICONST_4 => self.stack.push(StackVal::Int(4)), - ICONST_5 => self.stack.push(StackVal::Int(5)), + ICONST_M1 => self.push(StackVal::Int(-1)), + ICONST_0 => self.push(StackVal::Int(0)), + ICONST_1 => self.push(StackVal::Int(1)), + ICONST_2 => self.push(StackVal::Int(2)), + ICONST_3 => self.push(StackVal::Int(3)), + ICONST_4 => self.push(StackVal::Int(4)), + ICONST_5 => self.push(StackVal::Int(5)), BIPUSH => { if let Some(b) = insn.operand_u8() { - self.stack.push(StackVal::Int(b as i8 as i32)); + self.push(StackVal::Int(b as i8 as i32)); } else { - self.stack.push(StackVal::Unknown); + self.push(StackVal::Unknown); } } SIPUSH => { if let Some(w) = insn.operand_u16() { - self.stack.push(StackVal::Int(w as i16 as i32)); + self.push(StackVal::Int(w as i16 as i32)); } else { - self.stack.push(StackVal::Unknown); + self.push(StackVal::Unknown); } } // ldc/ldc_w: push Int when the operand is an Integer @@ -531,7 +604,7 @@ impl<'a> BindingDecoder<'a> { _ => None, }) .unwrap_or(StackVal::Unknown); - self.stack.push(v); + self.push(v); } // new X — push an uninit-object marker. The matching // invokespecial will consume this + the args and emit a @@ -542,12 +615,12 @@ impl<'a> BindingDecoder<'a> { .and_then(|i| self.pool.class_name(i)) .unwrap_or("") .to_string(); - self.stack.push(StackVal::NewObj(class_name)); + self.push(StackVal::NewObj(class_name)); } // dup — duplicate top of stack. 0x59 /* dup */ => { if let Some(top) = self.stack.last().cloned() { - self.stack.push(top); + self.push(top); } } // getstatic Y.Z — if Y is one of our master enum classes, @@ -572,7 +645,7 @@ impl<'a> BindingDecoder<'a> { } }) .unwrap_or(StackVal::Unknown); - self.stack.push(val); + self.push(val); } // invokespecial X.(...) — pop args per descriptor. // If the object on the stack underneath the args is a @@ -620,7 +693,7 @@ impl<'a> BindingDecoder<'a> { } // Push return placeholder unless void. if !member.descriptor.ends_with(")V") { - self.stack.push(StackVal::Unknown); + self.push(StackVal::Unknown); } } // pop / pop2 — drop stack values. @@ -1013,6 +1086,153 @@ mod tests { assert_eq!(parse_method_arg_count("(Ifoo)V"), 1); } + // ── Decompression-amplification bounds ────────────────────────────────── + + /// Build a `ClassFile` whose single `` has the given bytecode and + /// `max_stack`, over the given constant pool. The pool must hold + /// "" at 1, "()V" at 2 and "Code" at 3. + fn class_with_clinit(pool: ConstantPool, max_stack: u16, code: &[u8]) -> ClassFile { + let mut info = Vec::with_capacity(8 + code.len()); + info.extend_from_slice(&max_stack.to_be_bytes()); + info.extend_from_slice(&0u16.to_be_bytes()); // max_locals + info.extend_from_slice(&(code.len() as u32).to_be_bytes()); + info.extend_from_slice(code); + ClassFile { + minor_version: 0, + major_version: 49, + constant_pool: pool, + access_flags: 0, + this_class: 0, + super_class: 0, + interfaces: Vec::new(), + fields: Vec::new(), + methods: vec![super::super::class_reader::Member { + access_flags: 0, + name_index: 1, + descriptor_index: 2, + attributes: vec![super::super::class_reader::Attribute { + name_index: 3, + info, + }], + }], + attributes: Vec::new(), + } + } + + /// Pool for `class_with_clinit`: 1 "", 2 "()V", 3 "Code", + /// 4 String -> 5, 5 Utf8(`value`). + fn ldc_pool(value: &str) -> ConstantPool { + ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("".into()), + CpInfo::Utf8("()V".into()), + CpInfo::Utf8("Code".into()), + CpInfo::String { string_index: 5 }, + CpInfo::Utf8(value.into()), + ]) + } + + #[test] + fn clinit_ldc_string_count_is_capped() { + // A `.class` gated only by a `com/bydeluxe/` path prefix deflates from + // ~100 KB up to the 64 MiB MAX_CLASS_BYTES ceiling, giving ~33M 2-byte + // `ldc` instructions. Every resolved operand is retained as an owned + // String, and identify_master_enums keeps the whole vector per class in + // a HashMap — so the allocation scales with the DECOMPRESSED size while + // the only byte cap is on the compressed disc file. + const N: usize = 200_000; + let mut code = Vec::with_capacity(N * 2); + for _ in 0..N { + code.push(LDC); + code.push(4); // cp index 4 -> String -> "English" + } + let class = class_with_clinit(ldc_pool("English"), 2, &code); + let ldcs = clinit_ldc_strings(&class).expect(" present"); + // Asserted against a LITERAL, not against MAX_CLINIT_LDC_STRINGS. A test + // that compares the result to the very constant under test passes + // vacuously the moment someone raises that constant — which is the most + // likely future regression here, and exactly the tautology class this + // audit has now found six times. 8192 is double the current cap, so this + // still allows the cap to be tuned, but not removed. + assert!( + ldcs.len() <= 8192, + "retained {} ldc strings from {N} ldc instructions — the cap is not \ + bounding the walk", + ldcs.len() + ); + assert!( + ldcs.len() < N, + "nothing was truncated at all: retained all {N} strings" + ); + } + + #[test] + fn clinit_ldc_string_bytes_are_capped() { + // Few instructions, huge operands: the count cap alone still admits + // MAX_CLINIT_LDC_STRINGS x 64 KiB of Utf8. Bound the retained bytes too. + let big = "A".repeat(32 * 1024); + const N: usize = 512; + let mut code = Vec::with_capacity(N * 2); + for _ in 0..N { + code.push(LDC); + code.push(4); + } + let class = class_with_clinit(ldc_pool(&big), 2, &code); + let ldcs = clinit_ldc_strings(&class).expect(" present"); + let bytes: usize = ldcs.iter().map(|s| s.len()).sum(); + // Literal, not the constant under test — see the sibling test above. + assert!( + bytes <= 512 * 1024, + "retained {bytes} bytes of ldc strings — the byte cap is not bounding \ + the walk" + ); + } + + #[test] + fn clinit_ldc_strings_admits_largest_real_fingerprint() { + // The biggest framework-stable enum is Language at 70 values; the cap + // must not clip a real one. + let n = FINGERPRINTS + .iter() + .map(|fp| fp.expected_count) + .max() + .unwrap() + + LDC_COUNT_TOLERANCE; + let mut code = Vec::with_capacity(n * 2); + for _ in 0..n { + code.push(LDC); + code.push(4); + } + let class = class_with_clinit(ldc_pool("English"), 2, &code); + let ldcs = clinit_ldc_strings(&class).expect(" present"); + assert_eq!(ldcs.len(), n, "real-size enum must survive the cap"); + } + + #[test] + fn binding_decoder_stack_is_bounded_by_max_stack() { + // ~67M single-byte `iconst_0` fit in a 64 MiB decompressed class, and + // each pushes a StackVal onto a Vec with no depth limit (~2 GiB). The + // Code attribute's own max_stack is parsed and must be honoured: JVMS + // 4.7.3 requires the operand stack never exceed it. + const MAX_STACK: u16 = 4; + let code = vec![ICONST_0; 200_000]; + let pool = build_simple_pool(); + let master = lang_enum_master(); + let attr = super::super::class_reader::CodeAttribute { + max_stack: MAX_STACK, + max_locals: 0, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + assert!( + decoder.stack.len() <= MAX_STACK as usize, + "symbolic stack grew to {} with max_stack {}", + decoder.stack.len(), + MAX_STACK + ); + } + /// Construct a minimal ConstantPool that supports the synthetic /// bytecode in the tests below. Layout: /// 1: Utf8 "LanguageEnum" diff --git a/src/mux/ps.rs b/src/mux/ps.rs index 4b69658..0723857 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -340,10 +340,35 @@ impl PsDemuxer { } } + // Trim a start-code-free tail. Every other exit from the loop above + // leaves the buffer bounded (a pack, system header or length-bounded + // PES is at most ~64 KiB; a length-0 PES is force-flushed at + // MAX_PS_BUFFER), but a buffer that holds no `00 00 01` at all never + // reaches any of those branches: `find_start_code` returns None, `pos` + // stays 0 and nothing drains. Input that never contains a start code — + // a zero-filled VOB extent, or an AACS-encrypted clip probed as + // ciphertext — would then grow the buffer to the size of the whole + // title. Nothing in such a buffer can begin a PS unit except a 2-byte + // `00 00` prefix of a start code straddling the feed boundary, so keep + // exactly that and drop the rest. Lossless: the retained bytes are the + // only ones a later feed could complete into a start code. + if self.buffer.len() > START_CODE_PREFIX_KEEP && find_start_code(&self.buffer, 0).is_none() + { + let drop = self.buffer.len() - START_CODE_PREFIX_KEEP; + self.buffer.drain(..drop); + if self.has_base { + self.buffer_base += drop as u64; + } + } + packets } } +/// Bytes retained when the buffer holds no start code: a `00 00 01` prefix can +/// straddle a feed boundary by at most its first two bytes. +const START_CODE_PREFIX_KEEP: usize = 2; + /// Find the next PS-layer unit boundary at or after `from`: a start code whose /// ID byte is a pack (0xBA), system header (0xBB), program-end (0xB9), or a /// payload-carrying PES stream ID (0xBD..=0xEF). @@ -897,9 +922,53 @@ mod tests { } #[test] - fn unbounded_video_pes_buffer_is_bounded() { + fn input_with_no_start_code_at_all_is_bounded() { + // An extent that never contains a 00 00 01 start code — a zero-filled + // VOB extent, or an AACS-encrypted clip probed as ciphertext (see + // src/disc/hddvd.rs) — must not accumulate. The whole-title feed in + // src/mux/disc.rs would otherwise grow the buffer to the size of the + // title (up to ~90 GB for UHD). + let mut demuxer = PsDemuxer::new(); + let chunk = vec![0u8; 1024 * 1024]; + for _ in 0..(MAX_PS_BUFFER / chunk.len() + 8) { + assert!(demuxer.feed(&chunk).is_empty(), "no start code → no PES"); + } + assert!( + demuxer.buffer.len() <= MAX_PS_BUFFER, + "buffer grew to {} with no start code ever seen (cap {})", + demuxer.buffer.len(), + MAX_PS_BUFFER + ); + // Nothing in a start-code-free buffer can ever begin a unit except a + // 2-byte 00 00 prefix, so the retained tail is tiny. + assert!( + demuxer.buffer.len() <= 2, + "start-code-free tail retained {} bytes", + demuxer.buffer.len() + ); + } + + #[test] + fn start_code_split_across_feeds_still_parses() { + // The start-code-free trim must keep the 2 bytes that can be the + // prefix of a start code straddling a feed boundary. + let mut demuxer = PsDemuxer::new(); + assert!(demuxer.feed(&[0xFF, 0xFF, 0x00, 0x00]).is_empty()); + let mut rest = vec![0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00, 0xAA, 0xBB]; + rest.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // PS-layer boundary + let packets = demuxer.feed(&rest); + assert_eq!(packets.len(), 1, "split start code must still be found"); + assert_eq!(packets[0].stream_id, 0xE0); + assert_eq!(packets[0].data, vec![0xAA, 0xBB]); + } + + #[test] + fn unbounded_video_pes_over_cap_is_force_flushed() { // A corrupt stream declaring an unbounded PES followed by endless // non-boundary bytes must not grow the buffer without limit. + // NOTE: this case feeds a real start code first, so it exercises only + // the in-PES cap; the no-start-code path is covered by + // `input_with_no_start_code_at_all_is_bounded`. let mut demuxer = PsDemuxer::new(); let header = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00]; let packets = demuxer.feed(&header);