Bound three allocations an untrusted disc can drive without limit
The Program Stream demuxer appended every fed byte and enforced its 4 MiB cap only inside a branch reached once a start code had been found. Input containing no start code anywhere therefore hit no cap at all, and since a whole title is fed through this demuxer, a zero-filled or ciphertext VOB extent buffered the entire title — up to ~90 GB. When the buffer holds no start code, only a two-byte `00 00` prefix can begin a PS unit on the next feed, so that is kept and the rest dropped. The bound is exact rather than a heuristic: a start code can straddle a feed boundary by at most its first two bytes, so no real byte is discarded, and a test feeding `FF FF 00 00` then `01 E0 ...` pins that. The existing test named for this case fed a real start code first, so the cap it exercised was the in-PES one. Renamed to say what it covers. The BD-J label path had a different shape to anything found so far: the cap is on the COMPRESSED size of a disc file while the allocation scales with the decompressed size. A `.class` gated only by a path prefix inflates to the 64 MiB ceiling, yielding ~33M retained strings from `ldc` operands or ~67M pushes onto a symbolic stack whose depth was unbounded despite the Code attribute's own `max_stack` being parsed and then ignored. Bounded both, the stack by `max_stack` itself (JVMS §4.7.3). The VMG TT_SRPT title count is an untrusted u16 with no de-duplication, so ~800 KB of crafted IFO re-parsed one PGC 65535 times. Capped at 99, the DVD-Video maximum, so no conformant disc is clipped. Every cap carries stated headroom against real media, and each has a test locking that real media still passes. I rewrote three of the new assertions before landing them. They compared the result against the very constant under test — `total <= MAX_TT_SRPT_TITLES` — which passes vacuously the moment someone raises the constant, the most likely future regression and the seventh instance of this tautology shape in this audit. They now assert literals derived from the spec. The TT_SRPT fixture also had to change: with 65535 identical entries the de-duplication collapsed them on its own and the cap was never what bounded the result, so the test passed with the cap removed entirely. Distinct entries defeat dedup and leave the cap as the only guard; de-duplication now has its own fixture. Verified by raising each of the three constants and confirming all three tests fail.
This commit is contained in:
+139
-26
@@ -356,32 +356,7 @@ pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo>
|
||||
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<u8, Vec<(u16, u8)>> =
|
||||
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<DvdInfo>
|
||||
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<std::collections::BTreeMap<u8, Vec<(u16, u8)>>> {
|
||||
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<u8, Vec<(u16, u8)>> =
|
||||
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<DvdTitle>
|
||||
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<u8> {
|
||||
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
|
||||
|
||||
+237
-17
@@ -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 `<clinit>` 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 `<clinit>` 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 `<clinit>`.
|
||||
///
|
||||
/// 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<Vec<String>> {
|
||||
let mut found = false;
|
||||
let mut out = Vec::new();
|
||||
let mut out: Vec<String> = 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<Vec<Stri
|
||||
_ => 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<StackVal>,
|
||||
/// 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<Construction>,
|
||||
}
|
||||
|
||||
@@ -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 `<clinit>`, 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.<init>(...) — 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 `<clinit>` has the given bytecode and
|
||||
/// `max_stack`, over the given constant pool. The pool must hold
|
||||
/// "<clinit>" 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 "<clinit>", 2 "()V", 3 "Code",
|
||||
/// 4 String -> 5, 5 Utf8(`value`).
|
||||
fn ldc_pool(value: &str) -> ConstantPool {
|
||||
ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("<clinit>".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("<clinit> 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("<clinit> 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("<clinit> 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"
|
||||
|
||||
+70
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user