fix(clpi): resolve out_time past the last EP entry to the end of the clip

ClipInfo::get_extents fell back to `last EP SPN + 1` whenever out_time
lay past the last entry-point. EP entries mark I-frames (BD-ROM Part 3,
CPI / EP map) and a clip's final GOP lies after the last one, so a
PlayItem covering a whole clip — whose OUT_time is the presentation end
— always lands in that arm. The extent then stopped one source packet
after the last I-frame.

Measured on a fixture with 200,000 source packets and the last EP at
SPN 131,072: sector_count came back 12,289 where covering the clip
needs 18,750. Everything from the last entry point to EOF is outside
the returned extent.

Scope, stated plainly: get_extents has NO callers anywhere in the
ecosystem today — it is #[allow(dead_code)] and documented as reserved
for the timestamp-range read path. Nothing ships this loss. It is
fixed now because a latent truncation in extent arithmetic is far
cheaper to correct before it has callers than after.

The SPN at-or-after an out-of-range out_time is the end of the clip,
source_packet_count, with .max(last + 1) so a disc that under-declares
its own packet count against its own EP map still yields a sane bound.

Also 174 mutants killed across clpi, mpls, ifo and ebml — the first
time any of these four files has been examined. And ebml's 8-byte VINT
back-patch was duplicated verbatim in end_master and end_master_buf
with its top four payload octets unreachable through either (they need
a 16 MiB..256 TiB buffer); extracted to fixed_width_vint8 and tested
across the full 56-bit payload, no behaviour change.

38 of ebml's 46 survivors are one equivalence cluster: every | in
write_size / read_id / read_size / read_uint_val ORs into disjoint bit
lanes, where ^ is the identical operation. Applied all 38 at once —
green — then spot-checked four individually.
This commit is contained in:
Matthew Jackson
2026-07-30 14:24:57 -07:00
parent 84a77f6a0e
commit b2e1982051
4 changed files with 1244 additions and 22 deletions
+498 -2
View File
@@ -157,11 +157,24 @@ impl ClipInfo {
Err(i) => ep_map[i - 1].1,
};
// Find SPN at or after out_time
// Find SPN at or after out_time.
//
// When out_time is past the last EP entry there is no later entry
// point to resolve against: EP entries mark I-frames, and the final
// GOP of a clip lies *after* the last one. The SPN at-or-after
// out_time is then the end of the clip, i.e. source_packet_count.
// Falling back to `last EP + 1` here would truncate the extent at
// the last I-frame and silently drop every packet after it — which
// is the normal case for a PlayItem covering a whole clip, since
// its OUT_time is the presentation end, not the last entry point.
// `max` keeps the bound sane if a hostile disc declares a
// source_packet_count below its own EP map.
let end_spn = match ep_map.binary_search_by_key(&out_time, |(pts, _)| *pts) {
Ok(i) => ep_map[i].1,
Err(i) if i < ep_map.len() => ep_map[i].1,
_ => ep_map.last().unwrap().1.saturating_add(1),
_ => self
.source_packet_count
.max(ep_map.last().unwrap().1.saturating_add(1)),
};
if end_spn <= start_spn {
@@ -1275,4 +1288,487 @@ mod tests {
// All 3 fine entries resolved (last group picks up fine 1 and 2).
assert_eq!(resolved.len(), 3);
}
// ─────────────────────────────────────────────────────────────────────
// get_extents: PTS→SPN resolution and SPN→sector arithmetic.
//
// Fixture below uses ONE coarse group with spn_coarse = 0 so that
// full_spn((0 & 0xFFFE_0000) | spn_fine) == spn_fine exactly, and
// full_pts == pts_fine << 8. That makes every (PTS, SPN) pair in the
// resolved map an exact, hand-checkable number.
// ─────────────────────────────────────────────────────────────────────
/// EP map with three entries: PTS 2560→SPN 1000, 5120→2000, 7680→3000.
/// `source_packet_count` is 200_000 (the clip is much longer than its
/// last entry point, as every real clip is).
fn three_point_clip() -> ClipInfo {
let cpi = build_cpi(0x1011, &[(0, 0, 0)], &[(10, 1000), (20, 2000), (30, 3000)]);
let data = build_clpi(200_000, Some(&cpi));
parse(&data).expect("should parse")
}
/// An out_time past the LAST EP entry must extend to the end of the
/// clip, not stop at the last entry point.
///
/// EP entries mark I-frames (BD Part 3, CPI / EP map): the final GOP of
/// a clip lies after the last EP entry, and a PlayItem's OUT_time is the
/// presentation end, so out_time > last EP PTS is the ordinary case for
/// a whole-clip play item. Resolving that to `last_spn + 1` would return
/// an extent covering ~1 source packet past the last I-frame and drop
/// every packet after it. The clip end is `source_packet_count`.
#[test]
fn get_extents_out_time_past_last_ep_covers_clip_tail() {
let clip = three_point_clip();
let extents = clip.get_extents(2560, u64::MAX);
assert_eq!(extents.len(), 1);
// start: SPN 1000 × 192 = 192_000 bytes, floor(/2048) = sector 93.
assert_eq!(extents[0].start_lba, 93);
// end: SPN 200_000 × 192 = 38_400_000 bytes, ceil(/2048) = 18750.
assert_eq!(extents[0].sector_count, 18750 - 93);
// The extent must actually reach the last byte of the clip.
let last_byte = clip.source_packet_count as u64 * BD_SOURCE_PACKET_BYTES as u64;
let end_sector = (extents[0].start_lba + extents[0].sector_count) as u64;
assert!(
end_sector * SECTOR_BYTES_U64 >= last_byte,
"extent stops at sector {end_sector} but the clip runs to byte {last_byte}"
);
}
/// An out_time that falls strictly BETWEEN two EP entries resolves to
/// the next entry (the SPN at-or-after out_time), not to the clip end.
#[test]
fn get_extents_out_time_between_entries_uses_next_ep() {
let clip = three_point_clip();
// 6000 lies between EP PTS 5120 (SPN 2000) and 7680 (SPN 3000).
let extents = clip.get_extents(2560, 6000);
assert_eq!(extents.len(), 1);
assert_eq!(extents[0].start_lba, 93);
// end: SPN 3000 × 192 = 576_000 bytes, ceil(/2048) = 282.
assert_eq!(extents[0].sector_count, 282 - 93);
}
/// An in_time that falls strictly BETWEEN two EP entries resolves to the
/// PRECEDING entry (decoding must start at an entry point at or before
/// the requested time), and the SPN→byte→sector arithmetic is
/// ×BD_SOURCE_PACKET_BYTES then floor/ceil ÷SECTOR_BYTES_U64.
#[test]
fn get_extents_in_time_between_entries_uses_previous_ep() {
let clip = three_point_clip();
// 3000 lies between EP PTS 2560 (SPN 1000) and 5120 (SPN 2000):
// the preceding entry point is SPN 1000 → sector floor(192000/2048)
// = 93. Picking the FOLLOWING entry (SPN 2000 → sector 187) would
// start the extent after the I-frame the decoder needs.
let extents = clip.get_extents(3000, 6000);
assert_eq!(extents.len(), 1);
assert_eq!(extents[0].start_lba, 93);
assert_eq!(extents[0].sector_count, 282 - 93);
}
// ─────────────────────────────────────────────────────────────────────
// Section-offset gates in `parse`.
// ─────────────────────────────────────────────────────────────────────
/// A prog_info_start of 0 means "no ProgramInfo section" — the CLPI
/// header bytes at offset 0 must NOT be reinterpreted as a ProgramInfo
/// table. The fixture is crafted so that parsing from offset 0 WOULD
/// yield a stream (num_programs at [5], a second program header whose
/// num_streams byte at [20] is 1, then a well-formed stream record), so
/// the empty result can only come from the `prog_info_start > 0` gate.
#[test]
fn prog_info_start_zero_does_not_parse_header_as_program_info() {
let mut data = build_clpi(1000, None);
data[5] = 2; // num_programs = 2 if read from offset 0
// program 0 header = data[6..14]; its num_streams byte is data[12],
// which is prog_info_start's first byte and must stay 0.
data[20] = 1; // program 1 (header data[14..22]) declares 1 stream
data[22..24].copy_from_slice(&0x1011u16.to_be_bytes()); // pid
data[24] = 2; // sci_len
data[25] = 0x1B; // coding_type H.264
data[26] = 0x61; // video format 6 / rate 1
let clip = parse(&data).expect("should parse");
assert!(
clip.streams.is_empty(),
"prog_info_start == 0 must mean absent, got {:?}",
clip.streams
);
}
/// A cpi_start of 0 means "no CPI section" — the CLPI header bytes must
/// not be reinterpreted as an EP map. The fixture sets prog_info_start
/// to 0x0004_0000 purely so that, read as the 80-bit stream entry at
/// data[10..18], it decodes to num_EP_coarse = 1 and a coarse entry
/// would be produced. Only the `cpi_start > 0` gate keeps it empty.
#[test]
fn cpi_start_zero_does_not_parse_header_as_ep_map() {
let mut data = build_clpi(1000, None);
data[13] = 0x04; // → num_EP_coarse = 1 when data[10..18] is read as
// the stream PID entry
let clip = parse(&data).expect("should parse");
assert!(
clip.ep_coarse.is_empty(),
"cpi_start == 0 must mean absent, got {:?}",
clip.ep_coarse
);
assert!(clip.ep_fine.is_empty());
}
/// A section offset that points INSIDE the 60-byte CLPI header (a
/// hostile-disc value smaller than the header itself) must be handled
/// without panicking; here both offsets are 3 and both sections decode
/// to nothing.
#[test]
fn section_offsets_inside_header_do_not_panic() {
let mut data = build_clpi(1000, None);
data[12..16].copy_from_slice(&3u32.to_be_bytes()); // prog_info_start
data[16..20].copy_from_slice(&3u32.to_be_bytes()); // cpi_start
let clip = parse(&data).expect("should not panic");
assert!(clip.streams.is_empty());
assert!(clip.ep_coarse.is_empty());
assert!(clip.ep_fine.is_empty());
}
// ─────────────────────────────────────────────────────────────────────
// parse_program_info
// ─────────────────────────────────────────────────────────────────────
/// Secondary audio (0xA1 AC-3+ secondary, 0xA2 DTS-HD secondary) has the
/// same stream_coding_info layout as primary audio: sci[1] carries
/// audio_presentation_type in the high nibble and sampling_frequency in
/// the low nibble, sci[2..5] the ISO 639-2 language. Both sub-fields and
/// the language must be populated.
#[test]
fn program_info_secondary_audio_fields() {
for coding in [c_ac3_plus_secondary(), c_dts_hd_secondary()] {
let sci = vec![coding, 0x61, b'd', b'e', b'u'];
let pi = build_program_info(&[(0x1A00, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 1, "coding {coding:#04x}");
let s = &clip.streams[0];
assert_eq!(s.coding_type, coding);
// 0x61: high nibble 6, low nibble 1 — distinct values, so a
// swapped/ORed/XORed nibble extraction cannot pass.
assert_eq!(s.audio_format, 6, "coding {coding:#04x}");
assert_eq!(s.audio_rate, 1, "coding {coding:#04x}");
assert_eq!(s.language, "deu", "coding {coding:#04x}");
}
}
fn c_ac3_plus_secondary() -> u8 {
crate::consts::coding_type::AC3_PLUS_SECONDARY
}
fn c_dts_hd_secondary() -> u8 {
crate::consts::coding_type::DTS_HD_SECONDARY
}
/// A stream_coding_info of exactly 1 byte (coding_type only) is the
/// minimum the parser accepts: the stream is recorded with its PID and
/// coding_type, and every sub-field that needs more bytes stays empty.
/// Notably a PG stream must NOT read sci[1..4] when only sci[0] exists.
#[test]
fn program_info_sci_len_one_yields_bare_stream() {
let pi = build_program_info(&[(0x1200, vec![0x90u8])]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should not panic");
assert_eq!(clip.streams.len(), 1);
assert_eq!(clip.streams[0].pid, 0x1200);
assert_eq!(clip.streams[0].coding_type, 0x90);
assert_eq!(clip.streams[0].language, "");
}
/// Below the 6-byte ProgramInfo header (length(4)+reserved(1)+
/// num_programs(1)) there is nothing to read; the length guard must fire
/// before `data[5]`.
#[test]
fn program_info_below_header_size_is_empty() {
for len in 0..6usize {
assert!(parse_program_info(&vec![0u8; len]).is_empty(), "len={len}");
}
}
/// A declared program whose 8-byte header runs past the section end must
/// stop before reading num_streams at `data[pos + 6]`.
#[test]
fn program_info_truncated_program_header_is_empty() {
// length(4) + reserved(1) + num_programs=1 (1) + only 4 of the 8
// program-header bytes.
let mut data = vec![0u8; 6];
data[5] = 1;
data.extend_from_slice(&[0u8; 4]);
assert!(parse_program_info(&data).is_empty());
}
/// A declared stream whose 3-byte header (pid(2)+sci_len(1)) runs past
/// the section end must stop before reading the PID.
#[test]
fn program_info_truncated_stream_header_is_empty() {
let mut data = vec![0u8; 6];
data[5] = 1; // num_programs
data.extend_from_slice(&[0u8; 8]); // program header
data[6 + 6] = 1; // num_streams = 1
data.extend_from_slice(&[0u8; 2]); // only 2 of the 3 stream bytes
assert_eq!(data.len(), 16);
assert!(parse_program_info(&data).is_empty());
}
// ─────────────────────────────────────────────────────────────────────
// parse_cpi — low-level fixtures
// ─────────────────────────────────────────────────────────────────────
/// Pack the 80-bit stream PID entry body (ep_map[4..14]):
/// reserved(10) + EP_stream_type(4) + num_EP_coarse(16) +
/// num_EP_fine(18) + EP_map_start(32).
fn pack_stream_header(num_coarse: u32, num_fine: u32, ep_map_start: u32) -> [u8; 10] {
let packed: u128 = (1u128 << 66) // EP_stream_type = 1 (video)
| ((num_coarse as u128) << 50)
| ((num_fine as u128) << 32)
| (ep_map_start as u128);
let b = packed.to_be_bytes();
let mut out = [0u8; 10];
out.copy_from_slice(&b[6..16]);
out
}
/// Assemble a CPI section around one stream EP map.
/// `cpi_length` overrides the declared length field (default: exact).
/// `trailing` is appended AFTER the section, to model bytes belonging to
/// a neighbouring CLPI section.
fn assemble_cpi(
header: &[u8; 10],
stream_ep: &[u8],
cpi_length: Option<u32>,
trailing: &[u8],
) -> Vec<u8> {
let mut ep_map = Vec::new();
ep_map.push(0u8); // reserved
ep_map.push(1u8); // num_stream_pid_entries
ep_map.extend_from_slice(&0x1011u16.to_be_bytes());
ep_map.extend_from_slice(header);
ep_map.extend_from_slice(stream_ep);
let declared = cpi_length.unwrap_or((2 + ep_map.len()) as u32);
let mut cpi = Vec::new();
cpi.extend_from_slice(&declared.to_be_bytes());
cpi.extend_from_slice(&[0u8; 2]); // reserved + CPI_type
cpi.extend_from_slice(&ep_map);
cpi.extend_from_slice(trailing);
cpi
}
/// Below the 8-byte minimum there is no CPI section to read; the guard
/// must fire before the 4-byte length field is decoded.
#[test]
fn parse_cpi_below_8_bytes_is_empty() {
for len in 0..8usize {
let (coarse, fine) = parse_cpi(&vec![0u8; len]).expect("no error");
assert!(coarse.is_empty() && fine.is_empty(), "len={len}");
}
}
/// EP-map reads are bounded by the DECLARED cpi_length, not by the rest
/// of the file. A CPI section that declares room for one coarse entry
/// must yield one entry even when a second entry's worth of bytes
/// follows in the adjacent section.
#[test]
fn cpi_length_clamps_reads_to_the_section() {
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&20u32.to_be_bytes()); // fine_start (past end)
// coarse 0: dword0 = ref_to_fine_id 0 | pts_coarse 0x11, spn 0x20000
stream_ep.extend_from_slice(&0x11u32.to_be_bytes());
stream_ep.extend_from_slice(&0x20000u32.to_be_bytes());
// coarse 1 — inside the file, but OUTSIDE the declared section.
stream_ep.extend_from_slice(&0x22u32.to_be_bytes());
stream_ep.extend_from_slice(&0x40000u32.to_be_bytes());
// Declared length covers reserved(2) + ep_map header(14) +
// fine_start(4) + ONE coarse entry(8) = 28.
let hdr = pack_stream_header(2, 0, 14);
let cpi = assemble_cpi(&hdr, &stream_ep, Some(28), &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should parse");
assert_eq!(
clip.ep_coarse.len(),
1,
"second entry is outside cpi_length"
);
assert_eq!(clip.ep_coarse[0].pts_coarse, 0x11);
}
/// An EP map too short to hold the 16-byte (2 + 14) stream PID entry
/// must yield empty maps rather than decoding the 80-bit entry body.
#[test]
fn ep_map_shorter_than_stream_entry_is_empty() {
// Declared cpi_length 14 → section is 18 bytes → ep_map is 12 bytes,
// short of the 16 needed, but num_stream_pid_entries is non-zero.
let hdr = pack_stream_header(1, 1, 14);
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&4u32.to_be_bytes());
stream_ep.extend_from_slice(&0u64.to_be_bytes());
let cpi = assemble_cpi(&hdr, &stream_ep, Some(14), &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should not panic");
assert!(clip.ep_coarse.is_empty());
assert!(clip.ep_fine.is_empty());
}
/// The LAST coarse entry may end exactly at the end of the coarse table
/// (a clip with no fine entries). The loop bound is `off + 8 > len`, so
/// an entry finishing precisely at `len` is still read.
#[test]
fn last_coarse_entry_ending_at_table_end_is_kept() {
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&20u32.to_be_bytes()); // fine_start == stream_ep.len()
stream_ep.extend_from_slice(&0x11u32.to_be_bytes());
stream_ep.extend_from_slice(&0x20000u32.to_be_bytes());
stream_ep.extend_from_slice(&0x22u32.to_be_bytes());
stream_ep.extend_from_slice(&0x40000u32.to_be_bytes());
assert_eq!(stream_ep.len(), 20);
let hdr = pack_stream_header(2, 0, 14);
let cpi = assemble_cpi(&hdr, &stream_ep, None, &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should parse");
assert_eq!(clip.ep_coarse.len(), 2, "last coarse entry was dropped");
assert_eq!(clip.ep_coarse[0].pts_coarse, 0x11);
assert_eq!(clip.ep_coarse[1].pts_coarse, 0x22);
assert_eq!(clip.ep_coarse[1].spn_coarse, 0x40000);
}
/// A fine-table start address past the end of the stream EP map yields
/// no fine entries — and must not compute a negative remaining length.
#[test]
fn fine_start_past_stream_ep_yields_no_fine_entries() {
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&1000u32.to_be_bytes()); // fine_start ≫ len
stream_ep.extend_from_slice(&0x11u32.to_be_bytes());
stream_ep.extend_from_slice(&0x20000u32.to_be_bytes());
let hdr = pack_stream_header(1, 4, 14);
let cpi = assemble_cpi(&hdr, &stream_ep, None, &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should not panic");
assert_eq!(clip.ep_coarse.len(), 1);
assert!(clip.ep_fine.is_empty());
}
/// num_EP_fine bounds the fine-entry read. It is an 18-bit field packed
/// directly below num_EP_coarse in the 80-bit stream PID entry, so the
/// count must be masked out of its neighbours: with num_EP_coarse = 1
/// the bits above 18 are set, and a count that picked them up would run
/// on and swallow the four dwords of trailing section bytes instead of
/// the two entries the header declares.
#[test]
fn num_fine_is_masked_to_18_bits_and_bounds_the_read() {
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&12u32.to_be_bytes()); // fine_start
// one coarse entry (8 bytes) so num_EP_coarse = 1 sets the bits
// immediately above the num_EP_fine field
stream_ep.extend_from_slice(&0x11u32.to_be_bytes());
stream_ep.extend_from_slice(&0x20000u32.to_be_bytes());
// FOUR fine dwords present, but only TWO declared.
for (pts, spn) in [(7u32, 0x111u32), (9, 0x222), (11, 0x333), (13, 0x444)] {
stream_ep.extend_from_slice(&((pts << 17) | spn).to_be_bytes());
}
let hdr = pack_stream_header(1, 2, 14);
let cpi = assemble_cpi(&hdr, &stream_ep, None, &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should parse");
assert_eq!(clip.ep_coarse.len(), 1);
assert_eq!(clip.ep_fine.len(), 2, "read past the declared num_EP_fine");
assert_eq!(clip.ep_fine[0].pts_fine, 7);
assert_eq!(clip.ep_fine[0].spn_fine, 0x111);
assert_eq!(clip.ep_fine[1].pts_fine, 9);
assert_eq!(clip.ep_fine[1].spn_fine, 0x222);
}
/// An EP map of exactly 16 bytes is the minimum that holds the 2-byte
/// EP-map header plus one 14-byte stream PID entry, so it must be read,
/// not rejected. Built byte-by-byte (stream_PID 0, EP_stream_type 0)
/// so that EP_map_start = 2 lands the stream EP map on a zero
/// fine-table start address and one fine entry is decoded.
#[test]
fn ep_map_of_exactly_16_bytes_is_read() {
// 80-bit stream entry: reserved(10)+EP_stream_type(4)+
// num_EP_coarse(16)+num_EP_fine(18)+EP_map_start(32), all zero
// except num_EP_fine = 1 and EP_map_start = 2.
let packed: u128 = (1u128 << 32) | 2;
let b = packed.to_be_bytes();
let mut ep_map = vec![0u8, 1u8, 0u8, 0u8]; // reserved, 1 entry, PID 0
ep_map.extend_from_slice(&b[6..16]);
ep_map.extend_from_slice(&[0u8, 0u8]);
assert_eq!(ep_map.len(), 16);
let mut cpi = Vec::new();
cpi.extend_from_slice(&((2 + ep_map.len()) as u32).to_be_bytes());
cpi.extend_from_slice(&[0u8; 2]);
cpi.extend_from_slice(&ep_map);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should parse");
assert_eq!(clip.ep_fine.len(), 1);
assert!(clip.ep_coarse.is_empty());
}
/// EP_map_start is 32 bits wide and straddles the hi/lo split of the
/// 80-bit stream PID entry: its high 16 bits come from `hi`, its low 16
/// from the trailing two bytes. A section whose stream EP map starts at
/// 0x1_0000 (beyond what the low half alone can express) must be found
/// at that offset — the low half here is zero, so dropping the high
/// half would resolve the offset to 0.
#[test]
fn ep_map_start_above_16_bits_is_honoured() {
const START: usize = 0x1_0000;
let hdr = pack_stream_header(1, 0, START as u32);
let mut stream_ep = vec![0u8; START - 14]; // pad so the real map lands at START
stream_ep.extend_from_slice(&12u32.to_be_bytes()); // fine_start == len
stream_ep.extend_from_slice(&0x0000_2AAAu32.to_be_bytes()); // pts_coarse
stream_ep.extend_from_slice(&0x5555_0000u32.to_be_bytes()); // spn_coarse
let cpi = assemble_cpi(&hdr, &stream_ep, None, &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should parse");
assert_eq!(clip.ep_coarse.len(), 1);
assert_eq!(clip.ep_coarse[0].pts_coarse, 0x2AAA);
assert_eq!(clip.ep_coarse[0].spn_coarse, 0x5555_0000);
assert!(clip.ep_fine.is_empty());
}
/// EP_map_start is a 32-bit disc field; a value below 4 points back
/// into the stream PID entry table itself. The bounds check must handle
/// it without underflowing, and the read must stay inside the CPI
/// section. With EP_map_start = 0 the "stream EP map" is the whole EP
/// map, so the one declared coarse entry decodes out of the 80-bit
/// stream entry body — garbage, but bounded and deterministic.
#[test]
fn ep_map_start_below_4_does_not_underflow() {
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&4u32.to_be_bytes());
stream_ep.extend_from_slice(&[0u8; 8]);
let hdr = pack_stream_header(1, 0, 0); // EP_map_start = 0
let cpi = assemble_cpi(&hdr, &stream_ep, None, &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should not panic");
assert_eq!(clip.ep_coarse.len(), 1);
// dword0 = the first 4 bytes of the 80-bit stream entry body:
// 0x0004_0004 → ref_to_fine_id = 16, pts_coarse = 4.
assert_eq!(clip.ep_coarse[0].ref_to_fine_id, 16);
assert_eq!(clip.ep_coarse[0].pts_coarse, 4);
assert_eq!(clip.ep_coarse[0].spn_coarse, 0);
assert!(clip.ep_fine.is_empty());
}
/// A stream EP map occupying exactly the last 4 bytes of the EP map
/// (EP_map_start + 4 == ep_map.len()) is in bounds and is read: the
/// bound is `>`, not `>=`. Here fine_start is 0, so the fine table
/// overlaps the stream EP map's own header word — degenerate, but it
/// must stay inside the section and yield exactly one (zero) entry
/// rather than panicking or reading past the CPI section.
#[test]
fn stream_ep_map_at_section_end_is_read() {
let stream_ep = [0u8; 4]; // fine_start = 0
let hdr = pack_stream_header(0, 1, 14);
let cpi = assemble_cpi(&hdr, &stream_ep, None, &[]);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should not panic");
assert!(clip.ep_coarse.is_empty());
assert_eq!(clip.ep_fine.len(), 1);
assert_eq!(clip.ep_fine[0].pts_fine, 0);
assert_eq!(clip.ep_fine[0].spn_fine, 0);
}
}
+413
View File
@@ -1923,4 +1923,417 @@ mod tests {
title.chapter_times[0]
);
}
// ─────────────────────────────────────────────────────────────────────
// Added: cell-category bit isolation, palette/program-map arithmetic,
// and the malformed-language salvage paths.
// ─────────────────────────────────────────────────────────────────────
/// Each low flag of the cell-category byte comes from its OWN bit and no
/// other: bit3 seamless_play, bit2 interleaved, bit1 stc_discontinuity,
/// bit0 seamless_angle (DVD-Video cell playback `cell_category`).
/// Setting one bit must leave all three siblings clear.
#[test]
fn cell_category_low_flags_are_bit_isolated() {
let c = CellCategory::decode(0x00);
assert_eq!(
[
c.seamless_play,
c.interleaved,
c.stc_discontinuity,
c.seamless_angle
],
[false, false, false, false],
"category 0x00 sets no flag"
);
for bit in 0..4u8 {
let c = CellCategory::decode(1u8 << bit);
assert_eq!(
[
c.seamless_play,
c.interleaved,
c.stc_discontinuity,
c.seamless_angle
],
[bit == 3, bit == 2, bit == 1, bit == 0],
"only bit {bit} may be set"
);
}
}
/// `is_plain_feature` requires BOTH block_mode and block_type to be zero.
/// A cell inside a block is not plain feature content even when the other
/// field happens to be zero.
#[test]
fn is_plain_feature_requires_both_block_fields_zero() {
assert!(CellCategory::decode(0x00).is_plain_feature());
// block_type = 1, block_mode = 0
assert!(!CellCategory::decode(0b0001_0000).is_plain_feature());
// block_mode = 1, block_type = 0
assert!(!CellCategory::decode(0b0100_0000).is_plain_feature());
assert!(!CellCategory::decode(0b0101_0000).is_plain_feature());
}
/// be_u32 reads four CONSECUTIVE big-endian bytes from `offset`.
/// All four values are distinct so a repeated or skipped index shows up.
#[test]
fn be_u32_reads_four_consecutive_be_bytes() {
let data = [0xFFu8, 0x01, 0x02, 0x03, 0x04, 0xFF];
assert_eq!(be_u32(&data, 1).unwrap(), 0x0102_0304);
}
// ── malformed language codes ─────────────────────────────────────────
/// The ISO 639 language code in the audio/subtitle attribute block is
/// two bytes at +2. Only a pair of lowercase a-z bytes is taken verbatim;
/// anything else falls through to the ASCII-alphanumeric salvage, which
/// keeps only the usable characters. A byte outside a-z must never end up
/// in the language string.
#[test]
fn language_code_rejects_non_lowercase_bytes() {
// (byte0, byte1, expected language)
let cases: [(u8, u8, &str); 8] = [
(b'e', b'n', "en"), // both in range → verbatim
(0x21, b'n', "n"), // '!' is below 'a'
(0x7B, b'n', "n"), // '{' is above 'z'
(b'e', 0x21, "e"), // second byte below 'a'
(b'e', 0x7B, "e"), // second byte above 'z'
(b'E', 0x00, "E"), // only the second byte is zero
(0x00, b'E', "E"), // only the first byte is zero
(0x00, 0x00, ""), // both zero → unset
];
for (b0, b1, want) in cases {
let mut audio = vec![0u8; 8];
audio[2] = b0;
audio[3] = b1;
assert_eq!(
parse_audio_attr(&audio, 0).unwrap().language,
want,
"audio language for ({b0:#04x}, {b1:#04x})"
);
let mut sub = vec![0u8; 6];
sub[2] = b0;
sub[3] = b1;
assert_eq!(
parse_subtitle_attr(&sub, 0).unwrap().language,
want,
"subtitle language for ({b0:#04x}, {b1:#04x})"
);
}
}
// ── PGC fixtures ─────────────────────────────────────────────────────
/// Build a standalone PGC starting at offset 0.
///
/// Layout: 0xEA-byte header, then the program map (one byte per program,
/// the 1-based first cell number), then the 24-byte cell playback table.
/// `cells` are `(category, BCD time, first_sector, last_sector)`.
fn build_pgc(
pgc_time: [u8; 4],
cells: &[(u8, [u8; 4], u32, u32)],
programs: &[u8],
palette: Option<[[u8; 4]; 16]>,
) -> Vec<u8> {
let mut d = vec![0u8; 0xEA];
d[0x02] = programs.len() as u8;
d[0x03] = cells.len() as u8;
d[0x04..0x08].copy_from_slice(&pgc_time);
if let Some(p) = palette {
for (i, c) in p.iter().enumerate() {
d[0xA4 + i * 4..0xA4 + i * 4 + 4].copy_from_slice(c);
}
}
let pgm_off = 0xEAusize;
let cell_off = pgm_off + programs.len();
if !programs.is_empty() {
d[0xE6..0xE8].copy_from_slice(&(pgm_off as u16).to_be_bytes());
}
if !cells.is_empty() {
d[0xE8..0xEA].copy_from_slice(&(cell_off as u16).to_be_bytes());
}
d.extend_from_slice(programs);
for &(cat, time, first, last) in cells {
let mut c = vec![0u8; 24];
c[0] = cat;
c[4..8].copy_from_slice(&time);
c[8..12].copy_from_slice(&first.to_be_bytes());
c[20..24].copy_from_slice(&last.to_be_bytes());
d.extend_from_slice(&c);
}
d
}
/// BCD playback time of `secs` seconds (< 60) at the 29.97 fps flag.
fn bcd_secs(secs: u8) -> [u8; 4] {
assert!(secs < 60);
[0, 0, ((secs / 10) << 4) | (secs % 10), 0b11_000000]
}
/// When the PGC-level playback time is zero, the duration is recomputed
/// as the SUM of every cell's own BCD time, each read from its own
/// 24-byte cell playback record at +4.
#[test]
fn pgc_zero_duration_recomputed_as_sum_of_cell_times() {
let pgc = build_pgc(
[0, 0, 0, 0],
&[
(0x00, bcd_secs(10), 100, 199),
(0x00, bcd_secs(20), 200, 299),
(0x00, bcd_secs(31), 300, 399),
],
&[],
None,
);
let title = parse_pgc(&pgc, 0, 3).unwrap();
assert_eq!(title.cells.len(), 3);
assert_eq!(title.cells[0].duration_secs, 10.0);
assert_eq!(title.cells[1].duration_secs, 20.0);
assert_eq!(title.cells[2].duration_secs, 31.0);
assert_eq!(
title.duration_secs, 61.0,
"recomputed duration must be the sum of the distinct cell times"
);
}
/// Chapter times come from the program map: each program's byte is the
/// 1-based number of its first cell, and the chapter time is the sum of
/// the durations of every cell BEFORE it. Distinct cell durations mean a
/// misread program byte or a mis-strided cell table changes the result.
#[test]
fn pgc_chapter_times_sum_preceding_cell_durations() {
let pgc = build_pgc(
bcd_secs(59),
&[
(0x00, bcd_secs(10), 0, 9),
(0x00, bcd_secs(20), 10, 19),
(0x00, bcd_secs(31), 20, 29),
],
&[1, 2, 3],
None,
);
let title = parse_pgc(&pgc, 0, 3).unwrap();
assert_eq!(title.chapter_times, vec![0.0, 10.0, 30.0]);
}
/// A program map offset of 0 means there is no program map, so no chapter
/// times may be produced — the PGC header must not be read as one. Same
/// for a cell playback offset of 0.
#[test]
fn pgc_absent_program_map_or_cell_table_yields_no_chapter_times() {
// programs declared, but pgm_map_offset patched to 0
let mut pgc = build_pgc(
bcd_secs(30),
&[(0x00, bcd_secs(10), 0, 9), (0x00, bcd_secs(20), 10, 19)],
&[1, 2],
None,
);
pgc[0xE6..0xE8].copy_from_slice(&0u16.to_be_bytes());
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert!(
title.chapter_times.is_empty(),
"no program map → no chapter times, got {:?}",
title.chapter_times
);
// program map present, but cell_playback_offset patched to 0
let mut pgc = build_pgc(
bcd_secs(30),
&[(0x00, bcd_secs(10), 0, 9), (0x00, bcd_secs(20), 10, 19)],
&[1, 2],
None,
);
pgc[0xE8..0xEA].copy_from_slice(&0u16.to_be_bytes());
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert!(title.cells.is_empty());
assert!(
title.chapter_times.is_empty(),
"no cell table → no chapter times, got {:?}",
title.chapter_times
);
}
/// A declared cell count larger than the cell table actually holds must
/// not read past the end while collecting durations for the chapter-time
/// calculation; the missing cells contribute zero.
#[test]
fn pgc_cell_count_overshoot_does_not_read_past_end() {
let mut pgc = build_pgc(
bcd_secs(30),
&[(0x00, bcd_secs(10), 0, 9), (0x00, bcd_secs(20), 10, 19)],
&[1, 2],
None,
);
pgc[0x03] = 8; // declare 8 cells; only 2 records exist
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(title.cells.len(), 2, "only the readable cells are kept");
// Program 2 starts at cell 2, so its time is cell 0's duration.
assert_eq!(title.chapter_times, vec![0.0, 10.0]);
}
/// A program map that runs past the end of the data must stop at the
/// buffer end rather than reading past it. The fixture's map begins at
/// PGC+0xEA and the buffer holds 50 bytes from there, so a declared
/// count of 255 programs must yield exactly 50 chapter times.
#[test]
fn pgc_program_map_past_end_stops() {
let mut pgc = build_pgc(
bcd_secs(30),
&[(0x00, bcd_secs(10), 0, 9), (0x00, bcd_secs(20), 10, 19)],
&[1, 2],
None,
);
pgc[0x02] = 255; // declare 255 programs
let available = pgc.len() - 0xEA;
assert_eq!(available, 50, "fixture: 2 map bytes + 2 cells of 24");
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(
title.chapter_times.len(),
available,
"the program map walk must stop exactly at the buffer end"
);
assert_eq!(title.chapter_times[0], 0.0);
assert_eq!(title.chapter_times[1], 10.0);
}
/// The subtitle palette is 16 entries of 4 bytes at PGC+0xA4, each
/// `[padding, Y, Cb, Cr]`. Every byte of every entry is distinct here, so
/// a wrong stride, a wrong base or a shifted component shows up.
#[test]
fn pgc_palette_entries_read_at_correct_stride() {
let mut pal = [[0u8; 4]; 16];
for (i, c) in pal.iter_mut().enumerate() {
let b = i as u8;
*c = [0x10 + b, 0x30 + b, 0x50 + b, 0x70 + b];
}
let pgc = build_pgc(bcd_secs(30), &[], &[], Some(pal));
let title = parse_pgc(&pgc, 0, 1).unwrap();
let got = title.palette.expect("palette present");
assert_eq!(got.len(), 16);
for i in 0..16 {
let b = i as u8;
assert_eq!(
got[i],
[0x10 + b, 0x30 + b, 0x50 + b, 0x70 + b],
"palette entry {i}"
);
}
}
/// A palette is "present" when ANY of Y, Cb or Cr is non-zero in ANY
/// entry — a single non-zero chroma component is enough. Only the
/// padding byte [0] is ignored.
#[test]
fn pgc_palette_present_on_any_single_nonzero_component() {
for comp in 1..4usize {
let mut pal = [[0u8; 4]; 16];
pal[7][comp] = 0x40; // exactly one non-zero component, in one entry
let pgc = build_pgc(bcd_secs(30), &[], &[], Some(pal));
let title = parse_pgc(&pgc, 0, 1).unwrap();
assert!(
title.palette.is_some(),
"component {comp} alone must mark the palette present"
);
}
// Only the padding byte set → still empty.
let mut pal = [[0u8; 4]; 16];
for c in pal.iter_mut() {
c[0] = 0xFF;
}
let pgc = build_pgc(bcd_secs(30), &[], &[], Some(pal));
assert!(parse_pgc(&pgc, 0, 1).unwrap().palette.is_none());
}
// ── PGCIT ────────────────────────────────────────────────────────────
/// Build a VTS_PGCIT at offset 0: VTS_PGC_Ns(2) + reserved(2) +
/// VTS_PGCIT_EA(4), then one 8-byte VTS_PGCI_SRP per PGC
/// (VTS_PGC_CAT(4) + VTS_PGCI_SA(4), the PGC's byte offset from the
/// VTS_PGCIT start). `pgcs` are appended after the SRP table.
fn build_pgcit(pgcs: &[Vec<u8>]) -> Vec<u8> {
let mut d = vec![0u8; 8 + pgcs.len() * 8];
d[0..2].copy_from_slice(&(pgcs.len() as u16).to_be_bytes());
let mut off = d.len();
for (i, p) in pgcs.iter().enumerate() {
let e = 8 + i * 8;
d[e + 4..e + 8].copy_from_slice(&(off as u32).to_be_bytes());
off += p.len();
}
for p in pgcs {
d.extend_from_slice(p);
}
d
}
/// Each VTS_PGCI_SRP is 8 bytes and the PGC start address is the second
/// word of its own entry, so title N must resolve to PGC N. Two PGCs
/// with distinct durations catch an entry read at the wrong stride or
/// from the wrong entry.
#[test]
fn pgcit_entry_stride_selects_the_right_pgc() {
let pgc0 = build_pgc(bcd_secs(11), &[(0x00, bcd_secs(11), 0, 9)], &[], None);
let pgc1 = build_pgc(bcd_secs(22), &[(0x00, bcd_secs(22), 50, 59)], &[], None);
let data = build_pgcit(&[pgc0, pgc1]);
// vts_title_num is 1-based: title 2 → PGC index 1.
let titles = parse_pgcit(&data, 0, &[(5, 2)]).unwrap();
assert_eq!(titles.len(), 1);
assert_eq!(titles[0].duration_secs, 22.0);
assert_eq!(titles[0].cells[0].first_sector, 50);
let titles = parse_pgcit(&data, 0, &[(5, 1)]).unwrap();
assert_eq!(titles.len(), 1);
assert_eq!(titles[0].duration_secs, 11.0);
assert_eq!(titles[0].cells[0].first_sector, 0);
// Both titles, in order.
let titles = parse_pgcit(&data, 0, &[(5, 1), (7, 2)]).unwrap();
assert_eq!(titles.len(), 2);
assert_eq!(titles[0].duration_secs, 11.0);
assert_eq!(titles[1].duration_secs, 22.0);
}
/// A VTS_PGCIT whose 8-byte header ends exactly at the end of the data is
/// a complete header with no SRP entries: an empty title list, not an
/// error. Below 8 bytes the header itself is truncated → IfoParse.
#[test]
fn pgcit_header_boundary() {
let data = vec![0u8; 8];
let titles = parse_pgcit(&data, 0, &[(5, 1)]).expect("complete header parses");
assert!(titles.is_empty());
for len in 0..8usize {
assert!(
parse_pgcit(&vec![0u8; len], 0, &[(5, 1)]).is_err(),
"len={len}"
);
}
}
/// An SRP entry that runs past the end of the data is SKIPPED, leaving
/// an empty title list — a truncated entry table must not turn into a
/// parse error for the whole PGCIT.
#[test]
fn pgcit_entry_past_end_is_skipped_not_an_error() {
// 8-byte header declaring 3 PGCs, then only 12 bytes of table: the
// entry for PGC index 2 (offset 24..32) is entirely past the end.
let mut data = vec![0u8; 20];
data[0..2].copy_from_slice(&3u16.to_be_bytes());
let titles = parse_pgcit(&data, 0, &[(5, 3)])
.expect("a truncated SRP entry is skipped, not an error");
assert!(titles.is_empty());
}
/// A TT_SRP entry is 12 bytes: playback_type(1) + angles(1) +
/// number_of_PTTs(2) + parental_mask(2) + VTSN(1) + VTS_TTN(1) +
/// VTS_start_sector(4). The chapter count is the 16-bit field at +2, and
/// each entry's own value must be carried through. Distinct counts per
/// entry catch a read from a neighbouring offset.
#[test]
fn tt_srpt_chapter_count_read_from_entry_offset_2() {
let data = tt_srpt_bytes(3, &[(7, 1, 1), (13, 1, 2), (0x0102, 2, 1)]);
let map = parse_tt_srpt(&data, 0).unwrap();
assert_eq!(map[&1], vec![(7u16, 1u8), (13, 2)]);
assert_eq!(map[&2], vec![(0x0102u16, 1u8)]);
}
}
+281
View File
@@ -1416,4 +1416,285 @@ mod tests {
data[8..12].copy_from_slice(&40u32.to_be_bytes()); // playlist_start = 40 = len
assert!(parse(&data).is_err());
}
// ─────────────────────────────────────────────────────────────────────
// Added: STN-table block alignment and section-boundary hardening.
// ─────────────────────────────────────────────────────────────────────
/// Build an MPLS from raw PlayItem bodies, with no PlayListMark section
/// (mark_start = 0). Lets a test control item_length exactly.
fn build_mpls_raw_items(items: &[Vec<u8>]) -> Vec<u8> {
let playlist_start: u32 = 40;
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&playlist_start.to_be_bytes());
buf.extend_from_slice(&[0u8; 28]); // mark_start = 0, then padding
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // PlayList length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&(items.len() as u16).to_be_bytes());
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
for it in items {
buf.extend_from_slice(&(it.len() as u16).to_be_bytes());
buf.extend_from_slice(it);
}
let pl_len = (buf.len() - pl_start - 4) as u32;
buf[pl_start..pl_start + 4].copy_from_slice(&pl_len.to_be_bytes());
buf
}
/// The 20 bytes a PlayItem needs for clip_id(5) + codec_id(4) +
/// connection_condition(1) + reserved(2) + IN_time(4) + OUT_time(4).
fn play_item_20(clip: &[u8; 5], cc: u8, in_t: u32, out_t: u32) -> Vec<u8> {
let mut it = Vec::new();
it.extend_from_slice(clip);
it.extend_from_slice(b"M2TS");
it.push(cc);
it.extend_from_slice(&[0u8; 2]);
it.extend_from_slice(&in_t.to_be_bytes());
it.extend_from_slice(&out_t.to_be_bytes());
assert_eq!(it.len(), 20);
it
}
/// A PlayItem body of exactly 20 bytes carries every field the parser
/// reads (the last is OUT_time at [16..20]), so it must be RECORDED,
/// not skipped — and it has no STN table, which starts at byte 32.
#[test]
fn play_item_of_exactly_20_bytes_is_recorded_without_stn() {
let data = build_mpls_raw_items(&[play_item_20(b"00007", 5, 90_000, 180_000)]);
let pl = parse(&data).expect("a 20-byte PlayItem must parse");
assert_eq!(pl.play_items.len(), 1);
assert_eq!(pl.play_items[0].clip_id, "00007");
assert_eq!(pl.play_items[0].in_time, 90_000);
assert_eq!(pl.play_items[0].out_time, 180_000);
assert_eq!(pl.play_items[0].connection_condition, 5);
assert!(pl.streams.is_empty(), "no STN table exists below byte 32");
}
/// A 40-byte MPLS whose PlayList section is exactly its 10-byte header
/// (length(4)+reserved(2)+num_play_items(2)+num_sub_paths(2)) ending at
/// EOF is structurally complete, not truncated: nothing the parser reads
/// lies past the buffer, so it must parse to an empty playlist.
#[test]
fn minimum_size_mpls_with_empty_playlist_header_parses() {
let mut data = vec![0u8; 40];
data[0..4].copy_from_slice(b"MPLS");
data[4..8].copy_from_slice(b"0200");
data[8..12].copy_from_slice(&30u32.to_be_bytes()); // playlist_start + 10 == 40
// mark_start (12..16) stays 0; num_play_items at data[36..38] is 0.
let pl = parse(&data).expect("40-byte MPLS with a complete PlayList header must parse");
assert!(pl.play_items.is_empty());
assert!(pl.streams.is_empty());
assert!(pl.marks.is_empty());
}
/// A mark_start of 0 means "no PlayListMark section". The file header
/// bytes at offset 0 must not be decoded as one — data[4..6] is the
/// version string "02", which as a big-endian num_marks would be 12338.
#[test]
fn mark_start_zero_does_not_parse_header_as_marks() {
let data = build_mpls_raw_items(&[play_item_20(b"00007", 1, 0, 90_000)]);
assert_eq!(
&data[12..16],
&[0, 0, 0, 0],
"fixture must have mark_start 0"
);
let pl = parse(&data).expect("should parse");
assert!(
pl.marks.is_empty(),
"mark_start == 0 must mean absent, got {} marks",
pl.marks.len()
);
}
/// Full STN table walk with every category populated and DISTINCT
/// counts, so no count byte can be read from a neighbour's offset
/// without changing the result.
///
/// Each secondary block is followed by its reference block(s), which
/// per the BD STN table are num_refs(1) + reserved(1) + one byte per
/// ref + one padding byte when the ref count is odd. Every ref count
/// here is 1 — the value that distinguishes `n % 2` (=1) from `n / 2`
/// (=0) — so a wrong skip length misaligns the cursor and every
/// following stream decodes from the wrong offset. IG entries are
/// consumed to keep the cursor aligned but never retained.
#[test]
fn full_stn_table_block_alignment() {
let mut entries: Vec<Vec<u8>> = Vec::new();
entries.push(build_stream_entry_video(0x1011, 0x1B, 6, 1, None));
entries.push(build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"));
entries.push(build_stream_entry_audio(0x1101, 0x86, 3, 1, b"fra"));
entries.push(build_stream_entry_pg(0x1200, 0x90, b"eng"));
entries.push(build_stream_entry_pg(0x1201, 0x90, b"fra"));
entries.push(build_stream_entry_pg(0x1202, 0x90, b"deu"));
for i in 0..4u16 {
entries.push(build_stream_entry_pg(0x1400 + i, 0x91, b"eng"));
}
// secondary audio + its secondary-audio ref block (1 ref → 1 pad)
let mut sec_audio = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"spa");
sec_audio.extend_from_slice(&[1, 0, 0x55, 0x00]);
entries.push(sec_audio);
// secondary video + audio-ref block + PiP-PG-ref block
let mut sec_video = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
sec_video.extend_from_slice(&[1, 0, 0x55, 0x00]);
sec_video.extend_from_slice(&[1, 0, 0x66, 0x00]);
entries.push(sec_video);
// PiP PG + its ref block
let mut pip_pg = build_stream_entry_pg(0x1C00, 0x90, b"jpn");
pip_pg.extend_from_slice(&[1, 0, 0x77, 0x00]);
entries.push(pip_pg);
// Dolby Vision enhancement layer
entries.push(build_stream_entry_video(0x1015, 0x24, 8, 1, Some(0x12)));
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 2, 3, 4, 1, 1, 1, 1),
&entries,
);
let pl = parse(&data).expect("should parse");
let got: Vec<(u8, u16, bool)> = pl
.streams
.iter()
.map(|s| (s.stream_type, s.pid, s.secondary))
.collect();
assert_eq!(
got,
vec![
(1, 0x1011, false), // primary video
(2, 0x1100, false), // primary audio ×2
(2, 0x1101, false),
(3, 0x1200, false), // PG ×3
(3, 0x1201, false),
(3, 0x1202, false),
// the 4 IG entries are consumed and discarded
(5, 0x1A00, true), // secondary audio
(6, 0x1B00, true), // secondary video
(3, 0x1C00, true), // PiP PG
(7, 0x1015, true), // Dolby Vision EL
]
);
// Languages prove each entry was decoded at its own offset.
assert_eq!(pl.streams[1].language, "eng");
assert_eq!(pl.streams[2].language, "fra");
assert_eq!(pl.streams[6].language, "spa");
assert_eq!(pl.streams[8].language, "jpn");
}
/// A secondary block whose stream entry ends exactly at the end of the
/// PlayItem has no reference block at all; the count byte must not be
/// read from one-past-the-end. Covers all three secondary blocks that
/// carry reference data.
#[test]
fn secondary_ref_block_at_item_end_is_not_read() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
// Secondary audio is the last entry, with no ref bytes following.
let sec_audio = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng");
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 1, 0, 0, 0),
&[video.clone(), sec_audio],
);
let pl = parse(&data).expect("secondary audio at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1A00);
// Secondary video is the last entry, with no ref bytes following.
let sec_video = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 0, 1, 0, 0),
&[video.clone(), sec_video.clone()],
);
let pl = parse(&data).expect("secondary video at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1B00);
// Secondary video whose audio-ref block ends exactly at item end, so
// the PiP-PG ref count byte would sit one past it.
let mut sec_video_arefs = sec_video;
sec_video_arefs.extend_from_slice(&[0, 0]); // n_arefs = 0, reserved
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 0, 1, 0, 0),
&[video.clone(), sec_video_arefs],
);
let pl = parse(&data).expect("secondary video aref block at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1B00);
// PiP PG is the last entry, with no ref bytes following.
let pip_pg = build_stream_entry_pg(0x1C00, 0x90, b"jpn");
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 0, 0, 1, 0),
&[video, pip_pg],
);
let pl = parse(&data).expect("PiP PG at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1C00);
}
// ─────────────────────────────────────────────────────────────────────
// parse_stream_entry bounds, exercised directly.
// ─────────────────────────────────────────────────────────────────────
/// Fewer than 2 bytes remain for the stream_entry header
/// (length(1) + stream_entry_type(1)) → None, without reading either.
#[test]
fn stream_entry_header_past_end_is_none() {
let item = [0u8; 8];
for pos in 7..12usize {
assert!(
parse_stream_entry(&item, pos, STREAM_CATEGORY_VIDEO).is_none(),
"pos={pos}"
);
}
}
/// The stream_attributes header (length(1) + coding_type(1)) lies past
/// the end of the PlayItem → None, without reading the length byte.
#[test]
fn stream_attributes_header_past_end_is_none() {
// se_len = 3 → se_end = 4 == item.len(); the sa length byte would be
// at item[4] and the coding type at item[5].
let item = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x10, 0x11];
assert!(parse_stream_entry(&item, 0, STREAM_CATEGORY_VIDEO).is_none());
}
/// A declared stream_attributes length of 0 has no coding_type byte and
/// must be rejected — even when the (empty) attribute region is itself
/// in bounds.
#[test]
fn zero_length_attributes_in_bounds_is_none() {
// se_len = 3 → se_end = 4; sa_len = item[4] = 0 → sa_end = 5 ≤ 6.
let item = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x10, 0x11, 0, 0];
assert!(parse_stream_entry(&item, 0, STREAM_CATEGORY_VIDEO).is_none());
}
/// stream_attributes of exactly 1 byte carries only the coding_type.
/// That is the minimum the parser accepts, so the entry is returned
/// with its PID and coding_type and no format-specific fields — for a
/// PG stream the 3-byte language must NOT be read past the attributes.
#[test]
fn one_byte_stream_attributes_yields_bare_entry() {
// se_len = 3 → se_end = 4; sa_len = 1 → sa_end = 6 == item.len().
let item = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x10, 0x11, 1, 0x1B];
let (entry, next) =
parse_stream_entry(&item, 0, STREAM_CATEGORY_VIDEO).expect("1-byte attrs are valid");
assert_eq!(entry.pid, 0x1011);
assert_eq!(entry.coding_type, 0x1B);
assert_eq!(entry.video_format, 0);
assert_eq!(entry.video_rate, 0);
assert_eq!(next, 6);
let pg = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x12, 0x00, 1, 0x90];
let (entry, _) = parse_stream_entry(&pg, 0, STREAM_CATEGORY_PG_SUBTITLE)
.expect("1-byte PG attrs are valid");
assert_eq!(entry.pid, 0x1200);
assert_eq!(entry.coding_type, 0x90);
assert_eq!(entry.language, "");
}
}
+52 -20
View File
@@ -150,6 +150,30 @@ pub fn start_master<W: Write + Seek>(w: &mut W, id: u32) -> io::Result<u64> {
Ok(size_pos)
}
/// Encode `data_size` as the FIXED-WIDTH 8-octet EBML VINT used to back-patch
/// a master element's size field: the `0x01` VINT_MARKER octet followed by the
/// 56-bit VINT_DATA payload, big-endian (RFC 8794 section 4.4 — an 8-octet
/// VINT carries 7 octets of VINT_DATA).
///
/// Extracted so the full 56-bit payload can be exercised directly: reaching
/// the high payload bytes through [`end_master`] / [`end_master_buf`] would
/// take a multi-terabyte buffer, leaving them unconstrained by any test.
///
/// `data_size` must be below 2^56; both callers check that first.
fn fixed_width_vint8(data_size: u64) -> [u8; 8] {
debug_assert!(data_size < 0x0100_0000_0000_0000);
[
0x01,
(data_size >> 48) as u8,
(data_size >> 40) as u8,
(data_size >> 32) as u8,
(data_size >> 24) as u8,
(data_size >> 16) as u8,
(data_size >> 8) as u8,
data_size as u8,
]
}
/// End a master element: seek back and write the actual size.
///
/// `size_pos` must be the offset returned by [`start_master`], which always
@@ -170,16 +194,7 @@ pub fn end_master<W: Write + Seek>(w: &mut W, size_pos: u64) -> io::Result<()> {
);
w.seek(SeekFrom::Start(size_pos))?;
// Write as 8-byte VINT: 0x01 followed by 7 bytes of size
w.write_all(&[
0x01,
(data_size >> 48) as u8,
(data_size >> 40) as u8,
(data_size >> 32) as u8,
(data_size >> 24) as u8,
(data_size >> 16) as u8,
(data_size >> 8) as u8,
data_size as u8,
])?;
w.write_all(&fixed_width_vint8(data_size))?;
w.seek(SeekFrom::Start(end_pos))?;
Ok(())
}
@@ -220,16 +235,7 @@ pub fn end_master_buf(buf: &mut [u8], size_pos: usize) -> io::Result<()> {
if data_size >= 0x0100_0000_0000_0000 {
return Err(crate::error::Error::MkvUnencodable.into());
}
buf[size_pos..body_start].copy_from_slice(&[
0x01,
(data_size >> 48) as u8,
(data_size >> 40) as u8,
(data_size >> 32) as u8,
(data_size >> 24) as u8,
(data_size >> 16) as u8,
(data_size >> 8) as u8,
data_size as u8,
]);
buf[size_pos..body_start].copy_from_slice(&fixed_width_vint8(data_size));
Ok(())
}
@@ -1465,4 +1471,30 @@ mod tests {
"a size_pos past the end of the buffer must error, not panic"
);
}
/// The fixed-width 8-octet VINT is `0x01` followed by the 56-bit
/// VINT_DATA payload in BIG-ENDIAN order (RFC 8794 section 4.4). Every
/// payload octet is distinct here, so a shifted, reversed or dropped
/// octet is visible; the top payload octets are unreachable through
/// end_master without a multi-terabyte buffer, which is why this is
/// tested at the encoder.
#[test]
fn fixed_width_vint8_is_big_endian_over_the_full_payload() {
assert_eq!(
fixed_width_vint8(0x00AA_BB_CC_DD_EE_FF_11),
[0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11]
);
assert_eq!(fixed_width_vint8(0), [0x01, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(
fixed_width_vint8(0x00FF_FFFF_FFFF_FFFE),
[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE]
);
// Each payload octet in isolation lands in its own position.
for i in 0..7u32 {
let mut want = [0u8; 8];
want[0] = 0x01;
want[7 - i as usize] = 0x5A;
assert_eq!(fixed_width_vint8(0x5Au64 << (8 * i)), want, "octet {i}");
}
}
}