Stop trusting a byte offset when the feed is not what was measured

A clip's span is measured over the title's full extents when the disc is
scanned, and a frame is placed by the offset it was read from. Those only
agree while the mux reads every byte the scan counted.

On a disc carrying a forensic segment it does not: the read plan omits the
units belonging to another device group, so fewer bytes are fed than the
spans describe, and the shortfall grows through the title. Every frame past
the first segment then looks earlier than it is — placed in a clip it did
not come from, or dropped at a join for failing marks it was never inside.
The spans still tile one another perfectly, so the check that asks whether
they can be trusted cannot see any of it.

The plan is now compared against the extents it was built from. When they
differ the spans are dropped and placement falls back to timestamps, which
is what that path is for and what the surrounding comment already promised
would happen when an offset stops meaning anything. Ordinary discs are
untouched: with no forensic segment the plan IS the extents.
This commit is contained in:
Matthew Jackson
2026-08-08 19:42:53 -07:00
parent 32824fba5b
commit 17622a1b59
2 changed files with 69 additions and 0 deletions
+43
View File
@@ -695,6 +695,49 @@ mod tests {
// ── `decrypt_sectors_in_content` (now a legacy alias of `decrypt_sectors`) ── // ── `decrypt_sectors_in_content` (now a legacy alias of `decrypt_sectors`) ──
/// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes. /// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes.
/// A forensic map's read plan is NOT the extents it was given — and that is
/// the precondition the mux's provenance guard keys on.
///
/// A clip's feed span is measured over the FULL extents at scan time, while
/// the mux reads this reduced plan, so the byte offsets stamped on frames
/// and the offsets recorded in the spans describe different streams. The
/// deficit accumulates, so every frame after the first segment resolves to
/// an earlier clip than it came from. The spans still tile each other, so
/// the tiling check cannot see it; the mux compares the plan against the
/// full extents instead and stops trusting provenance when they differ.
#[test]
fn a_forensic_read_plan_drops_units_the_full_extents_include() {
let full = vec![crate::disc::Extent {
start_lba: 1000,
sector_count: 60,
}];
// No forensic segment: the plan IS the extents, byte for byte, so
// provenance stays trustworthy on an ordinary disc.
let plain = AacsKeyMap::from_ranges_phased(vec![(1000, 1060, 5, Phase::All)]);
assert_eq!(
plain.read_plan(&full, 3),
full,
"a non-forensic map must return the extents unchanged"
);
// With alternate phases, units are omitted — fewer sectors are read
// than the spans describe.
let phased = AacsKeyMap::from_ranges_phased(vec![(1000, 1060, 5, Phase::Even)]);
let plan = phased.read_plan(&full, 3);
let planned: u32 = plan.iter().map(|e| e.sector_count).sum();
let whole: u32 = full.iter().map(|e| e.sector_count).sum();
assert!(
planned < whole,
"a forensic segment must drop units: planned {planned} of {whole}"
);
assert_ne!(
plan, full,
"the plan differs from the extents, which is exactly what the mux \
detects before deciding whether a byte offset means anything"
);
}
#[test] #[test]
fn content_gate_none_keys_is_noop() { fn content_gate_none_keys_is_noop() {
let mut keys = DecryptKeys::None; let mut keys = DecryptKeys::None;
+26
View File
@@ -2180,10 +2180,25 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
// decrypted, or handed to the demux, so the demux sees one gapless our-variant // decrypted, or handed to the demux, so the demux sees one gapless our-variant
// stream (no ciphertext to trip a concealed-gap resync). A non-forensic map // stream (no ciphertext to trip a concealed-gap resync). A non-forensic map
// returns the extents unchanged, so the common disc reads exactly as before. // returns the extents unchanged, so the common disc reads exactly as before.
let full_extents = extents.clone();
let extents = match &key_map { let extents = match &key_map {
Some(map) => map.read_plan(&extents, unit_align as u32), Some(map) => map.read_plan(&extents, unit_align as u32),
None => extents, None => extents,
}; };
// The plan and the clips' feed spans must describe the SAME bytes.
//
// A clip's span was measured over the title's full extents at scan time,
// and a frame is placed by the offset it was read from. When a forensic
// segment makes the plan drop alternate-phase units, the mux feeds fewer
// bytes than the spans describe and the two drift apart cumulatively —
// every frame after the first segment looks earlier than it is, and near a
// join it is placed in the wrong clip or dropped. The spans still tile each
// other perfectly, so the trust check cannot see it.
//
// Provenance is only meaningful when the feed matches. When it does not,
// say so and let placement fall back to timestamps, which is what the
// untrusted path exists for.
let feed_matches_spans = extents == full_extents;
let mut decrypting = let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys); crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
if let Some(map) = key_map { if let Some(map) = key_map {
@@ -2202,6 +2217,17 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
// correct `0x8x` sub-streams. No-op for non-DVD or an empty probe. Reset the // correct `0x8x` sub-streams. No-op for non-DVD or an empty probe. Reset the
// unit base afterward so the prefetcher's first batch starts clean. // unit base afterward so the prefetcher's first batch starts clean.
let mut title = title; let mut title = title;
if !feed_matches_spans {
tracing::info!(
target: "freemkv::mux",
planned = extents.len(),
full = full_extents.len(),
"read plan omits units the clip spans include; placing by timestamps"
);
for c in &mut title.clips {
c.feed_span = None;
}
}
crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title); crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title);
decrypting.set_unit_base(0); decrypting.set_unit_base(0);