From 17622a1b59dfe206c14a5ddacbdb54a8c2ffe6ef Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:42:53 -0700 Subject: [PATCH] Stop trusting a byte offset when the feed is not what was measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/decrypt.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/mux/resolve.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/decrypt.rs b/src/decrypt.rs index 1dd77e3..5fbb0ae 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -695,6 +695,49 @@ mod tests { // ── `decrypt_sectors_in_content` (now a legacy alias of `decrypt_sectors`) ── /// `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] fn content_gate_none_keys_is_noop() { let mut keys = DecryptKeys::None; diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 6110df1..159b781 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -2180,10 +2180,25 @@ pub fn build_iso_pipeline( // 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 // returns the extents unchanged, so the common disc reads exactly as before. + let full_extents = extents.clone(); let extents = match &key_map { Some(map) => map.read_plan(&extents, unit_align as u32), 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 = crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys); if let Some(map) = key_map { @@ -2202,6 +2217,17 @@ pub fn build_iso_pipeline( // 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. 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); decrypting.set_unit_base(0);