test: constrain the AACS key-map gap fill, the PSI walk, and MP4 field offsets
Third pass over src/mux/. 40 survivors killed, no production change. resolve.rs — the deleted-statement cluster is now fully constrained. All 14 deletable statements probed; 9 were already caught, 5 survived: c.sort_unstable() in fill_base_key_gaps. Every existing case handed it cuts already in LBA order, but IndividualSegment.tbl is a record list. Verified on HEAD: deleting the sort passes all 54 resolve tests. The mutant lays a base-key fill straight over a forensic segment. last_idx = idx (FMTS gap fill) and last_idx = hit (multi-CPS cache hit). An extent with nothing to sample must inherit its neighbour's CPS unit; the mutants fall back to the first unit's key. Exactly the shape this file's own comments name — wrong key, no error, lost_bytes == 0. Both check_halt()? polls in probe_fmts_index_keys. These cannot be killed by outcome, since a later poll returns Halted too. The tests count reads instead, which is what the don't-hammer-a-struggling- drive rule actually says: after a Stop the drive is asked for zero content sectors. The four unresolved += 1 arms each got a test, and deleting each fails exactly one — one-to-one, so no fixture passes for the wrong reason. A control test pins that the baseline table resolves, so an expect_err cannot succeed for an unrelated reason. ts.rs::scan_streams was never entered. Six killed, two of which return wrong answers that look right: reading the PAT/PMT CRC as a table entry invents a stream on PID 546 out of CRC bytes, and dropping the ES_info_length skip decodes a descriptor as an entry and loses the one after it. Every existing PMT fixture declares ES_info_length = 0; a real BD PMT carries a registration descriptor on essentially every entry. Also ISO/IEC 13818-1 2.4.4.3 program_number == 0 is the network PID, not a program. mp4/read.rs — 13. Height read as the width beside it; channelcount; the 4-byte base-128 descriptor varint (every existing esds fixture uses a single byte); all three optional ES_Descriptor fields, whose loss is silent (an AAC track just loses its CodecPrivate); first-vs-last media edit, which is A/V desync of the difference; and the version-1 mvhd timescale offset, emitted by any writer whose duration exceeds 32 bits. dts.rs — 7, from a real cargo-mutants run over the file rather than guesswork. Including a buffer that IS the syncword, which is the state a sync split across PES packets lands in the moment its last byte arrives. Equivalents proven by application, not argued: the sample_encrypted_units guard pair is mutually redundant by construction (total*p/9 < total for p <= 8), so either alone is equivalent and both together are not; the PMT section_len guard is dead code where its PAT twin panics; three of the seven EXSS_HEADER_MIN_BYTES arithmetic mutants still sum to 10.
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+749
-7
@@ -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<u8> {
|
||||
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<u8>,
|
||||
}
|
||||
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<usize> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+247
@@ -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<u8> {
|
||||
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<u8>)],
|
||||
stuffing: &[u8],
|
||||
crc: [u8; 4],
|
||||
) -> Vec<u8> {
|
||||
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<u16> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user