diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index 1acb4bc..b9540ef 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -2146,4 +2146,149 @@ mod tests { bytes.len().saturating_sub(out_bytes) ); } + + // ── Exact-boundary behaviour of the AU framer ──────────────────────────── + + /// `find_sync` scans `0..=len-4`, so a buffer that is EXACTLY the syncword + /// still matches. The existing tests cover 0, 3 and "longer than 4", which + /// leaves the `len == 4` boundary open — and that is the case a syncword split + /// across PES packets lands on the moment its last byte arrives. + #[test] + fn find_sync_matches_a_buffer_that_is_exactly_the_syncword() { + assert_eq!( + find_sync(&DTS_CORE_SYNC, &DTS_CORE_SYNC), + Some(0), + "a four-byte buffer that IS the syncword matches at 0" + ); + let mut five = vec![0x00u8]; + five.extend_from_slice(&DTS_CORE_SYNC); + assert_eq!( + find_sync(&five, &DTS_CORE_SYNC), + Some(1), + "and the last possible start offset is len - 4" + ); + } + + /// `CORE_HEADER_MIN_BYTES` is "enough bytes to DECODE the size field", so a + /// buffer holding exactly that many must be decoded, not deferred. The + /// distinction is visible precisely at the boundary: with 10 bytes of a core + /// sync whose decoded size is sub-spec, the parser must recognise the false + /// sync, drain past it and resync down to the 3-byte carry-over tail. Waiting + /// instead leaves the false sync sitting at the front of the buffer, where it + /// blocks every later real core behind it. + #[test] + fn a_core_header_of_exactly_the_minimum_length_is_decoded_not_deferred() { + let mut parser = DtsParser::new(); + let mut d = vec![0u8; CORE_HEADER_MIN_BYTES]; + d[0..4].copy_from_slice(&DTS_CORE_SYNC); + d[6] = 0x01; // fsize = 16 → core_size 17, below the 96-byte ETSI floor + assert_eq!(d.len(), 10, "the fixture is exactly at the boundary"); + let out = parser.parse(&make_pes(d, Some(90_000))); + assert!(out.is_empty(), "a false sync emits nothing"); + assert_eq!( + parser.buf.len(), + 3, + "the false sync was decoded, drained and resynced past — leaving only \ + the 3-byte split-sync carry-over" + ); + assert_ne!( + find_sync(&parser.buf, &DTS_CORE_SYNC), + Some(0), + "and the bogus sync is no longer at the front of the buffer" + ); + } + + /// The end-of-stream flush must emit a final access unit whose core is exactly + /// as long as the buffer — the ordinary case, since the last AU is closed by + /// end-of-stream rather than by a following core sync. Rejecting it at the + /// boundary silently drops the last frame of every DTS track. + #[test] + fn flush_emits_a_core_that_exactly_fills_the_buffer() { + let mut parser = DtsParser::new(); + let core = make_dts_core(512); + parser.buf = core.clone(); + parser.pending_pts = 90_000; + let out = parser.flush(); + assert_eq!(out.len(), 1, "the final AU is emitted, not dropped"); + assert_eq!(out[0].data, core, "and it is the whole core frame"); + // One byte short is still refused — the bound is not simply absent. + let mut parser = DtsParser::new(); + parser.buf = core[..511].to_vec(); + parser.pending_pts = 90_000; + assert!( + parser.flush().is_empty(), + "a core one byte short of its declared size is not emitted truncated" + ); + } + + /// The flush guard is a DISJUNCTION: a buffer that does not BEGIN with a core + /// sync is discarded whatever its length. Requiring both conditions instead + /// lets a long run of junk through to `dts_core_frame_size`, which happily + /// decodes a 14-bit size out of arbitrary bytes — and the flush then emits an + /// "access unit" that is not DTS at all. + /// + /// The junk here is sized so that mis-decoding it yields a plausible core size + /// that the buffer fully covers, which is exactly when the wrong answer looks + /// like a right one. + #[test] + fn flush_discards_a_long_buffer_that_does_not_begin_with_a_core_sync() { + // A well-formed core frame with ONE byte of its syncword corrupted: every + // other field still decodes, and the decodability gate would pass it, so + // only the leading-sync test stands between it and the output. + let mut parser = DtsParser::new(); + let mut broken = make_dts_core(300); + broken[0] ^= 0xFF; // no longer 0x7FFE8001 at offset 0 + assert_ne!( + find_sync(&broken, &DTS_CORE_SYNC), + Some(0), + "the fixture really has no core sync at the front" + ); + parser.buf = broken; + parser.pending_pts = 90_000; + assert!( + parser.flush().is_empty(), + "a buffer whose front is not a core sync is discarded, not size-decoded" + ); + assert!(parser.buf.is_empty(), "and the junk is dropped"); + + // The other half of the disjunction: a buffer too short to size, whose + // front IS a core sync, is discarded too. + let mut parser = DtsParser::new(); + parser.buf = DTS_CORE_SYNC.to_vec(); + parser.pending_pts = 90_000; + assert!(parser.flush().is_empty(), "a bare sync tail is not an AU"); + } + + /// `EXSS_HEADER_MIN_BYTES` is the WORST-CASE header length — the long form's + /// 43 bits after the syncword, rounded up to 6 bytes, plus the 4-byte sync. + /// It gates whether an extension substream can be sized precisely, and that is + /// what keeps a chance core syncword inside XLL payload from being mistaken + /// for the next AU boundary; too large and every extension falls back to the + /// payload scan, too small and the reader runs off a truncated header. + /// + /// Pinned at both sides of the boundary rather than by value, so the field + /// widths it is summed from stay honest. + #[test] + fn exss_frame_size_needs_the_worst_case_header_and_no_more() { + assert_eq!( + EXSS_HEADER_MIN_BYTES, 10, + "4 sync bytes + ceil((8 + 2 + 1 + 12 + 20) / 8)" + ); + let ext = make_exss(10, None); + assert_eq!( + exss_frame_size(&ext), + Some(10), + "exactly the minimum is enough to size a substream" + ); + assert_eq!( + exss_frame_size(&ext[..9]), + None, + "one byte short cannot be sized — the long form's fields are not all in" + ); + assert_eq!( + exss_frame_size(&ext[..4]), + None, + "the bare sync sizes nothing" + ); + } } diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index fec7d7e..34f41c2 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -2437,4 +2437,318 @@ mod tests { v1[32..34].copy_from_slice(&packed); assert_eq!(mdhd_language(&v1).as_deref(), Some("eng")); } + + // ── Sample-entry field offsets (ISO/IEC 14496-12 §12.1.3, §12.2.3) ──────── + + /// Build an `stsd` payload holding ONE sample entry of type `fourcc`. + fn stsd_with(fourcc: &[u8; 4], entry_body: &[u8]) -> Vec { + let mut p = vec![0u8, 0, 0, 0]; // version + flags + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + p.extend_from_slice(&mp4_box(fourcc, entry_body)); + p + } + + /// A `VisualSampleEntry` body with DISTINCT width and height, plus optional + /// child boxes (ISO/IEC 14496-12 §12.1.3): 6 reserved + 2 data_reference_index + /// + 16 pre_defined/reserved, then width(2) at 24 and height(2) at 26, then 50 + /// more bytes of resolution / frame_count / compressorname / depth / pre_defined + /// to the 78-byte fixed part. + fn visual_entry(width: u16, height: u16, children: &[u8]) -> Vec { + let mut b = vec![0u8; 78]; + b[24..26].copy_from_slice(&width.to_be_bytes()); + b[26..28].copy_from_slice(&height.to_be_bytes()); + b.extend_from_slice(children); + b + } + + /// An `AudioSampleEntry` body (ISO/IEC 14496-12 §12.2.3): 6 reserved + 2 + /// data_reference_index + 8 reserved, then channelcount(2) at 16, samplesize(2), + /// pre_defined(2), reserved(2), samplerate(4) — 28 bytes — then child boxes. + /// `decoy` goes in the reserved field at 14 so an offset slip is visible. + fn audio_entry(channels: u16, decoy: u16, children: &[u8]) -> Vec { + let mut b = vec![0u8; 28]; + b[14..16].copy_from_slice(&decoy.to_be_bytes()); + b[16..18].copy_from_slice(&channels.to_be_bytes()); + b[18..20].copy_from_slice(&16u16.to_be_bytes()); // samplesize + b[24..28].copy_from_slice(&(48_000u32 << 16).to_be_bytes()); + b.extend_from_slice(children); + b + } + + /// `height` is the SECOND of the two 16-bit dimensions in a VisualSampleEntry, + /// at byte 26 — width sits at 24. Reading the wrong one is silent: the value is + /// still a plausible dimension, so the track's seeded resolution simply comes + /// out wrong (1920 read as a height would classify a 1080p title as UHD). + /// + /// The two are deliberately different here; a fixture with width == height + /// would pass under either offset. + #[test] + fn parse_stsd_takes_height_from_its_own_field_not_the_width_beside_it() { + let stsd = stsd_with(b"avc1", &visual_entry(1920, 1080, &[])); + let info = parse_stsd(&stsd).expect("an avc1 entry parses"); + assert!(matches!(info.codec, Codec::H264), "avc1 is H.264"); + assert_eq!(info.height, 1080, "height is at byte 26, width at 24"); + assert_eq!(info.channels, 0, "a video entry declares no channel count"); + + // And a VisualSampleEntry too short to hold the fixed part is refused + // rather than read out of a shorter buffer. + let short = stsd_with(b"avc1", &vec![0u8; 40]); + assert!( + parse_stsd(&short).is_none(), + "a truncated VisualSampleEntry has no dimensions to read" + ); + } + + /// `channelcount` is at byte 16 of an AudioSampleEntry, after the 8 reserved + /// bytes that follow `data_reference_index`. An offset slip reads a reserved + /// field, and reserved fields are conventionally zero — which would make every + /// audio track come out as 0 channels rather than fail. + /// + /// The short-entry fallback of 2 is pinned in the same test: an entry with no + /// room for the fixed part still has to name SOME channel count, and 0 is not a + /// usable one. + #[test] + fn parse_stsd_reads_channelcount_from_its_own_field_and_defaults_a_short_entry() { + let stsd = stsd_with(b"ac-3", &audio_entry(6, 0xBEEF, &[])); + let info = parse_stsd(&stsd).expect("an ac-3 entry parses"); + assert!(matches!(info.codec, Codec::Ac3), "ac-3 is AC-3"); + assert_eq!( + info.channels, 6, + "channelcount is at byte 16 — 0xBEEF at 14 is the reserved field" + ); + assert_eq!(info.height, 0, "an audio entry declares no height"); + + // Too short for the 28-byte fixed part: fall back to stereo, not to 0. + let short = stsd_with(b"ac-3", &vec![0u8; 12]); + let info = parse_stsd(&short).expect("a short audio entry still names a codec"); + assert_eq!( + info.channels, 2, + "an entry with no readable channelcount defaults to stereo" + ); + } + + // ── MPEG-4 expandable descriptors (ISO/IEC 14496-1 §8.3.3) ──────────────── + + /// A descriptor length is a base-128 varint: 7 bits per byte, continued while + /// the top bit is set, to a maximum of FOUR bytes. Every existing esds fixture + /// uses a single-byte length, so the continuation path is unconstrained by + /// them — yet a multi-byte length is exactly what an `esds` carrying a long + /// AudioSpecificConfig (or one written by a tool that always pads to 4 bytes, + /// which is common) uses. + #[test] + fn read_descriptor_len_is_a_four_byte_base_128_varint() { + let read = |b: &[u8]| { + let mut pos = 0usize; + let n = read_descriptor_len(b, &mut pos); + (n, pos) + }; + assert_eq!(read(&[0x02]), (2, 1), "a short length is one byte"); + assert_eq!( + read(&[0x7F]), + (127, 1), + "127 is the largest one-byte length" + ); + assert_eq!( + read(&[0x81, 0x00]), + (128, 2), + "128 continues into a second byte, 7 bits at a time" + ); + assert_eq!( + read(&[0x81, 0x80, 0x80, 0x01]), + ((1usize << 21) | 1, 4), + "four bytes contribute 7 bits each" + ); + // A fifth continuation byte is NOT consumed: the encoding is capped at 4. + let mut pos = 0usize; + read_descriptor_len(&[0x80, 0x80, 0x80, 0x80, 0x7F], &mut pos); + assert_eq!(pos, 4, "the walk stops after four bytes, whatever follows"); + // Truncated input stops at the end rather than reading past it. + assert_eq!(read(&[0x81]), (1, 1), "a dangling continuation just ends"); + } + + /// The optional `ES_Descriptor` fields (ISO/IEC 14496-1 §7.2.6.5) are selected + /// by three flag bits, and each one that is set inserts bytes before the + /// `DecoderConfigDescriptor`. Skipping them wrongly does not corrupt anything — + /// the tag check fails and `parse_esds_asc` returns `None`, so the AAC track + /// simply loses its CodecPrivate and the remux emits AAC no decoder can + /// initialise. + /// + /// The existing fixture has flags = 0, so all three skips are unconstrained. + /// This one sets all three at once and still has to reach the same ASC. + #[test] + fn parse_esds_asc_steps_over_every_optional_es_descriptor_field() { + let asc = vec![0x12u8, 0x10]; // AAC-LC 44.1 kHz stereo + let build = |flags: u8, extra: &[u8]| { + let mut v = vec![0u8, 0, 0, 0]; // FullBox version + flags + v.push(0x03); // ES_Descriptor + v.push(0x19); // length (unused by the parser) + v.extend_from_slice(&[0x00, 0x01]); // ES_ID + v.push(flags); + v.extend_from_slice(extra); + v.push(0x04); // DecoderConfigDescriptor + v.push(0x11); + v.push(0x40); // objectTypeIndication = AAC + v.extend_from_slice(&[0x15, 0, 0, 0]); // streamType + bufferSizeDB + v.extend_from_slice(&[0, 0, 0, 0]); // maxBitrate + v.extend_from_slice(&[0, 0, 0, 0]); // avgBitrate + v.push(0x05); // DecoderSpecificInfo + v.push(asc.len() as u8); + v.extend_from_slice(&asc); + v + }; + + // streamDependenceFlag alone: 2 bytes of dependsOn_ES_ID. + assert_eq!( + parse_esds_asc(&build(0x80, &[0xAA, 0xBB])).as_deref(), + Some(&asc[..]), + "dependsOn_ES_ID must be stepped over" + ); + // URL_flag alone: a length byte plus that many URL bytes. + assert_eq!( + parse_esds_asc(&build(0x40, b"\x05hello")).as_deref(), + Some(&asc[..]), + "URLlength + URLstring must be stepped over" + ); + // OCRstreamFlag alone: 2 bytes of OCR_ES_Id. + assert_eq!( + parse_esds_asc(&build(0x20, &[0xCC, 0xDD])).as_deref(), + Some(&asc[..]), + "OCR_ES_Id must be stepped over" + ); + // All three together, in the order the standard lists them. + assert_eq!( + parse_esds_asc(&build(0xE0, b"\xAA\xBB\x03abc\xCC\xDD")).as_deref(), + Some(&asc[..]), + "all three optional fields present at once" + ); + } + + /// A box header is 8 bytes, so a declared `size` below 8 cannot describe a box — + /// and taking it at face value slices `payload[pos + 8 .. pos + size]` with the + /// start past the end, which panics. A crafted `moov` is untrusted input read + /// straight off a user's file. + #[test] + fn find_boxes_capped_refuses_a_box_smaller_than_its_own_header() { + // size = 4, type = 'avcC': shorter than the header that declares it. + let payload = [0u8, 0, 0, 4, b'a', b'v', b'c', b'C']; + assert!( + find_boxes_capped(&payload, b"avcC", 8).is_empty(), + "a sub-header-size box is not a box" + ); + assert!(find_box(&payload, b"avcC").is_none()); + // Size 8 exactly IS a box — an empty one — so the bound is not off by one. + let empty = [0u8, 0, 0, 8, b'a', b'v', b'c', b'C']; + assert_eq!( + find_box(&empty, b"avcC"), + Some(&[][..]), + "an 8-byte box is a valid empty box" + ); + } + + /// An `stsc` entry names a `first_chunk` that may exceed the chunk count the + /// `stco` actually declares (a truncated or crafted table). The run it would + /// fill has to be clamped to the chunks that exist — indexing `spc` past its + /// length is a panic on a file the user merely opened. + /// + /// The clamp is only reachable through a NON-final entry: the final entry's end + /// is `n_chunks` by construction, so a fixture whose only over-range entry is + /// last never reaches the line. + #[test] + fn sample_offsets_clamps_an_stsc_run_that_outruns_the_chunk_table() { + let sizes = [10u32, 20]; + let chunk_offsets = [1000u64, 2000]; + // Three entries, two chunks: entry 0's run would end at chunk 3. + let stsc = [(1u32, 1u32), (4, 1), (9, 1)]; + let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc); + assert_eq!( + offsets, + vec![1000, 2000], + "one sample per existing chunk; the out-of-range runs place nothing" + ); + } + + /// ISO/IEC 14496-12 §8.6.6: an edit list may hold several media edits. This + /// frame model can only express a constant shift, so it honours the LEADING + /// one and logs the rest — taking the last instead would shift the whole track + /// by a trim that belongs to a later segment, i.e. silent A/V desync of exactly + /// the size of the difference. + #[test] + fn elst_offset_ticks_honours_the_first_media_edit_not_the_last() { + // Two non-empty edits with different media_time; no empty edit. + let entries = vec![(1000u64, 500i64, 1i16), (1000, 9000, 1)]; + assert_eq!( + elst_offset_ticks(&entries, Some(1000), 48_000, 0), + -500, + "the leading media edit's trim is the one applied" + ); + + // A leading EMPTY edit still delays, and only the empty edits BEFORE the + // first media edit count — one that trails a media edit does not. + let entries = vec![ + (100u64, -1i64, 1i16), // empty: 100 movie ticks of delay + (1000, 200, 1), // media edit: trims 200 media ticks + (5000, -1, 1), // a LATER empty edit — not a start delay + ]; + assert_eq!( + elst_offset_ticks(&entries, Some(1000), 48_000, 0), + 100 * 48 - 200, + "leading empty edits delay (converted to media ticks); trailing ones do not" + ); + } + + /// A version-1 `mvhd` carries 64-bit creation/modification times, so its + /// `timescale` sits at byte 20 rather than 12 (ISO/IEC 14496-12 §8.2.2). Every + /// existing fixture is version 0, so the version-1 offset is unconstrained by + /// them — and it is not a hypothetical: writers emit version 1 whenever the + /// movie duration does not fit 32 bits. + /// + /// A wrong offset reads part of the 64-bit modification time, which is a large + /// arbitrary number — and the movie timescale is the denominator that converts + /// an empty edit's delay into media ticks, so the A/V offset it produces is + /// arbitrary too. + #[test] + fn mvhd_timescale_version_1_reads_past_the_64_bit_times() { + let mut v1 = vec![0u8; 24]; + v1[0] = 1; // version 1 + v1[12..20].copy_from_slice(&0xDEAD_BEEF_CAFE_F00Du64.to_be_bytes()); // mod time + v1[20..24].copy_from_slice(&90_000u32.to_be_bytes()); + assert_eq!(mvhd_timescale(&v1), Some(90_000), "v1 timescale is at 20"); + assert_eq!( + mvhd_timescale(&v1[..23]), + None, + "a v1 mvhd too short to hold it yields nothing, not a partial read" + ); + + let mut v0 = vec![0u8; 16]; + v0[0] = 0; + v0[4..12].copy_from_slice(&0xDEAD_BEEF_CAFE_F00Du64.to_be_bytes()); + v0[12..16].copy_from_slice(&600u32.to_be_bytes()); + assert_eq!(mvhd_timescale(&v0), Some(600), "v0 timescale is at 12"); + } + + /// Only the leading empty edits and the FIRST media edit shape the offset, so + /// [`MAX_ELST_ENTRIES`] bounds what a crafted `elst` can allocate. Without it a + /// box declaring millions of entries — and carrying the bytes for them, inside a + /// `moov` already capped at 256 MiB — expands to a Vec of 20-byte tuples for no + /// benefit at all. + #[test] + fn parse_elst_entry_count_is_capped() { + let n = MAX_ELST_ENTRIES + 500; + let mut p = vec![0u8, 0, 0, 0]; // version 0 + flags + p.extend_from_slice(&(n as u32).to_be_bytes()); + for i in 0..n { + p.extend_from_slice(&(i as u32).to_be_bytes()); // segment_duration + p.extend_from_slice(&0u32.to_be_bytes()); // media_time + p.extend_from_slice(&1i16.to_be_bytes()); + p.extend_from_slice(&0i16.to_be_bytes()); + } + let entries = parse_elst(&p); + assert_eq!( + entries.len(), + MAX_ELST_ENTRIES, + "capped, not truncated short" + ); + assert_eq!(entries[0].0, 0, "and the entries kept are the LEADING ones"); + assert_eq!(entries[1].0, 1); + } } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index f5eb5f2..181ba11 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -4112,6 +4112,35 @@ mod tests { /// When set, LBAs at/above [`FMTS_CPS2_LBA`] belong to a SECOND CPS /// unit and are encrypted under [`FMTS_CPS2_KEY`], not the base key. second_cps: bool, + /// `[start, end)` LBA span that reads back as zeros — CLEAR content, with + /// no encrypted unit to sample and so no CPS-unit evidence of its own. + clear_span: Option<(u32, u32)>, + /// An operator Stop that lands MID-resolve: the token is cancelled as soon + /// as this many reads of the given kind have been served. + cancel_after: Option<(crate::halt::Halt, CancelWhen, u32)>, + } + + /// Which read counter [`FmtsDisc::cancel_after`] watches. + #[derive(Clone, Copy, PartialEq, Eq)] + enum CancelWhen { + /// UDF-metadata reads — the Stop lands after the segment table is loaded and + /// before a single content sector has been touched. + Meta, + /// Content-probe reads — the Stop lands with the drive already working. + Probe, + } + + /// The same synthetic disc with extra records appended to + /// `IndividualSegment.tbl` ONLY — the content region is laid out from + /// [`fmts_segments`] exactly as before, so the anchor and phase probes still + /// resolve normally and the extra records are seen purely by the range builder. + /// That is what isolates each of its "this record does not map" arms. + fn fmts_disc_with_extra_records(extra: &[crate::aacs::segment::Segment]) -> FmtsDisc { + let mut all = fmts_segments(); + all.extend_from_slice(extra); + let mut d = FmtsDisc::new(); + d.meta_disc = FmtsDisc::rebuild_meta(&all); + d } impl FmtsDisc { @@ -4122,24 +4151,32 @@ mod tests { /// `clip = false` drops `BDMV/STREAM/00001.fmts` from the tree: an FMTS disc /// (the table is there) whose forensic clip cannot be identified, so the /// segment SPNs have no defensible anchor. + /// The UDF metadata image alone, with `tbl_segs` as the segment table and + /// the forensic clip present — see [`fmts_disc_with_extra_records`]. + fn rebuild_meta( + tbl_segs: &[crate::aacs::segment::Segment], + ) -> crate::udf::fixture::MemDisc { + let mut d = Self::build(true, tbl_segs); + std::mem::replace(&mut d.meta_disc, crate::udf::fixture::MemDisc::new()) + } + fn with_forensic_clip(clip: bool) -> Self { + Self::build(clip, &fmts_segments()) + } + + fn build(clip: bool, tbl_segs: &[crate::aacs::segment::Segment]) -> Self { use crate::udf::fixture::{ DirSpec, PART_START, build_udf_skeleton, file, file_with, lay_dir, }; let segs = fmts_segments(); + let tbl_image = fmts_tbl(tbl_segs); let mut meta_disc = crate::udf::fixture::MemDisc::new(); build_udf_skeleton(&mut meta_disc, 10); let mut subdirs = vec![DirSpec { name: "AACS".to_string(), icb_lba: 12, dir_data_lba: 13, - files: vec![file_with( - "IndividualSegment.tbl", - 14, - 15, - fmts_tbl(&segs), - true, - )], + files: vec![file_with("IndividualSegment.tbl", 14, 15, tbl_image, true)], subdirs: Vec::new(), }]; if clip { @@ -4185,6 +4222,8 @@ mod tests { probe_reads: 0, fault_span: None, second_cps: false, + clear_span: None, + cancel_after: None, } } @@ -4201,6 +4240,19 @@ mod tests { /// The 6144-byte ciphertext of the aligned unit starting at clip byte /// `unit_byte`: inside a segment, EVEN units carry that index's content and /// ODD units the alternate variant; outside, ordinary base-Unit-Key content. + /// Flip the operator's Stop once the watched counter reaches its threshold. + fn maybe_cancel(&mut self) { + if let Some((h, when, n)) = &self.cancel_after { + let seen = match when { + CancelWhen::Meta => self.meta_reads, + CancelWhen::Probe => self.probe_reads, + }; + if seen >= *n { + h.cancel(); + } + } + } + fn unit_at(&self, unit_byte: u64) -> Vec { for s in &self.segs { let sb = s.start_byte(); @@ -4232,6 +4284,7 @@ mod tests { if let Some((a, b)) = self.fault_span { if lba >= a && lba < b { self.probe_reads += 1; + self.maybe_cancel(); return Err(crate::error::Error::DiscRead { sector: lba as u64, status: None, @@ -4241,13 +4294,25 @@ mod tests { } if lba < FMTS_CONTENT_LBA { self.meta_reads += 1; + self.maybe_cancel(); return self.meta_disc.read_sectors(lba, count, buf, recovery); } let want = count as usize * 2048; + // A clear span: readable, but carrying no encrypted unit at all. + if let Some((a, b)) = self.clear_span + && lba >= a + && lba < b + { + self.probe_reads += 1; + self.maybe_cancel(); + buf[..want].fill(0); + return Ok(want); + } // The SECOND CPS unit's extent: ordinary (non-forensic) content, // encrypted under that unit's own base Unit Key. if self.second_cps && lba >= FMTS_CPS2_LBA { self.probe_reads += 1; + self.maybe_cancel(); let unit = encrypted_clean_unit(&FMTS_CPS2_KEY); for s in 0..count as usize { let within = ((lba as usize + s - FMTS_CPS2_LBA as usize) % 3) * 2048; @@ -4256,6 +4321,7 @@ mod tests { return Ok(want); } self.probe_reads += 1; + self.maybe_cancel(); buf[..want].fill(0); for s in 0..count as u32 { let off = (lba + s - FMTS_CONTENT_LBA) as u64; @@ -5050,4 +5116,680 @@ mod tests { "the slot is the BASE key's, wherever it sits in the pool" ); } + + /// The forensic ranges reach `fill_base_key_gaps` in `IndividualSegment.tbl` + /// RECORD order, which is not LBA order — the table is a list of segments, and + /// nothing in `aacs::segment` sorts it. The gap walk is a single forward sweep + /// (`cur = cur.max(ce)`), so it is only correct on cuts in ascending order; that + /// is what `c.sort_unstable()` is for. + /// + /// Every existing gap-fill case happens to pass its cuts already sorted, so the + /// sort is unconstrained by them. Here the cuts arrive REVERSED: without the + /// sort the sweep takes the high cut first, jumps `cur` past it, then discards + /// the low cut as "already behind" — and emits a base-key fill straight over the + /// low forensic segment. That is the silent-wrong-key shape: the forensic LBAs + /// end up in TWO ranges, one of them keyed with the base Unit Key. + #[test] + fn fill_base_key_gaps_sorts_cuts_that_arrive_in_table_order_not_lba_order() { + use crate::decrypt::Phase::{All, Even, Odd}; + let ext = vec![Extent { + start_lba: 100, + sector_count: 60, + }]; + // Table order: the HIGH segment recorded before the LOW one. + let forensic = vec![(140, 150, 6, Odd), (110, 120, 5, Even)]; + let fills = super::fill_base_key_gaps(&ext, &forensic, 0); + assert_eq!( + fills, + vec![(100, 110, 0, All), (120, 140, 0, All), (150, 160, 0, All),], + "the gaps around BOTH forensic cuts must be filled, whatever order the \ + segment table listed them in" + ); + // The load-bearing invariant: exactly one range covers every content LBA. + assert_gapless(&ext, &forensic, &fills); + } + + /// A `SectorSource` that records every `(lba, count)` it is asked for and serves + /// a caller-chosen aligned unit, so the probe SPREAD of `sample_encrypted_units` + /// is observable directly. + struct RecordingSource { + reads: Vec<(u32, u16)>, + unit: Vec, + } + impl SectorSource for RecordingSource { + fn capacity_sectors(&self) -> u32 { + 1_000_000 + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + self.reads.push((lba, count)); + let want = count as usize * 2048; + buf[..want].copy_from_slice(&self.unit[..want]); + Ok(want) + } + } + + /// `sample_encrypted_units` is the evidence every CPS-unit decision rests on, so + /// WHICH units it reads matters: 8 probes at `total * p / 9` for p in 1..=8, each + /// a whole aligned unit (3 sectors) measured from the extent's own start. + /// + /// Pinned exactly. A probe count, a divisor or a `p` range that drifts moves the + /// sample set — clustering probes at one end of a 20-minute clip, where an + /// authored-bad or padding region can make an extent look unopenable — and each + /// of those is an independently reachable mutation of this arithmetic. + #[test] + fn sample_encrypted_units_probes_eight_aligned_units_spread_across_the_extent() { + let unit = encrypted_clean_unit(&[0x5Au8; 16]); + let mut src = RecordingSource { + reads: Vec::new(), + unit: unit.clone(), + }; + // 90 aligned units (270 sectors) from LBA 3000. + let got = super::sample_encrypted_units(&mut src, 3000, 270, ContentFormat::BdTs); + assert_eq!( + src.reads, + vec![ + (3000 + 10 * 3, 3), + (3000 + 20 * 3, 3), + (3000 + 30 * 3, 3), + (3000 + 40 * 3, 3), + (3000 + 50 * 3, 3), + (3000 + 60 * 3, 3), + (3000 + 70 * 3, 3), + (3000 + 80 * 3, 3), + ], + "8 probes at total*p/9 aligned units, each a whole 3-sector unit, \ + anchored on the extent start" + ); + assert_eq!(got.len(), 8, "every encrypted probe is returned"); + assert!( + got.iter().all(|s| *s == unit), + "the returned samples are the units as read" + ); + } + + /// Only genuinely AACS-encrypted units come back — that is the whole contract + /// that lets a caller treat a decrypt-to-clean as proof of the key. A clear + /// (unencrypted) extent yields NO samples, which is what makes `pick_pool_slot` + /// return `None` and the caller inherit rather than fail loud. + #[test] + fn sample_encrypted_units_drops_clear_units_and_reads_nothing_below_one_unit() { + // A clear unit: the AACS scrambling bits in byte 0 are zero. + let mut clear = encrypted_clean_unit(&[0x5Au8; 16]); + clear[0] &= 0x3F; + let mut src = RecordingSource { + reads: Vec::new(), + unit: clear, + }; + let got = super::sample_encrypted_units(&mut src, 3000, 270, ContentFormat::BdTs); + assert_eq!(src.reads.len(), 8, "the probes are still attempted"); + assert!(got.is_empty(), "a clear unit is not evidence of any key"); + + // Under one whole aligned unit there is nothing to sample: no read at all. + let mut src = RecordingSource { + reads: Vec::new(), + unit: encrypted_clean_unit(&[0x5Au8; 16]), + }; + let got = super::sample_encrypted_units(&mut src, 3000, 2, ContentFormat::BdTs); + assert!( + src.reads.is_empty(), + "an extent shorter than one aligned unit must not touch the drive" + ); + assert!(got.is_empty()); + } + + /// `pick_pool_slot` answers "which of THESE slots, in THIS order, opens the + /// extent" — and its two callers pass different slot lists for a reason: the + /// multi-CPS path offers the whole pool, the FMTS gap fill only the BASE keys + /// (offering a forensic index key there would key a whole extent with it). + /// + /// So both the RESTRICTION and the ORDER are load-bearing, and neither is + /// implied by "some slot matched". Two samples open under two different pool + /// slots here, so the answer is decided purely by which slot the caller listed + /// first — a `find` that ignored `slots` order, or that scanned the pool + /// instead, would return the same value for both directions. + #[test] + fn pick_pool_slot_honours_the_caller_s_slot_list_and_its_order() { + let k0 = [0x01u8; 16]; + let k1 = [0x02u8; 16]; + let k2 = [0x03u8; 16]; + let pool = vec![(0u32, k0), (1, k1), (2, k2)]; + // One sample opens under slot 0, another under slot 2. Slot 1 opens neither. + let samples = vec![encrypted_clean_unit(&k0), encrypted_clean_unit(&k2)]; + + assert_eq!( + super::pick_pool_slot(&samples, &pool, &[2, 0], ContentFormat::BdTs), + Some(2), + "the FIRST slot in the caller's order that opens a sample wins" + ); + assert_eq!( + super::pick_pool_slot(&samples, &pool, &[0, 2], ContentFormat::BdTs), + Some(0), + "reversing the caller's order reverses the answer" + ); + assert_eq!( + super::pick_pool_slot(&samples, &pool, &[1], ContentFormat::BdTs), + None, + "a slot list that excludes every opening key resolves to nothing" + ); + assert_eq!( + super::pick_pool_slot(&samples, &pool, &[7], ContentFormat::BdTs), + None, + "an out-of-range slot is skipped, not panicked on" + ); + assert_eq!( + super::pick_pool_slot(&[], &pool, &[0, 1, 2], ContentFormat::BdTs), + None, + "no samples is no evidence" + ); + } + + /// Extents are `[start_lba, start_lba + sector_count)` — half open. This decides + /// whether a title "reads the forensic clip", i.e. whether `resolve_fmts_key_map` + /// resolves index keys at all or returns `Ok(None)` and leaves the title on the + /// base-Unit-Key path. + /// + /// Both directions are wrong in a way that does not fail loudly: an inclusive + /// end makes a title that merely ABUTS the forensic clip resolve (and pay a + /// key-service round trip) for content it does not read, while a stricter test + /// makes a title that shares exactly one sector fall through to a base-key-only + /// map and silently garble its forensic units. + #[test] + fn extents_overlap_is_half_open_at_both_ends() { + let at = |start_lba, sector_count| { + vec![Extent { + start_lba, + sector_count, + }] + }; + assert!( + !super::extents_overlap(&at(100, 10), &at(110, 10)), + "b starts exactly where a ends → no shared sector" + ); + assert!( + !super::extents_overlap(&at(110, 10), &at(100, 10)), + "and the same the other way round" + ); + assert!( + super::extents_overlap(&at(100, 11), &at(110, 10)), + "one shared sector (110) IS an overlap" + ); + assert!( + super::extents_overlap(&at(110, 10), &at(100, 11)), + "and the same the other way round" + ); + assert!( + super::extents_overlap(&at(100, 100), &at(120, 5)), + "wholly contained is an overlap" + ); + assert!( + !super::extents_overlap(&at(100, 10), &[]), + "nothing overlaps an empty extent list" + ); + assert!( + !super::extents_overlap(&[], &at(100, 10)), + "in either position" + ); + // A LATER extent of `a` matching is still an overlap — the scan must not + // stop at the first extent of either list. + assert!( + super::extents_overlap(&[at(0, 10)[0], at(500, 10)[0]], &at(505, 10)), + "any extent pair sharing a sector is an overlap" + ); + } + + // ── An unmappable forensic record is a HOLE, and a hole is a hard failure ── + // + // `resolve_fmts_key_map`'s range builder has four independent arms that refuse + // to emit a range for a record: inverted SPNs, an index with no key, clip bytes + // past the clip's end, and a span that is not one contiguous run of sectors. + // Each one tallies `unresolved`, and a non-zero tally aborts the disc. + // + // The tally is what makes those refusals SAFE. Drop it (or the `unresolved != 0` + // check) and the `continue` still fires — so the record's LBAs fall through to + // `fill_base_key_gaps`, which covers them with the BASE Unit Key. The forensic + // units then decrypt to garbage under a key that was never theirs, the map + // reports no error, and the rip completes with `lost_bytes == 0`. That is the + // exact silent-wrong-key shape this module's comments call out; the four tests + // below drive one arm each so no single tally can be deleted unnoticed. + + fn fmts_missing_err() -> String { + std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string() + } + + /// Resolve the synthetic FMTS disc with `extra` bogus records appended to its + /// segment table, and return the error text. + fn fmts_resolve_err_with(extra: &[crate::aacs::segment::Segment]) -> String { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls); + let mut reader = fmts_disc_with_extra_records(extra); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_title(FMTS_CONTENT_SECTORS); + super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect_err("a forensic record that maps to no range must abort the disc") + .to_string() + } + + /// Control: the SAME resolve with no extra records succeeds. Without this the + /// four tests below could be passing for any reason at all — a fixture that + /// never reaches the range builder would `expect_err` just as happily. + #[test] + fn fmts_baseline_table_resolves_so_the_unmappable_record_tests_mean_something() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls); + let mut reader = fmts_disc_with_extra_records(&[]); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_title(FMTS_CONTENT_SECTORS); + let map = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("the well-formed synthetic FMTS disc resolves"); + // And it really is the forensic map: the segments carry their own index-key + // slots (appended after the single base key), the gaps carry the base key. + assert_eq!(map.key_idx_for(10_300), Some(1), "segment 1 → index key 1"); + assert_eq!(map.key_idx_for(10_600), Some(2), "segment 2 → index key 2"); + assert_eq!( + map.key_idx_for(10_000), + Some(0), + "a gap → the base Unit Key" + ); + } + + /// Arm 1 — an INVERTED record (`start_spn > end_spn`). `end_byte - 1 - + /// start_byte` would underflow, so the record is refused; the tally is what + /// turns that refusal into a loud failure instead of a base-keyed hole. + #[test] + fn fmts_inverted_segment_record_aborts_rather_than_base_keying_the_hole() { + let bad = crate::aacs::segment::Segment { + index: 1, + start_spn: 12_000, + end_spn: 11_000, + }; + assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); + } + + /// Arm 2 — a record whose forensic INDEX has no key. The synthetic source + /// returns two index keys, so tags 1 and 2 have pool slots and tag 3 has none. + /// On a real disc this is a table that outruns the key set the service + /// returned — precisely the case where guessing a key is worst. + #[test] + fn fmts_record_with_no_index_key_aborts_rather_than_base_keying_the_hole() { + let bad = crate::aacs::segment::Segment { + index: 3, + start_spn: 12_000, + end_spn: 12_000 + 2559, + }; + assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); + } + + /// Arm 3 — a record whose START is addressable within the clip (so + /// `filter_addressable_segments` keeps it) but whose END runs past the clip's + /// last byte. Only the second `clip_byte_to_lba` fails, which is why the + /// filter upstream cannot stand in for this check. + #[test] + fn fmts_record_running_past_the_clips_end_aborts_rather_than_base_keying_it() { + // The clip is FMTS_CONTENT_SECTORS * 2048 bytes = 21_333 whole packets. + let bad = crate::aacs::segment::Segment { + index: 2, + start_spn: 21_000, // byte 4_032_000 — inside the clip + end_spn: 22_000, // byte 4_224_191 — past its 4_096_000-byte end + }; + assert!( + crate::aacs::segment::clip_byte_to_lba( + &[Extent { + start_lba: FMTS_CONTENT_LBA, + sector_count: FMTS_CONTENT_SECTORS, + }], + bad.start_byte(), + ) + .is_some(), + "the fixture must reach the END check — its START has to be addressable" + ); + assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); + } + + /// Arm 4 — a record whose LBA span is not one contiguous run: `b - a` counts + /// SECTOR crossings while `(end_byte - 1 - start_byte) / 2048` counts the + /// span's own length in sectors, and the two disagree exactly when the record + /// is not aligned to the aligned-unit grid the forensic interleave is defined + /// on (or when it straddles a clip extent boundary). + /// + /// A structurally valid forensic segment cannot hit this: the interleave is + /// per 6144-byte aligned unit, so a real record starts and ends on a + /// 32-packet boundary and the two counts agree. `start_spn = 10` does not — + /// clip byte 1920 is mid-sector — so the span is refused. + #[test] + fn fmts_record_off_the_aligned_unit_grid_aborts_rather_than_spanning_wrongly() { + let bad = crate::aacs::segment::Segment { + index: 2, + start_spn: 10, // clip byte 1920 — 1920 % 2048 != 0 + end_spn: 19, // last byte 3839 — a different sector, but < 2048 long + }; + assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); + } + + /// The multi-CPS loop's inheritance chain has to run THROUGH a cache hit. An + /// extent served from the memo never re-samples, so its index reaches the next + /// extent only because the hit arm carries it into `last_idx`; without that, a + /// following extent with no sampleable ciphertext inherits whatever the loop + /// started at (slot 0) instead of its neighbour's key. + /// + /// That is silent: an unsampleable extent produces no error either way, so the + /// map simply keys those LBAs to the wrong CPS unit and the mux decrypts them + /// to garbage with `lost_bytes == 0`. The existing shared-extent test resolves a + /// SINGLE-extent title through the cache, so nothing downstream of the hit is + /// observed; here the clear extent is deliberately placed AFTER the hit. + #[test] + fn multi_cps_cache_hit_still_feeds_the_next_extents_inheritance() { + let key_a = [0x01u8; 16]; + let key_b = [0x02u8; 16]; + let key_c = [0x03u8; 16]; + let shared = 1000u32; + let clear = 5000u32; // no registered ciphertext → zeros → no samples + let sectors = 30u32; + let mut reader = CountingCipherSource::new(vec![( + shared, + shared + sectors, + encrypted_clean_unit(&key_c), + )]); + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)], + read_data_key: None, + format: ContentFormat::BdTs, + }; + let mut cache = super::DiscKeyCache::new(); + + // Title 1 fills the memo for `shared` (index 2) by really sampling it. + let first = super::resolve_mux_key_map_cached( + &mut reader, + &multi_cps_title(shared, sectors), + &mut keys, + None, + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("title 1 resolves"); + assert_eq!(first.key_idx_for(shared), Some(2), "sampled to its own key"); + let after_first = reader.probes; + + // Title 2: the same clip (a cache HIT — assert that below) followed by an + // extent with nothing to sample. + let mut title2 = DiscTitle::empty(); + title2.extents = vec![ + Extent { + start_lba: shared, + sector_count: sectors, + }, + Extent { + start_lba: clear, + sector_count: sectors, + }, + ]; + let second = super::resolve_mux_key_map_cached( + &mut reader, + &title2, + &mut keys, + None, + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("title 2 resolves"); + assert_eq!( + reader.probes - after_first, + 8, + "only the CLEAR extent is sampled — the shared one must be a cache hit, \ + which is the path under test" + ); + assert_eq!(second.key_idx_for(shared), Some(2), "the hit's own index"); + assert_eq!( + second.key_idx_for(clear), + Some(2), + "an unsampleable extent inherits the PRECEDING extent's index even when \ + that index came from the cache, not from a fresh sample" + ); + } + + /// The FMTS gap fill on a MULTI-CPS disc runs its own extent loop, with its own + /// inheritance chain (`base_slot_for_extent`'s `last_idx`). A title whose last + /// extent has no sampleable ciphertext — a clear/nav tail, which is exactly the + /// case that cannot fail loudly — must take the CPS unit its neighbour is in, + /// not `base_slots[0]`. + /// + /// Slot 0 here is CPS unit 1's key and the neighbour is CPS unit 2, so losing + /// the carry keys the tail with the wrong unit's key and decrypts it to garbage + /// with no error at all. + #[test] + fn fmts_multi_cps_gap_fill_carries_the_preceding_extents_cps_unit_to_a_clear_tail() { + const CLEAR_LBA: u32 = FMTS_CPS2_LBA + FMTS_CPS2_SECTORS; + const CLEAR_SECTORS: u32 = 300; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls); + let mut reader = FmtsDisc::with_second_cps_unit(); + reader.clear_span = Some((CLEAR_LBA, CLEAR_LBA + CLEAR_SECTORS)); + let mut keys = fmts_two_cps_keys(); + let mut cache = super::DiscKeyCache::new(); + let mut title = fmts_two_cps_title(); + title.extents.push(Extent { + start_lba: CLEAR_LBA, + sector_count: CLEAR_SECTORS, + }); + + let map = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("a two-CPS FMTS disc with a clear tail resolves"); + assert_eq!( + map.key_idx_for(FMTS_CPS2_LBA), + Some(1), + "the preceding extent is CPS unit 2 (pool slot 1)" + ); + for lba in [CLEAR_LBA, CLEAR_LBA + CLEAR_SECTORS - 1] { + assert_eq!( + map.key_idx_for(lba), + Some(1), + "LBA {lba} has nothing to sample and must inherit CPS unit 2 from its \ + neighbour, not fall back to the first CPS unit's key" + ); + } + } + + /// The FMTS gap fill samples extents off the LIVE DRIVE through the same + /// per-disc [`CpsUnitCache`] the multi-CPS path uses — 8 random 6144-byte reads + /// per extent, ~200 ms of seek apiece. Every input to that decision is in the + /// cache key, so a second title over the same extents must cost ZERO further + /// content reads. + /// + /// The index-key memo alone does not deliver that: it short-circuits the anchor + /// and phase probes but the gap-fill loop still runs, and on a multi-CPS disc it + /// re-samples every extent unless `base_slot_for_extent` banked its verdict. + #[test] + fn fmts_multi_cps_gap_fill_samples_each_extent_once_per_disc() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls); + let mut reader = FmtsDisc::with_second_cps_unit(); + let mut keys = fmts_two_cps_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_two_cps_title(); + + let first = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("first title resolves"); + let after_first = reader.probe_reads; + assert!( + after_first >= 16, + "the first title really samples both extents (8 probes each), saw \ + {after_first} content reads" + ); + + let second = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("second title resolves"); + assert_eq!( + reader.probe_reads, after_first, + "a second title over the same extents must touch the drive ZERO more \ + times — the CPS verdict is a property of the extent's own bytes" + ); + for lba in [ + 10_000u32, + 10_300, + 10_600, + FMTS_CPS2_LBA, + FMTS_CPS2_LBA + 599, + ] { + assert_eq!( + second.key_idx_for(lba), + first.key_idx_for(lba), + "and the cached map must be identical at LBA {lba}" + ); + } + } + + // ── An operator Stop that lands MID-probe ──────────────────────────────── + // + // The FMTS probes are the heaviest thing this crate does to a live drive: + // hundreds of random 6144-byte reads, each able to stall to the SCSI recovery + // timeout on a marginal disc. The module's hard rule is that `/api/stop` is + // honored at every loop boundary rather than after the whole probe completes. + // + // The existing halt tests all pre-cancel, so the ENTRY poll alone satisfies + // them and the two polls inside the probe loops are unconstrained. Cancelling + // by OUTCOME is not enough either — a later poll still returns `Halted`, so a + // deleted poll looks identical. What distinguishes them is what the drive was + // asked to do after the Stop, so both tests below count reads. + + fn halted_err() -> String { + std::io::Error::from(crate::error::Error::Halted).to_string() + } + + /// Stop lands while the UDF walk is still running — before the anchor loop. + /// The anchor loop's own poll must catch it, so the drive is never asked for a + /// single CONTENT sector. Without that poll the entry poll has already passed + /// and the next one is inside the phase loop, so the full anchor batch (two + /// `MIN_SAMPLE_UNITS` phase reads plus a key-service round trip) is issued to a + /// drive the operator has already stopped. + #[test] + fn fmts_stop_during_the_udf_walk_touches_no_content_sector() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls.clone()); + let halt = crate::halt::Halt::new(); + let mut reader = FmtsDisc::new(); + reader.cancel_after = Some((halt.clone(), CancelWhen::Meta, 1)); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_title(FMTS_CONTENT_SECTORS); + + let err = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + Some(&halt), + &mut cache, + ) + .expect_err("a Stop during the UDF walk must abort the resolve"); + assert_eq!(err.to_string(), halted_err(), "the verdict is Halted"); + assert!( + reader.meta_reads > 0, + "the fixture must really have reached the UDF walk" + ); + assert_eq!( + reader.probe_reads, 0, + "a stopped drive must not be asked for a single content sector" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "nor the key service asked for anything" + ); + } + + /// Stop lands with the drive already working, during the anchor batch. The + /// PHASE loop's poll must catch it before probing index 1's parity — otherwise + /// every index in the set is probed (`MAX_ANCHOR_ATTEMPTS` segments × + /// `MIN_SAMPLE_UNITS` × 2 parities each) after the operator said stop. + #[test] + fn fmts_stop_during_the_anchor_batch_stops_before_the_phase_probes() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls.clone()); + let halt = crate::halt::Halt::new(); + let mut reader = FmtsDisc::new(); + // One content read in, i.e. inside the very first anchor phase batch: the + // anchor loop's poll has already run and passed for this segment. + reader.cancel_after = Some((halt.clone(), CancelWhen::Probe, 1)); + let mut keys = fmts_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_title(FMTS_CONTENT_SECTORS); + + let err = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + Some(&halt), + &mut cache, + ) + .expect_err("a Stop during the anchor batch must abort the resolve"); + assert_eq!(err.to_string(), halted_err(), "the verdict is Halted"); + // The anchor completed (it is one uninterruptible batch by construction); + // the phase probes must NOT have started. Each phase probe reads + // 2 * MIN_SAMPLE_UNITS units per attempted segment, so anything beyond the + // anchor's own reads is the phase loop running past the Stop. + let anchor_cost = crate::keysource::MIN_SAMPLE_UNITS as u32; + assert!( + reader.probe_reads > 0, + "the fixture must really have reached the anchor batch" + ); + assert!( + reader.probe_reads <= 2 * anchor_cost, + "the phase probes must not run after the Stop — saw {} content reads, \ + at most {} belong to the anchor", + reader.probe_reads, + 2 * anchor_cost + ); + } } diff --git a/src/mux/ts.rs b/src/mux/ts.rs index fa11fe6..ef2542a 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -2598,4 +2598,251 @@ mod tests { "recovered PES carries only the post-reset ES bytes" ); } + + // ── PAT / PMT section-walk boundaries (ISO/IEC 13818-1 §2.4.4.3, §2.4.4.8) ── + + /// A PAT TS packet with an arbitrary program loop and arbitrary CRC bytes. + /// `entries` is `(program_number, pid)`; `crc` is the 4 bytes that follow the + /// loop, which the scanner never validates and must never PARSE either. + fn pat_packet_entries(entries: &[(u16, u16)], crc: [u8; 4]) -> Vec { + let mut body = [0xFFu8; 184]; + body[0] = 0x00; // pointer_field + let s = 1; + body[s] = 0x00; // table_id = PAT + // tsid(2) + version(1) + section_number(1) + last_section(1) + // + 4 per program entry + 4-byte CRC. + let section_length = 5 + entries.len() * 4 + 4; + body[s + 1] = 0xB0 | (((section_length >> 8) as u8) & 0x0F); + body[s + 2] = (section_length & 0xFF) as u8; + body[s + 3] = 0x00; // tsid hi + body[s + 4] = 0x01; // tsid lo + body[s + 5] = 0xC1; // version / current_next + body[s + 6] = 0x00; // section_number + body[s + 7] = 0x00; // last_section_number + let mut p = s + 8; + for &(prog, pid) in entries { + body[p] = (prog >> 8) as u8; + body[p + 1] = (prog & 0xFF) as u8; + body[p + 2] = 0xE0 | (((pid >> 8) as u8) & 0x1F); + body[p + 3] = (pid & 0xFF) as u8; + p += 4; + } + body[p..p + 4].copy_from_slice(&crc); + bdts_packet(body, 0, true) + } + + /// A PMT TS packet whose ES entries may carry descriptors, and whose section + /// may declare `stuffing` extra bytes between the last entry and the CRC. + /// `entries` is `(stream_type, es_pid, es_info bytes)`. + fn pmt_packet_desc( + pmt_pid: u16, + entries: &[(u8, u16, Vec)], + stuffing: &[u8], + crc: [u8; 4], + ) -> Vec { + let mut body = [0xFFu8; 184]; + body[0] = 0x00; // pointer_field + let s = 1; + body[s] = 0x02; // table_id = PMT + let entries_len: usize = entries.iter().map(|(_, _, d)| 5 + d.len()).sum(); + let section_length = 9 + entries_len + stuffing.len() + 4; + body[s + 1] = 0xB0 | (((section_length >> 8) as u8) & 0x0F); + body[s + 2] = (section_length & 0xFF) as u8; + body[s + 3] = 0x00; // program_number hi + body[s + 4] = 0x01; // program_number lo + body[s + 5] = 0xC1; + body[s + 6] = 0x00; + body[s + 7] = 0x00; + body[s + 8] = 0xE0; // PCR PID + body[s + 9] = 0x00; + body[s + 10] = 0xF0; // program_info_length = 0 + body[s + 11] = 0x00; + let mut p = s + 12; + for (stype, es_pid, desc) in entries { + body[p] = *stype; + body[p + 1] = 0xE0 | (((es_pid >> 8) as u8) & 0x1F); + body[p + 2] = (es_pid & 0xFF) as u8; + body[p + 3] = 0xF0 | (((desc.len() >> 8) as u8) & 0x0F); + body[p + 4] = (desc.len() & 0xFF) as u8; + body[p + 5..p + 5 + desc.len()].copy_from_slice(desc); + p += 5 + desc.len(); + } + body[p..p + stuffing.len()].copy_from_slice(stuffing); + p += stuffing.len(); + body[p..p + 4].copy_from_slice(&crc); + bdts_packet(body, pmt_pid, true) + } + + fn stream_pids(streams: &[crate::disc::Stream]) -> Vec { + use crate::disc::Stream; + streams + .iter() + .map(|s| match s { + Stream::Video(v) => v.pid, + Stream::Audio(a) => a.pid, + Stream::Subtitle(t) => t.pid, + }) + .collect() + } + + /// ISO/IEC 13818-1 §2.4.4.3: a PAT entry with `program_number == 0` carries the + /// **network PID**, not a program's PMT PID. Taking it would point pass 2 at the + /// NIT, where there is no `table_id 0x02` — so the title's whole stream list + /// comes back empty and the disc looks like it has no streams. + /// + /// Two entries with DISTINCT PIDs, the NIT first, so the test also pins the loop + /// STRIDE: a 4-byte program entry. A stride that is not 4 lands mid-entry on the + /// second one and decodes a PID belonging to nothing. + #[test] + fn scan_streams_skips_the_pat_network_entry_and_takes_the_real_program() { + let pmt_pid = 0x0100u16; + let nit_pid = 0x0010u16; // the customary network PID + let pat = pat_packet_entries(&[(0, nit_pid), (1, pmt_pid)], [0xFF; 4]); + let mut data = pat.clone(); + data.extend(pmt_packet(pmt_pid, &[(0x1B, 0x1011)])); + data.extend(pat); // follower, for the resync corroboration + let streams = + scan_streams(&data).expect("the program-1 entry must be the one that is used"); + assert_eq!( + stream_pids(&streams), + vec![0x1011], + "the PMT found through program 1's PID" + ); + + // And a PAT that carries ONLY a network entry declares no program at all. + let nit_only = pat_packet_entries(&[(0, nit_pid)], [0xFF; 4]); + let mut data = nit_only.clone(); + data.extend(pmt_packet(pmt_pid, &[(0x1B, 0x1011)])); + data.extend(nit_only); + assert!( + scan_streams(&data).is_none(), + "a network entry is not a program — there is nothing to scan" + ); + } + + /// The PAT program loop stops before the 4-byte `CRC_32` + /// (ISO/IEC 13818-1 Table 2-30): `section_length` counts the CRC, the loop + /// must not. The scanner never validates the CRC, so those 4 bytes are + /// effectively arbitrary — here they are the bit pattern of a valid-looking + /// `program_number 1 → pmt_pid` entry. + /// + /// Reading them as a program is not a crash, it is a WRONG ANSWER that looks + /// right: the scan returns a full stream list for a PAT that declares no + /// program at all. So the assertion is that the streams are NOT found. + #[test] + fn scan_streams_does_not_read_the_pat_crc_as_a_program_entry() { + let pmt_pid = 0x0100u16; + // A CRC whose bytes spell "program 1 → 0x0100". + let crc = [ + 0x00, + 0x01, + 0xE0 | ((pmt_pid >> 8) as u8 & 0x1F), + pmt_pid as u8, + ]; + let pat = pat_packet_entries(&[(0, 0x0010)], crc); + let mut data = pat.clone(); + data.extend(pmt_packet(pmt_pid, &[(0x1B, 0x1011)])); + data.extend(pat); + assert!( + scan_streams(&data).is_none(), + "the CRC_32 is not a program entry, however much it looks like one" + ); + } + + /// ISO/IEC 13818-1 §2.4.4.8: each ES entry is followed by `ES_info_length` + /// bytes of descriptors, and the next entry starts after them. Every existing + /// PMT fixture declares `ES_info_length = 0`, so the skip is unconstrained by + /// them — while a real BD PMT carries a registration descriptor on essentially + /// every entry. + /// + /// Failing to skip them does not fail loudly: the descriptor bytes are decoded + /// as an ES entry, so the scan reports streams on PIDs that carry nothing and + /// silently loses the entries that follow. Here the first entry's descriptor is + /// a real `registration_descriptor` for 'HDMV'. + #[test] + fn scan_streams_steps_over_es_descriptors_to_reach_the_next_entry() { + let pmt_pid = 0x0100u16; + let hdmv = vec![0x05, 0x04, b'H', b'D', b'M', b'V']; + let pmt = pmt_packet_desc( + pmt_pid, + &[ + (0x1B, 0x1011, hdmv), // H.264 video, with a descriptor + (0x81, 0x1100, Vec::new()), // AC-3 audio, without + ], + &[], + [0xFF; 4], + ); + let mut data = pat_packet(pmt_pid); + data.extend(pmt); + data.extend(pat_packet(pmt_pid)); + let streams = scan_streams(&data).expect("both entries parse"); + assert_eq!( + stream_pids(&streams), + vec![0x1011, 0x1100], + "the descriptor bytes are skipped, not decoded as an entry, and the \ + entry after them is still reached" + ); + assert!( + matches!(streams[0], crate::disc::Stream::Video(_)), + "0x1B is video" + ); + assert!( + matches!(streams[1], crate::disc::Stream::Audio(_)), + "0x81 is audio" + ); + } + + /// The PMT ES loop stops before the `CRC_32` for the same reason the PAT loop + /// does. It only shows when the declared section is longer than the entries — + /// a padded PMT — because otherwise the loop runs out of room on its own and + /// the CRC is never in reach. + /// + /// Here two stuffing bytes sit between the last entry and the CRC, and the + /// first of them is `0x1B`. Parsing into the CRC therefore invents a SECOND + /// video stream, on a PID assembled from CRC bytes — a stream the mux would go + /// on to demux, finding nothing. + #[test] + fn scan_streams_does_not_read_the_pmt_crc_as_an_es_entry() { + let pmt_pid = 0x0100u16; + let pmt = pmt_packet_desc( + pmt_pid, + &[(0x1B, 0x1011, Vec::new())], + &[0x1B, 0xE2], // padding: a stream_type byte and a PID high byte + [0x22, 0xF0, 0x00, 0x99], + ); + let mut data = pat_packet(pmt_pid); + data.extend(pmt); + data.extend(pat_packet(pmt_pid)); + let streams = scan_streams(&data).expect("the real entry parses"); + assert_eq!( + stream_pids(&streams), + vec![0x1011], + "only the declared ES entry — the CRC_32 is not an entry" + ); + } + + /// `section_length` counts the bytes AFTER the length field including the + /// 4-byte CRC, so the program loop's end is `3 + section_length - 4`. A section + /// declaring less than 4 underflows that subtraction — a debug panic, and in + /// release a wrapped bound clamped to the whole buffer. The guard turns a + /// malformed PAT into an ordinary "no program here" instead. + /// + /// Reachable exactly as written: `collect_psi_section` truncates the section to + /// `3 + section_length`, so a declared 0 yields the 3-byte header alone. + #[test] + fn scan_streams_pat_section_length_below_the_crc_size_is_rejected_not_underflowed() { + let mut body = [0xFFu8; 184]; + body[0] = 0x00; // pointer_field + body[1] = 0x00; // table_id = PAT + body[2] = 0xB0; // section_syntax + reserved, length high nibble = 0 + body[3] = 0x00; // section_length = 0 — shorter than its own CRC + let pat = bdts_packet(body, 0, true); + let mut data = pat.clone(); + data.extend(pmt_packet(0x0100, &[(0x1B, 0x1011)])); + data.extend(pat); + assert!( + scan_streams(&data).is_none(), + "an undersized PAT section declares no program" + ); + } }