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:
Matthew Jackson
2026-07-30 16:05:37 -07:00
parent e99b634635
commit 0bc8d7af9c
4 changed files with 1455 additions and 7 deletions
+145
View File
@@ -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"
);
}
}
+314
View File
@@ -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
View File
@@ -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
View File
@@ -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"
);
}
}