From b2e1982051623dcf537aaf63795cf98cc949b11f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:24:57 -0700 Subject: [PATCH] fix(clpi): resolve out_time past the last EP entry to the end of the clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/clpi.rs | 500 +++++++++++++++++++++++++++++++++++++++++++++++- src/ifo.rs | 413 +++++++++++++++++++++++++++++++++++++++ src/mpls.rs | 281 +++++++++++++++++++++++++++ src/mux/ebml.rs | 72 +++++-- 4 files changed, 1244 insertions(+), 22 deletions(-) diff --git a/src/clpi.rs b/src/clpi.rs index cc4630a..b2765a2 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -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, + trailing: &[u8], + ) -> Vec { + 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); + } } diff --git a/src/ifo.rs b/src/ifo.rs index ddbc7b9..6b3ad28 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -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 { + 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]) -> Vec { + 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)]); + } } diff --git a/src/mpls.rs b/src/mpls.rs index 7d864ba..503f7da 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -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]) -> Vec { + 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 { + 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::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, ""); + } } diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index 8df6e84..418da52 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -150,6 +150,30 @@ pub fn start_master(w: &mut W, id: u32) -> io::Result { 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: &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}"); + } + } }