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);