Fix fifteen defects across perf, resource, panics and key hygiene
All 21 findings held up under verification; 15 fixed here, 6 deferred to files another agent held this round, 0 rejected. **A defect in my own round-2 probe fix.** CHUNK_SECTORS was 1024, and 1024 % 3 == 1 — verified — so every chunk after the first was misaligned against the 6144-byte AACS aligned unit and would be REJECTED by DecryptingSectorSource's alignment gate. On an encrypted disc the forced-subtitle probe I added last round would have read almost nothing past its first chunk. Now 1023 sectors (341 aligned units) with a const assertion that fails the build if it stops dividing, plus set_unit_base per extent so the source's gate is anchored where the extent actually starts. **The same probe skipped sectors on a short read**, advancing by the REQUESTED count rather than the bytes actually returned, so a partial read silently left a gap in the middle of the evidence. It now advances by n/SECTOR_BYTES and clamps n to the buffer. **Its cache key omitted the PGS PID set**, so a playlist declaring an extra subtitle PID got another playlist's verdict for a track that had never been probed. And the key was the whole extent list, so partial clip sharing missed entirely. Both fixed by keying (start_lba, sector_count, pid) — and per-extent keying was shown SOUND rather than assumed: ForcedTracker is two monotone booleans, so per-extent evidence composes by field-wise OR, order- and grouping-independently. Making that honest required per-extent demux state, so an extent's evidence comes only from its own bytes, and memoising only extents whose read reached a designed stop. **A reachable panic in the timeline.** mkvstream::parse_block accepts a TimestampScale up to i64::MAX, so a video frame can set high_ns = i64::MAX and the next passive frame panicked adding the backstep. In release it wrapped negative instead, firing the straggler clamp for essentially every passive frame — audio and subtitles rewritten onto the wrong point of the output timeline. All four sites saturate. **A public constructor divided by zero**: PrefetchedSectorSource::new_with_events with unit_align == 0. Now InvalidInput, matching its batch_sectors sibling. **Two Debug impls printed key material.** DiscInputs (volume_id, mkb, unit_key_ro, samples) and UnitKeyFile both derived Debug. Nothing logs them today — fixed as prevention, because the next tracing::debug! someone adds is the leak. A doc claim that DiscInputs "contains no secrets" was false and is corrected. **An env-var multiply could overflow** in file_sector_source; now bounded at 64 GiB like its writeback sibling, with the parse split out so the bound is testable without touching process env. **The mp4 demuxer allowed one sample per file byte** — ~64x RAM amplification. Now file_len/16, since only vide/soun tracks are indexed and the shortest legal AC-3 frame is 128 bytes. **Two pipeline concurrency defects**: a consumer apply() error was invisible to the producer, and abandon/finalise had a TOCTOU where a caller could report an unfinalised output. Both fixed with compare-exchange state rather than a bool. **Two per-frame copies removed**, both MEASURED rather than reasoned: the AU assembler now hands its allocation to the frame (same pointer, unchanged capacity, proven by asserting the pointer) and tsmux reuses one Annex-B buffer across frames. Both keep capacity deliberately — a naive split_off would have cost more than it saved. **A comment pointed at the wrong file** for a mirrored constant; the mirror is now compiler-enforced with a const assertion converting 90 kHz ticks to ns, so drift fails the build. Deferred to another agent's files, all confirmed: detect_rate's fractional-twin snap, the mp4 reserve's u32 truncation, round_up_grain's overflow, the quadratic base-key gap fill, and MkvStream's frame cap counting frames rather than bytes. Every fix verified red by reverting it. Also noted for later: DecodeSampleSet still derives Debug over multi-MB of on-disc ciphertext.
This commit is contained in:
+488
-90
@@ -29,8 +29,22 @@ use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SECTOR_BYTES: usize = 2048;
|
||||
/// Read the clip in 2 MiB chunks.
|
||||
const CHUNK_SECTORS: u16 = 1024;
|
||||
/// Read the clip in ~2 MiB chunks.
|
||||
///
|
||||
/// A whole number of AACS aligned units (3 sectors / 6144 B), because with a
|
||||
/// decrypting source — the case this module's doc promises — every read must
|
||||
/// begin on a unit boundary measured from the extent base or
|
||||
/// `DecryptingSectorSource` rejects it outright with `DecryptFailed`. At 1024
|
||||
/// (`1024 % 3 == 1`) every chunk after the first drifted off the boundary, so
|
||||
/// content-based forced detection was unreachable past the first chunk of an
|
||||
/// AACS disc. 1023 = 341 units.
|
||||
const CHUNK_SECTORS: u16 = 1023;
|
||||
|
||||
// The alignment requirement above is enforced, not just described.
|
||||
const _: () = assert!(
|
||||
CHUNK_SECTORS as u32 % crate::aacs::content::ALIGNED_UNIT_SECTORS == 0,
|
||||
"probe chunks must be a whole number of AACS aligned units"
|
||||
);
|
||||
|
||||
/// Hard ceiling on sectors read per probe call (256 MiB).
|
||||
///
|
||||
@@ -43,12 +57,46 @@ const CHUNK_SECTORS: u16 = 1024;
|
||||
/// non-forced set before accepting the forced verdict.
|
||||
const PROBE_BUDGET_SECTORS: u32 = 131_072;
|
||||
|
||||
/// Memoises probe results across titles. Keyed by the title's exact extent
|
||||
/// list, so a hit returns a result computed from byte-identical input — many
|
||||
/// playlists on one disc reference the same clips (main feature, play-all,
|
||||
/// seamless-branch variants), and without this the same physical extents are
|
||||
/// re-read from the drive once per playlist.
|
||||
pub(crate) type ForcedProbeCache = HashMap<Vec<(u32, u32)>, HashMap<u16, bool>>;
|
||||
/// What one probed extent showed about one PGS track — the two monotone facts a
|
||||
/// [`ForcedTracker`] accumulates, and nothing else.
|
||||
///
|
||||
/// Keeping the EVIDENCE (rather than a composed forced/not-forced verdict) is
|
||||
/// what makes per-extent memoisation sound: both fields only ever go from
|
||||
/// `false` to `true` as more data is seen, so a title's verdict is the
|
||||
/// field-wise OR over its extents, in any order, with no dependence on how the
|
||||
/// extents were grouped into playlists.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
|
||||
pub(crate) struct TrackEvidence {
|
||||
/// A PGS display set was actually seen for this track in this extent.
|
||||
observed: bool,
|
||||
/// At least one of those display sets was NOT forced.
|
||||
non_forced: bool,
|
||||
}
|
||||
|
||||
impl TrackEvidence {
|
||||
fn merge(&mut self, other: Self) {
|
||||
self.observed |= other.observed;
|
||||
self.non_forced |= other.non_forced;
|
||||
}
|
||||
}
|
||||
|
||||
/// Memoises probe results across titles, keyed PER PHYSICAL EXTENT and per PGS
|
||||
/// track — `(start_lba, sector_count, pid)`.
|
||||
///
|
||||
/// Many playlists on one disc reference the same clips (main feature, play-all,
|
||||
/// seamless-branch variants) but rarely with byte-identical extent LISTS: 00800
|
||||
/// = [A, B], 00801 = [A], 00802 = [B] are three different lists over two clips.
|
||||
/// Keying on the whole list de-duplicated only exactly-identical playlists and
|
||||
/// re-read every shared clip once per list — up to `PROBE_BUDGET_SECTORS`
|
||||
/// (256 MiB) of optical-drive time each. Per-extent keying reads each physical
|
||||
/// extent at most once per disc, and per-track keying means a playlist that
|
||||
/// declares MORE PGS tracks over the same extents still probes the extra ones
|
||||
/// instead of silently taking a verdict map that has no entry for them.
|
||||
///
|
||||
/// Only extents whose read reached a DESIGNED stop are memoised (see
|
||||
/// `probe_and_set_forced`), so one cancellation or read fault is never frozen in
|
||||
/// as an extent's answer.
|
||||
pub(crate) type ForcedProbeCache = HashMap<(u32, u32, u16), TrackEvidence>;
|
||||
|
||||
/// Why the read loop stopped — which decides whether the observations it
|
||||
/// accumulated may be applied as an authoritative verdict.
|
||||
@@ -125,57 +173,109 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Same extents → same verdicts. Serve from cache rather than re-reading.
|
||||
let key: Vec<(u32, u32)> = title
|
||||
.extents
|
||||
// Same extent → same evidence. Take from the cache what is already known and
|
||||
// read only the extents that are not (for every declared track).
|
||||
let mut evidence: HashMap<u16, TrackEvidence> = pg_pids
|
||||
.iter()
|
||||
.map(|e| (e.start_lba, e.sector_count))
|
||||
.map(|&p| (p, TrackEvidence::default()))
|
||||
.collect();
|
||||
if let Some(hit) = cache.get(&key) {
|
||||
apply_verdicts(title, hit);
|
||||
let mut todo: Vec<crate::disc::Extent> = Vec::new();
|
||||
for ext in &title.extents {
|
||||
let hits: Option<Vec<TrackEvidence>> = pg_pids
|
||||
.iter()
|
||||
.map(|&p| cache.get(&(ext.start_lba, ext.sector_count, p)).copied())
|
||||
.collect();
|
||||
match hits {
|
||||
Some(known) => {
|
||||
for (&pid, ev) in pg_pids.iter().zip(known) {
|
||||
if let Some(slot) = evidence.get_mut(&pid) {
|
||||
slot.merge(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
// At least one declared track has no evidence for this extent — read
|
||||
// it. (A playlist that declares a PGS PID a previous playlist did not
|
||||
// lands here, so the extra track is genuinely probed.)
|
||||
None => todo.push(*ext),
|
||||
}
|
||||
}
|
||||
if todo.is_empty() {
|
||||
// Every extent's evidence came from a run that reached a designed stop, so
|
||||
// an absence claim over the composed evidence is as sound as the run that
|
||||
// produced each part.
|
||||
apply_verdicts(title, &verdicts(&evidence, true));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut demux = TsDemuxer::new(&pg_pids);
|
||||
let mut parsers: HashMap<u16, PgsParser> =
|
||||
pg_pids.iter().map(|&p| (p, PgsParser::new())).collect();
|
||||
let mut trackers: HashMap<u16, ForcedTracker> =
|
||||
pg_pids.iter().map(|&p| (p, ForcedTracker::new())).collect();
|
||||
|
||||
let extents = title.extents.clone();
|
||||
let mut buf = vec![0u8; CHUNK_SECTORS as usize * SECTOR_BYTES];
|
||||
let mut sectors_read: u32 = 0;
|
||||
// Record WHY the loop ended rather than leaving it implicit in the control
|
||||
// flow: every exit below names its reason, and the reason decides what may be
|
||||
// asserted from what was observed.
|
||||
let stop = 'outer: {
|
||||
for ext in &extents {
|
||||
let mut lba = ext.start_lba;
|
||||
let mut remaining = ext.sector_count;
|
||||
while remaining > 0 {
|
||||
// Bounded work and a responsive cancel: without these the probe
|
||||
// reads the entire title whenever a track really is forced.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
break 'outer StopReason::Halted;
|
||||
let mut stop = StopReason::Exhausted;
|
||||
'outer: for ext in &todo {
|
||||
// Demux/parse state is PER EXTENT, so the evidence an extent yields is
|
||||
// derived from that extent's own bytes and nothing else — which is what
|
||||
// makes the per-extent cache entry mean what it claims, and is required
|
||||
// now that a cache hit can make the read skip an extent in the middle of
|
||||
// the title (a demuxer carried across a skipped extent would splice two
|
||||
// non-adjacent byte runs into one PES). Each extent is a clip's own
|
||||
// contiguous run, so this loses at most a display set that straddles an
|
||||
// extent boundary of a fragmented file.
|
||||
let mut demux = TsDemuxer::new(&pg_pids);
|
||||
let mut parsers: HashMap<u16, PgsParser> =
|
||||
pg_pids.iter().map(|&p| (p, PgsParser::new())).collect();
|
||||
let mut trackers: HashMap<u16, ForcedTracker> =
|
||||
pg_pids.iter().map(|&p| (p, ForcedTracker::new())).collect();
|
||||
// AACS aligned units are anchored at THIS extent's start LBA, so tell a
|
||||
// decrypt-on-read source to gate relative to it rather than absolute disc
|
||||
// LBA 0 — without this the very first read of a clip whose start_lba is
|
||||
// not itself 3-aligned is rejected. Mirrors the mux read paths.
|
||||
reader.set_unit_base(ext.start_lba);
|
||||
let mut lba = ext.start_lba;
|
||||
let mut remaining = ext.sector_count;
|
||||
// `None` = this extent was read to its end, so its evidence is complete
|
||||
// and may be memoised. `Some(reason)` = the read stopped early.
|
||||
let mut cut_short: Option<StopReason> = None;
|
||||
while remaining > 0 {
|
||||
// Bounded work and a responsive cancel: without these the probe
|
||||
// reads the entire title whenever a track really is forced.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
cut_short = Some(StopReason::Halted);
|
||||
break;
|
||||
}
|
||||
if sectors_read >= PROBE_BUDGET_SECTORS {
|
||||
cut_short = Some(StopReason::Budget);
|
||||
break;
|
||||
}
|
||||
let budget_left = PROBE_BUDGET_SECTORS - sectors_read;
|
||||
let count = remaining.min(CHUNK_SECTORS as u32).min(budget_left) as u16;
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
let n = match reader.read_sectors(lba, count, &mut buf[..want], false) {
|
||||
Ok(n) => n,
|
||||
// Best-effort — stop reading, but the data past here was never
|
||||
// seen, so the observation is a truncated prefix.
|
||||
Err(_) => {
|
||||
cut_short = Some(StopReason::ReadFailed);
|
||||
break;
|
||||
}
|
||||
if sectors_read >= PROBE_BUDGET_SECTORS {
|
||||
break 'outer StopReason::Budget;
|
||||
}
|
||||
let budget_left = PROBE_BUDGET_SECTORS - sectors_read;
|
||||
let count = remaining.min(CHUNK_SECTORS as u32).min(budget_left) as u16;
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
let n = match reader.read_sectors(lba, count, &mut buf[..want], false) {
|
||||
Ok(n) => n,
|
||||
// Best-effort — stop reading, but the data past here was never
|
||||
// seen, so the observation is a truncated prefix.
|
||||
Err(_) => break 'outer StopReason::ReadFailed,
|
||||
};
|
||||
if n == 0 {
|
||||
// Short read: the extent claimed sectors the source would not
|
||||
// yield. Same truncated prefix as an error.
|
||||
break 'outer StopReason::ReadFailed;
|
||||
}
|
||||
for pes in demux.feed(&buf[..n]) {
|
||||
};
|
||||
// Advance by what was actually READ, not by what was requested. A
|
||||
// short-but-nonzero read (a source whose batch is smaller than the
|
||||
// request — `PrefetchedSectorSource` returns its producer's batch)
|
||||
// used to advance `lba`/`remaining`/`sectors_read` by the full
|
||||
// `count`, silently SKIPPING the unread tail of the chunk while
|
||||
// `stop` stayed `Exhausted` — so an absence-based forced verdict was
|
||||
// asserted (and memoised) over data that was never seen. The partial
|
||||
// trailing sector, if any, is left for the next read rather than fed
|
||||
// twice.
|
||||
let got = (n.min(want) / SECTOR_BYTES) as u32;
|
||||
if got == 0 {
|
||||
// Less than one whole sector: the bytes are real, so feed them,
|
||||
// but the loop cannot advance (re-reading the same partial sector
|
||||
// would feed it twice) — so this is the truncated prefix an error
|
||||
// is. A source that claims sectors and yields none is the same case.
|
||||
for pes in demux.feed(&buf[..n.min(want)]) {
|
||||
if let (Some(parser), Some(tracker)) =
|
||||
(parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid))
|
||||
{
|
||||
@@ -184,64 +284,111 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every track has already shown a non-forced set → nothing left to
|
||||
// learn; stop reading the (huge) clip.
|
||||
if trackers.values().all(ForcedTracker::settled_not_forced) {
|
||||
break 'outer StopReason::Exhausted;
|
||||
cut_short = Some(StopReason::ReadFailed);
|
||||
break;
|
||||
}
|
||||
for pes in demux.feed(&buf[..got as usize * SECTOR_BYTES]) {
|
||||
if let (Some(parser), Some(tracker)) =
|
||||
(parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid))
|
||||
{
|
||||
for frame in parser.parse(&pes) {
|
||||
tracker.observe(&frame.data);
|
||||
}
|
||||
}
|
||||
lba += count as u32;
|
||||
remaining -= count as u32;
|
||||
sectors_read += count as u32;
|
||||
}
|
||||
lba += got;
|
||||
remaining -= got;
|
||||
sectors_read += got;
|
||||
// Every track has already shown a non-forced set — counting the
|
||||
// evidence carried in from other extents — so there is nothing left to
|
||||
// learn; stop reading the (huge) clip.
|
||||
if pg_pids.iter().all(|p| {
|
||||
let carried = evidence.get(p).copied().unwrap_or_default().non_forced;
|
||||
carried
|
||||
|| trackers
|
||||
.get(p)
|
||||
.is_some_and(ForcedTracker::settled_not_forced)
|
||||
}) {
|
||||
cut_short = Some(StopReason::Exhausted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
StopReason::Exhausted
|
||||
};
|
||||
|
||||
// Drain any buffered final display set.
|
||||
for (pid, parser) in parsers.iter_mut() {
|
||||
if let Some(tracker) = trackers.get_mut(pid) {
|
||||
for frame in parser.flush() {
|
||||
tracker.observe(&frame.data);
|
||||
// Drain any buffered final display set of THIS extent.
|
||||
for (pid, parser) in parsers.iter_mut() {
|
||||
if let Some(tracker) = trackers.get_mut(pid) {
|
||||
for frame in parser.flush() {
|
||||
tracker.observe(&frame.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fold this extent's evidence in, and memoise it if the extent's read
|
||||
// reached a DESIGNED stop — read to its end, stopped at the sector budget,
|
||||
// or stopped because every track had already settled. The budget is a
|
||||
// designed stop for exactly the reason [`StopReason`] documents (a forced
|
||||
// track's display sets appear throughout, so a bounded prefix is
|
||||
// representative), and it is the stop that fires on every disc that HAS a
|
||||
// forced track — excluding it from the cache would mean nothing is ever
|
||||
// memoised on precisely those discs.
|
||||
//
|
||||
// A halt or a read fault is different: the cut-off point is arbitrary, so
|
||||
// its evidence is real for THIS title (nothing observed is retracted) but
|
||||
// must not be frozen in as the extent's answer, or one transient fault
|
||||
// would be replayed onto every other playlist sharing the clip.
|
||||
let cacheable = cut_short.is_none_or(StopReason::absence_is_conclusive);
|
||||
for (&pid, t) in trackers.iter() {
|
||||
let ev = TrackEvidence {
|
||||
observed: t.observed(),
|
||||
non_forced: t.settled_not_forced(),
|
||||
};
|
||||
if let Some(slot) = evidence.get_mut(&pid) {
|
||||
slot.merge(ev);
|
||||
}
|
||||
if cacheable {
|
||||
cache.insert((ext.start_lba, ext.sector_count, pid), ev);
|
||||
}
|
||||
}
|
||||
if let Some(reason) = cut_short {
|
||||
stop = reason;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the verdicts we are entitled to assert and apply them. A track
|
||||
// absent from the map keeps its vendor-derived flag.
|
||||
//
|
||||
// Two gates, both PER TRACK, because the evidence is per track:
|
||||
// * `observed()` — saw no display set at all, so nothing is known. (Never
|
||||
// assert "not forced" from having seen nothing.)
|
||||
// * on a truncated run, `settled_not_forced()` — the track saw an actual
|
||||
// non-forced display set, which no further reading could retract, so that
|
||||
// verdict stands even though the run was cut short. A track that merely
|
||||
// hadn't YET seen a non-forced set is exactly the claim the truncation
|
||||
// invalidates, so it is dropped and keeps the vendor flag.
|
||||
let conclusive = stop.absence_is_conclusive();
|
||||
let verdicts: HashMap<u16, bool> = trackers
|
||||
.iter()
|
||||
.filter(|(_, t)| t.observed() && (conclusive || t.settled_not_forced()))
|
||||
.map(|(&pid, t)| (pid, t.is_forced()))
|
||||
.collect();
|
||||
// Only memoise a run that reached a designed stop. The cache key is the
|
||||
// extent list, so caching a truncated run would replay one read fault (or one
|
||||
// cancellation) onto every other playlist that shares these clips, and a later
|
||||
// title would never get the chance to re-read them successfully.
|
||||
if conclusive {
|
||||
cache.insert(key, verdicts.clone());
|
||||
} else {
|
||||
let verdicts = verdicts(&evidence, conclusive);
|
||||
if !conclusive {
|
||||
tracing::debug!(
|
||||
target: "freemkv::scan",
|
||||
stop = ?stop,
|
||||
sectors_read,
|
||||
asserted = verdicts.len(),
|
||||
tracks = pg_pids.len(),
|
||||
"forced-subtitle probe truncated; verdicts limited and not cached"
|
||||
"forced-subtitle probe truncated; verdicts limited and truncated extents not cached"
|
||||
);
|
||||
}
|
||||
apply_verdicts(title, &verdicts);
|
||||
}
|
||||
|
||||
/// Compose the per-track verdicts a run is ENTITLED to assert from the evidence
|
||||
/// it gathered. A track absent from the result keeps its vendor-derived flag.
|
||||
///
|
||||
/// Two gates, both PER TRACK, because the evidence is per track:
|
||||
/// * `observed` — saw no display set at all, so nothing is known. (Never assert
|
||||
/// "not forced" from having seen nothing.)
|
||||
/// * on a truncated run, `non_forced` — the track saw an actual non-forced
|
||||
/// display set, which no further reading could retract, so that verdict
|
||||
/// stands even though the run was cut short. A track that merely hadn't YET
|
||||
/// seen a non-forced set is exactly the claim the truncation invalidates, so
|
||||
/// it is dropped and keeps the vendor flag.
|
||||
fn verdicts(evidence: &HashMap<u16, TrackEvidence>, conclusive: bool) -> HashMap<u16, bool> {
|
||||
evidence
|
||||
.iter()
|
||||
.filter(|(_, e)| e.observed && (conclusive || e.non_forced))
|
||||
.map(|(&pid, e)| (pid, !e.non_forced))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set `forced` on every PGS subtitle track named in `verdicts`. A track absent
|
||||
/// from the map was never observed and keeps its vendor-derived flag.
|
||||
fn apply_verdicts(title: &mut DiscTitle, verdicts: &HashMap<u16, bool>) {
|
||||
@@ -435,6 +582,11 @@ mod tests {
|
||||
/// A reader that serves a fixed BD-TS byte stream once (across sequential
|
||||
/// `read_sectors` calls), then EOF — so the probe's demux→parse→observe→apply
|
||||
/// path runs on real synthetic PGS content.
|
||||
///
|
||||
/// Sector-granular, like every real [`SectorSource`]: a read that is served
|
||||
/// from the payload's short tail zero-pads to the sector boundary and reports
|
||||
/// whole sectors. (The probe accounts in SECTORS, so a source that returned a
|
||||
/// sub-sector byte count could never advance.)
|
||||
struct TsReader {
|
||||
data: Vec<u8>,
|
||||
pos: usize,
|
||||
@@ -453,7 +605,10 @@ mod tests {
|
||||
let n = buf.len().min(self.data.len() - self.pos);
|
||||
buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
|
||||
self.pos += n;
|
||||
Ok(n)
|
||||
let padded = n.div_ceil(SECTOR_BYTES) * SECTOR_BYTES;
|
||||
let out = padded.min(buf.len());
|
||||
buf[n..out].fill(0);
|
||||
Ok(out)
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.data.len().div_ceil(SECTOR_BYTES) as u32
|
||||
@@ -532,6 +687,14 @@ mod tests {
|
||||
pos: 0,
|
||||
};
|
||||
let mut title = pgs_title(pid, false); // vendor label says NOT forced
|
||||
// One sector, which is exactly what the reader serves: an extent that
|
||||
// claims more sectors than the source yields is a SHORT read, and a short
|
||||
// read is (correctly) inconclusive — see
|
||||
// `read_error_after_partial_content_preserves_vendor_forced`.
|
||||
title.extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 1,
|
||||
}];
|
||||
probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None);
|
||||
let Stream::Subtitle(s) = &title.streams[0] else {
|
||||
panic!()
|
||||
@@ -705,7 +868,13 @@ mod tests {
|
||||
s.forced,
|
||||
"a forced verdict from a budget-bounded prefix must still be applied"
|
||||
);
|
||||
assert_eq!(cache.len(), 1, "a conclusive probe is memoised");
|
||||
// One entry per (extent, PGS track): both extents reached a designed stop
|
||||
// (the first was read to its end, the second stopped at the budget), so
|
||||
// both are memoised — the budget is the stop that fires on every disc that
|
||||
// HAS a forced track, so excluding it would memoise nothing there.
|
||||
assert_eq!(cache.len(), 2, "a conclusive probe is memoised per extent");
|
||||
assert!(cache.contains_key(&(0, 4, pid)));
|
||||
assert!(cache.contains_key(&(100, u32::MAX, pid)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -731,9 +900,14 @@ mod tests {
|
||||
!s.forced,
|
||||
"a verdict from a cancelled probe must not overwrite the vendor flag"
|
||||
);
|
||||
// The cancel landed inside the SECOND extent, whose read is therefore an
|
||||
// arbitrary prefix: that extent must not be memoised, or the one
|
||||
// cancellation would be replayed onto every other playlist sharing the
|
||||
// clip. (The first extent WAS read to its end before the cancel, so its
|
||||
// own evidence is sound and keeping it is the point of per-extent keying.)
|
||||
assert!(
|
||||
cache.is_empty(),
|
||||
"a cancelled probe must not poison the extent cache"
|
||||
!cache.contains_key(&(100, u32::MAX, pid)),
|
||||
"a cancelled probe must not poison the cancelled extent's cache entry"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -748,4 +922,228 @@ mod tests {
|
||||
probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None);
|
||||
assert_eq!(reader.served, 0, "no PGS PIDs → no reads");
|
||||
}
|
||||
|
||||
// ── per-extent, per-track memoisation ───────────────────────────────────
|
||||
|
||||
/// MEASURED: overlapping-but-not-identical extent lists must not re-read the
|
||||
/// shared clips. A disc's playlists share clips without sharing whole extent
|
||||
/// LISTS (00800 = [X, Y], 00801 = [X], 00802 = [Y]), and keying the cache on
|
||||
/// the whole list de-duplicated only exactly-identical playlists: each of the
|
||||
/// three lists missed, so clip X was read twice and Y twice — up to
|
||||
/// PROBE_BUDGET_SECTORS (256 MiB) of optical-drive time per miss.
|
||||
#[test]
|
||||
fn overlapping_extent_lists_read_each_clip_once() {
|
||||
let pid = 0x1200u16;
|
||||
let x = Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 600,
|
||||
};
|
||||
let y = Extent {
|
||||
start_lba: 10_000,
|
||||
sector_count: 900,
|
||||
};
|
||||
let mut reader = EndlessReader { served: 0 };
|
||||
let mut cache = ForcedProbeCache::new();
|
||||
|
||||
let mut both = pgs_title(pid, true);
|
||||
both.extents = vec![x, y];
|
||||
probe_and_set_forced(&mut reader, &mut both, &mut cache, None);
|
||||
let after_both = reader.served;
|
||||
assert_eq!(
|
||||
after_both,
|
||||
x.sector_count + y.sector_count,
|
||||
"the first title reads both clips exactly once"
|
||||
);
|
||||
|
||||
// A playlist over X alone, and one over Y alone: every extent is already
|
||||
// known, so neither costs a single further sector.
|
||||
let mut only_x = pgs_title(pid, true);
|
||||
only_x.extents = vec![x];
|
||||
probe_and_set_forced(&mut reader, &mut only_x, &mut cache, None);
|
||||
let mut only_y = pgs_title(pid, true);
|
||||
only_y.extents = vec![y];
|
||||
probe_and_set_forced(&mut reader, &mut only_y, &mut cache, None);
|
||||
assert_eq!(
|
||||
reader.served, after_both,
|
||||
"clips shared with an already-probed playlist must not be re-read"
|
||||
);
|
||||
|
||||
// And a list that mixes a known extent with a NEW one reads only the new
|
||||
// one.
|
||||
let z = Extent {
|
||||
start_lba: 50_000,
|
||||
sector_count: 300,
|
||||
};
|
||||
let mut mixed = pgs_title(pid, true);
|
||||
mixed.extents = vec![x, z];
|
||||
probe_and_set_forced(&mut reader, &mut mixed, &mut cache, None);
|
||||
assert_eq!(
|
||||
reader.served,
|
||||
after_both + z.sector_count,
|
||||
"a partially-known list reads only the extents it adds"
|
||||
);
|
||||
}
|
||||
|
||||
/// A later playlist that declares MORE PGS tracks over the SAME extents must
|
||||
/// still probe the extra track. With the cache keyed on the extent list alone,
|
||||
/// the verdict map it hit had no entry for the new PID, so that track was never
|
||||
/// probed and silently kept its vendor-label flag — `info` then reported a
|
||||
/// different forced flag for it depending purely on playlist ordering.
|
||||
#[test]
|
||||
fn extra_pgs_track_over_known_extents_is_still_probed() {
|
||||
let ext = Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 600,
|
||||
};
|
||||
let mut reader = EndlessReader { served: 0 };
|
||||
let mut cache = ForcedProbeCache::new();
|
||||
|
||||
let mut one_track = pgs_title(0x1200, true);
|
||||
one_track.extents = vec![ext];
|
||||
probe_and_set_forced(&mut reader, &mut one_track, &mut cache, None);
|
||||
let after_first = reader.served;
|
||||
assert_eq!(after_first, ext.sector_count);
|
||||
|
||||
// Same extents, two declared PGS tracks.
|
||||
let mut two_tracks = pgs_title(0x1200, true);
|
||||
two_tracks.extents = vec![ext];
|
||||
two_tracks.streams.push(Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1201,
|
||||
codec: Codec::Pgs,
|
||||
language: "fra".into(),
|
||||
forced: true,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}));
|
||||
probe_and_set_forced(&mut reader, &mut two_tracks, &mut cache, None);
|
||||
assert!(
|
||||
reader.served > after_first,
|
||||
"a newly declared PGS track must be probed, not served from a verdict \
|
||||
map that has no entry for it"
|
||||
);
|
||||
assert!(
|
||||
cache.contains_key(&(ext.start_lba, ext.sector_count, 0x1201)),
|
||||
"the new track gets its own per-extent evidence"
|
||||
);
|
||||
}
|
||||
|
||||
/// Records every (lba, count) served and every `set_unit_base` call.
|
||||
struct AlignSpy {
|
||||
reads: Vec<(u32, u16)>,
|
||||
bases: Vec<u32>,
|
||||
}
|
||||
impl SectorSource for AlignSpy {
|
||||
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 * SECTOR_BYTES;
|
||||
buf[..want].fill(0);
|
||||
Ok(want)
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
u32::MAX
|
||||
}
|
||||
fn set_unit_base(&mut self, lba: u32) {
|
||||
self.bases.push(lba);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every probe read must begin on an AACS aligned-unit boundary measured from
|
||||
/// the extent's own base, and the probe must declare that base to the source.
|
||||
/// A `DecryptingSectorSource` holding AACS keys rejects any other read outright
|
||||
/// (`DecryptFailed`) — and with a 1024-sector chunk (`1024 % 3 == 1`) every
|
||||
/// read after the first was misaligned, so content-based forced detection was
|
||||
/// unreachable past the first chunk of an encrypted disc, silently.
|
||||
#[test]
|
||||
fn probe_reads_stay_on_aacs_unit_boundaries() {
|
||||
let pid = 0x1200u16;
|
||||
// A start_lba that is NOT itself 3-aligned, so absolute `lba % 3` and the
|
||||
// base-relative gate disagree — the case the gate exists for.
|
||||
let base = 4_001u32;
|
||||
let mut reader = AlignSpy {
|
||||
reads: Vec::new(),
|
||||
bases: Vec::new(),
|
||||
};
|
||||
let mut title = pgs_title(pid, true);
|
||||
title.extents = vec![Extent {
|
||||
start_lba: base,
|
||||
sector_count: CHUNK_SECTORS as u32 * 3,
|
||||
}];
|
||||
probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None);
|
||||
|
||||
assert_eq!(
|
||||
reader.bases,
|
||||
vec![base],
|
||||
"the probe must anchor the source's unit gate at the extent's start_lba"
|
||||
);
|
||||
assert!(reader.reads.len() > 1, "more than one chunk was read");
|
||||
for &(lba, _) in &reader.reads {
|
||||
assert!(
|
||||
crate::aacs::content::is_unit_aligned(lba, base),
|
||||
"read at lba {lba} is not on an aligned-unit boundary from base {base}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A source that serves only `frac` of the sectors requested, never erroring —
|
||||
/// what `PrefetchedSectorSource` does (it returns its producer's batch, not
|
||||
/// `count * 2048`). Records the LBAs it actually served.
|
||||
struct ShortReader {
|
||||
frac: u32,
|
||||
served: Vec<(u32, u32)>,
|
||||
}
|
||||
impl SectorSource for ShortReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let give = (count as u32 / self.frac).max(1);
|
||||
self.served.push((lba, give));
|
||||
let n = give as usize * SECTOR_BYTES;
|
||||
buf[..n].fill(0);
|
||||
Ok(n)
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
u32::MAX
|
||||
}
|
||||
}
|
||||
|
||||
/// A short-but-nonzero read must advance by what was READ, not by what was
|
||||
/// requested. Advancing by the request skipped the unread tail of every chunk
|
||||
/// — silently, with `StopReason` still `Exhausted`, so the absence-based
|
||||
/// forced verdict was asserted (and memoised) over sectors nobody read.
|
||||
#[test]
|
||||
fn short_reads_do_not_skip_sectors() {
|
||||
let pid = 0x1200u16;
|
||||
let count = CHUNK_SECTORS as u32 * 2;
|
||||
let mut reader = ShortReader {
|
||||
frac: 4,
|
||||
served: Vec::new(),
|
||||
};
|
||||
let mut title = pgs_title(pid, true);
|
||||
title.extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: count,
|
||||
}];
|
||||
probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None);
|
||||
|
||||
// The served ranges must tile the extent exactly: contiguous, no gaps.
|
||||
let mut next = 0u32;
|
||||
for &(lba, given) in &reader.served {
|
||||
assert_eq!(lba, next, "gap: sectors {next}..{lba} were never read");
|
||||
next += given;
|
||||
}
|
||||
assert_eq!(
|
||||
next, count,
|
||||
"every sector of the extent must be read when the source short-reads"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user