From 7ba43d9c027476a41704e6621002bcbc72ae7123 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:28:09 -0700 Subject: [PATCH] mux: distinguish FMTS phase-probe read fault from wrong key; cover multi-CPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 (correctness): resolve_fmts_key_map's per-index phase probe read a single representative segment via read_unit (whose read_sectors(...).ok()? swallows read errors into None) with no fault fallback. A transient live-drive read fault (e.g. NOT READY 2/04/3E on the BU40N) made every probe read return None, giving even==0 && odd==0 — indistinguishable from a genuine wrong key — so resolve_tie_phase returned FmtsKeyMissing and aborted the entire multi-title rip even though the forensic index keys were valid and in hand. Extract the probe into probe_index_phase, which returns Phase / WrongKey / ReadFault. It mirrors the anchor loop's tolerance: it tries multiple same-index segments and only concludes WrongKey once a read actually succeeded and decrypted to no clean parity. If every read of every same-index segment faults it returns ReadFault; the caller then leaves the phase unresolved so the range-builder defaults to Phase::All (decrypt both parities, demux drops the garbled alternate half) instead of aborting. A wrong key can never be masked as a read fault: ReadFault requires that not a single read succeeded, so zero decrypt evidence. Fix 2 (test coverage): resolve_mux_key_map's multi-CPS branch was only exercised with an all-zero source, so pick(), the KeyFetch cold path, and the fail-loud DecryptFailed guard never ran. Add tests over real AACS ciphertext (via aacs_encrypt_unit_for_test) covering pick() selecting the correct pool index, the DecryptFailed guard firing when a clean sample matches no held/fetched key, and the fetch cold path recovering a missing unit key. Mutation-verified. Fix 3 (doc): move the reader_event_fn EventKind->MuxEvents mapping doc off session_mux_keys onto reader_event_fn. --- src/mux/driver.rs | 36 ++-- src/mux/resolve.rs | 483 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 464 insertions(+), 55 deletions(-) diff --git a/src/mux/driver.rs b/src/mux/driver.rs index 0f04902..176b0e0 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -443,24 +443,6 @@ pub fn mux_stream( ) } -/// Translate libfreemkv's reader-side [`Event`]s into [`MuxEvents`] calls, -/// producing the `'static` [`EventFn`](crate::sector::prefetched::EventFn) the -/// file highway ([`build_iso_pipeline`]) and the live -/// [`DiscStream`](crate::mux::DiscStream) constructors require. Cloning the -/// `Arc` into the returned closure is precisely what lets a borrowed-lifetime -/// consumer's events reach the highway's producer thread — a `&dyn MuxEvents` -/// borrow cannot satisfy the `'static` bound. -/// -/// Mapping (real [`EventKind`] variants): -/// - `BytesRead { bytes, total }` → [`MuxEvents::on_read_progress`] (read-side; -/// the file highway's only reader event — `total` is the extents' byte total) -/// - `SectorSkipped { sector }` → [`MuxEvents::on_sector_skipped`] (live only) -/// - `BatchSizeChanged { new_size, reason }` → [`MuxEvents::on_batch_size_changed`] -/// (live only) -/// - `ReadError { sector, .. }` → [`MuxEvents::on_read_error`] -/// -/// Sector numbers are the library's `u64`; the `MuxEvents` LBA hooks take `u32` -/// (the disc's LBA space), so they are narrowed with `as u32`. /// Decrypt keys for the live `Session` mux of `disc`. /// /// A DVD is handed [`DecryptKeys::None`] so [`DiscStream::new`](crate::mux::DiscStream) @@ -530,6 +512,24 @@ fn resolve_inline_base_map( Ok(Some(Arc::new(map))) } +/// Translate libfreemkv's reader-side [`Event`]s into [`MuxEvents`] calls, +/// producing the `'static` [`EventFn`](crate::sector::prefetched::EventFn) the +/// file highway ([`build_iso_pipeline`]) and the live +/// [`DiscStream`](crate::mux::DiscStream) constructors require. Cloning the +/// `Arc` into the returned closure is precisely what lets a borrowed-lifetime +/// consumer's events reach the highway's producer thread — a `&dyn MuxEvents` +/// borrow cannot satisfy the `'static` bound. +/// +/// Mapping (real [`EventKind`] variants): +/// - `BytesRead { bytes, total }` → [`MuxEvents::on_read_progress`] (read-side; +/// the file highway's only reader event — `total` is the extents' byte total) +/// - `SectorSkipped { sector }` → [`MuxEvents::on_sector_skipped`] (live only) +/// - `BatchSizeChanged { new_size, reason }` → [`MuxEvents::on_batch_size_changed`] +/// (live only) +/// - `ReadError { sector, .. }` → [`MuxEvents::on_read_error`] +/// +/// Sector numbers are the library's `u64`; the `MuxEvents` LBA hooks take `u32` +/// (the disc's LBA space), so they are narrowed with `as u32`. fn reader_event_fn(events: Arc) -> crate::sector::prefetched::EventFn { Box::new(move |e: Event| match e.kind { EventKind::BytesRead { bytes, total } => events.on_read_progress(bytes, total), diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index a11586f..6f7512e 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -774,7 +774,7 @@ fn resolve_fmts_key_map( format: ContentFormat, halt: Option<&crate::halt::Halt>, ) -> io::Result> { - use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean}; + use crate::aacs::content::ALIGNED_UNIT_LEN; use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments}; // Cooperative cancel: this probes the LIVE drive across up to a few hundred @@ -930,44 +930,45 @@ fn resolve_fmts_key_map( for (i, k) in index_keys.iter().enumerate() { check_halt()?; let tag = (i + 1) as u16; - let Some(seg) = segments.iter().find(|s| s.index == tag) else { - continue; // no segment carries this index on this feature — skip - }; - let (mut even, mut odd) = (0usize, 0usize); - for p in 0..BATCH_UNITS { - for (phase_off, counter) in [(0usize, &mut even), (1usize, &mut odd)] { - if let Some(mut c) = read_unit(reader, seg, p * 2 + phase_off) { - if aacs_unit_encrypted(&c, format) { - decrypt_unit(&mut c, k); - if is_clean(&c, format) { - *counter += 1; - } - } - } + // Probe this index's parity with the anchor loop's read-fault tolerance: + // try up to MAX_ANCHOR_ATTEMPTS same-index segments (not a single `.find`), + // skipping any whose reads all fault. The outcome distinguishes a genuine + // wrong key (reads succeeded, no clean parity) from a transient live-drive + // read fault (zero decrypt evidence) — the load-bearing distinction so a + // recoverable fault never hard-aborts a rip whose index keys are valid. + match probe_index_phase( + &segments, + tag, + BATCH_UNITS, + MAX_ANCHOR_ATTEMPTS, + format, + k, + |seg, unit| read_unit(reader, seg, unit), + ) { + IndexProbe::Phase(phase) => { + phase_of_index.insert(tag, phase); + } + IndexProbe::WrongKey => { + // Reads SUCCEEDED but NEITHER parity decrypts clean under this index's + // key on any same-index segment: the key is wrong (or the sample isn't + // this index's real content). The map would be wrong — fail loud rather + // than emit a broken segment. (Preserves the genuine-wrong-key path.) + tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: no clean phase under index key — refusing broken map"); + return Err(crate::error::Error::FmtsKeyMissing.into()); + } + IndexProbe::ReadFault => { + // EVERY probe read of EVERY same-index segment faulted (a transient + // live-drive read fault — e.g. NOT READY 2/04/3E, the common bad-sector + // sense on the BU40N). There is ZERO decrypt evidence, so this is NOT a + // wrong key: the index key is valid and already in hand. Do NOT abort a + // rip whose forensic keys are good. Leave this index's phase unresolved + // so the range-builder below defaults it to `Phase::All` — decrypt BOTH + // parities and let the demux drop the garbled alternate half (the + // coherent-stream outcome the module doc describes for whole-range key + // application). Degraded but complete; never a wrong-key abort. + tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: index phase probe read-faulted on every segment — defaulting Phase::All (recoverable read fault, not a wrong key)"); } } - let phase = match resolve_tie_phase(even, odd) { - Ok(p) => { - if even == odd { - // BOTH halves decrypt clean (even == odd > 0): the sampled units - // are source-zero padding (`is_clean_ts` is true for all-zero - // content under ANY key), so the key is valid and the parity is - // immaterial here — default Even (we decrypt one parity; padding - // in the dropped parity is harmless). A padding-heavy sample must - // NOT abort the rip. - tracing::debug!(target: "freemkv::keysource", index = tag, even, odd, "fmts: padding tie — defaulting Even"); - } - p - } - Err(e) => { - // NEITHER half decrypts clean under this index's key: the key is - // wrong or the sampled units aren't this index's real content. The - // map would be wrong — fail loud rather than emit a broken segment. - tracing::warn!(target: "freemkv::keysource", index = tag, even, odd, "fmts: no clean phase under index key — refusing broken map"); - return Err(e); - } - }; - phase_of_index.insert(tag, phase); } // ── Build the per-segment LBA ranges: each forensic segment → its tag's key @@ -1072,6 +1073,90 @@ fn resolve_tie_phase(even_clean: usize, odd_clean: usize) -> io::Result Option>, +) -> IndexProbe { + use crate::aacs::content::{aacs_unit_encrypted, decrypt_unit, is_clean}; + let mut any_read = false; + for seg in segments + .iter() + .filter(|s| s.index == tag) + .take(max_segments) + { + let (mut even, mut odd) = (0usize, 0usize); + let mut seg_read = false; + for p in 0..batch_units { + for (phase_off, counter) in [(0usize, &mut even), (1usize, &mut odd)] { + if let Some(mut c) = read(seg, p * 2 + phase_off) { + seg_read = true; + if aacs_unit_encrypted(&c, format) { + decrypt_unit(&mut c, key); + if is_clean(&c, format) { + *counter += 1; + } + } + } + } + } + if !seg_read { + continue; // every read of this segment faulted — try the next same-index one + } + any_read = true; + // A clean parity (even != odd) or a padding tie (even == odd > 0) resolves the + // phase; even == odd == 0 is this segment's wrong-key signature, but a DIFFERENT + // same-index segment could still anchor (this one's sampled units may all be the + // alternate variant), so keep trying rather than concluding immediately. + if let Ok(phase) = resolve_tie_phase(even, odd) { + return IndexProbe::Phase(phase); + } + } + if any_read { + IndexProbe::WrongKey + } else { + IndexProbe::ReadFault + } +} + /// Back-fill the LBA gaps NOT covered by the forensic segment ranges with the base /// Unit Key, so the finished map is a COMPLETE positive list over the title's /// content extents: every content LBA resolves to either a forensic key (inside a @@ -2425,4 +2510,328 @@ mod tests { ); assert_gapless(&exts, &forensic, &fills); } + + // ── Fix 1: FMTS phase-probe read-fault vs wrong-key distinction ───────── + + /// Build a 6144-byte aligned unit of CLEAN MPEG-TS (sync `0x47` + non-zero + /// payload in packets 1.., packet 0 is the clear seed) then AACS-encrypt it + /// under `key`. Decrypting under the SAME key restores clean TS (`is_clean` → + /// true); decrypting under any other key yields garbage. + fn encrypted_clean_unit(key: &[u8; 16]) -> Vec { + use crate::aacs::content::ALIGNED_UNIT_LEN; + let mut u = vec![0u8; ALIGNED_UNIT_LEN]; + let mut off = 0; + while off + 192 <= ALIGNED_UNIT_LEN { + u[off + 4] = 0x47; // TS sync at the BD-TS packet stride + for b in &mut u[off + 5..off + 192] { + *b = 0xAB; // non-zero payload so is_clean counts it as content + } + off += 192; + } + crate::aacs::content::aacs_encrypt_unit_for_test(&mut u, key); + u + } + + fn a_segment(index: u16) -> crate::aacs::segment::Segment { + crate::aacs::segment::Segment { + index, + start_spn: 0, + end_spn: 100, + } + } + + /// A probe whose EVERY read faults (`read` returns `None`) must classify as + /// [`IndexProbe::ReadFault`], NOT [`IndexProbe::WrongKey`] — a transient + /// live-drive read fault while probing must not be read as a missing key (which + /// the caller turns into a rip-aborting `FmtsKeyMissing`). + /// + /// Mutation: reverting to the no-fallback single-segment probe (i.e. treating + /// even==odd==0 as unconditional `FmtsKeyMissing` regardless of whether any read + /// succeeded) makes this return `WrongKey` → the assert fails. + #[test] + fn probe_index_phase_all_faults_is_read_fault_not_wrong_key() { + let segs = vec![a_segment(1)]; + let key = [0x11u8; 16]; + let got = super::probe_index_phase( + &segs, + 1, + 8, + 16, + ContentFormat::BdTs, + &key, + |_seg, _unit| None, // every read faults + ); + assert_eq!( + got, + super::IndexProbe::ReadFault, + "all-faulted probe is a recoverable read fault, never a wrong key" + ); + } + + /// Reads SUCCEED but decrypt to NEITHER clean parity (ciphertext under a key we + /// do NOT hold) → [`IndexProbe::WrongKey`]. This is the genuine-missing-key path + /// the caller MUST keep as a hard `FmtsKeyMissing`. + #[test] + fn probe_index_phase_reads_succeed_but_no_clean_phase_is_wrong_key() { + let segs = vec![a_segment(1)]; + let cipher = encrypted_clean_unit(&[0xAAu8; 16]); // encrypted under key A + let probe_key = [0xBBu8; 16]; // ... probed under the WRONG key B + let got = super::probe_index_phase( + &segs, + 1, + 8, + 16, + ContentFormat::BdTs, + &probe_key, + |_seg, _unit| Some(cipher.clone()), + ); + assert_eq!( + got, + super::IndexProbe::WrongKey, + "reads that decrypt to no clean parity under the probed key are a wrong key" + ); + } + + /// Reads succeed and the EVEN units decrypt clean under this index's key while + /// the ODD units are (unencrypted) padding → [`IndexProbe::Phase`]`(Even)`. + #[test] + fn probe_index_phase_resolves_clean_even_phase() { + use crate::aacs::content::ALIGNED_UNIT_LEN; + use crate::decrypt::Phase; + let segs = vec![a_segment(1)]; + let key = [0x33u8; 16]; + let even_unit = encrypted_clean_unit(&key); + let got = super::probe_index_phase( + &segs, + 1, + 8, + 16, + ContentFormat::BdTs, + &key, + // even unit index → clean ciphertext under `key`; odd → zero padding + // (aacs_unit_encrypted false → not counted). + |_seg, unit| { + if unit % 2 == 0 { + Some(even_unit.clone()) + } else { + Some(vec![0u8; ALIGNED_UNIT_LEN]) + } + }, + ); + assert_eq!( + got, + super::IndexProbe::Phase(Phase::Even), + "clean even units + padding odd → Even phase" + ); + } + + /// Read-fault TOLERANCE across segments: the first same-index segment faults on + /// every read, but a SECOND same-index segment decrypts clean → the probe must + /// fall through to it and resolve a phase (mirrors the anchor loop's multi- + /// segment retry). A single-`.find` probe would have stopped at the faulting + /// first segment. + #[test] + fn probe_index_phase_falls_through_faulting_segment_to_next() { + use crate::decrypt::Phase; + let mut faulting = a_segment(1); + faulting.start_spn = 1; // distinguish the two same-index segments + let good = a_segment(1); + let segs = vec![faulting, good]; + let key = [0x44u8; 16]; + let clean = encrypted_clean_unit(&key); + let got = super::probe_index_phase( + &segs, + 1, + 8, + 16, + ContentFormat::BdTs, + &key, + // The faulting segment (start_spn == 1) reads None; the good one reads a + // clean even unit / padding odd. + |seg, unit| { + if seg.start_spn == 1 { + None + } else if unit % 2 == 0 { + Some(clean.clone()) + } else { + Some(vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]) + } + }, + ); + assert_eq!( + got, + super::IndexProbe::Phase(Phase::Even), + "a faulting first segment must not block resolving from the next same-index one" + ); + } + + // ── Fix 2: resolve_mux_key_map multi-CPS key selection (real ciphertext) ─ + + /// A SectorSource that tiles a fixed 6144-byte ciphertext unit across each + /// registered extent range (`(start_lba, end_lba, unit)`), zeros elsewhere. + /// Every 3-sector aligned-unit read inside a range returns the same ciphertext, + /// so `sample_units` collects real encrypted content for `pick`/fetch to run on. + /// Low LBAs are zero, so `udf::read_filesystem` fails → the FMTS branch returns + /// `Ok(None)` and the multi-CPS path is exercised. + struct CipherSource { + units: Vec<(u32, u32, Vec)>, + } + impl SectorSource for CipherSource { + 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 { + let want = count as usize * 2048; + buf[..want].fill(0); + for s in 0..count as usize { + let cur = lba + s as u32; + if let Some((start, _end, unit)) = + self.units.iter().find(|(a, b, _)| cur >= *a && cur < *b) + { + // 3 sectors per aligned unit; tile the ciphertext by sector. + let within = ((cur - start) % 3) as usize; + let src = &unit[within * 2048..within * 2048 + 2048]; + buf[s * 2048..s * 2048 + 2048].copy_from_slice(src); + } + } + Ok(want) + } + } + + fn multi_cps_title(start_lba: u32, sectors: u32) -> DiscTitle { + let mut t = DiscTitle::empty(); + t.extents = vec![Extent { + start_lba, + sector_count: sectors, + }]; + t + } + + /// `pick()` must select the pool index of the key that actually opens the + /// extent's real ciphertext — index 2 here, NOT 0. Feeds units encrypted under + /// the third pool key through the live multi-CPS path. + /// + /// Mutation: `pick` hard-returning `Some(0)` keys the extent to 0 → this assert + /// (Some(2)) fails. + #[test] + fn resolve_mux_key_map_multi_cps_pick_selects_correct_index() { + let key_a = [0x01u8; 16]; + let key_b = [0x02u8; 16]; + let key_c = [0x03u8; 16]; + let unit = encrypted_clean_unit(&key_c); // extent content opens under C (idx 2) + let start = 1000u32; + let sectors = 30u32; // 10 aligned units + let mut reader = CipherSource { + units: vec![(start, start + sectors, unit)], + }; + 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 title = multi_cps_title(start, sectors); + let map = super::resolve_mux_key_map( + &mut reader, + &title, + &mut keys, + None, + ContentFormat::BdTs, + None, + ) + .expect("multi-CPS resolve succeeds when a held key opens the extent"); + assert_eq!( + map.key_idx_for(start), + Some(2), + "the extent must be keyed to the pool index whose key opens its ciphertext" + ); + } + + /// Fail-loud: a sample that decrypts clean under NO held key and NO fetched key + /// (fetch = None) must surface [`Error::DecryptFailed`], never silently key the + /// extent to a neighbour's (wrong) index. + /// + /// Mutation: dropping the `None => Err(DecryptFailed)` guard (e.g. falling back + /// to `last_idx`) returns `Ok` → this `expect_err` fails. + #[test] + fn resolve_mux_key_map_multi_cps_fail_loud_on_absent_key() { + let key_a = [0x01u8; 16]; + let key_b = [0x02u8; 16]; + let key_x = [0x09u8; 16]; // NOT in the pool, NOT fetchable (fetch None) + let unit = encrypted_clean_unit(&key_x); + let start = 1000u32; + let sectors = 30u32; + let mut reader = CipherSource { + units: vec![(start, start + sectors, unit)], + }; + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key_a), (1, key_b)], + read_data_key: None, + format: ContentFormat::BdTs, + }; + let title = multi_cps_title(start, sectors); + let err = super::resolve_mux_key_map( + &mut reader, + &title, + &mut keys, + None, + ContentFormat::BdTs, + None, + ) + .expect_err("an extent no held/fetched key opens must fail loud, not mis-key"); + let expected = std::io::Error::from(crate::error::Error::DecryptFailed).to_string(); + assert_eq!(err.to_string(), expected, "expected DecryptFailed"); + } + + /// KeyFetch cold path: the pool is missing the extent's key, but the injected + /// `KeyFetch::unit_keys` returns it from the failing samples → the extent + /// resolves to the newly-appended pool index (2) and the map succeeds. Proves + /// the on-miss fetch+re-pick branch runs end to end. + #[test] + fn resolve_mux_key_map_multi_cps_fetch_recovers_missing_key() { + let key_a = [0x01u8; 16]; + let key_b = [0x02u8; 16]; + let key_x = [0x09u8; 16]; // absent from the pool, supplied by fetch + let unit = encrypted_clean_unit(&key_x); + let start = 1000u32; + let sectors = 30u32; + let mut reader = CipherSource { + units: vec![(start, start + sectors, unit)], + }; + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key_a), (1, key_b)], + read_data_key: None, + format: ContentFormat::BdTs, + }; + let title = multi_cps_title(start, sectors); + // A unit-only KeyFetch that hands back key_x for any non-empty sample batch. + let fetch = crate::sector::KeyFetch::unit_only(std::sync::Arc::new( + move |samples: &[Vec]| { + if samples.is_empty() { + Vec::new() + } else { + vec![key_x] + } + }, + )); + let map = super::resolve_mux_key_map( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + ) + .expect("the fetched key recovers the extent"); + assert_eq!( + map.key_idx_for(start), + Some(2), + "the fetched key is appended at pool index 2 and keys the extent" + ); + } }