From 1d784203666bb84cd277b427fb68ff980c9f2e7c Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:20:43 -0700 Subject: [PATCH] keys: read damage is never a key failure; legacy key API removed Damaged AACS units are holed, blanked and counted on every path instead of stopping with E7013; failed batch partners fall back to side reads and a wrong FMTS key is judged per key. ResolvedKeySet is the only AACS decrypt door; a raw whole-disc reader never decrypts and refuses titles with no stream folder (E6003) or a piece no held key opens (E7032). --- Cargo.lock | 1 + Cargo.toml | 3 + src/aacs/content.rs | 10 +- src/aacs/mod.rs | 2 +- src/aacs/provider.rs | 46 +- src/aacs/resolve.rs | 2 +- src/aacs/segment.rs | 2 +- src/decrypt.rs | 179 +- src/diag.rs | 10 +- src/disc/encrypt.rs | 111 +- src/disc/extract.rs | 111 +- src/disc/mod.rs | 2002 +-------- src/disc/scan_order_tests.rs | 27 +- src/error.rs | 4 +- src/halt.rs | 3 +- src/io/flush/tests.rs | 4 +- src/io/image_writer.rs | 7 +- src/keys/arrival.rs | 170 +- src/keys/fmts.rs | 266 +- src/keys/mod.rs | 104 +- src/keys/public_api_cannot_decrypt_aacs.rs | 50 + src/keys/resolve.rs | 22 + src/keys/tests.rs | 480 ++- src/keys/tests/stop_tests.rs | 7 +- src/keysource.rs | 761 +--- src/lib.rs | 19 +- src/mux/disc.rs | 46 +- src/mux/driver.rs | 887 ++-- .../mux/fvi_pipeline_tests.rs | 20 +- src/mux/interop_tests.rs | 7 +- src/mux/mod.rs | 8 +- src/mux/mpg/mod.rs | 2 +- src/mux/mpg/tests.rs | 7 +- src/mux/pipelined_stream.rs | 21 + src/mux/resolve.rs | 3570 +---------------- src/mux/videomap.rs | 2 +- src/sector/decrypting.rs | 322 +- src/sector/mod.rs | 2 +- src/session.rs | 343 +- src/session/stop_tests.rs | 4 +- src/test_util.rs | 32 +- src/test_util_tests.rs | 16 +- src/whole_disc.rs | 429 +- src/whole_disc_tests.rs | 563 +-- tests/crypto_tests.rs | 6 +- tests/disc_tests.rs | 15 - tests/legacy_key_api_guard.rs | 275 ++ tests/lint_stop.rs | 2 +- tests/macos_drive_tests.rs | 4 - tests/pass_n_patch_fix.rs | 6 - tests/spec_sites.txt | 10 +- 51 files changed, 2461 insertions(+), 8541 deletions(-) create mode 100644 src/keys/public_api_cannot_decrypt_aacs.rs rename tests/fvi_pipeline.rs => src/mux/fvi_pipeline_tests.rs (96%) create mode 100644 tests/legacy_key_api_guard.rs diff --git a/Cargo.lock b/Cargo.lock index 40044d6a..1fdab1dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,7 @@ dependencies = [ "crossbeam-channel", "freemkv-unlock", "libc", + "libfreemkv", "loom", "memchr", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 4c6b41dc..212ff472 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,9 @@ libc = "0.2" [dev-dependencies] tempfile = "3" +# The crate's own `test_util` for its integration tests (`tests/crypto_tests.rs` decrypts +# through `test_util::decrypt_unit`; the unit decrypt is crate-private, KU §2.2). +libfreemkv = { path = ".", features = ["test-util"] } # Permutation model checker for the `scsi::fd_handoff` atomics — the fd # hand-off between a Linux recovery thread and `SgIoTransport::drop`. A normal diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 08b2af86..1927e31d 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -117,8 +117,8 @@ pub(crate) fn cpi_cleared(mut plain: Vec) -> Vec { /// read off the grid starts mid-unit on ciphertext. Clear units, and `MpegPs` /// (seed layout unverified), always pass. A wrong-grid chunk with CPI=0 also /// passes (~1 in 4 per unit), so single-unit reads can slip; multi-unit reads are -/// caught almost surely. An on-grid unit whose seed is corrupt (media or read -/// damage in bytes 0..5) fails the whole read as DecryptFailed. +/// caught almost surely. A damaged seed fails it too: either way the unit opens under +/// no key and readers blank it (`decrypt::blank_damaged_units`); it is never E7013. pub(crate) fn aacs_unit_on_grid(unit: &[u8], format: crate::disc::ContentFormat) -> bool { format != crate::disc::ContentFormat::BdTs || !aacs_unit_seed_encrypted(unit, format) @@ -235,7 +235,7 @@ fn is_clean_ps(unit: &[u8]) -> bool { /// Applies the key UNCONDITIONALLY: does NOT check the encrypted-flag, so the CALLER must gate /// on [`aacs_unit_encrypted`] first. Block Key = AES-128E(Kcu, seed) ⊕ seed, then AES-128-CBC /// decrypt bytes 16..6144 under the AACS IV. -pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { +pub(crate) fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { if unit.len() < ALIGNED_UNIT_LEN { return; } @@ -266,7 +266,7 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { } } -/// Encrypt one AACS aligned unit (6144 bytes) IN PLACE — the exact inverse of [`decrypt_unit`]. +/// Encrypt one AACS aligned unit (6144 bytes) IN PLACE — the exact inverse of `decrypt_unit`. /// Caller must set the encrypted flag BEFORE calling and check the returned bool. #[must_use = "returns false when the slice is too short to encrypt, leaving \ plaintext behind a flag that already says 'encrypted'"] @@ -1579,7 +1579,7 @@ mod tests { } // Spec guards (keys-upfront-design §7.8) on the unit decrypt; here because -// `decrypt_unit` leaves the public API (KU-X2). Vectors come from the `aes` crate. +// `decrypt_unit` is crate-private (KU §2.2). Vectors come from the `aes` crate. #[cfg(test)] mod spec_guards { use super::*; diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index eb50abb0..8dee99ce 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -131,7 +131,7 @@ where } // The module structure IS the public API — consumers import from the owning module -// (e.g. `aacs::content::decrypt_unit`). A small set of flat re-exports below is kept +// (e.g. `aacs::content::is_clean`). A small set of flat re-exports below is kept // for typed key primitives and content-decrypt entry points that downstream crates rely on. pub use content::ALIGNED_UNIT_LEN; pub use derive::derive_vuk; diff --git a/src/aacs/provider.rs b/src/aacs/provider.rs index 2540afea..46f53d60 100644 --- a/src/aacs/provider.rs +++ b/src/aacs/provider.rs @@ -110,9 +110,9 @@ impl Providers<'_> { } } -// Bridges a single caller-supplied key's raw material into a KeyProvider, for -// `Disc::decrypt_with`. Fills only its own level (DK/PK/MK/VUK) so the resolver runs the -// matching path. +// One key's raw material as a KeyProvider, for the resolver tests. Fills only its own +// level (DK/PK/MK/VUK) so the resolver runs the matching path. +#[cfg(test)] pub(crate) struct SuppliedKey { pub device_keys: Vec, pub processing_keys: Vec<[u8; 16]>, @@ -120,6 +120,7 @@ pub(crate) struct SuppliedKey { pub disc_entry: Option, } +#[cfg(test)] impl KeyProvider for SuppliedKey { fn device_keys(&self) -> Vec { self.device_keys.clone() @@ -363,43 +364,4 @@ mod tests { assert!(p.lookup_disc_by_hash(&[0u8; 20]).is_none()); assert!(p.lookup_disc_by_vid(&[0u8; 16]).is_none()); } - - // ── SuppliedKey: each level exposes only its own material ────────────── - - #[test] - fn supplied_key_exposes_only_populated_fields() { - // A SuppliedKey filled at the DK level exposes DKs and nothing else, - // so the resolver runs the matching (DK→…) path and no other. - let sk = SuppliedKey { - device_keys: vec![dk(0x33, 9)], - processing_keys: Vec::new(), - media_keys: Vec::new(), - disc_entry: None, - }; - assert_eq!(sk.device_keys().len(), 1); - assert!(sk.processing_keys().is_empty()); - assert!(sk.media_keys().is_empty()); - assert!(sk.lookup_disc_by_hash(&[0u8; 20]).is_none()); - assert!(sk.lookup_disc_by_vid(&[0u8; 16]).is_none()); - } - - #[test] - fn supplied_key_disc_entry_returned_for_any_hash_or_vid() { - // decrypt_with already knows the disc, so a present disc_entry is - // returned regardless of the hash/VID argument (the lookup args are - // irrelevant in this bridge). - let sk = SuppliedKey { - device_keys: Vec::new(), - processing_keys: Vec::new(), - media_keys: Vec::new(), - disc_entry: Some(entry("supplied", 0x44)), - }; - // Two unrelated hashes both return the same entry. - let h1 = sk.lookup_disc_by_hash(&[0x01u8; 20]).unwrap(); - let h2 = sk.lookup_disc_by_hash(&[0xFFu8; 20]).unwrap(); - assert_eq!(h1.disc_hash, "supplied"); - assert_eq!(h2.disc_hash, "supplied"); - // And by VID likewise. - assert!(sk.lookup_disc_by_vid(&[0x00u8; 16]).is_some()); - } } diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index 28c4d4af..5469a00b 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -2027,7 +2027,7 @@ mod tests { } // ── resolve_keys_with_reason / classify_resolve_failure ──────────────── - // Also exercised end-to-end via `ensure_decryptable` in `disc/mod.rs`; these + // Also exercised end-to-end via the decrypt gate (`keys::disc_gate`); these // pin the *classifier* directly, covering branches that gate test doesn't. /// A `SuppliedKey` provider with the given derivation material and no diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index 691ca792..f32f8485 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -149,7 +149,7 @@ pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Opti /// segment are left for the map's default (the Unit Key). A segment straddling a UDF extent /// boundary is skipped rather than emitting a wrong span. /// -/// Feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges) +/// Feeds `AacsKeyMap::from_ranges` /// with the Unit-Key index as the default. pub fn fmts_key_ranges( segments: &[Segment], diff --git a/src/decrypt.rs b/src/decrypt.rs index 8e81775c..a4702d33 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -120,7 +120,9 @@ pub enum DecryptKeys { /// here. The `format` is the disc's content container (BD/UHD/FMTS = Transport /// Stream, HD-DVD `.evo` = Program Stream); it travels with the keys because /// both are resolved once per disc, and the key SELECTOR (`is_clean`) needs it - /// to prove a key structurally against the right container. + /// to prove a key structurally against the right container. Only a + /// [`ResolvedKeySet`](crate::keys::ResolvedKeySet) builds one (KU §2.2). + #[non_exhaustive] Aacs { unit_keys: Vec<(u32, [u8; 16])>, format: crate::disc::ContentFormat, @@ -195,7 +197,7 @@ impl AacsKeyMap { /// Build from `[start_lba, end_lba) → key_idx` ranges that decrypt EVERY unit /// (single- or multi-CPS): each range is [`Phase::All`]. An LBA in no range is /// passed through untouched. - pub fn from_ranges(ranges: Vec<(u32, u32, usize)>) -> Self { + pub(crate) fn from_ranges(ranges: Vec<(u32, u32, usize)>) -> Self { let phased = ranges .into_iter() .map(|(s, e, i)| (s, e, i, Phase::All)) @@ -206,7 +208,7 @@ impl AacsKeyMap { /// Build a PHASE-AWARE map (FMTS): each range carries which unit-parity its key /// opens ([`Phase::Even`]/[`Phase::Odd`] for a forensic segment, [`Phase::All`] /// for base/CPS). Ranges are sorted; an LBA in no range is passed through. - pub fn from_ranges_phased(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Self { + pub(crate) fn from_ranges_phased(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Self { ranges.sort_by_key(|&(start, _, _, _)| start); let mut key_indices: Vec = ranges.iter().map(|&(_, _, i, _)| i).collect(); key_indices.sort_unstable(); @@ -251,7 +253,7 @@ impl AacsKeyMap { /// The distinct key indices this map selects — the CPS units / segments the /// title actually reaches. The resolver secures exactly these up front. Computed - /// once at construction (see [`from_ranges_phased`](Self::from_ranges_phased)). + /// once at construction (see `from_ranges_phased`). pub fn key_indices(&self) -> &[usize] { &self.key_indices } @@ -339,7 +341,7 @@ pub(crate) fn decrypt_sectors_mapped( base_lba: u32, map: &AacsKeyMap, ) -> Result<(), crate::error::Error> { - decrypt_sectors_mapped_in_content(buf, keys, base_lba, map, None) + decrypt_sectors_mapped_in_content(buf, keys, base_lba, map, None).map(|_| ()) } /// `decrypt_sectors_mapped` restricted to the disc's encrypted-content extents. @@ -349,20 +351,21 @@ pub(crate) fn decrypt_sectors_mapped( /// verified, or counted as loss. `None` means "the caller only reads encrypted /// content", so every unit is treated as content (the legacy behaviour). This is /// how [`crate::sector::DecryptingSectorSource::with_content_ranges`] honours its contract. +/// Returns how many damaged FMTS units (a lone failed verify) it blanked. pub(crate) fn decrypt_sectors_mapped_in_content( buf: &mut [u8], keys: &DecryptKeys, base_lba: u32, map: &AacsKeyMap, content: Option<&[(u32, u32)]>, -) -> Result<(), crate::error::Error> { +) -> Result { match keys { // The AACS arm only reads the keys: no per-batch deep clone. DecryptKeys::Aacs { .. } => apply_aacs_map(buf, keys, base_lba, map, content), // CSS re-cracks into its title key, so it needs its own mutable copy. _ => { let mut keys = keys.clone(); - decrypt_span(buf, &mut keys, base_lba, Some(map), content).map(|_| ()) + decrypt_span(buf, &mut keys, base_lba, Some(map), content).map(|_| 0) } } } @@ -400,12 +403,12 @@ fn apply_aacs_map( base_lba: u32, map: &AacsKeyMap, content: Option<&[(u32, u32)]>, -) -> Result<(), crate::error::Error> { +) -> Result { let (unit_keys, format) = match keys { DecryptKeys::Aacs { unit_keys, format } => (unit_keys, *format), // Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched; // CSS descrambles via `decrypt_sectors` and `None` is already clear. - _ => return Ok(()), + _ => return Ok(0), }; let unit_len = aacs::content::ALIGNED_UNIT_LEN; @@ -420,10 +423,11 @@ fn apply_aacs_map( } } - // Cheap safety net: a correct map always decrypts CORRECT-PHASE forensic - // units to clean TS, so this never fires happy-path — a map bug surfaces as - // loud DecryptFailed, not silent corruption. Only forensic ranges are verified. + // Cheap safety net: a correct map decrypts CORRECT-PHASE forensic units to clean TS, so + // a map bug surfaces as loud DecryptFailed, not silent corruption (verdict at the end). let verify_failed = std::sync::atomic::AtomicBool::new(false); + // Per mapped key: (failed, verified) FMTS units of this read. + let tally: Vec<[AtomicUsize; 2]> = unit_keys.iter().map(|_| Default::default()).collect(); let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| { let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors); @@ -455,10 +459,10 @@ fn apply_aacs_map( } return; }; - // Off-grid guard: a real unit's clear seed carries its sync byte, so a flagged - // chunk without it was cut across two units (wrong unit base) — fail loud. + // A flagged unit whose seed lacks its sync (damage, or a read cut off its grid) opens + // under no key: blanked, never refused (`blank_damaged_units`, which readers run first). if !aacs::content::aacs_unit_on_grid(chunk, format) { - verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); + chunk.fill(0); return; } // PHASE GATE (FMTS forensic segment): the segment interleaves two variants @@ -491,9 +495,14 @@ fn apply_aacs_map( return; } aacs::content::decrypt_unit(chunk, key); - // Correct-phase forensic verify (silent unless the map is wrong). - if matches!(phase, Phase::Even | Phase::Odd) && !aacs::content::is_clean(chunk, format) { - verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); + // Correct-phase forensic verify: a failing unit is blanked; the read is judged below. + if matches!(phase, Phase::Even | Phase::Odd) { + let clean = aacs::content::is_clean(chunk, format); + tally[key_idx][clean as usize].fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if !clean { + chunk.fill(0); + return; + } } // KS-5 [BD] §3.10.2: CPI "shall be set to 00₂ if the data is not encrypted": a unit // we decrypted (damaged or not) is no longer ciphertext (KU design §5.4, K-13). @@ -523,7 +532,94 @@ fn apply_aacs_map( if verify_failed.load(std::sync::atomic::Ordering::Relaxed) { return Err(crate::error::Error::DecryptFailed); } - Ok(()) + // A wrong key fails every unit it keys (resolve's phase probe catches it first); damage + // fails one. Two or more failures, none verifying, under one key or the read: E7013. + let tally: Vec<[usize; 2]> = tally + .into_iter() + .map(|t| t.map(|n| n.into_inner())) + .collect(); + let read = tally.iter().fold([0, 0], |a, t| [a[0] + t[0], a[1] + t[1]]); + let wrong = |t: &[usize; 2]| t[0] >= WRONG_KEY_FAILURES && t[1] == 0; + let failed = read[0]; + if wrong(&read) || tally.iter().any(wrong) { + tracing::error!( + target: "freemkv::decrypt", + lba = base_lba, + failed, + code = crate::error::E_DECRYPT_FAILED, + "no FMTS unit of the read verifies under its mapped key: wrong key" + ); + return Err(crate::error::Error::DecryptFailed); + } + if failed > 0 { + tracing::warn!( + target: "freemkv::decrypt", + lba = base_lba, + units = failed, + "damaged AACS unit (fails its FMTS verify): blanked, the rip carries on" + ); + } + Ok(failed) +} + +/// FMTS units of one read that must fail their verify, none verifying, under one key or +/// across the read, before it is a wrong key rather than damage: a wrong key fails them all. +const WRONG_KEY_FAILURES: usize = 2; + +/// Blank the damaged BD-TS units of `buf` (read at `base_lba` on the caller's unit grid): +/// zero-fill them, like a sweep's unread sector, and return how many. Damaged: flagged (CPI) +/// with no TS sync in the seed, or a zero-filled first sector over a non-TS rest. No key opens +/// either, so it is read damage, never a key verdict and never E7013, whether the unit is +/// alone, in a cluster, or a read cut off its grid (1.7.7 muxed through all three). Only units +/// `covered` (keyed or proven on arrival) are judged. A garbage seed that keeps 0x47 at byte 4 +/// (~1 in 256) is not caught here: it decrypts as garbage, or on arrival reads as unopened. +pub(crate) fn blank_damaged_units( + buf: &mut [u8], + base_lba: u32, + format: crate::disc::ContentFormat, + covered: &dyn Fn(u32) -> bool, +) -> usize { + if format != crate::disc::ContentFormat::BdTs { + return 0; + } + let unit_len = aacs::content::ALIGNED_UNIT_LEN; + let mut damaged = Vec::new(); + for (i, unit) in buf.chunks_exact(unit_len).enumerate() { + let lba = base_lba.saturating_add(i as u32 * aacs::content::ALIGNED_UNIT_SECTORS); + if !covered(lba) { + continue; + } + let lost = if aacs::content::aacs_unit_seed_encrypted(unit, format) { + // KS-4 [BD] §3.10.1: "The first 16 bytes of each Aligned Unit is used as the seed"; + // KS-2: each source packet is "the TP_extra_header (4 bytes) and an MPEG Transport + // packet", so an intact seed carries the TS sync at byte 4 (KS-22 corroborates). + !aacs::content::aacs_unit_on_grid(unit, format) + } else { + // A zero-filled head took the seed (KS-4); a non-zero rest that is not TS is + // ciphertext no key opens. A trailing zero run keeps its head and decrypts. + let (head, rest) = unit.split_at(crate::consts::SECTOR_BYTES); + head.iter().all(|&b| b == 0) + && rest.iter().any(|&b| b != 0) + && !aacs::content::is_clean(unit, format) + }; + if lost { + damaged.push(i); + } + } + // KS-3 [BD] §3.10.1: "A new CBC cipher chain is started for each Aligned Unit", so the + // loss is this unit alone. + for &i in &damaged { + buf[i * unit_len..(i + 1) * unit_len].fill(0); + } + if let Some(&first) = damaged.first() { + tracing::warn!( + target: "freemkv::decrypt", + lba = base_lba.saturating_add(first as u32 * aacs::content::ALIGNED_UNIT_SECTORS), + units = damaged.len(), + "damaged AACS unit (no key can open it): blanked, the rip carries on" + ); + } + damaged.len() } /// Decrypt a buffer of sectors in-place — the CSS / clear path only. @@ -1729,22 +1825,53 @@ mod tests { ); } - /// The correct-phase safety `is_clean` fires loud: an even unit whose mapped - /// key is wrong does NOT come clean → `DecryptFailed` (not silent corruption). + /// The correct-phase safety `is_clean` fires loud: even units whose mapped key is + /// wrong do NOT come clean → `DecryptFailed` (not silent corruption). A wrong key + /// fails every unit it keys (here both even ones); a lone failure is damage. #[test] fn mapped_phase_verify_fails_loud_on_wrong_key() { use crate::disc::ContentFormat; let ul = aacs::content::ALIGNED_UNIT_LEN; let usz = (ul / 2048) as u32; - let mut buf = vec![0u8; 2 * ul]; - let mut u0 = clear_ts_unit(); - aacs_encrypt_unit_for_test(&mut u0, &[0xAAu8; 16]); // encrypted under A - buf[..ul].copy_from_slice(&u0); + let mut buf = vec![0u8; 4 * ul]; + for i in 0..4 { + let mut u = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut u, &[0xAAu8; 16]); // encrypted under A + buf[i * ul..(i + 1) * ul].copy_from_slice(&u); + } let keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xCCu8; 16])], // map slot points at the WRONG key format: ContentFormat::BdTs, }; - let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * usz, 0, Phase::Even)]); + let map = AacsKeyMap::from_ranges_phased(vec![(0, 4 * usz, 0, Phase::Even)]); + assert!(matches!( + decrypt_sectors_mapped(&mut buf, &keys, 0, &map), + Err(crate::error::Error::DecryptFailed) + )); + } + + /// The wrong-key verdict is per key: a read spanning a good FMTS range and a wrong-keyed + /// one stops, even though the good range's units verify. + #[test] + fn mapped_phase_wrong_key_stops_beside_a_good_range() { + use crate::disc::ContentFormat; + let ul = aacs::content::ALIGNED_UNIT_LEN; + let usz = (ul / 2048) as u32; + let good = [0xAAu8; 16]; + let mut buf = vec![0u8; 8 * ul]; + for i in 0..8 { + let mut u = clear_ts_unit(); + aacs_encrypt_unit_for_test(&mut u, &good); + buf[i * ul..(i + 1) * ul].copy_from_slice(&u); + } + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, good), (1, [0xCCu8; 16])], + format: ContentFormat::BdTs, + }; + let map = AacsKeyMap::from_ranges_phased(vec![ + (0, 4 * usz, 0, Phase::Even), + (4 * usz, 8 * usz, 1, Phase::Even), + ]); assert!(matches!( decrypt_sectors_mapped(&mut buf, &keys, 0, &map), Err(crate::error::Error::DecryptFailed) diff --git a/src/diag.rs b/src/diag.rs index 3db97e78..c8333065 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -520,22 +520,16 @@ fn dump_aacs(disc: &Disc) { } return; }; - // CPS-unit / unit-key counts: at scan `unit_keys` is empty (keys are - // resolved later); the unit-key count is the BE16 in the raw - // Unit_Key_RO.inf if captured. Report both: resolved count and raw len. + // Key material is never on the disc (KU §2.2); report the raw AACS inputs' sizes. tracing::debug!( target: DIAG, - "tag=aacs version={} bus_enc={} mkb_version={} disc_hash={} key_source={:?} \ - vuk={} unit_keys_resolved={} uk_ro_bytes={} mkb_bytes={}", + "tag=aacs version={} bus_enc={} mkb_version={} disc_hash={} uk_ro_bytes={} mkb_bytes={}", a.version, a.bus_encryption, a.mkb_version .map(|v| v.to_string()) .unwrap_or_else(|| "unknown".to_string()), a.disc_hash, - a.key_source, - a.vuk.is_some(), - a.unit_keys.len(), a.uk_ro.len(), a.mkb.len(), ); diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 927bac2f..e13214c7 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -116,7 +116,7 @@ pub(super) fn aacs_verdict(bus_encryption: bool, bus: &BusOutcome) -> Option Option { +pub(crate) fn handshake_class_error(e: &Error) -> Option { match e { Error::AacsNoHostCert { path } => Some(Error::AacsNoHostCert { path: path.clone() }), Error::AacsNoUsableHostCert => Some(Error::AacsNoUsableHostCert), @@ -127,22 +127,6 @@ pub(super) fn handshake_class_error(e: &Error) -> Option { } } -impl Disc { - // Sticky refusal: a failed handshake on a bus-encrypted disc, or a live disc without - // its key file (E7031; key sources then trace BUS_BLOCKED), admits no supplied key. - // With no AACS state the bus flag is unknown, so bus encryption is assumed. - pub(crate) fn bus_blocked_error(&self) -> Option { - if matches!(self.aacs_error, Some(Error::AacsKeyFileUnreadable)) { - return Some(Error::AacsKeyFileUnreadable); - } - let bus = self.aacs.as_ref().is_none_or(|a| a.bus_encryption); - if !bus { - return None; - } - self.aacs_error.as_ref().and_then(handshake_class_error) - } -} - // Positive UHD evidence independent of the cert: index.bdmv version "0300" // (BD-ROM Part 3) or an AACS2 MKB. Unknown is `false`. fn disc_is_uhd(udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource) -> bool { @@ -261,9 +245,6 @@ pub(super) fn resolve_aacs( bus_encryption: cap.bus_encryption, mkb_version, disc_hash: aacs::inf::disc_hash_hex(&dh), - key_source: KeyOrigin::ExternalUk, - vuk: None, - unit_keys: vec![], volume_id: bus.handshake().map(|h| h.volume_id).unwrap_or([0u8; 16]), uk_ro, mkb: cap.mkb, @@ -810,9 +791,6 @@ mod tests { let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); assert_eq!(st.version, 1, "V10 cert → AACS version 1"); assert!(!st.bus_encryption); - assert_eq!(st.key_source, KeyOrigin::ExternalUk); - assert!(st.unit_keys.is_empty(), "vid-only resolves no keys"); - assert!(st.vuk.is_none()); } /// A V20 content cert (type != 0x00) → version 2 (encrypt.rs Some(_) → 2). @@ -1144,7 +1122,7 @@ mod tests { let a = scanned.aacs.as_ref().expect("AACS metadata kept"); assert!(!a.disc_hash.is_empty(), "disc hash must survive"); assert_eq!(a.version, 2); - assert!(a.bus_encryption && a.unit_keys.is_empty()); + assert!(a.bus_encryption); assert!(matches!( scanned.decrypt_keys(), crate::decrypt::DecryptKeys::None @@ -1192,23 +1170,6 @@ mod tests { }) } - /// A bus-blocked disc stays blocked: an unvalidated key (no samples) must not - /// commit or clear the handshake error. - #[test] - fn bus_blocked_disc_rejects_supplied_keys() { - let mut d = scan_cert_disc(0x10, true, None, no_host_cert()); - let err = d - .decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) - .expect_err("bus-blocked disc must refuse keys"); - assert_eq!(err.code(), crate::error::E_AACS_NO_HOST_CERT); - assert!(d.inject_unit_keys(vec![(0, [0x11; 16])]).is_err()); - assert!(matches!( - d.decrypt_keys(), - crate::decrypt::DecryptKeys::None - )); - assert!(matches!(d.aacs_error, Some(Error::AacsNoHostCert { .. }))); - } - /// The unlocker matrix must not credit the AACS route when its handshake failed. #[test] fn unlocker_matrix_aacs_requires_a_working_handshake() { @@ -1234,67 +1195,10 @@ mod tests { let d = scan_cert_disc(0x00, false, None, no_host_cert()); assert!(d.aacs.is_some()); assert!(d.aacs_error.is_none(), "{:?}", d.aacs_error); - let e = d.ensure_decryptable(false).expect_err("no key"); + let e = gate(&d).expect_err("no key"); assert_eq!(e.code(), crate::error::E_NO_DISC_KEY); } - /// The `aacs = None` fail-safe: bus state unknown, so a recorded handshake-class - /// error refuses supplied keys as for a bus-blocked disc. - #[test] - fn failed_handshake_without_aacs_state_refuses_keys() { - let (mut disc, udf) = disc_with_cert(0x10, true); - let mut d = finish_disc(&mut disc, udf, BusOutcome::FileOrIso); - d.aacs = None; - d.aacs_error = no_host_cert(); - assert!( - d.decrypt_with(Key::Unit(vec![(1, [0x11; 16])]), &[]) - .is_err() - ); - assert!(d.inject_unit_keys(vec![(1, [0x11; 16])]).is_err()); - assert!(matches!(d.aacs_error, Some(Error::AacsNoHostCert { .. }))); - } - - /// A live key-file failure recorded by a raw-copy scan refuses every key, even - /// with no AACS state to say the disc is bus-encrypted. - #[test] - fn unreadable_key_file_refuses_keys() { - let (mut disc, udf) = disc_with_cert(0x00, false); - let mut d = finish_disc(&mut disc, udf, BusOutcome::FileOrIso); - d.aacs = None; - d.aacs_error = Some(Error::AacsKeyFileUnreadable); - let e = d - .decrypt_with(Key::Unit(vec![(1, [0x11; 16])]), &[]) - .expect_err("no key file, no key"); - assert_eq!(e.code(), crate::error::E_AACS_KEY_FILE_UNREADABLE); - assert!(d.inject_unit_keys(vec![(1, [0x11; 16])]).is_err()); - } - - /// A bus-blocked disc does not query key sources at all. - #[test] - fn bus_blocked_disc_skips_key_sources() { - use std::sync::atomic::{AtomicUsize, Ordering}; - static CALLS: AtomicUsize = AtomicUsize::new(0); - struct Counting; - impl crate::keysource::KeySource for Counting { - fn get_unit_keys( - &self, - _ctx: &dyn crate::keysource::ResolveCtx, - ) -> Result> { - CALLS.fetch_add(1, Ordering::Relaxed); - Ok(vec![crate::aacs::types::UnitKey::new(0, [0x11; 16])]) - } - } - let mut d = scan_cert_disc(0x10, true, None, no_host_cert()); - let inputs = d.inputs().expect("inputs"); - let sources: Vec> = vec![Box::new(Counting)]; - let (ok, trace) = crate::keysource::resolve_and_apply_traced(&sources, &inputs, &mut d); - assert!(!ok); - assert_eq!(CALLS.load(Ordering::Relaxed), 0, "no source may be queried"); - // One step names the block, so a UI can tell it from "no sources". - assert_eq!(trace.keys.len(), 1); - assert_eq!(trace.keys[0].who, crate::aacs::trace::BUS_BLOCKED); - } - /// A handshake with no bus key and no drive unlock keeps the metadata and /// reports AacsBusKeyUnavailable. #[test] @@ -1305,14 +1209,19 @@ mod tests { assert!(matches!(d.aacs_error, Some(Error::AacsBusKeyUnavailable))); } + // The public decrypt gate with no key set. + fn gate(d: &Disc) -> Result<()> { + crate::keys::check_decryptable(d, false, None, &crate::keys::KeyScope::WholeDisc) + } + /// The decrypt gate reports the handshake reason, not a generic NoDiscKey. #[test] fn decrypt_gate_passes_handshake_errors_through() { let d = scan_cert_disc(0x10, true, None, no_host_cert()); - let e = d.ensure_decryptable(false).expect_err("no key"); + let e = gate(&d).expect_err("no key"); assert_eq!(e.code(), crate::error::E_AACS_NO_HOST_CERT); let d = scan_cert_disc(0x10, true, None, Some(Error::AacsHostCertRejected)); - let e = d.ensure_decryptable(false).expect_err("no key"); + let e = gate(&d).expect_err("no key"); assert_eq!(e.code(), crate::error::E_AACS_HOST_CERT_REJECTED); } diff --git a/src/disc/extract.rs b/src/disc/extract.rs index e624b1d9..ca6ad97b 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -44,8 +44,8 @@ pub struct ExtractOptions<'a> { pub halt: Option, /// The rip's up-front key set (KU §3.1), scope `WholeDisc`. For an AACS disc every file /// is read through the set's reader: proven files by its map, the rest proven on - /// arrival, and a readable unit no held key opens stops the run (E7032). `None` keeps - /// the legacy disc-banked keys (until KU-X2). + /// arrival, and a readable unit no held key opens stops the run (E7032). `None` + /// decrypts no AACS: the caller's `check_decryptable` gate refuses an AACS disc first. pub keys: Option<&'a crate::keys::ResolvedKeySet>, } @@ -120,7 +120,7 @@ impl Disc { /// /// `reader` is consumed for content reads. `dest` receives the tree /// STRAIGHT IN (no auto-named subfolder). The caller must have run the - /// pre-flight decrypt gate ([`ensure_decryptable`](Disc::ensure_decryptable)). + /// pre-flight decrypt gate ([`check_decryptable`](crate::keys::check_decryptable)). /// /// Bad sectors become zero-filled holes (the run does not abort); files /// are written `.partial` and renamed on success. @@ -192,10 +192,8 @@ impl Disc { } // Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily - // when a scrambled VOB group needs it. AACS / None discs keep the - // disc-wide keys for every file. + // when a scrambled VOB group needs it. An AACS disc reads through the rip's set. let mut base_keys = self.decrypt_keys(); - // The rip's key set replaces the disc-banked keys and the count rule below (K-2). let keyed = opts.keys.filter(|s| s.is_aacs()); if let Some(set) = keyed { crate::keys::check_decryptable( @@ -207,45 +205,15 @@ impl Disc { base_keys = set.decrypt_keys(); } - // AACS key map, by CPS-unit count. Single-CPS: one Unit Key opens - // everything, so a blanket key-0 map covers even orphan clips. Multi-CPS - // builds an exact per-title map instead (a blanket map would mis-decrypt). - let key_map = match &base_keys { - _ if keyed.is_some() => None, - DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => { - Some(std::sync::Arc::new( - crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]), - )) - } - DecryptKeys::Aacs { .. } => { - match self.resolve_content_key_map(reader, &mut base_keys, None, opts.halt.as_ref()) - { - Ok(map) => Some(std::sync::Arc::new(map)), - // A Stop is a halted run, not an error (same as the CSS arm). - Err(Error::Halted) => { - return Ok(ExtractResult { - halted: true, - ..Default::default() - }); - } - Err(e) => return Err(e), - } - } - _ => None, - }; - // Phase 2: stream each file through the decrypting decorator, which owns a // borrowing wrapper so the caller keeps `reader`. Keys swap per CSS VTS - // group via `set_keys`; AACS/None keep `base_keys` throughout. + // group via `set_keys`; AACS reads through the set's reader. let mut dec = match keyed { Some(set) => { set.decrypting(Borrowed(reader), None, crate::keys::StopKind::Image, false)? } None => DecryptingSectorSource::new(Borrowed(reader), base_keys.clone()), }; - if let Some(map) = key_map { - dec = dec.with_key_map(map); - } let mut result = ExtractResult::default(); let total_bytes = required; @@ -710,6 +678,7 @@ fn extract_one_file( let batch = whole_unit_batch(sectors - sector_off); let want = batch as usize * SECTOR_BYTES; let start = abs_lba.checked_add(sector_off); + let blanked_before = dec.blanked_units(); let read_ok = match start.filter(|l| l.checked_add(batch - 1).is_some()) { // Crafted extent past u32::MAX: no such sector — a hole, not a wrapped read. None => false, @@ -726,7 +695,13 @@ fn extract_one_file( let usable = chunk_bytes.min(remaining) as usize; if read_ok { write_all(&mut writer, &buf[..usable], &partial_path)?; - fr.bytes_good = fr.bytes_good.saturating_add(usable as u64); + // Damaged AACS units the reader blanked are unreadable, not good bytes. + let unit = crate::aacs::content::ALIGNED_UNIT_LEN as u64; + let blanked = (dec.blanked_units() - blanked_before) * unit; + let blanked = blanked.min(usable as u64); + fr.bytes_good = fr.bytes_good.saturating_add(usable as u64 - blanked); + fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(blanked); + *done_unreadable = done_unreadable.saturating_add(blanked); } else { // Bad sector(s): zero-fill this byte range, record the hole, // keep going (no abort, no sweep-skip). @@ -1362,9 +1337,9 @@ mod tests { unit } - // A `Disc` carrying an AACS unit key so `decrypt_keys()` engages the - // unit-alignment gate. Content is genuinely encrypted under the key, so a - // clean decrypt isolates the GATE from a false decrypt-loss tally. + // An AACS `Disc`: its key comes from a set over it (`keyed_for_test`), which engages + // the unit-alignment gate. Content is genuinely encrypted under the key, so a clean + // decrypt isolates the GATE from a false decrypt-loss tally. fn aacs_disc() -> Disc { let mut d = clear_disc(); d.encrypted = true; @@ -1373,9 +1348,6 @@ mod tests { bus_encryption: false, mkb_version: None, disc_hash: String::new(), - key_source: crate::disc::KeyOrigin::ExternalUk, - vuk: None, - unit_keys: vec![(0u32, [0u8; 16])], volume_id: [0u8; 16], uk_ro: Vec::new(), mkb: Vec::new(), @@ -1947,8 +1919,19 @@ mod tests { disc.put_bytes(PART_START + 11, &root_fids); let out = TmpDir::new("multiextent_aacs"); - let res = aacs_disc() - .extract_tree(&mut disc, out.path(), &ExtractOptions::default()) + let d = aacs_disc(); + let (a, b) = (PART_START + DATA_A, PART_START + DATA_B); + let set = crate::keys::ResolvedKeySet::keyed_for_test( + &d, + key, + &[(a, a + SECTORS_EACH), (b, b + SECTORS_EACH)], + ); + let opts = ExtractOptions { + keys: Some(&set), + ..Default::default() + }; + let res = d + .extract_tree(&mut disc, out.path(), &opts) .expect("extract"); let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written"); @@ -3063,42 +3046,6 @@ mod tests { assert!(res.files.is_empty()); } - // A Stop during multi-CPS AACS key-map resolution is a halt (Ok + halted), - // matching the CSS arm, not an Err(Halted) out of extract_tree. - #[test] - fn halt_during_aacs_key_map_resolution_is_a_halt() { - let root = DirSpec { - name: String::new(), - icb_lba: 10, - dir_data_lba: 11, - files: vec![file("a.bin", 30, 31, b"x".to_vec(), false)], - subdirs: vec![], - }; - let mut disc = build_disc(root); - let mut d = aacs_disc(); - if let Some(a) = d.aacs.as_mut() { - a.unit_keys = vec![(0, [1u8; 16]), (1, [2u8; 16])]; - } - let mut t = crate::disc::DiscTitle::empty(); - t.extents = vec![crate::disc::Extent { - start_lba: PART_START + 31, - sector_count: 3, - }]; - d.titles = vec![t]; - let halt = crate::halt::Halt::new(); - halt.cancel(); - let opts = ExtractOptions { - halt: Some(halt), - ..Default::default() - }; - let out = TmpDir::new("aacs_map_halt"); - let res = d - .extract_tree(&mut disc, out.path(), &opts) - .expect("a Stop during key-map resolution is a halt, not an error"); - assert!(res.halted); - assert!(res.files.is_empty()); - } - // A read that reports FEWER bytes than asked leaves a stale buffer tail: it // must be retried and then holed, never written out as good data. #[test] diff --git a/src/disc/mod.rs b/src/disc/mod.rs index d303a4f3..4a96e5fd 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -12,6 +12,7 @@ mod bluray; mod dvd; pub(crate) mod dvd_audio_probe; mod encrypt; +pub(crate) use encrypt::handshake_class_error; mod extract; mod hddvd; pub(crate) mod pgs_forced_probe; @@ -815,31 +816,6 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut } } -// Merges per-title AACS key ranges into the sorted, disjoint set the whole-disc map needs. -// Same-key overlaps are UNIONED (never drop coverage); a malformed different-key overlap is -// dropped to stay disjoint. -fn merge_content_key_ranges( - mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>, -) -> Vec<(u32, u32, usize, crate::decrypt::Phase)> { - ranges.sort_by_key(|&(s, _, _, _)| s); - let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); - for r in ranges { - match merged.last_mut() { - // Overlaps the previous kept range. - Some(last) if r.0 < last.1 => { - // Same key + phase → union (coverage-preserving); a genuine - // different-key overlap (malformed disc) is dropped to stay disjoint. - if r.2 == last.2 && r.3 == last.3 { - last.1 = last.1.max(r.1); - } - } - // Disjoint or exactly adjacent → keep as its own range. - _ => merged.push(r), - } - } - merged -} - /// Calculate how many bytes of bad/unreadable data fall within a title's extents. /// `pub(crate)` so autorip can use it for main-movie lost_ms computation. pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 { @@ -1665,14 +1641,6 @@ pub struct AacsState { pub mkb_version: Option, /// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix pub disc_hash: String, - /// How keys were resolved - pub key_source: KeyOrigin, - /// Volume Unique Key (16 bytes). `None` when keys were resolved - /// via the [`KeyOrigin::KeyDbUnitKeys`] path — that source delivers - /// pre-decrypted unit keys without a VUK to derive them from. - pub vuk: Option<[u8; 16]>, - /// Decrypted unit keys (CPS unit number, key) - pub unit_keys: Vec<(u32, [u8; 16])>, /// Volume ID (16 bytes) -- from SCSI handshake pub volume_id: [u8; 16], /// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at @@ -1694,9 +1662,6 @@ impl std::fmt::Debug for AacsState { .field("bus_encryption", &self.bus_encryption) .field("mkb_version", &self.mkb_version) .field("disc_hash", &self.disc_hash) - .field("key_source", &self.key_source) - .field("vuk", &self.vuk.map(|_| "")) - .field("unit_keys_len", &self.unit_keys.len()) .field("volume_id", &"") .field("uk_ro_len", &self.uk_ro.len()) .field("mkb_len", &self.mkb.len()) @@ -1748,7 +1713,7 @@ pub struct DriveCredentials { /// Options for disc scanning. /// /// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key -/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The +/// out-of-band through [`ResolvedKeySet::resolve`](crate::keys::ResolvedKeySet::resolve). The /// only scan input is the optional drive credentials for the live-drive /// authenticated handshake. #[derive(Default)] @@ -1769,7 +1734,7 @@ pub struct ScanOptions { /// certs into `credentials` may leave it empty too. The library still /// resolves NO keys from these at scan time; they are consulted only for /// their host certs here (key *resolution* stays out-of-band via - /// `Disc::decrypt_with`). + /// `ResolvedKeySet::resolve`). pub key_sources: Vec>, /// Optional cooperative-cancellation token. When set, long scan-time /// loops (notably the CSS known-plaintext crack, which can scan up to @@ -2320,8 +2285,8 @@ impl Disc { /// Read a disc's AACS key-input files from an ISO image: returns /// `(Unit_Key_RO.inf, MKB, aacs_major_version)`. For callers that resolve a - /// Unit Key out-of-band: obtain the key however you like, then apply it via - /// [`Disc::decrypt_with`]. libfreemkv never makes a network call. + /// Unit Key out-of-band: the key reaches a read only through + /// [`ResolvedKeySet`](crate::keys::ResolvedKeySet). libfreemkv never makes a network call. pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec, Vec, u8)> { // Preserve the underlying open error (E5000) instead of collapsing // ENOENT/EPERM into `Error::AacsNoKeys` (E7000): a missing ISO is an @@ -2434,8 +2399,8 @@ impl Disc { /// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The /// out-of-band Unit Key path fetches the disc's key files from the drive, - /// resolves a key from them however it likes, then applies it via - /// [`Disc::decrypt_with`]. These files are plaintext UDF metadata — no + /// resolves a key from them however it likes, through + /// [`ResolvedKeySet`](crate::keys::ResolvedKeySet). These files are plaintext UDF metadata — no /// AACS handshake or keys are required to read them. pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec, Vec, u8)> { // No READ CAPACITY: the key files do not need the disc size. @@ -2474,7 +2439,7 @@ impl Disc { tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin"); let encrypted = aacs.is_some(); // Lookup-free: the state carries the disc's AACS inputs but no key; the caller - // resolves one and applies it via `Disc::decrypt_with`. + // resolves one into a `ResolvedKeySet`. let (aacs, aacs_error) = match aacs { Some((cap, bus)) => encrypt::resolve_aacs(cap, &bus), None => (None, None), @@ -2939,7 +2904,7 @@ impl Disc { /// hands it in here; libfreemkv derives any remaining AACS-chain steps from /// disc-read inputs (MKB / VID / `Unit_Key_RO.inf`). `#[non_exhaustive]`: /// AACS is a derivation chain (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ -/// UK`); each variant enters at one level, and [`Disc::decrypt_with`] +/// UK`); each variant enters at one level, and the library /// derives down from it — new levels can be added without breaking callers. #[derive(Clone)] #[non_exhaustive] @@ -2970,7 +2935,7 @@ pub enum Key { Unit(Vec<(u32, [u8; 16])>), } -// Redacting `Debug`: `Key` is the key-transport type crossing `Disc::decrypt_with`; +// Redacting `Debug`: `Key` is a key-transport type; // every variant carries raw key material. Print only variant name and count — // never bytes. Guarded by `aacs_state_and_key_debug_are_redacted`. impl std::fmt::Debug for Key { @@ -2985,55 +2950,6 @@ impl std::fmt::Debug for Key { } } -// True if `unit_keys` covers EVERY supplied scrambled content `sample` — validation gate for -// Disc::decrypt_with. `false` if a set only covers some CPS units. -fn aligned_unit_keys_validate( - unit_keys: &[(u32, [u8; 16])], - samples: &[Vec], - format: ContentFormat, -) -> bool { - use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit, is_clean}; - let scrambled: Vec<&[u8]> = samples - .iter() - .map(|s| s.as_slice()) - .filter(|s| aacs_unit_needs_decrypt(s, format)) - .collect(); - if scrambled.is_empty() { - return true; // nothing to disprove against — accept - } - if unit_keys.is_empty() { - return false; - } - let mut probe = vec![0u8; ALIGNED_UNIT_LEN]; - let total = (scrambled.len() as u64) * (unit_keys.len() as u64); - let mut tried = 0u64; - let mut hb = crate::progress::Heartbeat::new("scan_key_trial"); - // Every scrambled sample must be covered by SOME unit key. A single sample - // that no key descrambles means the key set is incomplete (wrong key, or a - // CPS unit left uncovered) — reject so the wrong/partial set never commits. - for sample in scrambled { - let mut covered = false; - for (_, k) in unit_keys { - // Pure-CPU inner loop: only consult the clock every 256 trials. - hb.tick_cpu(tried, total); - tried += 1; - probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]); - // Samples arrive ALREADY de-bussed (the drive's single de-bus point - // removed AACS 2.0 bus encryption at read time), so validation only - // applies the CPS unit key, then the structural proof. - decrypt_unit(&mut probe, k); - if is_clean(&probe, format) { - covered = true; - break; - } - } - if !covered { - return false; - } - } - true -} - // Read-ahead confined to one file's recorded extents: a single-sector read inside an // extent fetches up to a batch of THAT extent only (never adjacent essence). Other // single reads (the ICB) are memoised; the caller's `recovery` flag is forwarded. @@ -3192,20 +3108,11 @@ impl Disc { Ok(self.capacity_sectors) } - /// Get the resolved decryption keys for this disc. - /// Used by disc-to-ISO and other full-disc operations. - pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys { - if let Some(ref aacs) = self.aacs { - // An AACS state with NO unit keys is "encrypted, no keys" (e.g. a - // VID-only state before a Unit Key is supplied); report None so callers - // treat it as missing keys, not a usable empty key set. - if aacs.unit_keys.is_empty() { - return crate::decrypt::DecryptKeys::None; - } - crate::decrypt::DecryptKeys::Aacs { - unit_keys: aacs.unit_keys.clone(), - format: self.content_format, - } + /// The disc's own decryption keys: CSS, or `None`. AACS keys come only from a + /// [`ResolvedKeySet`](crate::keys::ResolvedKeySet) (KU §2.2), so an AACS disc is `None`. + pub(crate) fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys { + if self.aacs.is_some() { + crate::decrypt::DecryptKeys::None } else if let Some(ref css) = self.css { crate::decrypt::DecryptKeys::Css { title_key: css.title_key, @@ -3215,42 +3122,6 @@ impl Disc { } } - /// Resolve a WHOLE-DISC AACS key map for a decrypting sweep (`disc:// → iso://`): - /// the union of every title's proactive key map ([`crate::mux::resolve_mux_key_map`]), - /// so a sequential read of the entire disc decrypts each content unit with its - /// mapped key and passes clear filesystem/nav sectors (in no range) through. - /// Fails loud (via the per-title resolve) if any content unit's key is missing. - /// `keys` is mutated as fetched keys are banked; the merged ranges are disjoint - /// (titles that share a clip resolve the same span — the duplicate is dropped). - pub fn resolve_content_key_map( - &self, - reader: &mut dyn SectorSource, - keys: &mut crate::decrypt::DecryptKeys, - fetch: Option<&crate::sector::KeyFetch>, - halt: Option<&crate::halt::Halt>, - ) -> Result { - let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); - // ONE per-disc memo across every title, else every playlist re-derives the - // same facts: multi-CPS key-per-extent seeks, FMTS-detection seeks, and on - // FMTS a key-server round-trip storm. The FMTS memo makes the CPS memo reachable. - let mut cache = crate::mux::resolve::DiscKeyCache::new(); - for title in &self.titles { - let map = crate::mux::resolve::resolve_mux_key_map_cached( - reader, - title, - keys, - fetch, - self.content_format, - halt, - &mut cache, - )?; - ranges.extend_from_slice(map.ranges()); - } - Ok(crate::decrypt::AacsKeyMap::from_ranges_phased( - merge_content_key_ranges(ranges), - )) - } - /// The scanned titles' encrypted content as a sorted, merged, disjoint set /// of `(start_lba, sector_count)` ranges — the union of every title's /// stream extents. Covers kept titles ONLY: stream files no title plays @@ -3272,7 +3143,8 @@ impl Disc { AacsVersion::V10 }; // KS-14 [BD] §3.9.3: "Num_of_CPS_Unit field (16 bits) indicates the number of CPS - // Units on the disc" — the declared count, never the number of keys held. + // Units on the disc" — the declared count, never the number of keys held. K-8: an + // HD DVD VTKF counts too, through `parse_title_keys` (KS-27, evidence). let ukf = crate::aacs::inf::parse_title_keys(&aacs.uk_ro, version)?; Some(ukf.encrypted_keys.len()) } @@ -3314,10 +3186,10 @@ impl Disc { "AACS" => { self.aacs.as_ref().is_some_and(|a| a.volume_id != [0u8; 16]) && !fw_removed_bus - && !self + && self .aacs_error .as_ref() - .is_some_and(|e| encrypt::handshake_class_error(e).is_some()) + .is_none_or(|e| encrypt::handshake_class_error(e).is_none()) } // DVD read-unlock (CSS bus-auth) runs for EVERY DVD during scan // and isn't tracked separately, so this reports the MEDIUM @@ -3332,221 +3204,6 @@ impl Disc { .collect() } - /// The system-wide decrypt correctness gate. Returns `Ok(())` when safe to proceed with a - /// copy or mux, and a clear typed error when decryption is **needed but unavailable** — the - /// case that would otherwise write ciphertext or feed the demux undecryptable bytes and - /// exit 0. Every copy/mux entry point calls this after key resolution and before any - /// source-data processing, so the failure is pre-flight (no partial output). `raw == true` - /// always succeeds. - pub fn ensure_decryptable(&self, raw: bool) -> Result<()> { - self.ensure_decryptable_keys(raw, &self.decrypt_keys()) - } - - /// [`Self::ensure_decryptable`] against a caller-resolved key set, for - /// the per-title path. A multi-VTS CSS DVD resolves its key with - /// [`Self::decrypt_keys_for_title`] (which can return `None` when the - /// chosen title's VTS could not be re-cracked even though the disc-wide - /// `decrypt_keys()` is `Css{..}`); the gate must judge THAT key. "Is the - /// source encrypted?" is answered by scan-captured disc state - /// (`css_error`/`aacs`/`css`), never by the keys — so an unencrypted - /// disc never false-errors regardless of `keys`. - pub fn ensure_decryptable_keys( - &self, - raw: bool, - keys: &crate::decrypt::DecryptKeys, - ) -> Result<()> { - // --raw skips decryption entirely: never error, even on an encrypted - // disc with no key (the user asked for the encrypted image). - if raw { - return Ok(()); - } - // Scrambled-but-uncracked CSS: `css` is None so the key check can't see - // it. `css_error` is a WHOLE-DISC verdict raised as `CssNoDiscKey`, never - // per-title `CssKeyMissing` (which would log "empty stub" per title and exit 0). - if self.css_error.is_some() { - return Err(Error::CssNoDiscKey); - } - // Decryption is needed iff the disc carries cipher state. A no-key - // verdict on a non-encrypted disc is impossible here (the disc has no - // AACS/CSS state), so a genuinely unencrypted disc never errors. - let needs_key = matches!(keys, crate::decrypt::DecryptKeys::None); - if needs_key { - if self.aacs.is_some() { - // E7017 vs E7022 split: with derivation material but no VID, report - // `AacsVidUnavailable`, not generic `NoDiscKey`. Likewise a key SOURCE - // failure surfaces as ITSELF, else operators hunt a VUK during an outage. - if let Some(e) = self - .aacs_error - .as_ref() - .and_then(encrypt::handshake_class_error) - { - return Err(e); - } - match self.aacs_error { - Some(Error::KeyServiceUnavailable) => { - return Err(Error::KeyServiceUnavailable); - } - Some(Error::KeyServiceUnauthorized) => { - return Err(Error::KeyServiceUnauthorized); - } - Some(Error::KeyServiceRateLimited) => { - return Err(Error::KeyServiceRateLimited); - } - _ => {} - } - return Err(Error::NoDiscKey { - disc_hash: self.aacs_disc_hash(), - }); - } - if self.css.is_some() { - return Err(Error::CssKeyMissing); - } - } - Ok(()) - } - - /// Resolve decryption keys for muxing a *specific* title. For a **DVD** the CSS title key - /// MUST be recovered before descrambling: either reuse the scan's cracked key if its span - /// covers this title's VTS, or crack it fresh from this title's own extents in natural - /// PLAYBACK ORDER (never largest-cell-first, the 1.5.1 bug). The crack path does NOT gate - /// on `self.css`, so a detection miss can never route the mux into raw passthrough of - /// scrambled sectors. Non-DVD schemes return [`Self::decrypt_keys`] unchanged. - /// `halt` cancels the crack. `Err` is a crack that reached no verdict: `Halted`, - /// or the read error when no sector could be read. - pub fn decrypt_keys_for_title( - &self, - idx: usize, - reader: &mut dyn SectorSource, - batch_sectors: u16, - halt: Option<&crate::halt::Halt>, - ) -> Result<(crate::decrypt::DecryptKeys, bool)> { - // Non-DVD (AACS / FMTS / genuinely unencrypted): disc-wide keys, unchanged. - if self.format != DiscFormat::Dvd { - return Ok((self.decrypt_keys(), false)); - } - let title = match self.titles.get(idx) { - Some(t) if !t.extents.is_empty() => t, - // No extents to crack from: mark it clear (`true`). Returning `false` - // would let the gate's "None keys + not clear = scrambled-uncracked" - // rule wrongly hard-fail a genuinely-unencrypted extentless title. - _ => return Ok((self.decrypt_keys(), true)), - }; - // Fast path: reuse the scan's cracked key if its span covers this title's - // VTS. `crack_span: None` (unknown provenance) is treated as covering. - if let Some(css) = self.css.as_ref() { - let covers = match css.crack_span { - None => true, - Some((cs, ce)) => title - .extents - .iter() - .any(|e| e.start_lba < ce && cs < e.start_lba.saturating_add(e.sector_count)), - }; - if covers { - return Ok(( - crate::decrypt::DecryptKeys::Css { - title_key: css.title_key, - }, - false, - )); - } - } - // Detection miss or different VTS: crack this title's own extents in a - // SINGLE scan, in playback order (never largest-cell-first — the 1.5.1 - // garbage bug). One call, so a locked title isn't re-hammered (rule #2). - Ok( - match crate::css::crack_key_outcome(reader, &title.extents, batch_sectors, halt) { - crate::css::CrackOutcome::Cracked(state) => ( - crate::decrypt::DecryptKeys::Css { - title_key: state.title_key, - }, - false, - ), - // Scrambled but no key recoverable → hard failure. - crate::css::CrackOutcome::ScrambledUncracked => { - (crate::decrypt::DecryptKeys::None, false) - } - // No scrambled sector anywhere in the whole title → genuinely clear. - crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true), - crate::css::CrackOutcome::Unreadable(e) => return Err(e), - crate::css::CrackOutcome::Halted => return Err(Error::Halted), - }, - ) - } - - /// Per-title decrypt gate that honours the `title_is_clear` verdict from - /// [`Self::decrypt_keys_for_title`]. Identical to - /// [`Self::ensure_decryptable_keys`] EXCEPT it does not raise `E7023` - /// when the chosen title proved genuinely clear: a multi-VTS CSS disc - /// can carry an unencrypted stub title in its own VTS that needs no key, - /// even though disc-wide `css.is_some()` is true. A scrambled-but- - /// uncrackable title (`title_is_clear == false`, key `None`) still - /// hard-fails exactly as before. - pub fn ensure_title_decryptable( - &self, - raw: bool, - keys: &crate::decrypt::DecryptKeys, - title_is_clear: bool, - ) -> Result<()> { - if raw { - return Ok(()); - } - // A title proven clear by its own re-crack needs no key even though the - // disc is CSS — pass it. Disc-wide `css_error` is deliberately NOT - // consulted here: it reflects the MAIN feature's crack, not this title. - if title_is_clear && !keys.is_encrypted() { - return Ok(()); - } - // A DVD title cracked to no key and NOT clear is scrambled-but-uncrackable; - // hard-fail directly (the disc-wide gate can miss this). `CssKeyMissing` - // is right here: a sibling VTS may still crack, so the rip skips only this one. - if self.format == DiscFormat::Dvd && !title_is_clear && !keys.is_encrypted() { - return Err(Error::CssKeyMissing); - } - // A usable per-title key was resolved — pass WITHOUT the disc-wide gate: - // it hard-fails on `self.css_error` (the MAIN feature's crack), and a - // bonus title that cracked its own key must not be blocked by that. - if keys.is_encrypted() { - return Ok(()); - } - self.ensure_decryptable_keys(raw, keys) - } - - // Injects pre-resolved AACS unit keys into a scanned disc — the deferred-mux/resume path - // (keys from the mapfile's `# freemkv-uk:` header). - pub(crate) fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) -> Result<()> { - if let Some(e) = self.bus_blocked_error() { - return Err(e); - } - if let Some(aacs) = self.aacs.as_mut() { - aacs.unit_keys = keys; - aacs.key_source = KeyOrigin::ExternalUk; - } else if self.encrypted && self.css.is_none() { - // FMTS is AACS 2.1, a UHD-family (bus-encrypted) format — not BD. - let uhd_family = matches!(self.format, DiscFormat::Uhd | DiscFormat::Fmts); - self.aacs = Some(AacsState { - version: if uhd_family { - crate::aacs::mkb::AACS_MAJOR_UHD - } else { - crate::aacs::mkb::AACS_MAJOR_BD - }, - bus_encryption: uhd_family, - mkb_version: None, - disc_hash: String::new(), - key_source: KeyOrigin::ExternalUk, - vuk: None, - unit_keys: keys, - volume_id: [0u8; 16], - uk_ro: Vec::new(), - mkb: Vec::new(), - }); - // The prior resolution error (e.g. KeydbLoad) is now moot — we have - // the decryption key. Clear it so callers don't treat the disc as - // keyless on the stale error. - self.aacs_error = None; - } - Ok(()) - } - /// The public AACS inputs for this disc, for a [`crate::KeySource`] to look /// a key up. `None` when the disc carries no AACS state (unencrypted, CSS, /// or AACS inputs not captured at scan). Contains no secrets — just disc @@ -3603,111 +3260,6 @@ impl Disc { .max_by_key(|t| t.size_bytes) .or_else(|| self.titles.iter().max_by_key(|t| t.size_bytes)) } - - /// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable - /// decryption state. **Lookup-free**: the caller does all resolution and - /// hands the key in here. For [`Key::Unit`] this is the deferred-mux / - /// resume path (see [`Self::inject_unit_keys`]). `samples` are encrypted - /// on-disc aligned units (6144 bytes each) for content validation, since - /// a wrong key can still *derive* a non-empty (garbage) unit-key set. A - /// rejected key (`Err(AacsKeyRejected)`) falls through to the caller's - /// next candidate. Pass `&[]` to skip validation (resume / mapfile cache). - pub fn decrypt_with(&mut self, key: Key, samples: &[Vec]) -> Result<()> { - if let Some(e) = self.bus_blocked_error() { - return Err(e); - } - // Samples arrive already de-bussed (drive's single bus-removal point), so - // validation needs no bus key. Resolve the key DOWN to candidate unit keys - // WITHOUT committing — a wrong higher key is rejected before it poisons state. - let (candidate_unit_keys, candidate_vuk) = if let Key::Unit(keys) = key { - // Terminal — the source / mapfile already holds the final UKs. - (keys, None) - } else { - // Every higher level derives DOWN via the version-dispatched resolver - // (single home for all AACS derivation), needing the AACS inputs - // (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time. - let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?; - if aacs.uk_ro.is_empty() { - // Scan captured no Unit_Key_RO.inf — nothing to derive into. - return Err(crate::error::Error::AacsNoKeys); - } - - // Map the supplied Key -> raw material. The source handed in material - // at exactly one level; choosing/applying it is the resolver's job. - let mut supplied = crate::aacs::provider::SuppliedKey { - device_keys: Vec::new(), - processing_keys: Vec::new(), - media_keys: Vec::new(), - disc_entry: None, - }; - match key { - Key::Device(dks) => supplied.device_keys = dks, - Key::Processing(pks) => supplied.processing_keys = pks, - Key::Media(mks) => supplied.media_keys = mks, - Key::Volume(vuk) => { - supplied.disc_entry = Some(crate::aacs::types::DiscEntry { - disc_hash: aacs.disc_hash.clone(), - title: String::new(), - media_key: None, - disc_id: None, - vuk: Some(vuk), - unit_keys: Vec::new(), - }); - } - Key::Unit(_) => unreachable!("Key::Unit handled above"), - } - - // Snapshot inputs (releases the &self borrow before the &mut below). - let volume_id = aacs.volume_id; - let mkb = aacs.mkb.clone(); - let uk_ro = aacs.uk_ro.clone(); - let version_u8 = aacs.version; - - let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied]; - let ctx = crate::aacs::resolve::ResolveContext { - unit_key_ro: &uk_ro, - content_cert: None, - volume_id: &volume_id, - providers: &provider_refs, - mkb: if mkb.is_empty() { None } else { Some(&mkb) }, - }; - - // Version dispatch — V10 uses the classical 48-byte-stride resolver, - // V20/V21 the 64-byte stride. The reason-preserving wrapper lets the - // gate report E7017 vs E7022 instead of a flat AacsKeyRejected. - let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8) - .map_err(|_reason| crate::error::Error::AacsKeyRejected)?; - - if resolved.unit_keys.is_empty() { - return Err(crate::error::Error::AacsKeyRejected); - } - (resolved.unit_keys, resolved.vuk) - }; - - // VALIDATE against real ciphertext. Conservative: reject only when a - // supplied sample is AACS-scrambled and NO candidate key can de-scramble - // it; with no samples the key is accepted as-is (sample-less paths unchanged). - if !aligned_unit_keys_validate(&candidate_unit_keys, samples, self.content_format) { - return Err(crate::error::Error::AacsKeyRejected); - } - - // Commit — only now does the key touch disc state. - match self.aacs.as_mut() { - Some(a) => { - a.unit_keys = candidate_unit_keys; - if candidate_vuk.is_some() { - a.vuk = candidate_vuk; - } - a.key_source = KeyOrigin::ExternalUk; - } - // A Unit key for an AACS disc whose scan built no state (keyless - // scan, no keydb): synthesize a minimal ExternalUk state. - None => self.inject_unit_keys(candidate_unit_keys)?, - } - // A prior scan-time resolution error (e.g. keyless scan) is now moot. - self.aacs_error = None; - Ok(()) - } } // Mapfile path for a regular output file: appends `.mapfile` to the output @@ -4599,9 +4151,6 @@ mod tests { bus_encryption: true, mkb_version: Some(77), disc_hash: "0xAA".into(), - key_source: KeyOrigin::ExternalUk, - vuk: Some([0xD5; 16]), - unit_keys: vec![(1, [0xD5; 16])], volume_id: [0xD5; 16], uk_ro: vec![1, 2, 3], mkb: vec![4, 5, 6], @@ -4669,76 +4218,6 @@ mod tests { assert_eq!(merged_extents(v.iter()), vec![(100, 50)]); } - // ── merge_content_key_ranges (whole-disc AACS map assembly) ──────────────── - use crate::decrypt::Phase; - - /// Ranges from different titles are sorted by start LBA and kept disjoint. - #[test] - fn merge_key_ranges_sorts_and_keeps_disjoint() { - let v = vec![ - (500u32, 600u32, 1usize, Phase::All), - (100, 200, 0, Phase::All), - (300, 400, 2, Phase::All), - ]; - assert_eq!( - merge_content_key_ranges(v), - vec![ - (100, 200, 0, Phase::All), - (300, 400, 2, Phase::All), - (500, 600, 1, Phase::All), - ] - ); - } - - /// A clip shared by two titles resolves the SAME span twice; the duplicate is - /// dropped so `entry_for` sees a disjoint set (one key for the span). - #[test] - fn merge_key_ranges_dedups_shared_clip_span() { - let v = vec![ - (100u32, 300u32, 0usize, Phase::All), - (100, 300, 0, Phase::All), - ]; - assert_eq!(merge_content_key_ranges(v), vec![(100, 300, 0, Phase::All)]); - } - - /// A later range that partially overlaps a kept one carrying the SAME key is - /// UNIONED, not dropped — the tail (400..500) must stay covered, or those - /// encrypted LBAs would fall in no range and pass through as ciphertext. - #[test] - fn merge_key_ranges_unions_same_key_overlap() { - let v = vec![ - (100u32, 400u32, 0usize, Phase::All), - (200, 500, 0, Phase::All), - ]; - assert_eq!(merge_content_key_ranges(v), vec![(100, 500, 0, Phase::All)]); - } - - /// A different-key partial overlap (malformed disc) is dropped rather than - /// unioned, so one unit key is never stretched over another key's LBAs; the set - /// stays disjoint for `entry_for`. - #[test] - fn merge_key_ranges_drops_conflicting_key_overlap() { - let v = vec![ - (100u32, 400u32, 0usize, Phase::All), - (200, 500, 1, Phase::All), - ]; - assert_eq!(merge_content_key_ranges(v), vec![(100, 400, 0, Phase::All)]); - } - - /// Adjacent (touching) ranges are BOTH kept — `r.0 >= prev_end` holds when the - /// next starts exactly at the previous end, so no coverage is lost. - #[test] - fn merge_key_ranges_keeps_adjacent() { - let v = vec![ - (100u32, 200u32, 0usize, Phase::All), - (200, 300, 1, Phase::All), - ]; - assert_eq!( - merge_content_key_ranges(v), - vec![(100, 200, 0, Phase::All), (200, 300, 1, Phase::All)] - ); - } - // A Windows-form path (\\.\CdRom0, \\.\D:) must never fall through to // the block default (8192 sectors, over the optical 510-sector cap): it // has no forward slash, so the Linux-sysfs name parse cannot apply. @@ -6863,102 +6342,38 @@ mod tests { } } - #[test] - fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() { - // Regression (E8005 deferred-mux loop): a disc swept without a keydb scans to - // aacs=None + KeydbLoad error, but its persisted UK is recovered at remux, so - // injecting it must yield usable keys, not leave inject_unit_keys a no-op. - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - disc.aacs_error = Some(crate::error::Error::KeydbLoad { - path: "".into(), - }); - assert!( - matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None), - "precondition: encrypted disc with no aacs state => no decrypt keys" - ); - - let uk = vec![(0u32, [0x11u8; 16])]; - disc.inject_unit_keys(uk.clone()).expect("inject"); - - match disc.decrypt_keys() { - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => { - assert_eq!(unit_keys, uk, "injected UK must be the decrypt key"); - } - _ => panic!("expected Aacs decrypt keys after injecting a UK"), - } - assert!( - disc.aacs_error.is_none(), - "stale KeydbLoad must be cleared once a UK is in hand" - ); - assert_eq!( - disc.aacs.as_ref().unwrap().key_source, - KeyOrigin::ExternalUk - ); - } - - #[test] - fn inject_unit_keys_labels_fmts_as_uhd_family() { - // FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK on an - // FMTS disc must synthesize the UHD version + bus encryption, not mislabel it - // AACS 1.0 / bus-off, which would break FMTS decryption on the recovered-UK path. - let mut disc = make_test_disc(1000, "FMTS"); - disc.format = DiscFormat::Fmts; - disc.encrypted = true; - disc.inject_unit_keys(vec![(0u32, [0x22u8; 16])]) - .expect("inject"); - let aacs = disc.aacs.as_ref().expect("aacs state synthesized"); - assert_eq!( - aacs.version, - crate::aacs::mkb::AACS_MAJOR_UHD, - "FMTS is AACS 2.x (UHD major), not BD" - ); - assert!(aacs.bus_encryption, "FMTS is bus-encrypted like UHD"); - } - - /// Build an AacsState carrying the given unit keys (other fields are inert - /// defaults — these tests only exercise the unit-key/decrypt-keys plumbing). - fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState { + /// An AACS state with every capture field inert (no keys live on `AacsState`, KU §2.2). + fn aacs_empty() -> AacsState { AacsState { version: 2, bus_encryption: true, mkb_version: None, disc_hash: String::new(), - key_source: KeyOrigin::DeviceKey, - vuk: None, - unit_keys, volume_id: [0u8; 16], uk_ro: Vec::new(), mkb: Vec::new(), } } - // ensure_decryptable: the single gate every copy/mux entry point calls. Full truth - // table below — only "decryption needed AND unavailable AND not --raw" may error; - // every legit non-error case (raw / unencrypted / a resolved key) must proceed. + // The disc-wide decrypt gate with no key set (`keys::disc_gate`). Only "decryption + // needed AND unavailable AND not --raw" may error. - fn css_state() -> crate::css::CssState { - crate::css::CssState { - title_key: [0u8; 5], - crack_span: None, - } + fn gate(disc: &Disc, raw: bool) -> Result<()> { + crate::keys::disc_gate(disc, raw) } - /// AACS-encrypted disc, decryption requested, no unit key resolved → the - /// gate must fail with NoDiscKey (this is the headline bug: a pass-through - /// `DecryptingSectorSource` would otherwise write ciphertext at exit 0). + /// AACS disc with no key set → NoDiscKey (a pass-through reader would otherwise + /// write ciphertext at exit 0). An AACS disc's own keys are always `None`. #[test] - fn ensure_decryptable_aacs_no_key_errors() { + fn disc_gate_aacs_no_key_errors() { let mut disc = make_test_disc(1000, "UHD"); disc.encrypted = true; - disc.aacs = Some(aacs_with(Vec::new())); // present but no unit keys → None + disc.aacs = Some(aacs_empty()); assert!(matches!( disc.decrypt_keys(), crate::decrypt::DecryptKeys::None )); - let err = disc - .ensure_decryptable(false) - .expect_err("AACS disc, no key, !raw must error"); + let err = gate(&disc, false).expect_err("AACS disc, no key, !raw must error"); assert_eq!( err.code(), crate::error::Error::NoDiscKey { @@ -6966,16 +6381,13 @@ mod tests { } .code() ); + assert!(gate(&disc, true).is_ok(), "--raw must proceed"); } - // E7017 vs E7022 split (rc.6 WS1): with derivation material but no VID, - // aacs_error is AacsVidUnavailable and the gate must surface E7017, not - // the generic E7022. With no key material at all, E7022 stands. + // E7017 vs E7022: with derivation material but no VID the scan records + // AacsVidUnavailable, and the gate surfaces E7017, not the generic E7022. #[test] - fn ensure_decryptable_aacs_vid_unavailable_vs_no_key() { - // Branch 1 — derivation material present, but no VID: E7017. - // The resolver classifies a device-keys-but-zero-VID context as - // `VidUnavailable`; that reason rides on `aacs_error`. + fn disc_gate_aacs_vid_unavailable_vs_no_key() { let supplied = crate::aacs::provider::SuppliedKey { device_keys: vec![crate::aacs::types::DeviceKey { key: [0x11; 16], @@ -6988,16 +6400,14 @@ mod tests { disc_entry: None, }; let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied]; - // A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys) - // so resolution proceeds to the path-try logic and fails for lack of a - // VID — not because the .inf failed to parse. + // A parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys), so resolution + // fails for lack of a VID, not because the .inf failed to parse. let mut uk_ro = vec![0u8; 40]; - uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32 - // num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file. + uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); let ctx = crate::aacs::resolve::ResolveContext { unit_key_ro: &uk_ro, content_cert: None, - volume_id: &[0u8; 16], // the "no VID" sentinel + volume_id: &[0u8; 16], providers: &provider_refs, mkb: None, }; @@ -7007,897 +6417,92 @@ mod tests { "device keys + zero VID must classify as VidUnavailable" ); - let mut disc_e7017 = make_test_disc(1000, "UHD"); - disc_e7017.encrypted = true; - disc_e7017.aacs = Some(aacs_with(Vec::new())); // present but no unit keys - disc_e7017.aacs_error = Some(crate::error::Error::AacsVidUnavailable); - let err = disc_e7017 - .ensure_decryptable(false) - .expect_err("AACS disc, material-but-no-VID, !raw must error"); + let mut disc = make_test_disc(1000, "UHD"); + disc.encrypted = true; + disc.aacs = Some(aacs_empty()); + disc.aacs_error = Some(crate::error::Error::AacsVidUnavailable); assert_eq!( - err.code(), + gate(&disc, false).expect_err("must error").code(), crate::error::Error::AacsVidUnavailable.code(), - "material-but-no-VID must surface E7017 (AacsVidUnavailable), not E7022" + "material-but-no-VID must surface E7017, not E7022" ); - - // Branch 2 — no key material at all: classified NoMaterial, gate E7022. - let supplied_none = crate::aacs::provider::SuppliedKey { - device_keys: Vec::new(), - processing_keys: Vec::new(), - media_keys: Vec::new(), - disc_entry: None, - }; - let provider_refs_none: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied_none]; - let ctx_none = crate::aacs::resolve::ResolveContext { - unit_key_ro: &uk_ro, - content_cert: None, - volume_id: &[0u8; 16], - providers: &provider_refs_none, - mkb: None, - }; + disc.aacs_error = None; assert_eq!( - crate::aacs::resolve::resolve_keys_with_reason(&ctx_none, 2).err(), - Some(crate::aacs::resolve::ResolveFailure::NoMaterial), - "no key material must classify as NoMaterial" - ); - - let mut disc_e7022 = make_test_disc(1000, "UHD"); - disc_e7022.encrypted = true; - disc_e7022.aacs = Some(aacs_with(Vec::new())); - disc_e7022.aacs_error = None; // no reason captured → generic no-key - let err = disc_e7022 - .ensure_decryptable(false) - .expect_err("AACS disc, no material, !raw must error"); - assert_eq!( - err.code(), + gate(&disc, false).expect_err("must error").code(), crate::error::Error::NoDiscKey { disc_hash: String::new() } .code(), - "no-material must keep E7022 (NoDiscKey)" + "no reason captured keeps E7022" ); } - // THE defect: a key SOURCE that could not answer must not be reported as "this disc has no - // key" (E7022 for both used to send an operator hunting for a VUK through seven hours of - // 502s). + // A key SOURCE that could not answer must not be reported as "this disc has no key" + // (E7022 for both once sent an operator hunting for a VUK through hours of 502s). #[test] fn key_source_failure_is_not_reported_as_a_missing_disc_key() { - use crate::keysource::{KeySource, ResolveCtx, resolve_and_apply_traced}; - - /// A source that fails the way a down / hostile key service fails. - struct FailingSource(fn() -> crate::error::Error); - impl KeySource for FailingSource { - fn get_unit_keys( - &self, - _ctx: &dyn ResolveCtx, - ) -> std::result::Result, crate::error::Error> - { - Err((self.0)()) - } - fn label(&self) -> &'static str { - "online" - } + let miss_code = crate::error::Error::NoDiscKey { + disc_hash: String::new(), } - /// A source that ANSWERS and holds nothing — the genuine miss. - struct AnsweredNoEntry; - impl KeySource for AnsweredNoEntry { - fn get_unit_keys( - &self, - _ctx: &dyn ResolveCtx, - ) -> std::result::Result, crate::error::Error> - { - Ok(Vec::new()) - } - fn label(&self) -> &'static str { - "online" - } - } - - let inputs = crate::keysource::DiscInputs { - disc_hash: "0x422EB".into(), - volume_id: [0u8; 16], - version: crate::aacs::mkb::AACS_MAJOR_UHD, - mkb: Vec::new(), - unit_key_ro: Vec::new(), - samples: Vec::new(), - volume_label: None, - }; - let encrypted_aacs_disc = || { - let mut d = make_test_disc(1000, "UHD"); - d.encrypted = true; - d.aacs = Some(aacs_with(Vec::new())); // AACS state, no unit keys - d - }; - - // The genuine miss — the service answered, nothing for this disc. - let mut answered = encrypted_aacs_disc(); - let sources: Vec> = vec![Box::new(AnsweredNoEntry)]; - let (ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut answered); - assert!(!ok); - assert_eq!( - trace.keys[0].path, - vec![crate::aacs::trace::KeyNode::NoEntry], - "a source that ANSWERED and holds nothing is the one true `NoEntry`" - ); - assert!( - answered.aacs_error.is_none(), - "a genuine miss stamps no failure reason on the disc" - ); - let miss_code = answered - .ensure_decryptable(false) - .expect_err("no key, !raw must error") - .code(); - assert_eq!( - miss_code, - crate::error::Error::NoDiscKey { - disc_hash: String::new() - } - .code(), - "a genuine miss keeps E7022 — that wording is correct for it" - ); - - // Each way the service can FAIL to answer, and the code it must produce. - type MakeError = fn() -> crate::error::Error; - let failures: &[(MakeError, u16)] = &[ + .code(); + let failures = [ ( - || crate::error::Error::KeyServiceUnavailable, + crate::error::Error::KeyServiceUnavailable, crate::error::E_KEY_SERVICE_UNAVAILABLE, ), ( - || crate::error::Error::KeyServiceUnauthorized, + crate::error::Error::KeyServiceUnauthorized, crate::error::E_KEY_SERVICE_UNAUTHORIZED, ), ( - || crate::error::Error::KeyServiceRateLimited, + crate::error::Error::KeyServiceRateLimited, crate::error::E_KEY_SERVICE_RATE_LIMITED, ), ]; - for (make, want) in failures { - let mut down = encrypted_aacs_disc(); - let sources: Vec> = vec![Box::new(FailingSource(*make))]; - let (ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut down); - assert!(!ok); - assert!( - trace.keys[0].path.is_empty(), - "a source that could not ANSWER must not claim `no entry` in the trace" - ); - assert_eq!( - down.aacs_error.as_ref().map(crate::error::Error::code), - Some(*want), - "the source's failure reason must reach the disc" - ); - let code = down - .ensure_decryptable(false) - .expect_err("no key, !raw must error") - .code(); - assert_eq!(code, *want, "the gate must surface the SOURCE's code"); - assert_ne!( - code, miss_code, - "a source that could not answer must NOT render as \"this disc has no key\"" - ); + for (reason, want) in failures { + let mut down = make_test_disc(1000, "UHD"); + down.encrypted = true; + down.aacs = Some(aacs_empty()); + down.aacs_error = Some(reason); + let code = gate(&down, false).expect_err("no key must error").code(); + assert_eq!(code, want, "the gate must surface the SOURCE's code"); + assert_ne!(code, miss_code); } } - /// Issue #46: a source that MATCHED the disc but derived no key must render - /// distinctly from a true miss — `matched disc > no VID`, NOT `no entry` — - /// and carry the matched entry's shape for the app to log. + /// A genuinely unencrypted disc has `None` keys legitimately — the gate keys off + /// the scan-captured disc state, never the keys, so it must not false-error. #[test] - fn matched_but_underivable_disc_is_not_reported_as_no_entry() { - use crate::aacs::trace::{KeyNode, MatchedEntry}; - use crate::keysource::{ - KeySource, ResolveCtx, UnitKeyResolution, resolve_and_apply_traced, - }; - - /// Matched the disc, had a Media Key, but no VID on this path — the - /// classic issue-#46 case (a keydb hit that cannot finish). - struct MatchedNoVid; - impl KeySource for MatchedNoVid { - fn get_unit_keys( - &self, - _ctx: &dyn ResolveCtx, - ) -> std::result::Result, crate::error::Error> - { - Ok(Vec::new()) - } - fn resolve_unit_keys( - &self, - _ctx: &dyn ResolveCtx, - ) -> std::result::Result { - Ok(UnitKeyResolution { - keys: Vec::new(), - matched: true, - miss_path: vec![KeyNode::NoVid], - matched_entry: Some(MatchedEntry { - has_media_key: true, - enc_title_keys_len: 1, - ..Default::default() - }), - store_entries: Some(3), - }) - } - fn label(&self) -> &'static str { - "keydb" - } - } - - let inputs = crate::keysource::DiscInputs { - disc_hash: "0xMATCH".into(), - volume_id: [0u8; 16], - version: crate::aacs::mkb::AACS_MAJOR_UHD, - mkb: Vec::new(), - unit_key_ro: Vec::new(), - samples: Vec::new(), - volume_label: None, - }; - let mut disc = { - let mut d = make_test_disc(1000, "UHD"); - d.encrypted = true; - d.aacs = Some(aacs_with(Vec::new())); - d - }; - let sources: Vec> = vec![Box::new(MatchedNoVid)]; - let (ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut disc); - assert!(!ok); - assert_eq!( - trace.keys[0].path, - vec![KeyNode::MatchedDisc, KeyNode::NoVid], - "a matched-but-underivable disc must NOT collapse to `no entry`" - ); - assert_eq!( - trace.keys[0].matched_entry.map(|m| m.has_media_key), - Some(true), - "the matched entry's shape must survive to the trace for logging" - ); - } - - /// Companion to the above: a matched entry with NO derivation material at all - /// gets the library's default `no derivable key` node (empty `miss_path`), - /// still distinct from a true miss. - #[test] - fn matched_with_no_material_renders_no_derivable_key() { - use crate::aacs::trace::KeyNode; - use crate::keysource::{ - KeySource, ResolveCtx, UnitKeyResolution, resolve_and_apply_traced, - }; - - struct MatchedNoMaterial; - impl KeySource for MatchedNoMaterial { - fn get_unit_keys( - &self, - _ctx: &dyn ResolveCtx, - ) -> std::result::Result, crate::error::Error> - { - Ok(Vec::new()) - } - fn resolve_unit_keys( - &self, - _ctx: &dyn ResolveCtx, - ) -> std::result::Result { - Ok(UnitKeyResolution { - matched: true, - ..Default::default() - }) - } - } - - let inputs = crate::keysource::DiscInputs { - disc_hash: "0xMATCH".into(), - volume_id: [0u8; 16], - version: crate::aacs::mkb::AACS_MAJOR_UHD, - mkb: Vec::new(), - unit_key_ro: Vec::new(), - samples: Vec::new(), - volume_label: None, - }; - let mut disc = { - let mut d = make_test_disc(1000, "UHD"); - d.encrypted = true; - d.aacs = Some(aacs_with(Vec::new())); - d - }; - let sources: Vec> = vec![Box::new(MatchedNoMaterial)]; - let (_ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut disc); - assert_eq!( - trace.keys[0].path, - vec![KeyNode::MatchedDisc, KeyNode::NoDerivableKey], - ); - } - - /// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user - /// asked for the encrypted image and needs no key. - #[test] - fn ensure_decryptable_aacs_no_key_raw_proceeds() { - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - disc.aacs = Some(aacs_with(Vec::new())); - assert!(disc.ensure_decryptable(true).is_ok(), "--raw must proceed"); - } - - /// AACS disc WITH a resolved unit key → proceed (decrypt_keys is Aacs). - #[test] - fn ensure_decryptable_aacs_with_key_proceeds() { - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - disc.aacs = Some(aacs_with(vec![(0, [0x11u8; 16])])); - assert!(disc.ensure_decryptable(false).is_ok()); - } - - /// A genuinely unencrypted disc has `None` keys legitimately — the gate must - /// NOT false-error. This is the "is the source encrypted?" guard: the answer - /// is the scan-captured disc state, not the keys. - #[test] - fn ensure_decryptable_unencrypted_proceeds() { - let disc = make_test_disc(1000, "BD"); // aacs/css/css_error all None - assert!(matches!( - disc.decrypt_keys(), - crate::decrypt::DecryptKeys::None - )); - assert!( - disc.ensure_decryptable(false).is_ok(), - "unencrypted disc with None keys must proceed, not false-error" - ); - } - - // CSS scrambled-but-uncracked: css is None but css_error is Some — the - // disc IS encrypted. Gate must fail with DISC-LEVEL CssNoDiscKey, not - // the per-title skippable CssKeyMissing. - #[test] - fn ensure_decryptable_css_error_errors() { - let mut disc = make_test_disc(1000, "DVD"); - disc.encrypted = true; - disc.css_error = Some(crate::error::Error::CssKeyMissing); - let err = disc - .ensure_decryptable(false) - .expect_err("scrambled-but-uncracked CSS must error"); - assert_eq!(err.code(), crate::error::Error::CssNoDiscKey.code()); - // --raw is exempt. - assert!(disc.ensure_decryptable(true).is_ok()); - } - - // The two CSS no-key conditions must classify oppositely: disc-wide (css_error set) is - // disc_level_no_key and NOT skippable; per-title stays skippable and NOT disc-level. - #[test] - fn css_disc_wide_no_key_is_disc_level_while_per_title_stays_skippable() { - // Disc-wide: the scan saw scrambled sectors and recovered no key. - let mut disc = make_test_disc(1000, "DVD"); - disc.encrypted = true; - disc.css_error = Some(crate::error::Error::CssKeyMissing); - let wide: std::io::Error = disc - .ensure_decryptable(false) - .expect_err("scrambled-but-uncracked CSS disc must error") - .into(); - assert!( - crate::error::is_disc_level_no_key(&wide), - "a whole-disc CSS crack failure must classify as disc-level: {wide}" - ); - assert!( - !crate::error::is_skippable_title_stub(&wide), - "a whole-disc CSS crack failure must NOT be a skippable title stub: {wide}" - ); - - // Per-title: this title's VTS could not be re-cracked; the rest of the - // disc may still rip. - let (stub_disc, _) = css_disc_with_clear_stub(); - let per_title: std::io::Error = stub_disc - .ensure_title_decryptable(false, &crate::decrypt::DecryptKeys::None, false) - .expect_err("scrambled-uncracked title must error") - .into(); - assert!( - crate::error::is_skippable_title_stub(&per_title), - "a per-title CSS re-crack failure must stay skippable: {per_title}" - ); - assert!( - !crate::error::is_disc_level_no_key(&per_title), - "a per-title CSS re-crack failure must NOT stop the whole rip: {per_title}" - ); - } - - /// CSS-keyless-crack SUCCESS: `css` is Some with a title key → proceed. - #[test] - fn ensure_decryptable_css_with_key_proceeds() { - let mut disc = make_test_disc(1000, "DVD"); - disc.encrypted = true; - disc.css = Some(css_state()); - assert!(disc.ensure_decryptable(false).is_ok()); - } - - // Per-title gate: a multi-VTS CSS disc whose chosen title's VTS could not - // be re-cracked yields DecryptKeys::None even though disc-wide - // decrypt_keys() is Css{..}; must fail with CssKeyMissing. - #[test] - fn ensure_decryptable_keys_css_per_title_none_errors() { - let mut disc = make_test_disc(1000, "DVD"); - disc.encrypted = true; - disc.css = Some(css_state()); - let err = disc - .ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None) - .expect_err("CSS disc, per-title key None, !raw must error"); - assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); - // The same None key under --raw proceeds. - assert!( - disc.ensure_decryptable_keys(true, &crate::decrypt::DecryptKeys::None) - .is_ok() - ); - } - - /// `ensure_decryptable_keys` must never false-error an UNENCRYPTED disc no - /// matter the key argument (the verdict keys off disc state, not keys). - #[test] - fn ensure_decryptable_keys_unencrypted_never_errors() { + fn disc_gate_unencrypted_proceeds() { let disc = make_test_disc(1000, "BD"); - assert!( - disc.ensure_decryptable_keys(false, &crate::decrypt::DecryptKeys::None) - .is_ok() - ); + assert!(gate(&disc, false).is_ok()); } - // ── Fix 2/3: a genuinely-clear extra title on a CSS disc never E7023s ────── - - /// Reader that serves clear (unscrambled) sectors for one extent range and - /// CSS-locked errors elsewhere — enough to drive `decrypt_keys_for_title`'s - /// per-title re-crack to `Unencrypted` for a clear stub. - struct ClearStubReader { - clear_range: (u32, u32), - } - impl crate::sector::SectorSource for ClearStubReader { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::error::Result { - let n = count as usize * 2048; - buf[..n].fill(0); // clear sectors: scramble flag never set - let _ = self.clear_range; - Ok(n) - } - fn capacity_sectors(&self) -> u32 { - self.clear_range.1 - } + // CSS scrambled-but-uncracked (css None, css_error Some): the DISC-LEVEL + // CssNoDiscKey, never the per-title skippable CssKeyMissing. + #[test] + fn disc_gate_css_error_is_disc_level() { + let mut disc = make_test_disc(1000, "DVD"); + disc.encrypted = true; + disc.css_error = Some(crate::error::Error::CssKeyMissing); + let err = gate(&disc, false).expect_err("scrambled-but-uncracked CSS must error"); + assert_eq!(err.code(), crate::error::Error::CssNoDiscKey.code()); + let wide: std::io::Error = err.into(); + assert!(crate::error::is_disc_level_no_key(&wide)); + assert!(!crate::error::is_skippable_title_stub(&wide)); + assert!(gate(&disc, true).is_ok(), "--raw is exempt"); } - /// Build a multi-VTS CSS disc: `css` cracked from the main feature's span - /// `[main_lba, main_end)`, plus a clear stub title living in a DISJOINT VTS. - fn css_disc_with_clear_stub() -> (Disc, usize) { - let mut disc = make_test_disc(100_000, "DVD"); - disc.format = DiscFormat::Dvd; // make_test_disc defaults to Uhd - disc.content_format = ContentFormat::MpegPs; + /// CSS-keyless-crack SUCCESS: `css` holds a title key → proceed. + #[test] + fn disc_gate_css_with_key_proceeds() { + let mut disc = make_test_disc(1000, "DVD"); disc.encrypted = true; disc.css = Some(crate::css::CssState { - title_key: [0u8; 5], - crack_span: Some((0, 1000)), // main feature VTS span - }); - // Title 0: the main feature, overlaps the cracked span. - let mut feature = title_with_video(Codec::Mpeg2, Resolution::R480i); - feature.extents = vec![Extent { - start_lba: 0, - sector_count: 1000, - }]; - // Title 1: a tiny CLEAR stub in its own VTS, disjoint from the span. - let mut stub = title_with_video(Codec::Mpeg2, Resolution::R480i); - stub.extents = vec![Extent { - start_lba: 50_000, - sector_count: 7, // a 7-sector menu stub - }]; - disc.titles = vec![feature, stub]; - (disc, 1) // stub is title index 1 - } - - // A genuinely-clear extra title (menu stub in its own VTS, disjoint from - // crack_span) on a CSS DVD must mux without a false E7023 — the crack - // returns Unencrypted, and the gate must PASS with no key. - #[test] - fn clear_stub_title_on_css_disc_is_not_a_key_failure() { - let (disc, stub_idx) = css_disc_with_clear_stub(); - assert_eq!( - disc.format, - DiscFormat::Dvd, - "fixture must exercise the DVD path" - ); - let mut reader = ClearStubReader { - clear_range: (0, 100_000), - }; - let (keys, title_is_clear) = disc - .decrypt_keys_for_title(stub_idx, &mut reader, 8, None) - .expect("crack verdict"); - assert!( - matches!(keys, crate::decrypt::DecryptKeys::None), - "a clear stub in a disjoint VTS cracks to no key" - ); - assert!(title_is_clear, "the stub's own extents show no scrambling"); - // The gate must PASS a clear title — NO false E7023. - assert!( - disc.ensure_title_decryptable(false, &keys, title_is_clear) - .is_ok(), - "a genuinely clear extra title must never raise E7023" - ); - } - - /// Counterpart guard: a scrambled-but-uncrackable title (`title_is_clear == - /// false`, `None` keys) on a CSS disc must STILL hard-fail with CssKeyMissing. - /// Fix 2/3 must not weaken the genuine encrypted-but-uncrackable case. - #[test] - fn scrambled_uncracked_title_still_hard_fails() { - let (disc, _) = css_disc_with_clear_stub(); - let err = disc - .ensure_title_decryptable(false, &crate::decrypt::DecryptKeys::None, false) - .expect_err("scrambled-uncracked title (title_is_clear=false) must error"); - assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); - // --raw is exempt even for a scrambled-uncracked title. - assert!( - disc.ensure_title_decryptable(true, &crate::decrypt::DecryptKeys::None, false) - .is_ok() - ); - } - - #[test] - fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() { - // VID-only state (resolved but no Unit Key yet) must read as None, not - // an empty-but-usable key set — callers treat it as "keys missing". - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - disc.aacs = Some(aacs_with(Vec::new())); - assert!(matches!( - disc.decrypt_keys(), - crate::decrypt::DecryptKeys::None - )); - } - - #[test] - fn decrypt_with_replaces_existing_aacs_unit_keys_and_marks_external() { - // When scan DID build an AACS state, decrypt_with must overwrite its - // unit keys (not append) and mark the source ExternalUk. - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])])); - let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])]; - disc.decrypt_with(Key::Unit(new.clone()), &[]).unwrap(); - match disc.decrypt_keys() { - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => { - assert_eq!(unit_keys, new, "must replace, preserving every CPS unit"); - } - _ => panic!("expected Aacs decrypt keys"), - } - assert_eq!( - disc.aacs.as_ref().unwrap().key_source, - KeyOrigin::ExternalUk - ); - } - - /// Build a minimal valid `Unit_Key_RO.inf` carrying the given encrypted - /// unit keys at the V20 (64-byte) stride. Header is inert (no titles); only - /// the key-storage area matters for `parse_unit_key_ro`. - fn uk_ro_v20(enc_keys: &[[u8; 16]]) -> Vec { - let uk_pos = 32usize; - let keys_start = uk_pos + 48; - let stride = 64usize; - let mut data = vec![0u8; keys_start + enc_keys.len().max(1) * stride]; - data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes()); - data[uk_pos..uk_pos + 2].copy_from_slice(&(enc_keys.len() as u16).to_be_bytes()); - for (i, k) in enc_keys.iter().enumerate() { - let off = keys_start + i * stride; - data[off..off + 16].copy_from_slice(k); - } - data - } - - #[test] - fn decrypt_with_volume_derives_per_cps_unit_keys() { - // A Volume key (VUK) is NOT terminal — the lib must decrypt Unit_Key_RO.inf - // into ONE unit key per CPS unit. Oracle is the lib's own decrypt_unit_key, - // so this pins the derive-down wiring (Volume -> per-CPS Unit), not the cipher. - let vuk = [0x5au8; 16]; - let enc0 = [0x12u8; 16]; - let enc1 = [0x34u8; 16]; - let exp0 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc0); - let exp1 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc1); - - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - let mut a = aacs_with(Vec::new()); - a.uk_ro = uk_ro_v20(&[enc0, enc1]); - disc.aacs = Some(a); - - disc.decrypt_with(Key::Volume(vuk), &[]).unwrap(); - match disc.decrypt_keys() { - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => { - assert_eq!( - unit_keys, - vec![(1u32, exp0), (2u32, exp1)], - "VUK must decrypt EACH CPS unit's encrypted key (does not stop at VK)" - ); - } - _ => panic!("expected Aacs decrypt keys after Volume-key derive-down"), - } - assert_eq!( - disc.aacs.as_ref().unwrap().key_source, - KeyOrigin::ExternalUk - ); - } - - #[test] - fn decrypt_with_higher_key_without_inputs_errors() { - // A non-Unit key needs the AACS inputs (Unit_Key_RO.inf) stashed at - // scan. Without them the lib cannot derive — surfaces AacsNoKeys, not a - // panic and not a silent keyless "success". - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - disc.aacs = Some(aacs_with(Vec::new())); // uk_ro empty - assert!(matches!( - disc.decrypt_with(Key::Volume([0x11u8; 16]), &[]) - .unwrap_err(), - crate::error::Error::AacsNoKeys - )); - - // No AACS state at all → same. - let mut disc2 = make_test_disc(1000, "UHD"); - disc2.encrypted = true; - assert!(matches!( - disc2 - .decrypt_with(Key::Media(vec![[0x22u8; 16]]), &[]) - .unwrap_err(), - crate::error::Error::AacsNoKeys - )); - } - - #[test] - fn decrypt_with_volume_yielding_no_units_is_rejected() { - // A key that produces zero unit keys (here: an empty key-storage area) - // is a rejection, not a silent empty success. - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - let mut a = aacs_with(Vec::new()); - a.uk_ro = uk_ro_v20(&[]); // num_uk = 0 - disc.aacs = Some(a); - assert!(matches!( - disc.decrypt_with(Key::Volume([0x11u8; 16]), &[]) - .unwrap_err(), - crate::error::Error::AacsKeyRejected - )); - } - - #[test] - fn decrypt_with_unit_key_yields_decrypt_keys() { - // The public lookup-free entry point: hand libfreemkv a Key::Unit and - // decrypt_keys() must return usable AACS state (same path as the - // deferred-mux resume — autorip resolves the UK and passes it in). - let mut disc = make_test_disc(1000, "UHD"); - disc.encrypted = true; - let uk = vec![(0u32, [0x44u8; 16])]; - disc.decrypt_with(Key::Unit(uk.clone()), &[]).unwrap(); - match disc.decrypt_keys() { - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => { - assert_eq!(unit_keys, uk); - } - _ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"), - } - } - - #[test] - fn unit_key_validation_gates_on_real_ciphertext() { - use crate::aacs::content::ALIGNED_UNIT_LEN; - - // No samples -> nothing to disprove against -> accept (sample-less paths - // like resume / mapfile must be unaffected). - assert!(super::aligned_unit_keys_validate( - &[(0, [0x11u8; 16])], - &[], - ContentFormat::BdTs - )); - - // A clear unit (TS syncs intact) is not scrambled -> proves nothing -> - // accept even with an arbitrary key. - let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; - let mut off = 4; - while off < ALIGNED_UNIT_LEN { - clear[off] = 0x47; - off += 192; - } - assert!(crate::aacs::content::is_clean( - &clear, - crate::disc::ContentFormat::BdTs - )); - assert!(super::aligned_unit_keys_validate( - &[(0, [0x11u8; 16])], - &[clear.clone()], - ContentFormat::BdTs - )); - - // A genuinely scrambled unit the RIGHT key restores to clear TS. - let uk = [0x5au8; 16]; - let enc = encrypt_unit_for_test(&clear, &uk); - assert!( - !crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs), - "encrypted unit must read scrambled" - ); - - // Right key -> de-scrambles -> accept (NO false reject of a good key). - assert!(super::aligned_unit_keys_validate( - &[(7, uk)], - std::slice::from_ref(&enc), - ContentFormat::BdTs - )); - // Wrong key -> cannot de-scramble a scrambled sample -> reject. - assert!(!super::aligned_unit_keys_validate( - &[(7, [0x00u8; 16])], - std::slice::from_ref(&enc), - ContentFormat::BdTs - )); - // Empty key set against a scrambled sample -> reject. - assert!(!super::aligned_unit_keys_validate( - &[], - &[enc], - ContentFormat::BdTs - )); - } - - #[test] - fn unit_key_validation_rejects_partial_cps_unit_coverage() { - // Regression: on a 2-CPS-unit disc, a key set covering only CPS unit 0 used to - // pass validation (old gate accepted on the first sample any key decrypted), - // silently letting CPS-unit-1 sectors through raw. Must reject any coverage gap. - use crate::aacs::content::ALIGNED_UNIT_LEN; - - let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; - let mut off = 4; - while off < ALIGNED_UNIT_LEN { - clear[off] = 0x47; - off += 192; - } - - let uk0 = [0x11u8; 16]; - let uk1 = [0x22u8; 16]; - let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body - let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body - assert!(!crate::aacs::content::is_clean( - &sample0, - crate::disc::ContentFormat::BdTs - )); - assert!(!crate::aacs::content::is_clean( - &sample1, - crate::disc::ContentFormat::BdTs - )); - - let samples = vec![sample0.clone(), sample1.clone()]; - - // Partial key set (CPS unit 0 only) against samples from BOTH units -> - // reject. This is the bug fix: previously this returned true. - assert!(!super::aligned_unit_keys_validate( - &[(0, uk0)], - &samples, - ContentFormat::BdTs - )); - - // Complete key set (both CPS units) -> accept. - assert!(super::aligned_unit_keys_validate( - &[(0, uk0), (1, uk1)], - &samples, - ContentFormat::BdTs - )); - - // Order-independent: covering key present anywhere in the set is fine. - assert!(super::aligned_unit_keys_validate( - &[(1, uk1), (0, uk0)], - &samples, - ContentFormat::BdTs - )); - } - - // Inverse of decrypt_unit for one 6144-byte unit: produce ciphertext that - // decrypt_unit(uk) restores to clear (ECB-derive the per-unit key, then - // AES-CBC encrypt with the fixed AACS IV). - fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec { - use crate::aacs::content::ALIGNED_UNIT_LEN; - use crate::aacs::crypto::AACS_IV; - use aes::Aes128; - use aes::cipher::{Array, BlockCipherEncrypt, KeyInit}; - let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec(); - // Flag the unit encrypted (CPI bits on byte 0) before key derivation so - // the recovered plaintext header matches and `decrypt_unit`'s CPI gate - // attempts the decrypt. - unit[0] |= 0xC0; - let mut header = [0u8; 16]; - header.copy_from_slice(&unit[..16]); - let cipher = Aes128::new(&(*uk).into()); - let mut blk: Array = header.into(); - cipher.encrypt_block(&mut blk); - let mut dk = [0u8; 16]; - for i in 0..16 { - dk[i] = blk[i] ^ header[i]; - } - let bc = Aes128::new(&dk.into()); - let mut prev = AACS_IV; - let mut i = 16; - while i + 16 <= ALIGNED_UNIT_LEN { - let mut b = [0u8; 16]; - for j in 0..16 { - b[j] = unit[i + j] ^ prev[j]; - } - let mut g: Array = b.into(); - bc.encrypt_block(&mut g); - for j in 0..16 { - unit[i + j] = g[j]; - } - prev.copy_from_slice(&unit[i..i + 16]); - i += 16; - } - unit - } - - #[test] - fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() { - // Unencrypted disc: nothing to inject into, stays None. - let mut plain = make_test_disc(1000, "PLAIN"); - plain - .inject_unit_keys(vec![(0, [0x22; 16])]) - .expect("inject"); - assert!(plain.aacs.is_none()); - assert!(matches!( - plain.decrypt_keys(), - crate::decrypt::DecryptKeys::None - )); - - // Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state. - let mut dvd = make_test_disc(1000, "DVD"); - dvd.format = DiscFormat::Dvd; - dvd.encrypted = true; - dvd.css = Some(crate::css::CssState { title_key: [0u8; 5], crack_span: None, }); - dvd.inject_unit_keys(vec![(0, [0x33; 16])]).expect("inject"); - assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state"); - } - - /// Records the LBAs read; returns all-zero (unscrambled) sectors so any - /// re-crack attempt finds no key and falls back, while we observe WHETHER - /// the title's extents were read at all. - struct RecordingSource { - reads: std::cell::RefCell>, - } - impl SectorSource for RecordingSource { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - self.reads.borrow_mut().push(lba); - let n = (count as usize * 2048).min(buf.len()); - for b in buf[..n].iter_mut() { - *b = 0; - } - Ok(n) - } - } - - fn css_disc_with_two_vts() -> Disc { - // Title 0 (cracked VTS) at LBA 100..200; title 1 (other VTS) at - // 5000..5100. The cracked key's span is title 0's extents. - let mut t0 = title_with_video(Codec::Mpeg2, Resolution::R480p); - t0.extents = vec![Extent { - start_lba: 100, - sector_count: 100, - }]; - let mut t1 = title_with_video(Codec::Mpeg2, Resolution::R480p); - t1.playlist = "00801.mpls".into(); - t1.extents = vec![Extent { - start_lba: 5000, - sector_count: 100, - }]; - let mut disc = make_test_disc(6000, "DVD"); - disc.format = DiscFormat::Dvd; - disc.content_format = ContentFormat::MpegPs; - disc.encrypted = true; - disc.titles = vec![t0, t1]; - disc.css = Some(crate::css::CssState { - title_key: [0xAB; 5], - crack_span: Some((100, 200)), - }); - disc + assert!(gate(&disc, false).is_ok()); } /// Build a crackable scrambled CSS sector (a periodic run in the @@ -7916,353 +6521,6 @@ mod tests { sec } - /// A reader that serves crackable CSS sectors for LBAs in `scrambled` - /// (half-open), all-zero (clear) elsewhere — records every LBA read. - struct CssMapReader { - key: [u8; 5], - scrambled: (u32, u32), - reads: std::cell::RefCell>, - } - impl SectorSource for CssMapReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - self.reads.borrow_mut().push(lba); - let n = (count as usize * 2048).min(buf.len()); - for s in 0..(n / 2048) { - let this = lba + s as u32; - let dst = &mut buf[s * 2048..(s + 1) * 2048]; - if this >= self.scrambled.0 && this < self.scrambled.1 { - dst.copy_from_slice(&crackable_css_sector(&self.key)); - } else { - dst.fill(0); - } - } - Ok(n) - } - } - - fn css_dvd_with_extents(extents: Vec) -> Disc { - let mut disc = make_test_disc(200_000, "DVD"); - disc.format = DiscFormat::Dvd; - disc.content_format = ContentFormat::MpegPs; - disc.encrypted = true; - let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p); - t.extents = extents; - disc.titles = vec![t]; - disc - } - - // decrypt_keys_for_title cracks a scrambled DVD title's key from its OWN - // extents and hands the mux the validated key (an un-seeded mux would - // emit corrupt PES, since CSS leaves the pack/PES header clear). - #[test] - fn decrypt_keys_for_title_cracks_the_titles_key() { - let key = [0x11, 0x22, 0x33, 0x44, 0x55]; - let disc = css_dvd_with_extents(vec![Extent { - start_lba: 100, - sector_count: 64, - }]); - let mut src = CssMapReader { - key, - scrambled: (100, 164), - reads: std::cell::RefCell::new(Vec::new()), - }; - let (keys, title_is_clear) = disc - .decrypt_keys_for_title(0, &mut src, 16, None) - .expect("crack verdict"); - assert!(!title_is_clear, "a scrambled title is not clear"); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => { - assert_eq!(title_key, key, "must crack the title's own key") - } - _ => panic!("expected Css{{key}} for a scrambled DVD title"), - } - } - - // REGRESSION (1.5.1 garbage bug): crack scans extents in PLAYBACK ORDER, - // never largest-cell-first — must crack from the smaller scrambled-early - // cell that plays first, not starve on a larger clear cell. See docs. - #[test] - fn decrypt_keys_for_title_scans_playback_order_not_largest_first() { - let key = [0xDE, 0xAD, 0xBE, 0xEF, 0x01]; - let disc = css_dvd_with_extents(vec![ - // Plays FIRST: small, scrambled from its start. - Extent { - start_lba: 100, - sector_count: 32, - }, - // A CLEAR cell far larger than the crack budget (would starve a - // largest-first scan before it reached the scrambled cell above). - Extent { - start_lba: 10_000, - sector_count: 100_000, - }, - ]); - let mut src = CssMapReader { - key, - scrambled: (100, 132), - reads: std::cell::RefCell::new(Vec::new()), - }; - let (keys, _) = disc - .decrypt_keys_for_title(0, &mut src, 16, None) - .expect("crack verdict"); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( - title_key, key, - "must crack from the scrambled cell that plays first, not miss it behind the clear giant" - ), - _ => panic!("largest-first regression: the title was read as unencrypted"), - } - assert!( - src.reads.borrow().iter().all(|&l| l < 10_000), - "the key is found in the first (scrambled) cell — the clear giant must never be scanned: {:?}", - src.reads.borrow() - ); - } - - // A reader whose every read is CSS-locked (05/6F/03) — a genuinely - // encrypted DVD whose sectors can't be authenticated/cracked. - struct LockedReader; - impl SectorSource for LockedReader { - fn read_sectors( - &mut self, - lba: u32, - _count: u16, - _buf: &mut [u8], - _recovery: bool, - ) -> Result { - Err(Error::DiscRead { - sector: lba as u64, - status: Some(2), - sense: Some(crate::scsi::ScsiSense { - sense_key: 0x05, - asc: 0x6F, - ascq: 0x03, - }), - }) - } - } - - // A Stop during the per-title crack ends it after the current batch. - #[test] - fn decrypt_keys_for_title_crack_honours_the_halt_token() { - struct StopOnRead(crate::halt::Halt, u32); - impl SectorSource for StopOnRead { - fn read_sectors(&mut self, _l: u32, c: u16, b: &mut [u8], _r: bool) -> Result { - self.1 += 1; - self.0.cancel(); - let n = c as usize * 2048; - b[..n].fill(0); - Ok(n) - } - } - let disc = css_dvd_with_extents(vec![Extent { - start_lba: 100, - sector_count: 64, - }]); - let halt = crate::halt::Halt::new(); - let mut src = StopOnRead(halt.clone(), 0); - let got = disc.decrypt_keys_for_title(0, &mut src, 16, Some(&halt)); - assert!( - matches!(got, Err(Error::Halted)), - "a stopped crack is no verdict" - ); - assert_eq!(src.1, 1, "no read after the Stop"); - } - - // End-to-end: a scrambled-but-uncrackable DVD title with NO up-front - // detection drives decrypt_keys_for_title to (None, false), and the - // gate MUST hard-fail (CssKeyMissing), never pass it to the muxer. - #[test] - fn decrypt_keys_for_title_scrambled_uncracked_dvd_hard_fails_even_without_detection() { - let disc = css_dvd_with_extents(vec![Extent { - start_lba: 100, - sector_count: 8, - }]); - assert!(disc.css.is_none(), "fixture: no up-front detection"); - let mut reader = LockedReader; - let (keys, title_is_clear) = disc - .decrypt_keys_for_title(0, &mut reader, 8, None) - .expect("crack verdict"); - assert!( - matches!(keys, crate::decrypt::DecryptKeys::None) && !title_is_clear, - "a locked/uncrackable scrambled title resolves to (None, false)" - ); - let err = disc - .ensure_title_decryptable(false, &keys, title_is_clear) - .expect_err("scrambled-uncracked DVD title must hard-fail without detection"); - assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); - } - - /// Fast path: when the scan already cracked a key whose `crack_span` COVERS - /// this title's VTS, `decrypt_keys_for_title` reuses it and never touches the - /// reader (no redundant crack, no second bus-auth on a live drive). - #[test] - fn decrypt_keys_for_title_reuses_covered_scan_key_without_reading() { - let disc = css_disc_with_two_vts(); // css=[0xAB;5], crack_span=(100,200) - let mut src = RecordingSource { - reads: std::cell::RefCell::new(Vec::new()), - }; - // Title 0's extents (100..200) overlap the cracked span → reuse. - let (keys, clear) = disc - .decrypt_keys_for_title(0, &mut src, 16, None) - .expect("crack verdict"); - assert!(!clear); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => { - assert_eq!(title_key, [0xAB; 5], "reuse the scan's cracked key") - } - _ => panic!("expected the reused Css key"), - } - assert!( - src.reads.borrow().is_empty(), - "a covered title must NOT re-read/re-crack: {:?}", - src.reads.borrow() - ); - } - - /// A title in a DIFFERENT VTS (extents disjoint from `crack_span`) does NOT - /// reuse the scan key — it cracks its own key from its own extents. - #[test] - fn decrypt_keys_for_title_cracks_other_vts_on_no_overlap() { - let key = [0x77, 0x66, 0x55, 0x44, 0x33]; - let disc = css_disc_with_two_vts(); // title 1 lives at 5000.., span=(100,200) - let mut src = CssMapReader { - key, - scrambled: (5000, 5100), - reads: std::cell::RefCell::new(Vec::new()), - }; - let (keys, _) = disc - .decrypt_keys_for_title(1, &mut src, 16, None) - .expect("crack verdict"); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( - title_key, key, - "a disjoint-VTS title cracks its OWN key, not the reused scan key" - ), - _ => panic!("expected a freshly-cracked Css key for the other VTS"), - } - assert!( - src.reads.borrow().iter().all(|&l| l >= 5000), - "must crack from title 1's own extents (>=5000): {:?}", - src.reads.borrow() - ); - } - - // A title whose clear front matter (studio logo/rating card) plays FIRST - // still cracks: the single playback-order scan reads through the small - // clear prefix and reaches the scrambled body within budget. - #[test] - fn decrypt_keys_for_title_cracks_feature_after_clear_front_matter() { - let key = [0xCA, 0xFE, 0xBA, 0xBE, 0x02]; - // css=None so the crack path runs. ~10 MB of clear front matter plays - // first (well under the crack budget), then the scrambled feature. - let disc = css_dvd_with_extents(vec![ - Extent { - start_lba: 10_000, - sector_count: 5_000, - }, // clear front matter (~10 MB), plays first - Extent { - start_lba: 100, - sector_count: 2_000, - }, // scrambled feature body - ]); - let mut src = CssMapReader { - key, - scrambled: (100, 2_100), - reads: std::cell::RefCell::new(Vec::new()), - }; - let (keys, _) = disc - .decrypt_keys_for_title(0, &mut src, 16, None) - .expect("crack verdict"); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( - title_key, key, - "must crack the scrambled feature after reading through clear front matter" - ), - _ => panic!("clear front matter wrongly starved the crack"), - } - } - - // Scrambling beginning well INTO a cell must still be cracked: the scan - // reads through the clear prefix and reaches the scrambled body within - // budget — never a silent "clear" verdict muxing corrupt PES. - #[test] - fn decrypt_keys_for_title_cracks_scrambling_after_a_clear_prefix_in_one_cell() { - let key = [0x0D, 0xEE, 0x40, 0x00, 0x05]; - // One cell: clear for the first 9000 sectors, then scrambled (well within - // the crack budget). css=None so the crack path runs. - let disc = css_dvd_with_extents(vec![Extent { - start_lba: 100, - sector_count: 20_000, - }]); - let mut src = CssMapReader { - key, - scrambled: (100 + 9_000, 100 + 20_000), - reads: std::cell::RefCell::new(Vec::new()), - }; - let (keys, _) = disc - .decrypt_keys_for_title(0, &mut src, 16, None) - .expect("crack verdict"); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( - title_key, key, - "the scan must crack scrambling that starts past a clear prefix" - ), - _ => panic!("in-cell-deep scrambling was misread as clear (silent-garbage direction)"), - } - } - - // A DVD title with EMPTY extents resolves to (decrypt_keys(), true) — - // clear, no key needed — and the gate must PASS it (returning false - // would wrongly hard-fail a genuinely-clear empty title). - #[test] - fn decrypt_keys_for_title_empty_extents_is_clear_not_hard_fail() { - let mut disc = css_dvd_with_extents(vec![Extent { - start_lba: 100, - sector_count: 8, - }]); - disc.titles - .push(title_with_video(Codec::Mpeg2, Resolution::R480p)); // idx 1: no extents - let mut reader = LockedReader; - let (keys, title_is_clear) = disc - .decrypt_keys_for_title(1, &mut reader, 8, None) - .expect("crack verdict"); - assert!( - title_is_clear, - "an empty-extents title is clear (nothing to descramble)" - ); - assert!( - disc.ensure_title_decryptable(false, &keys, title_is_clear) - .is_ok(), - "an empty-extents DVD title must not hard-fail" - ); - } - - // A bonus title that cracked its OWN valid key must NOT be blocked by - // disc-wide css_error from the MAIN feature's scan failure. (Regression - // for audit r5 css_error-over-valid-key.) - #[test] - fn ensure_title_decryptable_valid_key_ignores_disc_wide_css_error() { - let mut disc = css_dvd_with_extents(vec![Extent { - start_lba: 100, - sector_count: 8, - }]); - disc.css_error = Some(crate::error::Error::CssKeyMissing); // main feature failed - let keys = crate::decrypt::DecryptKeys::Css { - title_key: [0x42; 5], // this bonus title cracked its own key - }; - assert!( - disc.ensure_title_decryptable(false, &keys, false).is_ok(), - "a title with its own valid CSS key must pass despite disc-wide css_error" - ); - } - /// bytes_bad_in_title must overlap per-extent, not against a single /// bounding box: a bad range in the gap between two extents of the /// same title must NOT be counted. @@ -10405,101 +8663,17 @@ mod tests { ); disc.aacs = Some(AacsState { disc_hash: format!("0x{SHA1}"), - ..aacs_with(Vec::new()) + ..aacs_empty() }); assert_eq!(disc.aacs_disc_hash(), SHA1); // Already bare (no prefix) passes through unchanged, never re-stripped. disc.aacs = Some(AacsState { disc_hash: SHA1.to_string(), - ..aacs_with(Vec::new()) + ..aacs_empty() }); assert_eq!(disc.aacs_disc_hash(), SHA1); } - // ── decrypt_keys_for_title: CSS crack-span reuse is half-open ───────── crack_span uses - // STRICT `<` both sides, so a title that merely ABUTS the span must NOT reuse the key. - #[test] - fn decrypt_keys_for_title_css_span_reuse_is_half_open() { - const KEY: [u8; 5] = [0xA1, 0xB2, 0xC3, 0xD4, 0xE5]; - let mut disc = make_test_disc(200_000, "DVD"); - disc.format = DiscFormat::Dvd; - disc.content_format = ContentFormat::MpegPs; - disc.encrypted = true; - // Key cracked from sectors [250, 300). - disc.css = Some(crate::css::CssState { - title_key: KEY, - crack_span: Some((250, 300)), - }); - let mk = |extents: &[(u32, u32)]| { - let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p); - t.extents = extents - .iter() - .map(|&(start_lba, sector_count)| Extent { - start_lba, - sector_count, - }) - .collect(); - t - }; - disc.titles = vec![ - // 0: [200,250) — ends exactly where the span begins. - mk(&[(200, 50)]), - // 1: [300,350) — begins exactly where the span ends. - mk(&[(300, 50)]), - // 2: [260,270) — genuinely inside the span. - mk(&[(260, 10)]), - // 3: no extents at all. - mk(&[]), - ]; - let mut clear = CssMapReader { - key: KEY, - scrambled: (0, 0), - reads: std::cell::RefCell::new(Vec::new()), - }; - - for idx in [0usize, 1] { - let (keys, title_is_clear) = disc - .decrypt_keys_for_title(idx, &mut clear, 16, None) - .expect("crack verdict"); - assert!( - matches!(keys, crate::decrypt::DecryptKeys::None), - "title {idx} only ABUTS the crack span — it shares no sector with it, so the \ - per-VTS key must not be reused" - ); - assert!( - title_is_clear, - "title {idx} re-cracks from its own (clear) extents and is reported unencrypted" - ); - } - - let (keys, title_is_clear) = disc - .decrypt_keys_for_title(2, &mut clear, 16, None) - .expect("crack verdict"); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( - title_key, KEY, - "a title INSIDE the crack span reuses the scan's key" - ), - _ => panic!("expected the reused CSS key for an overlapping title"), - } - assert!(!title_is_clear); - - // A title with NO extents has nothing to crack from: it short-circuits - // to the disc-wide keys and is marked clear, so the decrypt gate's - // "None keys + not clear" rule cannot hard-fail it. - let (keys, title_is_clear) = disc - .decrypt_keys_for_title(3, &mut clear, 16, None) - .expect("crack verdict"); - match keys { - crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(title_key, KEY), - _ => panic!("an extent-less title must return the disc-wide keys"), - } - assert!( - title_is_clear, - "an extent-less title is clear — nothing scrambled to worry about" - ); - } - // ── mapfile paths ───────────────────────────────────────────────────── /// The mapfile sits BESIDE the output as `.mapfile`: the suffix is diff --git a/src/disc/scan_order_tests.rs b/src/disc/scan_order_tests.rs index 7cd6de4a..7faaca42 100644 --- a/src/disc/scan_order_tests.rs +++ b/src/disc/scan_order_tests.rs @@ -399,7 +399,7 @@ fn live_unreadable_uk_ro_fails_scan_before_any_aacs_scsi() { } #[test] -fn raw_copy_scan_records_unreadable_uk_ro_and_refuses_keys() { +fn raw_copy_scan_records_unreadable_uk_ro() { let mut rig = Rig::new(bd_disc(Some(false)), |t| { t.fail_read = Some(Box::new(|lba, n, _| { (lba..lba + n as u32) @@ -411,13 +411,9 @@ fn raw_copy_scan_records_unreadable_uk_ro_and_refuses_keys() { raw_copy: true, ..with_hc() }; - let mut d = Disc::scan(&mut rig.drive, &opts).expect("raw copy scans on"); + let d = Disc::scan(&mut rig.drive, &opts).expect("raw copy scans on"); assert!(d.aacs.is_none() && !d.titles.is_empty()); assert_eq!(d.aacs_error.as_ref().map(|e| e.code()), Some(KEY_FILE_CODE)); - assert!( - d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) - .is_err() - ); } #[test] @@ -456,7 +452,7 @@ fn non_bus_failed_handshake_is_info_only() { fn bus_disc_failed_handshake_lists_titles_one_error_refuses_keys() { let mut rig = Rig::new(bd_disc(Some(true)), |_| {}); let (r, ev) = crate::testlog::capture(|| Disc::scan(&mut rig.drive, &with_hc())); - let mut d = r.expect("scan"); + let d = r.expect("scan"); assert!(!d.titles.is_empty()); assert!( matches!(d.aacs_error, Some(Error::AacsHostCertRejected)), @@ -466,13 +462,10 @@ fn bus_disc_failed_handshake_lists_titles_one_error_refuses_keys() { let w = aacs_warns(&ev); assert_eq!(w.len(), 1, "{w:?}"); assert_eq!(w[0].field("phase"), Some("aacs_verdict")); - assert!( - d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) - .is_err() - ); - let inputs = d.inputs().expect("inputs"); - let (ok, _) = crate::keysource::resolve_and_apply_traced(&[], &inputs, &mut d); - assert!(!ok); + assert!(matches!( + crate::keys::check_decryptable(&d, false, None, &crate::keys::KeyScope::WholeDisc), + Err(Error::AacsHostCertRejected) + )); } #[test] @@ -702,7 +695,7 @@ fn hddvd_aacs_dir_is_captured_and_handshaked() { d.aacs_error ); assert!(matches!( - d.ensure_decryptable(false), + crate::keys::check_decryptable(&d, false, None, &crate::keys::KeyScope::WholeDisc), Err(Error::NoDiscKey { .. }) )); let cdbs = rig.cdbs(); @@ -790,15 +783,13 @@ fn image_unreadable_uk_ro_is_recorded_not_fatal() { subdirs: vec![], }, ); - let mut d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan"); + let d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan"); assert!(d.aacs.is_none()); assert!( matches!(d.aacs_error, Some(Error::AacsNoKeys)), "{:?}", d.aacs_error ); - d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) - .expect("a mapfile unit key is accepted on an image"); } // ── Stop design §5.1 "Scan" (LS1–LS6), over `test_util::FakeTransport` ── diff --git a/src/error.rs b/src/error.rs index f1e81f8e..be9051b2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1420,7 +1420,7 @@ pub fn is_skippable_title_stub(e: &std::io::Error) -> bool { /// Whether an [`io::Error`](std::io::Error) is a cooperative user stop /// ([`Error::Halted`], code [`E_HALTED`]) — vs a structural failure. A stop is -/// resumable, not a rip failure: `mux_stream` maps a mid-run halt to +/// resumable, not a rip failure: `mux_with_keys` maps a mid-run halt to /// `completed = false`, and consumers preserve staging rather than quarantining. /// Typed replacement for the consumers' `E`-leading-token string match. pub fn is_halt(e: &std::io::Error) -> bool { @@ -1544,7 +1544,7 @@ mod tests { #[test] fn is_skippable_title_stub_excludes_malformed_mkv_input() { // The two skippable per-title codes, round-tripped through io::Error as - // `mux_stream` returns them. `MkvInvalid` now means ONLY the no-muxable- + // `mux_with_keys` returns them. `MkvInvalid` now means ONLY the no-muxable- // frames stub, matching this predicate's documented meaning. let mkv: std::io::Error = Error::MkvInvalid.into(); let css: std::io::Error = Error::CssKeyMissing.into(); diff --git a/src/halt.rs b/src/halt.rs index cd1e375b..f7771e89 100644 --- a/src/halt.rs +++ b/src/halt.rs @@ -538,7 +538,8 @@ pub fn live_drive_holders() -> usize { } // Every test that spawns a Drive holder or reads the process-wide count holds this. -#[cfg(all(test, not(loom)))] +#[cfg(test)] +#[cfg_attr(loom, allow(dead_code))] pub(crate) static DRIVE_HOLDER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// A thread that holds the Drive (§2.5): the op joins it before returning. In debug diff --git a/src/io/flush/tests.rs b/src/io/flush/tests.rs index ea6662ee..1ed1e2a9 100644 --- a/src/io/flush/tests.rs +++ b/src/io/flush/tests.rs @@ -759,7 +759,9 @@ fn sync_file_range_wait_after_completes_written_range() { assert_eq!(unsafe { libc::fstatfs(f.as_raw_fd(), &mut st) }, 0); const TMPFS: i64 = 0x0102_1994; const OVERLAY: i64 = 0x794c_7630; - if [TMPFS, OVERLAY].contains(&(st.f_type as i64)) { + #[allow(clippy::unnecessary_cast)] // f_type is i64 on some targets, not all + let f_type = st.f_type as i64; + if [TMPFS, OVERLAY].contains(&f_type) { eprintln!("SKIP sync_file_range: tmpdir is tmpfs/overlay"); return; } diff --git a/src/io/image_writer.rs b/src/io/image_writer.rs index 50691669..ca4a16c9 100644 --- a/src/io/image_writer.rs +++ b/src/io/image_writer.rs @@ -186,7 +186,7 @@ mod tests { // loud at the first misaligned batch that touches content, never ships ciphertext. #[test] fn write_image_over_an_unwrapped_aacs_source_fails_loud_at_a_misaligned_batch() { - use crate::decrypt::{AacsKeyMap, DecryptKeys}; + use crate::decrypt::{DecryptKeys, Phase}; use std::sync::Arc; let key = [0x5A; 16]; let mut unit = clear_aacs_unit(); @@ -196,8 +196,9 @@ mod tests { unit_keys: vec![(0, key)], format: crate::disc::ContentFormat::BdTs, }; - let mut dec = crate::sector::DecryptingSectorSource::new(src, keys) - .with_key_map(Arc::new(AacsKeyMap::from_ranges(vec![(3000, 3030, 0)]))) + let map = crate::keys::test_key_map(vec![(3000, 3030, 0, Phase::All)]); + let dec = crate::sector::DecryptingSectorSource::new(src, keys); + let mut dec = crate::keys::test_keyed_source(dec, map) .with_content_ranges(Arc::from(vec![(3000u32, 30u32)])); dec.set_unit_base(0); // file grid (3000 % 3 == 0): fails loud past the no-base gate let dest = tmp("aacs-batch"); diff --git a/src/keys/arrival.rs b/src/keys/arrival.rs index fabb9a20..0e256548 100644 --- a/src/keys/arrival.rs +++ b/src/keys/arrival.rs @@ -1,19 +1,16 @@ //! Proof on first read (KU §2.4): the decrypting readers prove a held key on a piece that //! `resolve` could not prove up front, the first time one of its encrypted units is read. //! -//! - A partner is another readable, non-segment `Enc` unit of the same piece: first from the -//! current batch, then from at most one backward and one forward side read (`recovery = -//! false`, never retried, straight to the random-access inner source). A failed side read -//! only means "no partner there": it is swallowed, never classified, never surfaced. -//! - A held key that opens the unit and a partner is `Proven`. With no readable partner, a -//! key that opens the unit is `Provisional`; the piece's next readable `Enc` unit confirms -//! it or stops the rip (a key conflict). -//! - A readable unit is never reported as a read error or withheld. One no held key opens is -//! a loud stop (E7022 / E7032). FMTS segment units are never part of this proof. +//! A partner is another readable non-segment `Enc` unit of the piece, from the batch, else one +//! backward and one forward side read (`recovery = false`, never retried, failures swallowed). +//! A held key opening the unit and a partner is `Proven`; with no partner, `Provisional` until +//! the next readable unit confirms it or stops (key conflict). A unit no held key opens stops +//! E7022 / E7032, unless a held key opens a batch partner: then it is damage, blanked. use super::{Proof, ProofCache, ResolvedKeySet, StopKind}; use crate::aacs::content::{ - ALIGNED_UNIT_LEN, aacs_unit_encrypted, clear_copy_permission_indicator, decrypt_unit, is_clean, + ALIGNED_UNIT_LEN, aacs_unit_encrypted, aacs_unit_on_grid, clear_copy_permission_indicator, + decrypt_unit, is_clean, }; use crate::disc::ContentFormat; use crate::error::{Error, Result}; @@ -80,12 +77,20 @@ impl Arrival { is_clean(&u, self.format) } - // A non-segment encrypted unit of piece `piece` (a partner candidate). + /// Is `lba` in a piece this proof covers? + pub(crate) fn covers(&self, lba: u32) -> bool { + self.span_at(lba).is_some() + } + + // A readable, non-segment encrypted unit of piece `piece` (a partner candidate). fn is_partner(&self, lba: u32, unit: &[u8], piece: usize) -> bool { + // KS-4 [BD] §3.10.1: "The first 16 bytes of each Aligned Unit is used as the seed" — + // a unit whose seed is damaged (no TS sync) opens under no key: damage, not a partner. unit.len() == ALIGNED_UNIT_LEN && self.span_at(lba).is_some_and(|s| s.3 == piece) && !self.in_segment(lba) && aacs_unit_encrypted(unit, self.format) + && aacs_unit_on_grid(unit, self.format) } fn stop(&self, lba: u32, why: &'static str) -> Error { @@ -101,17 +106,19 @@ impl Arrival { } /// Prove and decrypt the lazy-piece units of `buf`, read at `lba` on the unit grid. - /// Keyed pieces are left to the key map; clear and segment units are untouched. + /// Keyed pieces are left to the key map; clear and segment units are untouched. Returns + /// how many damaged units it blanked (see [`is_damage`](Self::is_damage)). pub(crate) fn process( &self, inner: &mut dyn SectorSource, lba: u32, buf: &mut [u8], unit_keys: &[(u32, [u8; 16])], - ) -> Result<()> { + ) -> Result { if self.spans.is_empty() { - return Ok(()); + return Ok(0); } + let mut blanked = 0; let n_units = buf.len() / ALIGNED_UNIT_LEN; for u in 0..n_units { let at = lba.saturating_add(u as u32 * UNIT as u32); @@ -126,18 +133,82 @@ impl Arrival { let slot = match self.cache.get(id) { Some(Proof::Proven(s)) => s, Some(Proof::Provisional(s)) => { - // KU §2.4: the next readable unit confirms a provisional key or stops. - if !self.opens(&buf[range.clone()], &unit_keys[s].1) { + // KU §2.4: the next readable unit confirms a provisional key or stops; + // one no held key opens while the key opens a batch partner is damage. + if self.opens(&buf[range.clone()], &unit_keys[s].1) { + self.cache.set(id, Proof::Proven(s)); + s + } else if self.is_damage( + &buf[range.clone()], + &self.batch_partners(lba, buf, u, span.3), + candidate, + &[s], + unit_keys, + ) { + buf[range].fill(0); + blanked += 1; + continue; + } else { return Err(self.stop(at, "provisional key contradicted")); } - self.cache.set(id, Proof::Proven(s)); - s } - None => self.prove(inner, lba, buf, u, span, candidate, unit_keys)?, + None => match self.prove(inner, lba, buf, u, span, candidate, unit_keys)? { + Some(s) => s, + None => { + buf[range].fill(0); + blanked += 1; + continue; + } + }, }; self.decrypt_proven(&mut buf[range], &unit_keys[slot].1); } - Ok(()) + if blanked > 0 { + tracing::warn!( + target: "freemkv::keys", + lba, + units = blanked, + "damaged AACS unit (no held key opens it, one opens its partners): blanked" + ); + } + Ok(blanked) + } + + // The held keys, the piece's candidate first. + fn held(&self, candidate: Option) -> impl Iterator { + let rest = (0..self.base).filter(move |&s| Some(s) != candidate); + candidate.into_iter().chain(rest) + } + + /// Is `unit` damage rather than a key failure? KS-4 \[BD\] §3.10.1: "The first 16 bytes of + /// each Aligned Unit is used as the seed", and a garbled head can keep its CPI flag and TS + /// sync. A wrong key opens no unit of the piece; damage fails one. So: no held key opens + /// it and one of `witnesses` opens a batch partner (no side read: KU §2.4 stops an + /// unopenable unit before any). A blanked unit proves nothing itself. + fn is_damage( + &self, + unit: &[u8], + partners: &[Vec], + candidate: Option, + witnesses: &[usize], + unit_keys: &[(u32, [u8; 16])], + ) -> bool { + let opens = |u: &[u8], s: usize| self.opens(u, &unit_keys[s].1); + !self.held(candidate).any(|s| opens(unit, s)) + && partners + .iter() + .any(|p| witnesses.iter().any(|&s| opens(p, s))) + } + + // Unit `u`'s partners already in the batch, both ways (KU §2.4 step 1). + fn batch_partners(&self, lba: u32, buf: &[u8], u: usize, piece: usize) -> Vec> { + buf.chunks(ALIGNED_UNIT_LEN) + .enumerate() + .filter(|&(j, c)| { + j != u && self.is_partner(lba.saturating_add(j as u32 * UNIT as u32), c, piece) + }) + .map(|(_, c)| c.to_vec()) + .collect() } // Decrypt one unit with its proven key and mark it plaintext. @@ -148,7 +219,8 @@ impl Arrival { clear_copy_permission_indicator(unit, self.format); } - // Find the held key for unit `u` of `buf` (KU §2.4 steps 1–2) and record it. + // Find the held key for unit `u` of `buf` (KU §2.4 steps 1–2) and record it. `None`: + // no held key opens U but one opens a partner, so U is damage (`is_damage`), not proof. #[allow(clippy::too_many_arguments)] fn prove( &self, @@ -159,44 +231,46 @@ impl Arrival { span: (u32, u32, u64, usize), candidate: Option, unit_keys: &[(u32, [u8; 16])], - ) -> Result { + ) -> Result> { let at = lba.saturating_add(u as u32 * UNIT as u32); let unit = &buf[u * ALIGNED_UNIT_LEN..(u + 1) * ALIGNED_UNIT_LEN]; - // The held keys that open U, the candidate first. None: stop now, no side read. - let openers: Vec = candidate - .into_iter() - .chain((0..self.base).filter(|&s| Some(s) != candidate)) + // The held keys that open U, the candidate first. None: damage, or a wrong key. + let openers: Vec = self + .held(candidate) .filter(|&s| self.opens(unit, &unit_keys[s].1)) .collect(); if openers.is_empty() { + let held: Vec = self.held(candidate).collect(); + let partners = self.batch_partners(lba, buf, u, span.3); + if self.is_damage(unit, &partners, candidate, &held, unit_keys) { + return Ok(None); + } return Err(self.stop(at, "no held key opens the unit")); } - // Step 1: partners already in the batch, both ways; then the side reads. - let mut partners: Vec> = buf - .chunks(ALIGNED_UNIT_LEN) - .enumerate() - .filter(|&(j, c)| { - j != u && self.is_partner(lba.saturating_add(j as u32 * UNIT as u32), c, span.3) - }) - .map(|(_, c)| c.to_vec()) - .collect(); - if partners.is_empty() { - partners = self.side_partners(inner, at, span); + // Step 1: partners already in the batch, both ways; then the side reads, also when + // no batch partner opens (they may be damaged heads that kept their sync). + let find = |partners: &[Vec]| { + let opens = |s: usize| partners.iter().any(|p| self.opens(p, &unit_keys[s].1)); + openers.iter().copied().find(|&s| opens(s)) + }; + let batch = self.batch_partners(lba, buf, u, span.3); + let (mut proven, mut any_partner) = (find(&batch), !batch.is_empty()); + if proven.is_none() { + let side = self.side_partners(inner, at, span); + any_partner |= !side.is_empty(); + proven = find(&side); } let id = self.pieces[span.3].0; - if partners.is_empty() { - // No readable partner: provisional one-unit proof, false-accept ≈ pool × 1e-5. - tracing::debug!(target: "freemkv::keys", lba = at, "provisional one-unit proof"); - self.cache.set(id, Proof::Provisional(openers[0])); - return Ok(openers[0]); - } - match openers - .into_iter() - .find(|&s| partners.iter().any(|p| self.opens(p, &unit_keys[s].1))) - { + match proven { Some(s) => { self.cache.set(id, Proof::Proven(s)); - Ok(s) + Ok(Some(s)) + } + None if !any_partner => { + // No readable partner: provisional one-unit proof, false-accept ≈ pool × 1e-5. + tracing::debug!(target: "freemkv::keys", lba = at, "provisional one-unit proof"); + self.cache.set(id, Proof::Provisional(openers[0])); + Ok(Some(openers[0])) } None => Err(self.stop(at, "no held key opens the unit and a partner")), } diff --git a/src/keys/fmts.rs b/src/keys/fmts.rs index 14935bbd..d15dbe38 100644 --- a/src/keys/fmts.rs +++ b/src/keys/fmts.rs @@ -152,7 +152,6 @@ pub(crate) fn phases( format: ContentFormat, halt: Option<&Halt>, ) -> Result>> { - use crate::mux::resolve::{IndexProbe, probe_index_phase}; let mut out = HashMap::new(); for (i, key) in keys.iter().enumerate() { if halt.is_some_and(|h| h.is_cancelled()) { @@ -181,3 +180,268 @@ pub(crate) fn phases( } Ok(Some(out)) } + +// Decide a forensic index's decrypt phase from clean-sample counts of its EVEN vs ODD aligned +// units under that index's key. Extracted for unit-testing. +fn resolve_tie_phase( + even_clean: usize, + odd_clean: usize, +) -> std::io::Result { + match even_clean.cmp(&odd_clean) { + std::cmp::Ordering::Greater => Ok(crate::decrypt::Phase::Even), + std::cmp::Ordering::Less => Ok(crate::decrypt::Phase::Odd), + std::cmp::Ordering::Equal if even_clean == 0 => { + Err(crate::error::Error::FmtsKeyMissing.into()) + } + std::cmp::Ordering::Equal => Ok(crate::decrypt::Phase::Even), + } +} + +// Outcome of probing ONE forensic index's decrypt phase; the load-bearing split is WrongKey vs +// ReadFault — only the former is a real FmtsKeyMissing. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum IndexProbe { + /// A parity decrypted clean under this index's key (or a padding tie) → its phase. + Phase(crate::decrypt::Phase), + /// At least one unit was READ and decrypt-attempted, yet NEITHER parity came up + /// clean under this index's key on any same-index segment → genuine wrong key. + WrongKey, + /// EVERY probe read of every same-index segment faulted (`read` returned `None` + /// for all attempts) → zero decrypt evidence. A recoverable read fault, NOT a + /// wrong key: there is no data to conclude the key is bad. + ReadFault, +} + +// Probe one forensic index's decrypt phase (EVEN vs ODD aligned units) under `key`, tolerating +// read faults without masking a genuine wrong key. +pub(crate) fn probe_index_phase( + segments: &[crate::aacs::segment::Segment], + tag: u16, + batch_units: usize, + max_segments: usize, + format: ContentFormat, + key: &[u8; 16], + mut read: impl FnMut(&crate::aacs::segment::Segment, usize) -> 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 or 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, so keep trying. + if let Ok(phase) = resolve_tie_phase(even, odd) { + return IndexProbe::Phase(phase); + } + } + if any_read { + IndexProbe::WrongKey + } else { + IndexProbe::ReadFault + } +} + +#[cfg(test)] +mod probe_tests { + use crate::disc::ContentFormat; + + // BEHAVIOR 2 — phase-tie default: all four arms of the even/odd clean-count decision. + #[test] + fn resolve_tie_phase_covers_all_arms() { + use crate::decrypt::Phase; + // Non-tie: the clean half is the index's real variant. + assert_eq!( + super::resolve_tie_phase(5, 2).unwrap(), + Phase::Even, + "even majority → Even" + ); + assert_eq!( + super::resolve_tie_phase(2, 5).unwrap(), + Phase::Odd, + "odd majority → Odd" + ); + // Padding tie (both halves clean, > 0): parity immaterial → default Even. + assert_eq!( + super::resolve_tie_phase(3, 3).unwrap(), + Phase::Even, + "even == odd > 0 → default Even" + ); + assert_eq!(super::resolve_tie_phase(1, 1).unwrap(), Phase::Even); + // Neither half clean (even == odd == 0): fail loud with FmtsKeyMissing. + let err = super::resolve_tie_phase(0, 0).unwrap_err(); + let expected = std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string(); + assert_eq!( + err.to_string(), + expected, + "even == odd == 0 → FmtsKeyMissing" + ); + } + + // ── Fix 1: FMTS phase-probe read-fault vs wrong-key distinction ───────── + + // Build a 6144-byte aligned unit of CLEAN MPEG-TS then AACS-encrypt it under key. + 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; + } + // Flag encrypted BEFORE encrypting: bytes 0..16 are the key seed. + u[0] |= 0xC0; + assert!( + crate::aacs::content::encrypt_unit(&mut u, key), + "a full-length unit must encrypt" + ); + 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 must classify as ReadFault, NOT WrongKey. + #[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: first same-index segment faults every read, second decrypts clean + // -> probe falls through. + #[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" + ); + } +} diff --git a/src/keys/mod.rs b/src/keys/mod.rs index d4d06a01..00092619 100644 --- a/src/keys/mod.rs +++ b/src/keys/mod.rs @@ -14,11 +14,15 @@ mod arrival; mod fmts; +#[cfg(doctest)] +mod public_api_cannot_decrypt_aacs; mod resolve; #[cfg(test)] mod tests; pub(crate) use arrival::Arrival; +#[cfg(test)] +pub(crate) use resolve::whole_disc_pieces; use crate::aacs::trace::ResolutionTrace; use crate::decrypt::{AacsKeyMap, DecryptKeys}; @@ -31,6 +35,33 @@ use crate::whole_disc::{UnitSpan, WholeDiscReader}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; +// A title's `DiscStream` read through `map`: the read plan, then each unit's mapped key. +pub(crate) fn install_key_map( + mut stream: crate::mux::DiscStream, + map: Arc, +) -> crate::mux::DiscStream { + stream.plan_reads(&map); + stream.reader_mut().set_key_map(map); + stream +} + +// A test's own phased map; map construction stays in `keys` (KU §2.2). +#[cfg(test)] +pub(crate) fn test_key_map( + ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)>, +) -> Arc { + Arc::new(AacsKeyMap::from_ranges_phased(ranges)) +} + +// `src` reading through a test's map, for the tests outside `keys`. +#[cfg(test)] +pub(crate) fn test_keyed_source( + src: DecryptingSectorSource, + map: Arc, +) -> DecryptingSectorSource { + src.with_key_map(map) +} + /// What a rip decrypts, and so what `resolve` must key (KU §2.5). #[derive(Clone, Debug, PartialEq, Eq)] pub enum KeyScope { @@ -204,6 +235,10 @@ pub(crate) struct Inner { pub(crate) fmts_phases: HashMap, pub(crate) map: Arc, pub(crate) spans: Vec, + // `resolve` found no stream file (no `/BDMV/STREAM` or `/HVDVD_TS`, or an empty one). + pub(crate) no_stream_files: bool, + // A Lazy piece has intact encrypted probes and no held key opens any (KU §2.3 step 9.2). + pub(crate) lazy_unopened: bool, pub(crate) arrival: Vec, pub(crate) lazy: Vec<(u32, u32)>, pub(crate) proven: Vec, @@ -234,6 +269,8 @@ impl Inner { fmts_phases: HashMap::new(), map: Arc::new(AacsKeyMap::from_ranges(Vec::new())), spans: Vec::new(), + no_stream_files: false, + lazy_unopened: false, arrival: Vec::new(), lazy: Vec::new(), proven: Vec::new(), @@ -722,6 +759,20 @@ impl ResolvedKeySet { if !self.covers(&KeyScope::WholeDisc) { return Err(Self::caller_bug("whole disc outside the key set's scope")); } + if self.0.no_stream_files && !content.is_empty() { + // Titles but no stream file: sweeping would key bare title extents (freemkv#55). + tracing::warn!(target: "freemkv::scan", "titles but no AACS content files"); + let path = match disc.format { + DiscFormat::HdDvd => "/HVDVD_TS", + _ => "/BDMV/STREAM", + }; + return Err(Error::UdfNotFound { path: path.into() }); + } + if self.0.lazy_unopened { + // KU §2.1 (6) "Refuse first": a sweep reads that unit, and no held key opens it. + tracing::error!(target: "freemkv::keys", code = crate::error::E_WHOLE_DISC_KEY_MISSING, "a stream file holds a unit no held key opens: refusing the sweep before any output"); + return Err(Error::WholeDiscKeyMissing); + } let mut dec = self.decrypting(inner, None, StopKind::Image, false)?; content.extend(self.0.spans.iter().map(|&(s, n, _)| (s, n))); let content = crate::whole_disc::merge_ranges(content); @@ -736,8 +787,7 @@ impl ResolvedKeySet { } /// Whether `disc` can be decrypted with `keys` (KU §3.5). CSS is read from `disc.css` / -/// `css_error` exactly as [`Disc::ensure_decryptable_keys`] does. With `keys == None` the -/// legacy disc-banked AACS keys decide (the library fallback until KU-X2). +/// `css_error`; an AACS disc needs a set for it, so `keys == None` is `AacsKeysMissing`. pub fn decrypt_status(disc: &Disc, keys: Option<&ResolvedKeySet>) -> DecryptStatus { if disc.css_error.is_some() { return DecryptStatus::CssNotCracked(Error::CssNoDiscKey); @@ -758,7 +808,7 @@ pub fn decrypt_status(disc: &Disc, keys: Option<&ResolvedKeySet>) -> DecryptStat DecryptStatus::Ready } } - _ => match disc.ensure_decryptable_keys(false, &disc.decrypt_keys()) { + _ => match disc_gate(disc, false) { Ok(()) => DecryptStatus::Ready, Err(e) => DecryptStatus::AacsKeysMissing(e), }, @@ -767,8 +817,7 @@ pub fn decrypt_status(disc: &Disc, keys: Option<&ResolvedKeySet>) -> DecryptStat /// The pre-flight decrypt gate over a set (KU §3.5): `Ok` when `scope` can be decrypted /// from `keys`, else the typed refusal, before any output. `raw` always passes. CSS is read -/// from `disc.css` / `css_error` as [`Disc::ensure_decryptable_keys`] does; `keys == None` -/// falls back to the legacy disc-banked AACS keys (until KU-X2). +/// from `disc.css` / `css_error`; an AACS disc with no AACS set refuses. pub fn check_decryptable( disc: &Disc, raw: bool, @@ -791,9 +840,46 @@ pub fn check_decryptable( } Ok(()) } - Some(_) if disc.aacs.is_some() && disc.css.is_none() => { - disc.ensure_decryptable_keys(false, &DecryptKeys::None) - } - _ => disc.ensure_decryptable(false), + _ => disc_gate(disc, false), } } + +/// The disc-wide gate with no AACS set: `Ok` for a clear disc or a cracked CSS disc, else +/// the typed refusal. `raw` always passes. An AACS disc refuses with the scan's +/// handshake or key-service failure when it recorded one, else `NoDiscKey`. +pub(crate) fn disc_gate(disc: &Disc, raw: bool) -> Result<()> { + if raw { + return Ok(()); + } + // Scrambled-but-uncracked CSS is a WHOLE-DISC verdict (`CssNoDiscKey`), never the + // per-title `CssKeyMissing`, which would log "empty stub" per title and exit 0. + if disc.css_error.is_some() { + return Err(Error::CssNoDiscKey); + } + if !matches!(disc.decrypt_keys(), DecryptKeys::None) { + return Ok(()); + } + if disc.aacs.is_some() { + // E7017 vs E7022: a handshake or key-service failure surfaces as itself, else + // operators hunt a VUK during an outage. + if let Some(e) = disc + .aacs_error + .as_ref() + .and_then(crate::disc::handshake_class_error) + { + return Err(e); + } + return Err(match disc.aacs_error { + Some(Error::KeyServiceUnavailable) => Error::KeyServiceUnavailable, + Some(Error::KeyServiceUnauthorized) => Error::KeyServiceUnauthorized, + Some(Error::KeyServiceRateLimited) => Error::KeyServiceRateLimited, + _ => Error::NoDiscKey { + disc_hash: disc.aacs_disc_hash(), + }, + }); + } + if disc.css.is_some() { + return Err(Error::CssKeyMissing); + } + Ok(()) +} diff --git a/src/keys/public_api_cannot_decrypt_aacs.rs b/src/keys/public_api_cannot_decrypt_aacs.rs new file mode 100644 index 00000000..3e4a0bb8 --- /dev/null +++ b/src/keys/public_api_cannot_decrypt_aacs.rs @@ -0,0 +1,50 @@ +//! LK19 (KU §2.2): "within libfreemkv's public API, `ResolvedKeySet` is the only source of +//! AACS-decrypted data". Each door below compiled before KU-X2 and must not compile now. +//! Per spec; do not change without a spec citation proving otherwise. +//! +//! `DecryptKeys::Aacs` construction (§2.2: "`#[non_exhaustive]` on the variant"): +//! ```compile_fail +//! let _ = libfreemkv::decrypt::DecryptKeys::Aacs { +//! unit_keys: Vec::new(), +//! format: libfreemkv::disc::ContentFormat::BdTs, +//! }; +//! ``` +//! `AacsKeyMap::from_ranges` (§2.2: "`pub(crate)`"): +//! ```compile_fail +//! let _ = libfreemkv::decrypt::AacsKeyMap::from_ranges(vec![(0, 3, 0)]); +//! ``` +//! `AacsKeyMap::from_ranges_phased` (§2.2: "`pub(crate)`"): +//! ```compile_fail +//! use libfreemkv::decrypt::{AacsKeyMap, Phase}; +//! let _ = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::All)]); +//! ``` +//! `Disc::decrypt_with` (§2.2: "`pub(crate)`"): +//! ```compile_fail +//! fn f(d: &mut libfreemkv::Disc) { +//! let _ = d.decrypt_with(libfreemkv::disc::Key::Unit(Vec::new()), &[]); +//! } +//! ``` +//! `DecryptingSectorSource::with_key_map` (§2.2: "`pub(crate)`"): +//! ```compile_fail +//! use libfreemkv::{DecryptingSectorSource, FileSectorSource, decrypt::AacsKeyMap}; +//! fn f(s: DecryptingSectorSource, m: std::sync::Arc) { +//! let _ = s.with_key_map(m); +//! } +//! ``` +//! `DecryptingSectorSource::set_key_map` (§2.2: "`pub(crate)`"): +//! ```compile_fail +//! use libfreemkv::{DecryptingSectorSource, FileSectorSource, decrypt::AacsKeyMap}; +//! fn f(s: &mut DecryptingSectorSource, m: std::sync::Arc) { +//! s.set_key_map(m); +//! } +//! ``` +//! `DiscStream::with_key_map`, the same install on the live-drive stream: +//! ```compile_fail +//! fn f(s: libfreemkv::DiscStream, m: std::sync::Arc) { +//! let _ = s.with_key_map(m); +//! } +//! ``` +//! `aacs::content::decrypt_unit` (§2.2: "`pub(crate)`"): +//! ```compile_fail +//! libfreemkv::aacs::content::decrypt_unit(&mut [0u8; 6144], &[0u8; 16]); +//! ``` diff --git a/src/keys/resolve.rs b/src/keys/resolve.rs index 1ddda367..07ea849b 100644 --- a/src/keys/resolve.rs +++ b/src/keys/resolve.rs @@ -402,6 +402,16 @@ fn pieces(disc: &Disc, files: &[Vec<(u32, u32)>], sel: &[usize], whole: bool) -> out } +// Each whole-disc piece's unit spans, for the unit-grid guards in `whole_disc_tests`. +#[cfg(test)] +pub(crate) fn whole_disc_pieces(disc: &Disc, files: &[Vec<(u32, u32)>]) -> Vec> { + let sel: Vec = (0..disc.titles.len()).collect(); + pieces(disc, files, &sel, true) + .into_iter() + .map(|p| p.spans) + .collect() +} + fn in_segment(segments: &[(u32, u32)], lba: u32) -> bool { let i = segments.partition_point(|s| s.0 <= lba); i > 0 && lba < segments[i - 1].1 @@ -635,6 +645,7 @@ fn resolve_hddvd( } } let mut inner = aacs_inner(run); + inner.no_stream_files = files.is_empty(); inner.best_effort = true; inner.origin = run.origin.first().copied(); inner.proven = vec![0]; @@ -794,6 +805,17 @@ fn resolve_bd( return Err(failure.unwrap_or_else(|| missing_error(scope, disc))); } let mut inner = aacs_inner(run); + inner.no_stream_files = files.is_empty(); + // Refused up front only if no probe of the piece opens: arrival blanks a unit whose batch + // partner opens (damage), and a damaged seed opens under no key (E7013 option A). + inner.lazy_unopened = ps.iter().any(|p| { + let opened = |u: &Vec| (0..run.pool.len()).any(|s| run.opens(u, s)); + matches!(p.verdict, Verdict::Lazy(_)) + && !p.enc.iter().any(opened) + && p.enc + .iter() + .any(|u| crate::aacs::content::aacs_unit_on_grid(u, format)) + }); // Step 6 (continued): forensic keys, reused from the seed or anchored once. let forensic = match &layout { None => None, diff --git a/src/keys/tests.rs b/src/keys/tests.rs index db2cd1e6..d5080638 100644 --- a/src/keys/tests.rs +++ b/src/keys/tests.rs @@ -274,6 +274,8 @@ struct Spec { dependent: bool, keys: Vec<[u8; 16]>, fmts: Vec<[u8; 16]>, + // The online service only returns the full set for its held index-1 phase. + match_fmts_phase: bool, answer: Answer, calls: Calls, clock: Option>, @@ -295,6 +297,7 @@ impl Spec { dependent: false, keys: keys.to_vec(), fmts: Vec::new(), + match_fmts_phase: false, answer: Answer::All, calls: calls.clone(), clock: None, @@ -348,6 +351,16 @@ impl Fake { &self.spec.keys }; Ok(match self.spec.answer { + _ if forensic && self.spec.match_fmts_phase => { + if held + .first() + .is_some_and(|key| samples.iter().all(|s| opens(s, key))) + { + held.clone() + } else { + Vec::new() + } + } // KS-26 (evidence): an index-1 anchor gets the whole forensic set. _ if forensic => held.clone(), Answer::All => held.clone(), @@ -1159,6 +1172,228 @@ fn on_arrival_proof() { ); } +// File B (K2) Lazy as in `lazy_b`, its image first damaged by `damage`. +fn lazy_b_damaged(pool: &[[u8; 16]], damage: impl Fn(&mut Fx)) -> (Fx, ResolvedKeySet, Faulty) { + let mut fx = fixture( + &[stream(1, 10, Some(K1)), stream(2, 10, Some(K2))], + 2, + &[&[0, 1]], + ); + damage(&mut fx); + let (b, n) = fx.file(1); + let src = fx.source(); + src.kill(b, b + n); + let calls = Calls::default(); + let set = resolve_with( + &fx, + &mut src.clone(), + KeyScope::WholeDisc, + &[Spec::keydb(pool, &calls)], + ResolveKeysOptions::default(), + &FakeClock::default(), + ) + .unwrap(); + src.heal(); + (fx, set, src) +} + +// Unit `u` of file `i`: its first sector read back as garbage (seed damaged), or zero-filled. +fn damage_seed(fx: &mut Fx, i: usize, u: u32) { + let at = fx.unit(i, u) as usize * 2048; + crate::test_util::damage_unit_seed(&mut fx.img.image[at..at + ALIGNED_UNIT_LEN]); +} +fn zero_unit(fx: &mut Fx, i: usize, u: u32) { + let at = fx.unit(i, u) as usize * 2048; + fx.img.image[at..at + ALIGNED_UNIT_LEN].fill(0); +} + +/// ⚑ per spec; do not change without a spec citation. KU §2.4 proves a key on READABLE units: +/// a zero unit (KS-5: CPI "00₂ if the data is not encrypted") or a damaged seed (KS-4: "The +/// first 16 bytes of each Aligned Unit is used as the seed") is a hole, never proof or disproof. +#[test] +fn on_arrival_read_damage_neither_proves_nor_refutes() { + assert!( + crate::spec::keys::KS_4_SEED + .text + .contains("used as the seed") + ); + assert!( + crate::spec::keys::KS_5_CPI + .text + .contains("00₂ if the data is not encrypted") + ); + let edge = |fx: &mut Fx| { + damage_seed(fx, 1, 0); + zero_unit(fx, 1, 1); + }; + let holes = vec![0u8; 2 * ALIGNED_UNIT_LEN]; + // (a) damage alone proves nothing and reads as holes; the first intact unit proves K2. + let (fx, set, src) = lazy_b_damaged(&[K1, K2], edge); + let b = fx.file(1).0; + let mut r = set.title_reader(&fx.disc, 0, src).unwrap(); + assert_eq!(read(&mut r, &fx, 1, 0, 2).unwrap(), holes); + assert_eq!(set.proof_cache().get(b), None, "a hole is not proof"); + assert_eq!( + read(&mut r, &fx, 1, 2, 2).unwrap(), + fx.plain(fx.unit(1, 2), 2) + ); + assert_eq!(set.proof_cache().get(b), Some(Proof::Proven(1))); + + // (b) no held key opens B: the damage is still holes, the first intact unit stops E7022. + let (fx, set, src) = lazy_b_damaged(&[K1], edge); + let mut r = set.title_reader(&fx.disc, 0, src).unwrap(); + assert_eq!( + read(&mut r, &fx, 1, 0, 2).unwrap(), + holes, + "a hole is not disproof" + ); + assert_eq!( + code(read(&mut r, &fx, 1, 2, 1)), + E7022, + "a wrong key still stops" + ); + + // (c) damaged neighbours are not partners: the one intact unit is a provisional proof. + let (fx, set, src) = lazy_b_damaged(&[K1, K2], |fx| { + (0..10) + .filter(|&u| u != 5) + .for_each(|u| damage_seed(fx, 1, u)); + }); + let mut r = set.title_reader(&fx.disc, 0, src).unwrap(); + assert_eq!( + read(&mut r, &fx, 1, 5, 1).unwrap(), + fx.plain(fx.unit(1, 5), 1) + ); + assert_eq!( + set.proof_cache().get(fx.file(1).0), + Some(Proof::Provisional(1)) + ); +} + +// Unit `u` of file `i`: a garbage head that keeps both the CPI flag and the TS sync at byte 4 +// (~1 in 341 damaged heads), so the seed test cannot tell it from an intact unit. +fn garble_keeping_sync(fx: &mut Fx, i: usize, u: u32) { + damage_seed(fx, i, u); + let at = fx.unit(i, u) as usize * 2048; + fx.img.image[at + 4] = 0x47; +} + +/// KS-4 [BD] §3.10.1: "The first 16 bytes of each Aligned Unit is used as the seed". A unit +/// no held key opens while a held key opens its partners is damage: blanked and counted, +/// never E7022, whether it would prove the key (a) or confirm a provisional one (b). (c) A +/// wrong key opens no unit at all and still stops E7022. Partners come from the batch only. +#[test] +fn on_arrival_a_garbled_head_keeping_sync_is_blanked_never_e7022() { + // (a) the unit that would prove the key. + let (fx, set, src) = lazy_b_damaged(&[K1, K2], |fx| garble_keeping_sync(fx, 1, 0)); + let mut r = set.title_reader(&fx.disc, 0, src).unwrap(); + let mut want = fx.plain(fx.unit(1, 0), 4); + want[..ALIGNED_UNIT_LEN].fill(0); + assert_eq!(read(&mut r, &fx, 1, 0, 4).expect("damage, not E7022"), want); + assert_eq!(r.blanked_units(), 1); + assert_eq!(set.proof_cache().get(fx.file(1).0), Some(Proof::Proven(1))); + + // (b) the unit that would confirm a provisional key (dead neighbours made it provisional). + let (fx, set, src) = lazy_b_damaged(&[K1, K2], |fx| garble_keeping_sync(fx, 1, 7)); + let (b, n) = fx.file(1); + src.kill(b, fx.unit(1, 5)); + src.kill(fx.unit(1, 6), b + n); + let mut r = set.title_reader(&fx.disc, 0, src.clone()).unwrap(); + read(&mut r, &fx, 1, 5, 1).expect("provisional under K2"); + assert_eq!(set.proof_cache().get(b), Some(Proof::Provisional(1))); + src.heal(); + let mut want = fx.plain(fx.unit(1, 7), 3); + want[..ALIGNED_UNIT_LEN].fill(0); + let got = read(&mut r, &fx, 1, 7, 3).expect("damage does not contradict a key"); + assert_eq!(got, want); + assert_eq!(r.blanked_units(), 1); + assert_eq!( + set.proof_cache().get(b), + Some(Proof::Proven(1)), + "unit 8 confirms" + ); + + // (c) no held key opens B: the garbled unit and its partners alike, so E7022. + let (fx, set, src) = lazy_b_damaged(&[K1], |fx| garble_keeping_sync(fx, 1, 0)); + let mut r = set.title_reader(&fx.disc, 0, src).unwrap(); + assert_eq!( + code(read(&mut r, &fx, 1, 0, 4)), + E7022, + "a wrong key still stops" + ); +} + +/// KU §2.4 step 1: a unit that opens, whose only partners in the read are damaged (garbled +/// heads that kept their sync), looks further in side reads before any verdict: never E7022. +#[test] +fn on_arrival_damaged_batch_partners_fall_back_to_side_reads() { + let (fx, set, src) = lazy_b_damaged(&[K1, K2], |fx| garble_keeping_sync(fx, 1, 1)); + let mut r = set.title_reader(&fx.disc, 0, src).unwrap(); + let got = read(&mut r, &fx, 1, 0, 2).expect("a side read finds an intact partner"); + assert_eq!(&got[..ALIGNED_UNIT_LEN], &fx.plain(fx.unit(1, 0), 1)[..]); + assert_eq!(set.proof_cache().get(fx.file(1).0), Some(Proof::Proven(1))); +} + +/// Sweep, patch and image→ISO read through the whole-disc reader, one call per block. A +/// cluster of damaged units (KS-4: "The first 16 bytes of each Aligned Unit is used as the +/// seed") is blanked and counted wherever it falls, never E7013: multipass must not error. +#[test] +fn whole_disc_reader_blanks_clustered_damage() { + let fx = fixture( + &[stream(1, 10, Some(K1)), stream(2, 10, Some(K2))], + 2, + &[&[0, 1]], + ); + let calls = Calls::default(); + let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1, K2], &calls)]).unwrap(); + let mut fx = fx; + (3..6).for_each(|u| damage_seed(&mut fx, 1, u)); + let holes = vec![0u8; 3 * ALIGNED_UNIT_LEN]; + let read = |w: &mut WholeDiscReader, u: u32, n: u32| { + let mut buf = vec![0u8; n as usize * ALIGNED_UNIT_LEN]; + w.read_sectors(fx.unit(1, u), (n * 3) as u16, &mut buf, true) + .map(|_| masked(&buf)) + }; + // The cluster read first, then after an intact block: blanked and counted both ways. + let mut w = set.whole_disc_reader(&fx.disc, fx.source(), None).unwrap(); + assert_eq!(read(&mut w, 3, 3).unwrap(), holes, "a cluster read first"); + assert_eq!(read(&mut w, 0, 3).unwrap(), fx.plain(fx.unit(1, 0), 3)); + assert_eq!(w.blanked_units(), 3); + let mut w = set.whole_disc_reader(&fx.disc, fx.source(), None).unwrap(); + assert_eq!(read(&mut w, 0, 3).unwrap(), fx.plain(fx.unit(1, 0), 3)); + assert_eq!(read(&mut w, 3, 3).unwrap(), holes, "a cluster read later"); + assert_eq!(read(&mut w, 6, 4).unwrap(), fx.plain(fx.unit(1, 6), 4)); + assert_eq!(w.blanked_units(), 3); +} + +/// Extract (a decrypted folder) blanks a cluster of damaged units and reports their bytes as +/// unreadable, never E7013 or E7032. KS-4 [BD] §3.10.1: "The first 16 bytes of each Aligned +/// Unit is used as the seed" — no key opens a damaged one, so it is neither proof nor disproof. +#[test] +fn extract_tree_blanks_clustered_damage() { + let (fx, set, mut src) = + lazy_b_damaged(&[K1, K2], |fx| (3..6).for_each(|u| damage_seed(fx, 1, u))); + let dest = tempfile::tempdir().unwrap(); + let opts = crate::disc::ExtractOptions { + keys: Some(&set), + ..Default::default() + }; + let res = fx.disc.extract_tree(&mut src, dest.path(), &opts).unwrap(); + assert!( + !res.halted && res.files.iter().all(|f| f.complete), + "every file is written" + ); + assert!( + !res.complete, + "a folder with blanked units is not reported clean" + ); + assert_eq!(res.bytes_unreadable, 3 * ALIGNED_UNIT_LEN as u64); + let got = std::fs::read(dest.path().join("BDMV/STREAM/00002.m2ts")).unwrap(); + let mut want = fx.plain(fx.file(1).0, 10); + want[3 * ALIGNED_UNIT_LEN..6 * ALIGNED_UNIT_LEN].fill(0); + assert_eq!(masked(&got), want); +} + /// SG25 ⚑ — per spec; do not change without a spec citation — KS-1 [BD] §3.10.1: /// "encryption is applied to every Aligned Unit in the file"; KS-2. The final unit of a Lazy /// piece is decrypted (proven backward), never a read error. @@ -1251,6 +1486,43 @@ fn fmts_online(calls: &Calls) -> Spec { s } +// KS-26: the base-key request is separate from the forensic A/B anchor. Stop on +// A's success; an empty A answer must leave the source available for B. +#[test] +fn fmts_phase_a_success_stops_and_phase_a_miss_tries_b() { + for held_phase in [0, 1] { + let mut fx = fmts_fixture(); + if held_phase == 1 { + for (a, b, key) in [(0, 16, F1), (20, 36, F2)] { + for u in a..b { + fx.reencrypt(1, u, if u % 2 == 1 { &key } else { &ALT }); + } + } + } + let calls = Calls::default(); + let mut source = fmts_online(&calls); + source.keys = vec![K1, K2]; + source.match_fmts_phase = true; + let set = resolve(&fx, KeyScope::Titles(vec![2]), &[source]).unwrap(); + assert_eq!(set.status().forensic, ForensicState::Resolved); + let anchors = calls.of("online").iter().filter(|c| c.forensic).count(); + assert_eq!(anchors, held_phase + 1, "only ask B when A has no key"); + let before = calls.len(); + let mut reader = set.title_reader(&fx.disc, 2, fx.source()).unwrap(); + let got = read(&mut reader, &fx, 1, 0, 16).unwrap(); + let plain = fx.plain(fx.file(1).0, 16); + for u in 0..16 { + let range = u * ALIGNED_UNIT_LEN..(u + 1) * ALIGNED_UNIT_LEN; + if u % 2 == held_phase { + assert_eq!(got[range.clone()], plain[range]); + } else { + assert_ne!(got[range.clone()], plain[range]); + } + } + assert_eq!(calls.len(), before, "muxing never asks again"); + } +} + /// LK12. KS-25, KS-26 (evidence, no public FMTS spec): the forensic set is fetched once for /// all titles and passes; the clip's base content keeps its own proven key (K-3), and the /// alternate phase stays ciphertext. @@ -1510,7 +1782,7 @@ fn scannable_image() -> (EncryptedBdImage, Disc) { } /// `InputOptions::keys` (KU §3.1, §3.5): an `iso://` AACS source opens through the rip's set, -/// gated by `check_decryptable`, where the legacy path (no banked key) refuses E7022. +/// gated by `check_decryptable`; with no set it refuses E7022. #[test] fn iso_input_reads_through_the_key_set() { let _serial = crate::sector::prefetched::holder_test_lock(); @@ -1530,8 +1802,8 @@ fn iso_input_reads_through_the_key_set() { let path = dir.path().join("disc.iso"); std::fs::write(&path, &img.image).unwrap(); let url = format!("iso://{}", path.display()); - let legacy = crate::input(&url, &crate::InputOptions::default()); - assert_eq!(crate::error_code(&legacy.err().unwrap()), Some(E7022)); + let keyless = crate::input(&url, &crate::InputOptions::default()); + assert_eq!(crate::error_code(&keyless.err().unwrap()), Some(E7022)); let opts = crate::InputOptions { keys: Some(set), ..Default::default() @@ -1550,7 +1822,6 @@ fn session_resolves_a_key_set_through_its_reader() { let mut session = crate::session::DiscSession::from_parts_for_test( Some(two_units().disc), Some(Box::new(fx.source())), - None, ); let r = session .resolve_key_set(KeyScope::WholeDisc, &f, ResolveKeysOptions::default()) @@ -1622,17 +1893,19 @@ fn live_stream_stops_on_an_unkeyed_piece_without_recovery() { let (fx, set, src) = lazy_b(&[K1]); let recovery = Arc::new(Mutex::new(0u32)); let reader = RecoveryCount(src, recovery.clone()); - let mut stream = crate::mux::DiscStream::new( - Box::new(reader), - fx.disc.titles[0].clone(), - set.decrypt_keys(), - 30, - ContentFormat::BdTs, - false, - None, + let mut stream = super::install_key_map( + crate::mux::DiscStream::new( + Box::new(reader), + fx.disc.titles[0].clone(), + set.decrypt_keys(), + 30, + ContentFormat::BdTs, + false, + None, + ) + .unwrap(), + set.key_map(), ) - .unwrap() - .with_key_map(set.key_map()) .with_arrival(set.arrival(set.title_stop()).expect("B is Lazy")); stream.skip_errors = skip; let got = loop { @@ -1798,17 +2071,19 @@ fn keyless_set_over_overlapping_extents_stops_e7022() { let set = ResolvedKeySet::keyless_for(&title, ContentFormat::BdTs); for skip in [false, true] { let recovery = Arc::new(Mutex::new(0u32)); - let mut stream = crate::mux::DiscStream::new( - Box::new(RecoveryCount(fx.source(), recovery.clone())), - title.clone(), - set.decrypt_keys(), - 3, - ContentFormat::BdTs, - false, - None, + let mut stream = super::install_key_map( + crate::mux::DiscStream::new( + Box::new(RecoveryCount(fx.source(), recovery.clone())), + title.clone(), + set.decrypt_keys(), + 3, + ContentFormat::BdTs, + false, + None, + ) + .unwrap(), + set.key_map(), ) - .unwrap() - .with_key_map(set.key_map()) .with_arrival(set.arrival(set.title_stop()).expect("every extent is lazy")); stream.skip_errors = skip; // Start past the inner extent: its first read is unit 4 of the outer one. @@ -2320,3 +2595,160 @@ fn a_source_halted_mid_request_keeps_the_asked_step() { } mod stop_tests; + +/// LK21 (K-13), per spec — KS-5 [BD] §3.10.2: CPI "shall be set to 00₂ if the data is not +/// encrypted"; corroborated by KS-22 (libaacs clears it per source packet). A decrypted +/// image says so in every packet, so re-scanning it finds only clear pieces and asks nothing. +#[test] +fn decrypt_clears_cpi_on_every_source_packet() { + use crate::spec::keys::{KS_5_CPI, KS_22_LIBAACS_VERIFY_TS}; + assert!(KS_5_CPI.text.contains("00₂ if the data is not encrypted")); + assert!(KS_22_LIBAACS_VERIFY_TS.text.contains("buf[i] &= ~0xc0;")); + let mut fx = two_units(); + let calls = Calls::default(); + let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1, K2], &calls)]).unwrap(); + let mut w = set.whole_disc_reader(&fx.disc, fx.source(), None).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("out.iso"); + let cap = fx.disc.capacity_sectors; + crate::write_image(&mut w, &dest, cap, &Halt::new(), |_| {}).unwrap(); + let bytes = std::fs::read(&dest).unwrap(); + for i in 0..2 { + let (start, n) = fx.file(i); + for lba in (start..start + n).step_by(3) { + let o = lba as usize * 2048; + for p in (0..ALIGNED_UNIT_LEN).step_by(192) { + assert_eq!(bytes[o + p] & 0xC0, 0, "LBA {lba}, packet byte {p}: CPI"); + } + } + } + // Re-scan the decrypted image: every piece reads Clear, and no source is asked. + fx.img.image = bytes; + let calls = Calls::default(); + let specs = [ + Spec::keydb(&[K1, K2], &calls), + Spec::online(&[K1, K2], &calls), + ]; + let set = resolve(&fx, KeyScope::WholeDisc, &specs).unwrap(); + assert_eq!((set.status().clear, set.status().lazy), (2, 0)); + assert_eq!((calls.len(), set.source_requests()), (0, 0), "requests"); +} + +/// K-8: `n_decl` is read through `parse_title_keys`, so an HD DVD title-key file counts +/// too. Per spec KS-14 ("Num_of_CPS_Unit … the number of CPS Units on the disc") for BD; +/// HD DVD has no public book, per evidence KS-27. +#[test] +fn declared_cps_units_reads_hd_dvd_title_keys_too() { + use crate::spec::keys::{KS_14_UNIT_KEY_BLOCK, KS_27_HDDVD_EVIDENCE}; + assert!( + KS_14_UNIT_KEY_BLOCK + .text + .contains("indicates the number of CPS Units") + ); + assert_eq!(KS_27_HDDVD_EVIDENCE.kind, crate::spec::QuoteKind::Evidence); + // A VTKF with one available Title Key Entry (AV_FLG set in slot 0). + let mut vtkf = vec![0u8; 2480]; + vtkf[..12].copy_from_slice(crate::aacs::inf::VTKF_MAGIC); + vtkf[0x80] = 0x80; + let mut fx = two_units(); + fx.disc.format = DiscFormat::HdDvd; + fx.disc.content_format = ContentFormat::MpegPs; + fx.disc.aacs.as_mut().unwrap().uk_ro = vtkf; + assert_eq!(fx.disc.declared_cps_units(), Some(1)); +} + +/// The whole-disc reader's E6003 (kept from the pre-KU-X2 reader, engine +/// `an_empty_stream_map_with_titles_fails_loud`): an AACS disc whose titles have content +/// but whose image has no stream folder names the missing folder before any output. +#[test] +fn whole_disc_reader_refuses_titles_without_a_stream_folder() { + let uk_ro = unit_key_ro(AacsVersion::V10, &[[0xEE; 16]], &[1]); + let files = [ + BdFile::new("BDMV/index.bdmv", 1, None), + BdFile::new("BDMV/AUXDATA/00000.bin", 30, None), + ]; + let img = encrypted_bd_image(&files, &uk_ro); + let disc = disc_over(&img, &uk_ro, &[&[1]], DiscFormat::BluRay); + let fx = Fx { img, disc }; + let calls = Calls::default(); + let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1], &calls)]).unwrap(); + match set.whole_disc_reader(&fx.disc, fx.source(), None) { + Err(Error::UdfNotFound { path }) => assert_eq!(path, "/BDMV/STREAM"), + other => panic!("expected E6003, got {:?}", other.err()), + } +} + +/// KU §2.1 (6) "Refuse first": a whole-disc sweep reads every unit, so a Lazy piece +/// holding a readable encrypted probe no held key opens is refused E7032 by the reader, +/// before any output (engine `a_multi_cps_sweep_refuses_a_first_unit_no_key_opens_*`). +#[test] +fn whole_disc_reader_refuses_a_lazy_piece_no_held_key_opens() { + let mut fx = fixture(&[stream(1, 10, Some(K1)), stream(2, 10, None)], 2, &[&[0]]); + let at = fx.unit(1, 0) as usize * 2048; + // An intact unit: TS sync at byte 4 of each packet, so its seed is not damage (KS-4). + fx.img.plain[at..at + ALIGNED_UNIT_LEN] + .chunks_mut(192) + .for_each(|p| p[4] = 0x47); + fx.reencrypt(1, 0, &WRONG); + let calls = Calls::default(); + let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1], &calls)]).unwrap(); + let (b, n) = fx.file(1); + assert_eq!( + set.lazy(), + &[(b, b + n)], + "one unopened unit: Lazy (step 9.2)" + ); + assert_eq!( + code( + set.whole_disc_reader(&fx.disc, fx.source(), None) + .map(|_| ()) + ), + E7032 + ); + // A title rip that never reads B is unaffected. + assert!(set.title_reader(&fx.disc, 0, fx.source()).is_ok()); +} + +/// E7013 option A with the E7032 refusal above: a probe whose seed is damaged (no TS sync, +/// KS-4 [BD] §3.10.1 "The first 16 bytes of each Aligned Unit is used as the seed") opens +/// under no key and is blanked on read, never a stop, so it refuses no sweep up front. +#[test] +fn whole_disc_reader_does_not_refuse_a_damaged_probe() { + let mut fx = fixture(&[stream(1, 10, Some(K1)), stream(2, 10, None)], 2, &[&[0]]); + let at = fx.unit(1, 0) as usize * 2048; + // A damaged seed: no TS sync at byte 4. + fx.img.plain[at..at + ALIGNED_UNIT_LEN] + .chunks_mut(192) + .for_each(|p| p[4] = 0); + fx.reencrypt(1, 0, &WRONG); + let calls = Calls::default(); + let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1], &calls)]).unwrap(); + let r = set + .whole_disc_reader(&fx.disc, fx.source(), None) + .map(|_| ()); + assert!(r.is_ok(), "{:?} lazy={:?}", r.err(), set.lazy()); +} + +/// With cb76dcc's damage rule (KU §2.4): a unit no held key opens while a held key opens a +/// partner in its batch is blanked, not a stop. So a Lazy piece with a probe a held key +/// opens is left to the on-arrival proof, never refused up front. +#[test] +fn whole_disc_reader_leaves_a_piece_with_an_opened_probe_to_arrival() { + let mut fx = fixture(&[stream(1, 10, Some(K1)), stream(2, 10, None)], 2, &[&[0]]); + for (u, key) in [(0, WRONG), (1, K1)] { + let at = fx.unit(1, u) as usize * 2048; + fx.img.plain[at..at + ALIGNED_UNIT_LEN] + .chunks_mut(192) + .for_each(|p| p[4] = 0x47); + fx.reencrypt(1, u, &key); + } + let calls = Calls::default(); + let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1], &calls)]).unwrap(); + let (b, n) = fx.file(1); + assert_eq!( + set.lazy(), + &[(b, b + n)], + "one opened, one unopened probe: Lazy" + ); + assert!(set.whole_disc_reader(&fx.disc, fx.source(), None).is_ok()); +} diff --git a/src/keys/tests/stop_tests.rs b/src/keys/tests/stop_tests.rs index bf4019c7..9e5b885b 100644 --- a/src/keys/tests/stop_tests.rs +++ b/src/keys/tests/stop_tests.rs @@ -147,11 +147,8 @@ fn session_token_wins_over_the_callers_in_resolve() { let reader = fx.source(); let (session_tok, caller_tok) = (Halt::new(), Halt::new()); let (f, seen) = spies(&session_tok, true); - let mut s = crate::session::DiscSession::from_parts_for_test( - Some(fx.disc), - Some(Box::new(reader)), - None, - ); + let mut s = + crate::session::DiscSession::from_parts_for_test(Some(fx.disc), Some(Box::new(reader))); s.set_halt_for_test(&session_tok); let opts = ResolveKeysOptions { halt: Some(&caller_tok), diff --git a/src/keysource.rs b/src/keysource.rs index dfee684d..4473f9fc 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -9,7 +9,6 @@ use crate::aacs::types::HostCert; use crate::aacs::types::{UnitKey, Vid}; -use crate::disc::Key; use crate::error::Error; /// Minimum encrypted-content unit samples a single online key request must carry. @@ -364,297 +363,9 @@ pub trait KeySource { } } -/// Drive `sources` until one resolves Unit Keys that decrypt `disc`. Returns -/// `true` at the first source whose keys validate and commit, `false` once every -/// source is exhausted (the genuine "no key for this disc"). Thin wrapper over -/// [`resolve_and_apply_traced`] that discards the trace. -pub fn resolve_and_apply( - sources: &[Box], - inputs: &DiscInputs, - disc: &mut crate::Disc, -) -> bool { - resolve_and_apply_traced(sources, inputs, disc).0 -} - -/// Like [`resolve_and_apply`] but also returns a structured -/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for -/// applications to render. ZERO English; the trace is typed enums only. -/// -/// One-shot per source: each source's [`KeySource::resolve_unit_keys`] is called exactly once; -/// non-empty Unit Keys are applied via [`crate::Disc::decrypt_with`], which validates against -/// `inputs.samples` and only mutates the disc on success. CPS-unit ORDER is load-bearing even -/// though the carried number is not. -pub fn resolve_and_apply_traced( - sources: &[Box], - inputs: &DiscInputs, - disc: &mut crate::Disc, -) -> (bool, crate::aacs::trace::ResolutionTrace) { - use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep}; - - let mut trace = crate::aacs::trace::ResolutionTrace::new(); - // A bus-blocked disc refuses every key: don't query sources, record why. - if disc.bus_blocked_error().is_some() { - trace.keys.push(KeyStep { - who: crate::aacs::trace::BUS_BLOCKED.to_string(), - path: Vec::new(), - outcome: KeyOutcome::NoKey, - matched_entry: None, - store_entries: None, - }); - return (false, trace); - } - - // The FIRST source failure seen, if any. An `Err` means the source could not - // answer at all (not "no key"), so its reason is stamped onto `disc.aacs_error` - // below, reporting the ordered sources' most-preferred failure to the operator. - let mut source_failure: Option = None; - // The first validation rejection (keys produced, but they don't open the disc). - let mut rejection: Option = None; - - // The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the - // disc's own AACS major), so the stride is the disc's single source of truth. - let ctx = DiscInputsCtx::new(inputs); - - for source in sources { - // `who` is the source's own stable identifier — no enum to map back to. - let who = source.label().to_string(); - match source.resolve_unit_keys(&ctx) { - Ok(r) if !r.keys.is_empty() => { - // Positional index → canonical CPS-unit number (position + 1). - let unit_keys: Vec<(u32, [u8; 16])> = r - .keys - .iter() - .map(|uk| (uk.idx.saturating_add(1), uk.key)) - .collect(); - let applied = disc.decrypt_with(Key::Unit(unit_keys), &inputs.samples); - if let Err(e) = applied { - rejection.get_or_insert(e); - } else { - trace.keys.push(KeyStep { - who, - path: vec![KeyNode::FoundUnitKeys, KeyNode::DerivedUnitKeys], - outcome: KeyOutcome::Resolved, - matched_entry: r.matched_entry, - store_entries: r.store_entries, - }); - return (true, trace); - } - // Keys produced but rejected by validation — record and continue. - trace.keys.push(KeyStep { - who, - path: vec![KeyNode::FoundUnitKeys], - outcome: KeyOutcome::NoKey, - matched_entry: r.matched_entry, - store_entries: r.store_entries, - }); - } - // The source ANSWERED with no keys. De-conflate a MATCHED-but- - // underivable disc from a true miss (`NoEntry`); a source that can't - // tell them apart (the trait default) reports `matched = false`. - Ok(r) => { - let path = if r.matched { - let mut p = vec![KeyNode::MatchedDisc]; - if r.miss_path.is_empty() { - p.push(KeyNode::NoDerivableKey); - } else { - p.extend(r.miss_path); - } - p - } else { - vec![KeyNode::NoEntry] - }; - trace.keys.push(KeyStep { - who, - path, - outcome: KeyOutcome::NoKey, - matched_entry: r.matched_entry, - store_entries: r.store_entries, - }); - } - // The source could NOT answer (unreachable/errored/refused), so path is - // EMPTY, not `NoEntry` — conflating the two once rendered 502s as "no - // key" and misled operators. The real reason rides on `disc.aacs_error`. - Err(e) => { - if source_failure.is_none() { - source_failure = Some(e); - } - trace.keys.push(KeyStep { - who, - path: Vec::new(), - outcome: KeyOutcome::NoKey, - matched_entry: None, - store_entries: None, - }); - } - } - } - // Nothing resolved. Stamp why — a source FAILURE first, else a key rejection — - // onto the disc for the decrypt gate, but never clobber a reason the scan - // already captured (e.g. `AacsVidUnavailable`), which is closer to the disc. - if let Some(e) = source_failure.or(rejection) - && disc.aacs_error.is_none() - { - disc.aacs_error = Some(e); - } - (false, trace) -} - -/// THE single key-fetch: drive `sources` in order and return the first non-empty Unit Key set -/// that opens the ctx's samples. Same sources, same call for both disc-open and read-miss -/// paths — only the samples in `ctx` differ. A source whose keys open none of the encrypted -/// samples is skipped (the next source is asked); with no encrypted sample there is nothing -/// to disprove and the first non-empty set wins. Does not commit to a disc. Empty on a miss -/// or a source failure (see [`key_fetch`] for the failure-reporting form). -pub fn fetch_unit_keys(sources: &[Box], ctx: &dyn ResolveCtx) -> Vec { - drive_unit_keys(sources, ctx).unwrap_or_default() -} - -// One source call: a `KeySource` op over a ctx. -type SourceOp = fn(&dyn KeySource, &dyn ResolveCtx) -> Result, Error>; - -// Drive `sources` in order: the first non-empty set `accept`ed wins. When none wins, -// `Err` = the FIRST source failure, else `AacsKeyRejected` if keys were produced but -// none accepted (neither is cached); empty `Ok` = a genuine absence. -fn drive_sources( - sources: &[Box], - ctx: &dyn ResolveCtx, - op: SourceOp, - accept: &dyn Fn(&[UnitKey]) -> bool, -) -> Result, Error> { - let mut failure: Option = None; - let mut rejected = false; - for source in sources { - match op(source.as_ref(), ctx) { - Ok(uks) if uks.is_empty() => {} - Ok(uks) if accept(&uks) => return Ok(uks), - Ok(_) => rejected = true, - Err(e) => { - failure.get_or_insert(e); - } - } - } - match failure { - Some(e) => Err(e), - None if rejected => Err(Error::AacsKeyRejected), - None => Ok(Vec::new()), - } -} - -// Base Unit Keys, validated against the ctx's samples (see [`keys_open_samples`]). -fn drive_unit_keys( - sources: &[Box], - ctx: &dyn ResolveCtx, -) -> Result, Error> { - let samples = ctx.samples(usize::MAX).unwrap_or_default(); - drive_sources(sources, ctx, |s, c| s.get_unit_keys(c), &|uks| { - keys_open_samples(uks, &samples) - }) -} - -// Forensic index set: the caller probes each index's phase itself (an anchor batch -// may be the wrong parity), so the first non-empty set is trusted as-is. -fn drive_fmts_indexes( - sources: &[Box], - ctx: &dyn ResolveCtx, -) -> Result, Error> { - drive_sources(sources, ctx, |s, c| s.get_fmts_indexes(c), &|_| true) -} - -// Does any key open any still-encrypted sample? The read-time ctx carries no content -// format, so each sample is judged under both; no encrypted sample ⇒ nothing to disprove. -// Callers must pass only samples already filtered by the REAL format's encrypted flag. -fn keys_open_samples(keys: &[UnitKey], samples: &[Vec]) -> bool { - use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit, is_clean}; - use crate::disc::ContentFormat; - let mut probe = vec![0u8; ALIGNED_UNIT_LEN]; - let mut evidence = false; - for format in [ContentFormat::BdTs, ContentFormat::MpegPs] { - for s in samples - .iter() - .filter(|s| aacs_unit_needs_decrypt(s, format)) - { - evidence = true; - for k in keys { - probe.copy_from_slice(&s[..ALIGNED_UNIT_LEN]); - decrypt_unit(&mut probe, &k.key); - if is_clean(&probe, format) { - return true; - } - } - } - } - !evidence -} - -/// The forensic counterpart to [`fetch_unit_keys`]: drive `sources` in order and -/// return the first source's non-empty AACS 2.1 forensic index set. `ctx` carries -/// the index-1 anchor batch (the mux, which owns disc geometry, gathers it and -/// injects it as the ctx's samples); a source that needs no samples (a keydb -/// keying on `disc_hash`) ignores them. Whatever the winning source returns — -/// ≥ 1 key — is trusted as the COMPLETE ordered set; no fixed count is assumed. -pub fn fetch_fmts_indexes(sources: &[Box], ctx: &dyn ResolveCtx) -> Vec { - drive_fmts_indexes(sources, ctx).unwrap_or_default() -} - -/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS inputs and a way -/// to (re)build the application's key sources. The returned resolver has the two explicit -/// operations the mux and recovery decorator call: -/// [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives [`fetch_unit_keys`], -/// [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes) drives [`fetch_fmts_indexes`]. One -/// builder shared by every read path and consumer. -pub fn key_fetch( - inputs: DiscInputs, - make_sources: std::sync::Arc Vec> + Send + Sync>, -) -> crate::sector::KeyFetch { - // One driver behind both operations, memoized per sample-batch fingerprint - // (keys are disc-level, so repeats hit cache). A genuinely-empty reply is - // cached, but a source FAILURE is not — it is returned, never memoized. - type FetchDriver = fn(&[Box], &dyn ResolveCtx) -> Result, Error>; - fn make_op( - inputs: DiscInputs, - make_sources: std::sync::Arc Vec> + Send + Sync>, - drive: FetchDriver, - ) -> crate::sector::KeyFetchFn { - let cache: std::sync::Arc>>> = - std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); - std::sync::Arc::new(move |samples: &[Vec]| -> Result, Error> { - let fp = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - samples.len().hash(&mut h); - for s in samples { - s.hash(&mut h); - } - h.finish() - }; - if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) { - return Ok(hit.clone()); - } - let sources = make_sources(); - let mut di = inputs.clone(); - di.samples = samples.to_vec(); - // Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`): - // a V10 disc parses `enc_title_keys` at the 48-byte stride, so - // hardcoding the V20 stride here would corrupt the derived unit keys. - let ctx = DiscInputsCtx::new(&di); - // A source failure propagates un-memoized: a transient outage must not - // permanently poison this fingerprint. - let keys: Vec<[u8; 16]> = drive(&sources, &ctx)?.into_iter().map(|u| u.key).collect(); - cache - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(fp, keys.clone()); - Ok(keys) - }) - } - let unit = make_op(inputs.clone(), make_sources.clone(), drive_unit_keys); - let fmts = make_op(inputs, make_sources, drive_fmts_indexes); - crate::sector::KeyFetch::new(unit, fmts) -} - /// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no decrypt) — the /// content samples that populate [`DiscInputs::samples`] for a key server to validate a -/// candidate against, and that [`resolve_and_apply`] hands to [`crate::Disc::decrypt_with`]. +/// candidate against. /// Lives in the library, not a key-source crate: carving units is decryption *mechanism*. /// "Encrypted" is the AACS CPI (`buf[0] & 0xc0`), NOT the `is_clean` TS-sync heuristic. pub fn read_encrypted_units( @@ -724,7 +435,6 @@ pub fn read_encrypted_units( mod tests { use super::*; use crate::aacs::types::UnitKey; - use std::sync::{Arc, Mutex}; fn units(n: usize) -> Vec> { (0..n).map(|i| vec![i as u8; 4]).collect() @@ -824,55 +534,7 @@ mod tests { assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16]))); } - /// `resolve_and_apply_traced` records each step's `who` as the source's own - /// `label()`, carried verbatim — no enum round-trip. A source with a custom - /// label surfaces it as-is in the trace. - #[test] - fn trace_who_is_the_source_label_verbatim() { - struct LabeledSource(&'static str); - impl KeySource for LabeledSource { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(Vec::new()) - } - fn label(&self) -> &'static str { - self.0 - } - } - let mut disc = crate::Disc { - volume_id: String::new(), - meta_title: None, - format: crate::DiscFormat::BluRay, - capacity_sectors: 0, - capacity_bytes: 0, - layers: 1, - titles: Vec::new(), - region: crate::disc::DiscRegion::Free, - aacs: None, - css: None, - encrypted: false, - aacs_error: None, - css_error: None, - content_format: crate::ContentFormat::BdTs, - }; - let inputs = DiscInputs { - disc_hash: "0x00".into(), - volume_id: [0u8; 16], - version: crate::aacs::mkb::AACS_MAJOR_UHD, - mkb: Vec::new(), - unit_key_ro: Vec::new(), - samples: Vec::new(), - volume_label: None, - }; - let sources: Vec> = vec![ - Box::new(LabeledSource("keydb")), - Box::new(LabeledSource("my-custom-source")), - ]; - let (_ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut disc); - let whos: Vec<&str> = trace.keys.iter().map(|s| s.who.as_str()).collect(); - assert_eq!(whos, vec!["keydb", "my-custom-source"]); - } - - // ── fetch_unit_keys / key_fetch (the one shared fetch path) ─────────────── + // ── fetch_unit_keys (the one shared fetch path) ─────────────────────────── fn empty_inputs() -> DiscInputs { DiscInputs { @@ -886,313 +548,6 @@ mod tests { } } - struct EmptySource; - impl KeySource for EmptySource { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(Vec::new()) - } - } - struct ErroringSource; - impl KeySource for ErroringSource { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Err(Error::AacsNoKeys) - } - } - struct HasKey([u8; 16]); - impl KeySource for HasKey { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(vec![UnitKey::new(0, self.0)]) - } - } - - /// `fetch_unit_keys` returns the FIRST source's non-empty keys, skipping a - /// source that returns empty or errors; empty when no source answers. - #[test] - fn fetch_unit_keys_first_nonempty_skips_empty_and_errors() { - let inputs = empty_inputs(); - let ctx = DiscInputsCtx::new(&inputs); - let key = [0xABu8; 16]; - - let sources: Vec> = vec![ - Box::new(EmptySource), - Box::new(ErroringSource), - Box::new(HasKey(key)), - ]; - let got = fetch_unit_keys(&sources, &ctx); - assert_eq!(got.len(), 1, "the first source that answers wins"); - assert_eq!(got[0].key, key); - - let none: Vec> = vec![Box::new(EmptySource), Box::new(ErroringSource)]; - assert!( - fetch_unit_keys(&none, &ctx).is_empty(), - "no source answers ⇒ empty" - ); - } - - /// `key_fetch` builds a closure that runs the sources with the GIVEN failing - /// samples and returns their keys — the exact bytes are forwarded to the - /// source, and `make_sources` is invoked per call. - #[test] - fn key_fetch_closure_forwards_samples_and_returns_keys() { - let key = [0x5au8; 16]; - let seen: Arc>>> = Arc::new(Mutex::new(Vec::new())); - let builds = Arc::new(Mutex::new(0usize)); - - struct Probe { - key: [u8; 16], - seen: Arc>>>, - } - impl KeySource for Probe { - fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result, Error> { - if let Ok(s) = ctx.samples(8) { - self.seen.lock().unwrap().extend(s); - } - Ok(vec![UnitKey::new(0, self.key)]) - } - } - - let seen_c = Arc::clone(&seen); - let builds_c = Arc::clone(&builds); - let make: Arc Vec> + Send + Sync> = Arc::new(move || { - *builds_c.lock().unwrap() += 1; - vec![Box::new(Probe { - key, - seen: Arc::clone(&seen_c), - }) as Box] - }); - - let cb = key_fetch(empty_inputs(), make); - // A real unit the source's key opens (a key that opens none is skipped). - let samples = vec![encrypted_unit(&key)]; - let got = cb.unit_keys(&samples).unwrap(); - assert_eq!( - got, - vec![key], - "the source's key flows back through the closure" - ); - assert_eq!( - seen.lock().unwrap().len(), - 1, - "the failing ciphertext sample is forwarded to the source" - ); - assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch"); - } - - /// `key_fetch` memoizes each operation by the fingerprint of the sample batch: - /// identical samples reuse the cached keys (no rebuild), different samples miss, - /// the two operations keep independent caches, and even an empty reply is cached. - #[test] - fn key_fetch_memoizes_per_op_by_sample_fingerprint() { - let builds = Arc::new(Mutex::new(0usize)); - let builds_c = Arc::clone(&builds); - let key = [0x11u8; 16]; - let make: Arc Vec> + Send + Sync> = Arc::new(move || { - *builds_c.lock().unwrap() += 1; - vec![Box::new(HasKey(key)) as Box] - }); - let cb = key_fetch(empty_inputs(), make); - let a = vec![vec![0xAAu8; 8]]; - let b = vec![vec![0xBBu8; 8]]; - - // First resolve for `a` builds sources; the identical repeat is cached. - assert_eq!(cb.unit_keys(&a).unwrap(), vec![key]); - assert_eq!(cb.unit_keys(&a).unwrap(), vec![key]); - assert_eq!( - *builds.lock().unwrap(), - 1, - "identical samples reuse the cache" - ); - - // A different sample batch is a cache miss → one more build. - assert_eq!(cb.unit_keys(&b).unwrap(), vec![key]); - assert_eq!( - *builds.lock().unwrap(), - 2, - "different samples miss the cache" - ); - - // The forensic op has its OWN cache (HasKey has no forensic keys → empty), - // so `a` builds once more here; its empty reply is then cached too. - assert!(cb.fmts_indexes(&a).unwrap().is_empty()); - assert_eq!( - *builds.lock().unwrap(), - 3, - "unit/fmts caches are independent" - ); - assert!(cb.fmts_indexes(&a).unwrap().is_empty()); - assert_eq!( - *builds.lock().unwrap(), - 3, - "an empty reply is cached, not re-asked" - ); - } - - /// A transient source outage must NOT be memoized as "no key" — a fingerprint whose first - /// fetch errored must be re-asked once recovered. Regression guard. - #[test] - fn errored_empty_is_not_cached_and_retries_when_source_recovers() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - let key = [0x77u8; 16]; - // Shared across every `make_sources()` rebuild: call 0 errors (source - // down), every later call succeeds (source recovered). - let calls = Arc::new(AtomicUsize::new(0)); - - struct Flaky { - calls: Arc, - key: [u8; 16], - } - impl KeySource for Flaky { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { - Err(Error::AacsNoKeys) // first attempt: source unreachable - } else { - Ok(vec![UnitKey::new(0, self.key)]) - } - } - } - - let calls_c = Arc::clone(&calls); - let make: Arc Vec> + Send + Sync> = Arc::new(move || { - vec![Box::new(Flaky { - calls: Arc::clone(&calls_c), - key, - }) as Box] - }); - - let cb = key_fetch(empty_inputs(), make); - let samples = vec![vec![0xCDu8; 8]]; - - // First fetch: the source errors → reported as a failure, NOT cached. - assert_eq!( - cb.unit_keys(&samples).expect_err("source down").code(), - Error::AacsNoKeys.code(), - "source down → its failure, this time" - ); - // Second fetch, SAME samples: not blocked by a cached empty → the now- - // recovered source resolves the key. - assert_eq!( - cb.unit_keys(&samples).unwrap(), - vec![key], - "recovered source resolves — errored empty was not memoized" - ); - } - - /// A GENUINE absence (a source that runs and returns an empty `Ok`) is still - /// memoized — the benefit the fix preserves. A source counting its calls must - /// be asked exactly once for a fingerprint whose first (clean) reply was empty. - #[test] - fn genuine_empty_is_still_memoized() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - let calls = Arc::new(AtomicUsize::new(0)); - - struct AlwaysEmpty { - calls: Arc, - } - impl KeySource for AlwaysEmpty { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - self.calls.fetch_add(1, Ordering::SeqCst); - Ok(Vec::new()) // ran fine, genuinely holds no key - } - } - - let calls_c = Arc::clone(&calls); - let make: Arc Vec> + Send + Sync> = Arc::new(move || { - vec![Box::new(AlwaysEmpty { - calls: Arc::clone(&calls_c), - }) as Box] - }); - - let cb = key_fetch(empty_inputs(), make); - let samples = vec![vec![0xEFu8; 8]]; - - assert!(cb.unit_keys(&samples).unwrap().is_empty()); - assert!(cb.unit_keys(&samples).unwrap().is_empty()); - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "a clean empty reply is cached — the source is asked only once" - ); - } - - /// The two `KeyFetch` operations route to the two DISTINCT trait methods (`unit_keys` → - /// `get_unit_keys`, `fmts_indexes` → `get_fmts_indexes`), not an overload keyed on return - /// length. - #[test] - fn key_fetch_routes_unit_and_fmts_to_distinct_source_methods() { - const BASE: [u8; 16] = [0xB0; 16]; - const F1: [u8; 16] = [0xF1; 16]; - const F2: [u8; 16] = [0xF2; 16]; - - struct TwoOp; - impl KeySource for TwoOp { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(vec![UnitKey::new(0, BASE)]) - } - fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(vec![UnitKey::new(0, F1), UnitKey::new(1, F2)]) - } - } - - let make: Arc Vec> + Send + Sync> = - Arc::new(|| vec![Box::new(TwoOp) as Box]); - let cb = key_fetch(empty_inputs(), make); - let samples = vec![vec![0x01u8; 4]]; - - assert_eq!( - cb.unit_keys(&samples).unwrap(), - vec![BASE], - "unit_keys resolves the base Unit Key via get_unit_keys" - ); - assert_eq!( - cb.fmts_indexes(&samples).unwrap(), - vec![F1, F2], - "fmts_indexes resolves the forensic set (any length) via get_fmts_indexes" - ); - } - - /// `KeyFetch::unit_only` serves base keys but NEVER a forensic set — the - /// contract the sweep/patch recovery decorator relies on (it resolves CPS - /// units only). Its `fmts_indexes` is unconditionally empty. - #[test] - fn key_fetch_unit_only_never_serves_forensic() { - let f = crate::sector::KeyFetch::unit_only(std::sync::Arc::new(|_| Ok(vec![[0xAA; 16]]))); - assert_eq!(f.unit_keys(&[vec![0u8; 4]]).unwrap(), vec![[0xAA; 16]]); - assert!( - f.fmts_indexes(&[vec![0u8; 4]]).unwrap().is_empty(), - "unit_only resolver yields no forensic keys" - ); - } - - /// `get_fmts_indexes` defaults to empty, so a base-only source (a keydb) opts - /// out of the forensic path without implementing it. `fetch_fmts_indexes` then - /// falls through to the next source, exactly like the unit-key driver. - #[test] - fn fetch_fmts_indexes_skips_default_optout_source() { - struct BaseOnly; // uses the default (empty) get_fmts_indexes - impl KeySource for BaseOnly { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(vec![UnitKey::new(0, [0x11; 16])]) - } - } - struct Forensic; - impl KeySource for Forensic { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(Vec::new()) - } - fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(vec![UnitKey::new(0, [0x77; 16])]) - } - } - let inputs = empty_inputs(); - let ctx = DiscInputsCtx::new(&inputs); - let sources: Vec> = vec![Box::new(BaseOnly), Box::new(Forensic)]; - let got = fetch_fmts_indexes(&sources, &ctx); - assert_eq!(got.len(), 1); - assert_eq!(got[0].key, [0x77; 16], "the base-only source is skipped"); - } - /// #4 regression: content NOT at the extent midpoint (late-starting, or midpoint in clear /// nav) must still be sampled. The old midpoint-forward sampler returned empty. #[test] @@ -1553,102 +908,6 @@ mod tests { // ── codeaudit keys cluster ──────────────────────────────────────────────── - // A real encrypted BD-TS unit under `key` (clear TS structure, CPI flag set). - fn encrypted_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; - u[off + 5..off + 192].fill(0xAB); - off += 192; - } - u[0] |= 0xC0; - assert!(crate::aacs::content::encrypt_unit(&mut u, key)); - u - } - - fn keyless_disc() -> crate::Disc { - crate::Disc { - volume_id: String::new(), - meta_title: None, - format: crate::DiscFormat::BluRay, - capacity_sectors: 0, - capacity_bytes: 0, - layers: 1, - titles: Vec::new(), - region: crate::disc::DiscRegion::Free, - aacs: None, - css: None, - encrypted: false, - aacs_error: None, - css_error: None, - content_format: crate::ContentFormat::BdTs, - } - } - - // Read-time fetch: a source whose keys do NOT open the samples must not win — - // the next source (holding the right key) is asked. - #[test] - fn fetch_unit_keys_skips_a_source_whose_keys_do_not_open_the_samples() { - let right = [0x21u8; 16]; - let mut inputs = empty_inputs(); - inputs.samples = vec![encrypted_unit(&right)]; - let ctx = DiscInputsCtx::new(&inputs); - let sources: Vec> = - vec![Box::new(HasKey([0x99; 16])), Box::new(HasKey(right))]; - let got: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx) - .into_iter() - .map(|u| u.key) - .collect(); - assert_eq!(got, vec![right], "the wrong first key must fall through"); - } - - // A source FAILURE (no source resolved) surfaces the source's own error from - // both read-time ops — not an empty "no key" that reads as key-missing. - #[test] - fn key_fetch_surfaces_source_failure_from_both_ops() { - struct Down; - impl KeySource for Down { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Err(Error::KeyServiceUnavailable) - } - fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Err(Error::KeyServiceUnavailable) - } - } - let make: Arc Vec> + Send + Sync> = - Arc::new(|| vec![Box::new(EmptySource) as Box, Box::new(Down)]); - let cb = key_fetch(empty_inputs(), make); - let s = vec![vec![1u8; 4]]; - let code = Error::KeyServiceUnavailable.code(); - assert_eq!(cb.unit_keys(&s).expect_err("unit op").code(), code); - assert_eq!(cb.fmts_indexes(&s).expect_err("fmts op").code(), code); - } - - // Every source produced keys, none opens the samples: that is a rejection - // (AacsKeyRejected), not a quiet "no key" — and it is not memoized. - #[test] - fn key_fetch_reports_all_rejected_keys_and_does_not_cache_it() { - let calls = Arc::new(Mutex::new(0usize)); - let calls_c = Arc::clone(&calls); - let make: Arc Vec> + Send + Sync> = Arc::new(move || { - *calls_c.lock().unwrap() += 1; - vec![Box::new(HasKey([0x99; 16])) as Box] - }); - let cb = key_fetch(empty_inputs(), make); - let samples = vec![encrypted_unit(&[0x21; 16])]; - for _ in 0..2 { - let err = cb.unit_keys(&samples).expect_err("wrong key"); - assert_eq!(err.code(), Error::AacsKeyRejected.code()); - } - assert_eq!( - *calls.lock().unwrap(), - 2, - "a rejection is re-asked, not cached" - ); - } - // HD DVD: `unit_key_ro` carries a VTKF (DVD_HD_V_TKF); the ctx must parse it // with the magic-dispatching parser, not the BD-only Unit_Key_RO layout. #[test] @@ -1670,22 +929,6 @@ mod tests { assert_eq!(ctx.enc_title_keys().unwrap(), &[key]); } - // A key produced but REJECTED by sample validation leaves its reason on the - // disc (AacsKeyRejected) instead of a reasonless "no key". - #[test] - fn rejected_keys_stamp_the_rejection_reason() { - let mut disc = keyless_disc(); - let mut inputs = empty_inputs(); - inputs.samples = vec![encrypted_unit(&[0x21; 16])]; - let sources: Vec> = vec![Box::new(HasKey([0x99; 16]))]; - let (ok, _) = resolve_and_apply_traced(&sources, &inputs, &mut disc); - assert!(!ok); - assert_eq!( - disc.aacs_error.as_ref().map(Error::code), - Some(Error::AacsKeyRejected.code()) - ); - } - // A small extent's probe windows overlap: each aligned unit must still be // sampled at most once (duplicates dilute the request's evidence). #[test] diff --git a/src/lib.rs b/src/lib.rs index c976c5cc..a11e86da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -141,8 +141,7 @@ pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive}; // key material into `ScanOptions` (the library derives no certs; see `KeySpec`). #[cfg(feature = "rip")] pub use session::{ - DeviceTarget, DiscSession, Finish, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, - scan_dir, scan_iso, + DeviceTarget, DiscSession, Finish, KeySourceFactory, KeySpec, scan_dir, scan_iso, }; // ─── Errors ───────────────────────────────────────────────────────────────── @@ -186,9 +185,9 @@ pub use identity::DriveId; // ─── Unlock seam: drive/disc unlocking (firmware, AACS cert, CSS bus-auth) lives entirely in `freemkv-unlock`; consumed via `unlock_bridge`, exposes nothing. ─── -// ─── Decryption (AACS/CSS): `Disc::scan()` resolves keys onto `Disc`; `DiscStream::new(...)` consumes them directly; `decrypt_sectors()` is for raw sector buffers (ISO patching). ─── +// ─── Decryption (AACS/CSS): AACS keys come only from `keys::ResolvedKeySet` (KU §2.2); `decrypt_sectors()` is for raw sector buffers (ISO patching). ─── #[cfg(feature = "rip")] -pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads}; +pub use decrypt::{decrypt_sectors, decrypt_threads, set_decrypt_threads}; // ─── Disc structure ───────────────────────────────────────────────────────── // `Disc::scan()` fully populates `Disc`; `Disc::identify()` is a UDF-only fast path @@ -203,7 +202,7 @@ pub use disc::{ SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream, }; #[cfg(feature = "rip")] -pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply}; +pub use keysource::{DiscInputs, KeySource, read_encrypted_units}; // ─── Streams ──────────────────────────────────────────────────────────────── // All types implement `pes::Stream` (re-exported `PesStream` to avoid colliding @@ -244,20 +243,14 @@ pub use mux::{Mp4FitReport, Mp4Sink, Mp4SkipReason, mp4_fit_report}; // `ScsiTransport` is the platform trait Drive uses, exposed for out-of-tree // backends. `DecryptingSectorSource` wraps any `SectorSource` to decrypt (AACS/CSS). #[cfg(feature = "rip")] -pub use mux::build_iso_pipeline; -#[cfg(feature = "rip")] -pub use mux::resolve_mux_key_map; -#[cfg(feature = "rip")] pub use mux::select::{PidFilter, StreamSelection}; #[cfg(feature = "rip")] -pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, MuxSource, mux_stream, mux_with_keys}; +pub use mux::{MuxEvents, MuxOptions, MuxOutcome, MuxSource, mux_with_keys}; pub use scsi::{ DiscPresence, DriveInfo, ScsiSense, ScsiTransport, SenseFamily, disc_presence, drive_has_disc, list_drives, }; #[cfg(feature = "rip")] -pub use sector::{ - DecryptingSectorSource, FileSectorSource, KeyFetch, PrefetchedSectorSource, SectorSource, -}; +pub use sector::{DecryptingSectorSource, FileSectorSource, PrefetchedSectorSource, SectorSource}; #[cfg(feature = "rip")] pub use udf::{UdfFs, read_filesystem}; diff --git a/src/mux/disc.rs b/src/mux/disc.rs index d2418a57..07a8e943 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -338,24 +338,20 @@ impl DiscStream { self } - /// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) on the inline - /// live-drive path — the counterpart to what - /// [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) does for the - /// file-backed highway. The map is the title's read plan: it decides which unit - /// each LBA is and, for an FMTS forensic segment, which phase is ours. - /// - /// The extent walk is rewritten so only our-phase units are read off the - /// drive, and each unit decrypts with its mapped key. A non-forensic map - /// leaves the extents unchanged. - pub fn with_key_map(mut self, map: std::sync::Arc) -> Self { + // The extent walk becomes `map`'s read plan: an FMTS forensic segment keeps only our-phase + // units (a non-forensic map leaves the extents unchanged). The key map itself is + // installed by `keys::install_key_map`. + pub(crate) fn plan_reads(&mut self, map: &crate::decrypt::AacsKeyMap) { self.extents = map.read_plan(&self.extents, self.unit_align.max(1) as u32); self.bytes_total_extents = self .extents .iter() .map(|e| e.sector_count as u64 * 2048) .sum(); - self.reader.set_key_map(map); - self + } + + pub(crate) fn reader_mut(&mut self) -> &mut DecryptingSectorSource> { + &mut self.reader } /// Install a [`ResolvedKeySet`](crate::keys::ResolvedKeySet)'s on-arrival proof on the @@ -1068,6 +1064,7 @@ impl crate::pes::Stream for DiscStream { // here rather than at each of three `admit` call sites (one place can't // drift); `dropped_total` catches gaps that RESOLVE, which most do. self.errors + + self.reader.blanked_units() + self .resync .iter() @@ -1076,10 +1073,10 @@ impl crate::pes::Stream for DiscStream { } fn lost_bytes(&self) -> u64 { - // Read-error zero-fill loss only — the abort gate needs real missing - // content. No decrypt-loss term: undecryptable units pass through as - // broken TS (the muxer's concern), indistinguishable from bad authoring. - self.lost_bytes + // Read-error zero-fill plus the damaged AACS units the reader blanked (one whole + // aligned unit each): both are real missing content. + let unit = crate::aacs::content::ALIGNED_UNIT_LEN as u64; + self.lost_bytes + self.reader.blanked_units() * unit } } @@ -1474,12 +1471,12 @@ mod tests { ); } - /// `with_key_map` on the inline live-drive path applies the same FMTS read plan + /// A key map on the inline live-drive path applies the same FMTS read plan /// the file-backed highway uses: within a forensic segment only our-phase units /// survive the extent walk, so the alternate device-group units are never read. #[test] - fn with_key_map_reads_only_our_phase_units() { - use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase}; + fn a_key_map_reads_only_our_phase_units() { + use crate::decrypt::{DecryptKeys, Phase}; // AACS keys → unit_align = 3, so a unit is 3 sectors and the phase filter // engages. Key contents are irrelevant to the read plan. let aacs = DecryptKeys::Aacs { @@ -1488,7 +1485,7 @@ mod tests { }; // 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60): // even units (30,36,42,48,54) are ours; odd (33,39,45,51,57) are dropped. - let map = AacsKeyMap::from_ranges_phased(vec![(30, 60, 1, Phase::Even)]); + let map = crate::keys::test_key_map(vec![(30, 60, 1, Phase::Even)]); let stream = DiscStream::new( Box::new(ZeroReader { capacity: 300 }), synthetic_title(300), @@ -1498,8 +1495,8 @@ mod tests { false, None, ) - .unwrap() - .with_key_map(std::sync::Arc::new(map)); + .unwrap(); + let stream = crate::keys::install_key_map(stream, map); let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum(); assert_eq!( total, @@ -1963,7 +1960,7 @@ mod tests { unit_keys: vec![(0, [0u8; 16])], format: crate::disc::ContentFormat::BdTs, }; - let mut stream = DiscStream::new( + let stream = DiscStream::new( Box::new(reader), title, keys, @@ -1973,6 +1970,9 @@ mod tests { None, ) .unwrap(); + // AACS decrypts only through a key map; without one every read is a decrypt refusal. + let map = crate::keys::test_key_map(vec![(0, COUNT, 0, crate::decrypt::Phase::All)]); + let mut stream = crate::keys::install_key_map(stream, map); stream.skip_errors = true; assert_eq!( stream.unit_align, ALIGN as u16, diff --git a/src/mux/driver.rs b/src/mux/driver.rs index 55de923b..920fe6e6 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -2,16 +2,15 @@ //! pump → finish` pipeline the consumers (CLI `pipe`/`pipe_disc`, autorip //! `run_mux`) each used to hand-roll. //! -//! `mux_stream` DRIVES the existing pipeline via the same -//! [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) and a -//! [`WRITE_PIPELINE_DEPTH`]-deep write [`Pipeline`]; it does not replace either. +//! [`mux_with_keys`] DRIVES the existing pipeline via the same ISO pipeline builders +//! and a [`WRITE_PIPELINE_DEPTH`]-deep write [`Pipeline`]; it does not replace either. use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -use crate::decrypt::{AacsKeyMap, DecryptKeys}; +use crate::decrypt::DecryptKeys; use crate::disc::DiscTitle; use crate::error::Error; use crate::event::{BatchSizeReason, Event, EventKind}; @@ -19,12 +18,11 @@ use crate::halt::Halt; use crate::io::FlushProgress; use crate::io::pipeline::{Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH}; use crate::pes::{CountingStream, PesFrame, Stream}; -use crate::sector::{FileSectorSource, KeyFetch, SectorSource}; +use crate::sector::{FileSectorSource, SectorSource}; use crate::session::DiscSession; use super::resolve::{ InputOptions, StreamUrl, build_iso_pipeline, input_with_halt, output, output_with, parse_url, - resolve_mux_key_map, }; use super::videomap::{Medium, SourceInfo}; @@ -59,73 +57,6 @@ fn effective_send_deadline(send_deadline: Option) -> Duration { // `codec_private` never resolves fails fast instead of OOM-killing the process. pub(crate) const HEADER_BUFFER_CAP_BYTES: usize = 512 * 1024 * 1024; -/// Where [`mux_stream`] reads its PES frames from. The driver owns the -/// construction of the underlying [`Stream`] so consumers stop hand-rolling -/// `DiscStream::new` / `build_iso_pipeline` / `input()`. -pub enum MuxInput<'a> { - /// Live single-pass mux off an opened [`DiscSession`]. The driver takes the - /// session's staged reader ([`DiscSession::take_reader`]); a missing reader - /// (never staged, or already consumed) is a clean error, never a panic. - Session { - /// The opened, scanned session (its keys resolved via - /// [`DiscSession::resolve_keys`]). - session: &'a mut DiscSession, - /// Index into `session.disc().titles` of the title to mux. - title_index: usize, - }, - /// Multipass / remux from a staged ISO on disk. Keys/map/fetch are the - /// pre-resolved material the consumer already banked — the driver does no - /// internal re-resolution beyond what [`build_iso_pipeline`] performs. - Iso { - /// Path to the ISO image. - path: &'a Path, - /// The scanned title to mux out of the image. - title: DiscTitle, - /// Container format of the title (TS vs PS demuxer selection). - format: crate::disc::ContentFormat, - /// Decryption keys for the title (`DecryptKeys::None` for raw/clear). - keys: DecryptKeys, - /// Optional read-time key fetch closure (banked by `resolve_keys`). - key_fetch: Option, - }, - /// Live single-pass mux off a raw disc [`SectorSource`] (autorip's - /// live-drive path). The driver wraps the reader in a - /// [`DiscStream`](crate::mux::DiscStream) — the **INLINE** stream, NOT the - /// prefetch highway — so the consumer's adaptive batch-retry in - /// `DiscStream::fill_extents` still fires on a bad sector (the highway would - /// bypass it). The live analogue of [`MuxInput::Iso`]: the consumer resolves - /// keys as its own app-layer policy and hands the already-banked material in; - /// the driver does no internal re-resolution. - Live { - /// The raw sector source (physical drive). Boxed `dyn` — the driver owns - /// it and moves it into `DiscStream::new` (whose reader param is exactly - /// `Box`), so no concrete drive type leaks in here. - reader: Box, - /// The scanned title to mux. - title: DiscTitle, - /// Container format (TS vs PS demux selection). - format: crate::disc::ContentFormat, - /// Decryption keys the consumer already banked (`DecryptKeys::None` for - /// raw/clear). The driver consumes them as-is — never re-resolves. - keys: DecryptKeys, - /// Optional pre-resolved forensic AACS key map, applied VERBATIM via - /// [`DiscStream::with_key_map`](crate::mux::DiscStream::with_key_map) - /// BEFORE any read. `None` for every non-FMTS disc. Single-pass FMTS - /// correctness depends on this reaching the inline reader: `with_key_map` - /// rewrites the extent walk to our-phase units only and installs the map - /// so each unit decrypts with its mapped key. - key_map: Option>, - }, - /// Any URL-addressed source (`iso://`, `mkv://`, `m2ts://`, `network://`, - /// stdio) opened via [`input`](super::resolve::input). - Url { - /// The source URL. - url: &'a str, - /// Input options forwarded to [`input`](super::resolve::input). - opts: InputOptions, - }, -} - /// Tuning / behaviour knobs for a mux run. /// /// `Default` = keep-everything, no-skip, decrypt, no send deadline — the @@ -146,7 +77,7 @@ pub struct MuxOptions { /// routing all follow the pruned list. See [`crate::StreamSelection`]. /// /// Applies to the `Iso`, `Session` and `Live` inputs. It does NOT apply to - /// [`MuxInput::Url`], which builds its demux inside `input()` — a Url-source + /// [`MuxSource::Url`], which builds its demux inside `input()` — a Url-source /// caller sets `InputOptions::selection` instead. Setting this field for a Url /// input has no effect. pub selection: crate::StreamSelection, @@ -160,7 +91,7 @@ pub struct MuxOptions { /// autorip's stream event handler). Every method has a no-op default so a /// consumer overrides only what it renders. /// -/// `Send + Sync + 'static` so [`mux_stream`] can clone the handle into the reader constructors' +/// `Send + Sync + 'static` so [`mux_with_keys`] can clone the handle into the reader constructors' /// `'static` `EventFn`. Progress is split into read-side and write-side callbacks (CLI renders /// WRITE, autorip READ). pub trait MuxEvents: Send + Sync + 'static { @@ -193,7 +124,7 @@ pub(crate) struct NoopEvents; #[cfg(test)] impl MuxEvents for NoopEvents {} -/// The result of a [`mux_stream`] run. +/// The result of a [`mux_with_keys`] run. #[derive(Debug, Clone)] pub struct MuxOutcome { /// The mux drained to a natural EOF, finalised cleanly, and produced real @@ -221,16 +152,10 @@ pub struct MuxOutcome { pub undelivered_streams: Vec, } -/// Run the decrypt + mux pipeline end-to-end: construct the source stream from -/// `input`, open the `dest_url` sink, and pump PES frames through a write -/// pipeline until EOF (or `halt`). Preserves the CLI's/autorip's semantics: -/// - metadata sinks short-circuit BEFORE the header gate; -/// - unresolved codec headers are refused with [`Error::MkvInvalid`]; -/// - a zero-output/zero-stream drain is refused with [`Error::NoStreams`]; -/// - a `halt` mid-run yields `completed = false`. `halt` is mandatory: the -/// caller threads a real [`Halt`] for `/api/stop` / SIGINT. -pub fn mux_stream( - input_src: MuxInput, +// The mux of a source with no AACS key set (clear, CSS, `raw`, or a URL that scans): +// construct the source stream, open the `dest_url` sink and pump it (`drive_mux`). +fn mux_unkeyed( + input_src: MuxSource, dest_url: &str, opts: &MuxOptions, halt: &Halt, @@ -244,7 +169,7 @@ pub fn mux_stream( // The Url path builds its demux INSIDE `input()`, pruned via // `InputOptions.selection`. `MuxOptions.selection` does NOT apply // here — that's the File/Session arms' field. - MuxInput::Url { url, opts: in_opts } => { + MuxSource::Url { url, opts: in_opts } => { // Provenance: source URL verbatim, scheme's medium, and the title // `input()` will open. `playlist` fills in below from the opened // stream's scanned title — not known until the scan runs. @@ -276,12 +201,10 @@ pub fn mux_stream( }; (stream, None, source) } - MuxInput::Iso { + MuxSource::Iso { path, title, format, - keys, - key_fetch, } => { // Prune to selected audio/subtitle streams BEFORE the highway // builds demux state (and before the DVD AC-3 channel probe). @@ -291,7 +214,7 @@ pub fn mux_stream( .apply(&mut title) .map_err(std::io::Error::from)?; // Provenance: staged image as an `iso://` URL, plus playlist. Title - // INDEX is NOT reachable here — `MuxInput::Iso` carries an already- + // INDEX is NOT reachable here — `MuxSource::Iso` carries an already- // scanned `DiscTitle` with no index, so it stays 0 rather than guessed. let source = SourceInfo { medium: Medium::Iso, @@ -303,24 +226,23 @@ pub fn mux_stream( let stream = build_iso_pipeline( reader, title, - keys, + DecryptKeys::None, opts.batch_sectors, format, opts.raw, Some(halt.clone()), Some(reader_event_fn(events.clone())), - key_fetch, )?; (Box::new(stream), None, source) } - MuxInput::Session { + MuxSource::Session { session, title_index, } => { // Pull everything we need out of the disc as owned values so the // immutable disc borrow is released before the mutable // `take_reader` below. - let (mut title, format, mut keys, playlist, source) = { + let (mut title, format, keys, playlist, source) = { let disc = session.disc().ok_or_else(|| Error::DeviceNotReady { path: session.device_path().to_string(), })?; @@ -356,29 +278,15 @@ pub fn mux_stream( source, ) }; - // Prune to selected streams before `DiscStream::new` builds demux - // tables. Only touches the stream list; ciphertext sampling in - // resolve_inline_base_map unaffected. + // Prune to selected streams before `DiscStream::new` builds demux tables. opts.selection .apply(&mut title) .map_err(std::io::Error::from)?; // A missing staged reader ("already consumed" / never staged) is a // clean error, not a panic (contract Q2). - let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady { + let reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady { path: session.device_path().to_string(), })?; - // Resolve the AACS key map off the STAGED reader BEFORE it moves into - // `DiscStream::new` (borrow to sample, then move). Without this the - // AACS reader has no map and fails `DecryptFailed` on the first unit. - let base_map = resolve_inline_base_map( - &mut *reader, - &title, - &mut keys, - session.key_fetch(), - format, - opts.raw, - Some(halt), - )?; let mut stream = crate::mux::DiscStream::new( reader, title, @@ -391,9 +299,6 @@ pub fn mux_stream( if opts.raw { stream.set_raw(); } - if let Some(map) = base_map { - stream = stream.with_key_map(map); - } stream.skip_errors = opts.skip_errors; // Live path: the `DiscStream` emits the full reader-side vocabulary // (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the @@ -401,12 +306,10 @@ pub fn mux_stream( stream.on_event(reader_event_fn(events.clone())); (Box::new(stream), Some(playlist), source) } - MuxInput::Live { - mut reader, + MuxSource::Live { + reader, title, format, - mut keys, - key_map, } => { // Prune to selected streams, exactly as the Iso/Session arms do — // without this, selection was silently ignored on the live-drive @@ -416,35 +319,20 @@ pub fn mux_stream( .apply(&mut title) .map_err(std::io::Error::from)?; // Provenance: medium is certain, playlist is on hand. Device PATH - // and title INDEX are NOT reachable — `MuxInput::Live` hands an + // and title INDEX are NOT reachable — `MuxSource::Live` hands an // opaque reader/title with neither, so both stay empty/0. let source = SourceInfo { medium: Medium::Disc, playlist: title.playlist.clone(), ..SourceInfo::default() }; - // Map installed BEFORE reads begin: a caller-supplied `key_map` - // (autorip's FMTS gate) is used VERBATIM; `None` on AACS resolves - // the base map here (else `DecryptFailed` on first unit); DVD/raw → None. - let base_map = match key_map { - Some(map) => Some(map), - None => resolve_inline_base_map( - &mut *reader, - &title, - &mut keys, - None, - format, - opts.raw, - Some(halt), - )?, - }; // INLINE `DiscStream`, same constructor as the `Session` arm, NOT // `build_iso_pipeline` — the highway would bypass the adaptive // batch-retry that lives in `DiscStream::fill_extents`. let mut stream = crate::mux::DiscStream::new( reader, title, - keys, + DecryptKeys::None, opts.batch_sectors, format, opts.raw, @@ -453,12 +341,6 @@ pub fn mux_stream( if opts.raw { stream.set_raw(); } - // Apply the key map BEFORE reads begin: for FMTS it rewrites the - // extent walk to our-phase units and installs the mapped key; `None` - // leaves it unchanged. Dropping the map mis-decrypts the segment. - if let Some(map) = base_map { - stream = stream.with_key_map(map); - } stream.skip_errors = opts.skip_errors; // Same reader-side event vocabulary as the `Session` arm // (`SectorSkipped` / `BatchSizeChanged` / `BytesRead`). @@ -485,9 +367,8 @@ pub fn mux_stream( ) } -/// Where [`mux_with_keys`] reads its PES frames from (KU §3.1). The same sources as -/// [`MuxInput`] without key fields: keys come only from the rip's -/// [`ResolvedKeySet`](crate::keys::ResolvedKeySet). +/// Where [`mux_with_keys`] reads its PES frames from (KU §3.1). No variant carries key +/// material: keys come only from the rip's [`ResolvedKeySet`](crate::keys::ResolvedKeySet). pub enum MuxSource<'a> { /// Live single-pass mux off an opened [`DiscSession`] (its reader staged). Session { @@ -514,12 +395,13 @@ pub enum MuxSource<'a> { Url { url: &'a str, opts: InputOptions }, } -/// [`mux_stream`] with the rip's up-front key set (KU §3.1): every AACS read goes through -/// the set's readers (its map, and the on-arrival proof for pieces it left unproven), with -/// no key lookup. `keys == None`, a non-AACS set, or `opts.raw`: no AACS decryption (CSS -/// and clear discs as before; a `Session` keeps the legacy disc-banked keys until KU-X2). -/// E7013 when the set is not for the disc or does not cover the title; E7026 when the title -/// needs forensic keys that are Pending. `events` stays last, as in `mux_stream` (J17). +/// Run the decrypt + mux pipeline end-to-end with the rip's up-front key set (KU §3.1): +/// every AACS read goes through the set's readers (its map, and the on-arrival proof for +/// pieces it left unproven), with no key lookup. `keys == None`, a non-AACS set, or +/// `opts.raw`: no AACS decryption (CSS and clear discs decrypt as before). E7013 when the +/// set is not for the disc or does not cover the title; E7026 when the title needs +/// forensic keys that are Pending. Unresolved codec headers are [`Error::MkvInvalid`], a +/// zero-output drain [`Error::NoStreams`]; a `halt` mid-run yields `completed = false`. pub fn mux_with_keys( source: MuxSource, keys: Option<&crate::keys::ResolvedKeySet>, @@ -538,8 +420,8 @@ pub fn mux_with_keys( { Some(crate::keys::ResolvedKeySet::keyless_for(title, *format)) } - // A Session over an AACS disc with no banked key: BD-TS as above; HD DVD is never - // probed (KU §2.6), so it refuses up front. Banked keys keep the legacy path (J20). + // A Session over an AACS disc with no set: BD-TS as above; HD DVD is never + // probed (KU §2.6), so it refuses up front. ( MuxSource::Session { session, @@ -547,7 +429,7 @@ pub fn mux_with_keys( }, None, ) if !opts.raw => match session.disc() { - Some(d) if d.aacs.is_some() && !d.decrypt_keys().is_encrypted() => { + Some(d) if d.aacs.is_some() => { if d.content_format != crate::disc::ContentFormat::BdTs { return Err(Error::NoDiscKey { disc_hash: d.aacs_disc_hash(), @@ -561,54 +443,22 @@ pub fn mux_with_keys( _ => None, }; let set = set.or(keyless.as_ref()); - let input = match (source, set) { + match (source, set) { (MuxSource::Url { url, opts: mut o }, _) => { if let Some(k) = keys { o.keys = Some(k.clone()); } - MuxInput::Url { url, opts: o } + mux_unkeyed( + MuxSource::Url { url, opts: o }, + dest_url, + opts, + halt, + events, + ) } - ( - MuxSource::Session { - session, - title_index, - }, - None, - ) => MuxInput::Session { - session, - title_index, - }, - ( - MuxSource::Iso { - path, - title, - format, - }, - None, - ) => MuxInput::Iso { - path, - title, - format, - keys: DecryptKeys::None, - key_fetch: None, - }, - ( - MuxSource::Live { - reader, - title, - format, - }, - None, - ) => MuxInput::Live { - reader, - title, - format, - keys: DecryptKeys::None, - key_map: None, - }, - (src, Some(set)) => return mux_keyed(src, set, dest_url, opts, halt, events), - }; - mux_stream(input, dest_url, opts, halt, events) + (src, None) => mux_unkeyed(src, dest_url, opts, halt, events), + (src, Some(set)) => mux_keyed(src, set, dest_url, opts, halt, events), + } } // The keyed arms of `mux_with_keys`: the set's readers, never a resolve. @@ -697,7 +547,7 @@ fn mux_keyed( let stream = live_keyed(reader, title, format, set, opts, halt, &events)?; (stream, None, source) } - MuxSource::Url { .. } => unreachable!("mux_with_keys routes Url to mux_stream"), + MuxSource::Url { .. } => unreachable!("mux_with_keys routes Url to mux_unkeyed"), }; let mut source = source; if let Some(name) = playlist.as_deref() { @@ -733,7 +583,7 @@ fn live_keyed( .map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count))) .collect(); set.gate(reader.random_access(), Some(&ranges), false)?; - let mut stream = crate::mux::DiscStream::new( + let stream = crate::mux::DiscStream::new( reader, title, set.decrypt_keys(), @@ -741,8 +591,8 @@ fn live_keyed( format, false, Some(halt.clone()), - )? - .with_key_map(set.key_map()); + )?; + let mut stream = crate::keys::install_key_map(stream, set.key_map()); if let Some(a) = set.arrival(set.title_stop()) { stream = stream.with_arrival(a); } @@ -762,28 +612,6 @@ fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys { } } -// Resolve the base AACS key map for an INLINE live-drive mux (`Session`/ `Live`) before the -// reader moves into `DiscStream::new`. AACS keys resolve to `Some(map)`; CSS/clear/`None`/`raw` -// return `Ok(None)`. -fn resolve_inline_base_map( - reader: &mut dyn SectorSource, - title: &DiscTitle, - keys: &mut DecryptKeys, - fetch: Option<&KeyFetch>, - format: crate::disc::ContentFormat, - raw: bool, - halt: Option<&crate::halt::Halt>, -) -> std::io::Result>> { - if raw || !matches!(keys, DecryptKeys::Aacs { .. }) { - return Ok(None); - } - // Thread the driver's cancel token into key resolution: the resolve chain - // samples ciphertext off the LIVE reader (FMTS can do hundreds of reads, - // each able to stall), so `/api/stop` must be honored here too. - let map = resolve_mux_key_map(reader, title, keys, fetch, format, halt)?; - Ok(Some(Arc::new(map))) -} - // Adapt reader events to mux progress callbacks with an owned, 'static closure. fn reader_event_fn(events: Arc) -> crate::sector::prefetched::EventFn { Box::new(move |e: Event| match e.kind { @@ -1954,11 +1782,11 @@ mod tests { t } - // End-to-end through `mux_stream` on the ISO path: asserts reader-side + // End-to-end through `mux_unkeyed` on the ISO path: asserts reader-side // progress AND `on_output_opened` reach the `Arc`. // Mutation: dropping `reader_event_fn` leaves `saw_read_total` false. #[test] - fn mux_stream_iso_forwards_reader_progress_through_arc() { + fn mux_iso_forwards_reader_progress_through_arc() { // Spawns the prefetch producer, a Drive holder. let _serial = crate::sector::prefetched::holder_test_lock(); let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22]; @@ -1985,13 +1813,11 @@ mod tests { selection: Default::default(), }; let halt = Halt::new(); - let out = mux_stream( - MuxInput::Iso { + let out = mux_unkeyed( + MuxSource::Iso { path: &iso_path, title, format: crate::disc::ContentFormat::BdTs, - keys: DecryptKeys::None, - key_fetch: None, }, "null://", &opts, @@ -2015,107 +1841,8 @@ mod tests { ); } - // Recording `SectorSource` for the live-path tests: logs every read's - // `(lba, count)` and returns zeroed sectors, the observable proof of - // WHICH extent walk the inline `DiscStream` executed. - struct RecordingReader { - capacity: u32, - log: std::sync::Arc>>, - } - impl crate::sector::SectorSource for RecordingReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::error::Result { - self.log.lock().unwrap().push((lba, count)); - let bytes = count as usize * 2048; - buf[..bytes].fill(0); - Ok(bytes) - } - fn capacity_sectors(&self) -> u32 { - self.capacity - } - } - - // `MuxInput::Live` builds the INLINE `DiscStream` and applies the forensic `key_map` before - // reading, so odd-phase forensic units are NEVER fetched. - #[test] - fn mux_input_live_uses_inline_discstream_and_applies_key_map() { - use crate::decrypt::{AacsKeyMap, Phase}; - use crate::disc::Extent; - - // 3 sectors == one AACS aligned unit. A 10-unit Even forensic segment at - // LBA [1030, 1060): kept even units start at 1030,1036,1042,1048,1054; - // the odd units 1033,1039,1045,1051,1057 must never be read. - let us = 3u32; - let map = std::sync::Arc::new(AacsKeyMap::from_ranges_phased(vec![( - 1030, - 1060, - 5, - Phase::Even, - )])); - - let mut title = aac_audio_title(0x1100); - title.extents = vec![Extent { - start_lba: 1000, - sector_count: 300, - }]; - - let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(u32, u16)>::new())); - let reader = Box::new(RecordingReader { - capacity: 4000, - log: log.clone(), - }); - - let opts = MuxOptions { - skip_errors: false, - // One unit per read so the recorded LBAs land on unit boundaries and - // an odd-phase unit can't hide inside a larger coalesced batch. - batch_sectors: us as u16, - raw: false, - send_deadline: Some(Duration::from_secs(60)), - selection: Default::default(), - }; - let halt = Halt::new(); - // Drains to a NoStreams refusal (zeroed data resolves no headers); we - // only care about the reads it performed on the way there. - let _ = mux_stream( - MuxInput::Live { - reader, - title, - format: crate::disc::ContentFormat::BdTs, - keys: DecryptKeys::None, - key_map: Some(map), - }, - "null://", - &opts, - &halt, - Arc::new(NoopEvents), - ); - - let reads = log.lock().unwrap(); - let read_lbas: std::collections::HashSet = reads.iter().map(|&(lba, _)| lba).collect(); - // The dropped odd-phase forensic units must never be fetched. - for odd in [1033u32, 1039, 1045, 1051, 1057] { - assert!( - !read_lbas.contains(&odd), - "alternate-phase forensic unit LBA {odd} was read — key_map not applied \ - (with_key_map dropped from the Live arm?)" - ); - } - // Sanity: our-phase units on either side ARE read (the walk still ran). - assert!( - read_lbas.contains(&1030) && read_lbas.contains(&1054), - "our-phase forensic units must still be read (kept LBAs 1030/1054)" - ); - } - // A `SectorSource` serving ONE genuinely-AACS-encrypted aligned unit - // (6144 bytes) at LBA 0..3 and zeros elsewhere, so the UDF probe inside - // `resolve_mux_key_map` fails cleanly (single-CPS base map). + // (6144 bytes) at LBA 0..3 and zeros elsewhere, so a UDF probe fails cleanly. struct AacsUnitReader { unit: Vec, // 6144 bytes, encrypted capacity: u32, @@ -2158,66 +1885,10 @@ mod tests { unit } - // END-TO-END decrypt on live `MuxInput::Live` with a plain AACS disc and NO caller key map: - // the `Live` arm must RESOLVE + INSTALL the base map itself. - #[test] - fn mux_input_live_aacs_without_caller_map_resolves_and_decrypts() { - use crate::disc::Extent; - - let unit_key = [0x5Au8; 16]; - let reader = Box::new(AacsUnitReader { - unit: encrypted_audio_unit(&unit_key), - capacity: 2048, - }); - let mut title = aac_audio_title(0x1100); - title.extents = vec![Extent { - start_lba: 0, - sector_count: 3, - }]; - let keys = DecryptKeys::Aacs { - unit_keys: vec![(0, unit_key)], - format: crate::disc::ContentFormat::BdTs, - }; - - let opts = MuxOptions { - skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill - batch_sectors: 3, // one aligned unit per read - raw: false, - send_deadline: Some(Duration::from_secs(60)), - selection: Default::default(), - }; - let halt = Halt::new(); - let out = mux_stream( - MuxInput::Live { - reader, - title, - format: crate::disc::ContentFormat::BdTs, - keys, - key_map: None, // plain AACS disc: the driver must resolve the base map - }, - "null://", - &opts, - &halt, - Arc::new(NoopEvents), - ) - .expect( - "a plain AACS live mux must resolve+install its base key map and DECRYPT \ - (no map → the content batch fails to decrypt and the mux aborts)", - ); - assert!( - out.completed, - "the decrypted audio PES drained and finalised — proves the unit decrypted" - ); - assert!( - out.bytes_written > 0, - "decrypted payload bytes reached the sink" - ); - } - - /// Build a synthetic single-CPS AACS `Disc` carrying `unit_key` and one - /// title whose sole extent is the encrypted unit at LBA 0..3 — the disc a - /// `MuxInput::Session` mux scans off a live drive, minus the hardware. - fn aacs_session_disc(title: DiscTitle, unit_key: [u8; 16]) -> crate::disc::Disc { + /// A synthetic AACS `Disc` with one title whose sole extent is the encrypted unit at + /// LBA 0..3 — the disc a `MuxSource::Session` mux scans off a live drive, minus the + /// hardware. It holds no key: AACS keys come only from a `ResolvedKeySet`. + fn aacs_session_disc(title: DiscTitle) -> crate::disc::Disc { crate::disc::Disc { volume_id: "TEST".into(), meta_title: None, @@ -2232,9 +1903,6 @@ mod tests { bus_encryption: false, mkb_version: None, disc_hash: "0xabc".into(), - key_source: crate::disc::KeyOrigin::KeyDb, - vuk: None, - unit_keys: vec![(0, unit_key)], volume_id: [0u8; 16], uk_ro: Vec::new(), mkb: Vec::new(), @@ -2247,77 +1915,22 @@ mod tests { } } - // END-TO-END decrypt on the live single-pass `MuxInput::Session` path (same take_reader → - // resolve_inline_base_map → DiscStream → with_key_map sequence as `Live`). - #[test] - fn mux_input_session_aacs_without_caller_map_resolves_and_decrypts() { - use crate::disc::Extent; - use crate::session::DiscSession; - - let unit_key = [0x5Au8; 16]; - let reader = Box::new(AacsUnitReader { - unit: encrypted_audio_unit(&unit_key), - capacity: 2048, - }); - let mut title = aac_audio_title(0x1100); - title.extents = vec![Extent { - start_lba: 0, - sector_count: 3, - }]; - let disc = aacs_session_disc(title, unit_key); - // No caller key_fetch: a single-CPS disc resolves its base map with the - // banked unit key alone (the FMTS/multi-CPS fetch path is not exercised). - let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader), None); - - let opts = MuxOptions { - skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill - batch_sectors: 3, // one aligned unit per read - raw: false, - send_deadline: Some(Duration::from_secs(60)), - selection: Default::default(), - }; - let halt = Halt::new(); - let out = mux_stream( - MuxInput::Session { - session: &mut session, - title_index: 0, - }, - "null://", - &opts, - &halt, - Arc::new(NoopEvents), - ) - .expect( - "a plain AACS Session mux must resolve+install its base key map and DECRYPT \ - (no map → the content batch fails to decrypt and the mux aborts)", - ); - assert!( - out.completed, - "the decrypted audio PES drained and finalised — proves the unit decrypted" - ); - assert!( - out.bytes_written > 0, - "decrypted payload bytes reached the sink" - ); - } - - // A `MuxInput::Session` with an unstaged reader (`take_reader()` → + // A `MuxSource::Session` with an unstaged reader (`take_reader()` → // `None`) must surface a clean typed error, NOT panic — guards // `ok_or_else(|| Error::DeviceNotReady …)` against `.unwrap()` regression. #[test] - fn mux_input_session_missing_reader_is_clean_error_not_panic() { + fn mux_session_missing_reader_is_clean_error_not_panic() { use crate::disc::Extent; use crate::session::DiscSession; - let unit_key = [0x5Au8; 16]; let mut title = aac_audio_title(0x1100); title.extents = vec![Extent { start_lba: 0, sector_count: 3, }]; - let disc = aacs_session_disc(title, unit_key); + let disc = aacs_session_disc(title); // reader: None — never staged. - let mut session = DiscSession::from_parts_for_test(Some(disc), None, None); + let mut session = DiscSession::from_parts_for_test(Some(disc), None); let opts = MuxOptions { skip_errors: false, @@ -2327,8 +1940,8 @@ mod tests { selection: Default::default(), }; let halt = Halt::new(); - let err = mux_stream( - MuxInput::Session { + let err = mux_unkeyed( + MuxSource::Session { session: &mut session, title_index: 0, }, @@ -2372,11 +1985,11 @@ mod tests { ); } - // FIX 4: `MuxInput::Session` with a `title_index` past the disc's title + // FIX 4: `MuxSource::Session` with a `title_index` past the disc's title // count must surface a clean `Error::MuxTrackRange` (E9011), NOT panic // on the out-of-range `titles.get(idx)`. #[test] - fn mux_input_session_out_of_range_title_is_clean_error_not_panic() { + fn mux_session_out_of_range_title_is_clean_error_not_panic() { use crate::disc::Extent; use crate::session::DiscSession; @@ -2390,9 +2003,9 @@ mod tests { start_lba: 0, sector_count: 3, }]; - let disc = aacs_session_disc(title, unit_key); + let disc = aacs_session_disc(title); let num_titles = disc.titles.len(); - let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader), None); + let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader)); let opts = MuxOptions { skip_errors: false, @@ -2402,8 +2015,8 @@ mod tests { selection: Default::default(), }; let halt = Halt::new(); - let err = mux_stream( - MuxInput::Session { + let err = mux_unkeyed( + MuxSource::Session { session: &mut session, title_index: num_titles + 5, // out of range }, @@ -2420,75 +2033,6 @@ mod tests { ); } - // The shared `resolve_inline_base_map` helper's gating: AACS yields - // `Some(map)`; CSS/clear/`None`/`raw` yield `None`. Guards Session/Live - // against mapping a DVD (suppresses the per-title CSS crack) or `--raw`. - #[test] - fn resolve_inline_base_map_gates_on_aacs_and_raw() { - use crate::disc::Extent; - let unit_key = [0x5Au8; 16]; - let mut title = aac_audio_title(0x1100); - title.extents = vec![Extent { - start_lba: 0, - sector_count: 3, - }]; - let mk_reader = || AacsUnitReader { - unit: encrypted_audio_unit(&unit_key), - capacity: 2048, - }; - - // AACS, not raw → a map is resolved and installed. - let mut r = mk_reader(); - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, unit_key)], - format: crate::disc::ContentFormat::BdTs, - }; - let map = resolve_inline_base_map( - &mut r, - &title, - &mut keys, - None, - crate::disc::ContentFormat::BdTs, - false, - None, - ) - .expect("resolve must not error for a single-CPS AACS disc"); - assert!(map.is_some(), "AACS non-raw must resolve a base map"); - - // AACS but raw → no map (ciphertext passthrough). - let mut r = mk_reader(); - let mut keys_raw = DecryptKeys::Aacs { - unit_keys: vec![(0, unit_key)], - format: crate::disc::ContentFormat::BdTs, - }; - let map_raw = resolve_inline_base_map( - &mut r, - &title, - &mut keys_raw, - None, - crate::disc::ContentFormat::BdTs, - true, - None, - ) - .expect("raw resolve is a no-op"); - assert!(map_raw.is_none(), "raw must NOT resolve a map"); - - // CSS / clear (DecryptKeys::None) → no map (CSS self-cracks per title). - let mut r = mk_reader(); - let mut keys_none = DecryptKeys::None; - let map_none = resolve_inline_base_map( - &mut r, - &title, - &mut keys_none, - None, - crate::disc::ContentFormat::MpegPs, - false, - None, - ) - .expect("clear/CSS resolve is a no-op"); - assert!(map_none.is_none(), "CSS/clear must NOT resolve an AACS map"); - } - // Video + a secondary AAC track whose first frame (and so its ASC) arrives at // 10 s, long after headers were finalised. struct LateAacStream { @@ -3454,7 +2998,7 @@ mod tests { start_lba: 0, sector_count: 3, }]; - let mut disc = aacs_session_disc(title.clone(), [0u8; 16]); + let mut disc = aacs_session_disc(title.clone()); disc.capacity_sectors = 16; let set = crate::keys::ResolvedKeySet::keyed_for_test(&disc, key, &[(0, 3)]); (reader, title, set) @@ -3496,15 +3040,15 @@ mod tests { ); } - /// `MuxSource::Session` uses the set, not the disc's banked keys (a wrong key here). + /// `MuxSource::Session` decrypts with the set: the disc holds no key. #[test] - fn mux_with_keys_session_uses_the_set_not_banked_keys() { + fn mux_with_keys_session_uses_the_set() { let key = [0x5A; 16]; let (reader, title, _) = keyed_live(key); - let mut disc = aacs_session_disc(title, [0x99; 16]); + let mut disc = aacs_session_disc(title); disc.capacity_sectors = 16; let set = crate::keys::ResolvedKeySet::keyed_for_test(&disc, key, &[(0, 3)]); - let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader), None); + let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader)); let out = mux_with_keys( MuxSource::Session { session: &mut session, @@ -3634,12 +3178,11 @@ mod tests { ); assert_eq!(code(iso), e7022); - // Session over an AACS disc with no banked key (review B1): BD-TS stops at the first + // Session over an AACS disc with no set (review B1): BD-TS stops at the first // AACS unit; HD DVD refuses up front (it is never probed, KU §2.6). for format in [crate::DiscFormat::Uhd, crate::DiscFormat::HdDvd] { let (_, title, _) = keyed_live(key); - let mut disc = aacs_session_disc(title, key); - disc.aacs.as_mut().unwrap().unit_keys.clear(); + let mut disc = aacs_session_disc(title); disc.capacity_sectors = 16; disc.format = format; if format == crate::DiscFormat::HdDvd { @@ -3649,7 +3192,7 @@ mod tests { unit: encrypted_audio_unit(&key), capacity: 16, }); - let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader), None); + let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader)); let r = mux_with_keys( MuxSource::Session { session: &mut session, @@ -3670,4 +3213,276 @@ mod tests { ); } } + + // A staged UHD image (AACS 2.0, bus encryption on) of `units` encrypted audio units under + // `key`, damaged by `damage`, its title over all of them, and a set keying the title. + fn damaged_uhd_image( + key: [u8; 16], + units: u32, + damage: impl Fn(&mut [u8]), + ) -> ( + tempfile::TempDir, + std::path::PathBuf, + DiscTitle, + crate::keys::ResolvedKeySet, + ) { + let (_, mut title, _) = keyed_live(key); + let sectors = units * 3; + title.extents = vec![crate::disc::Extent { + start_lba: 0, + sector_count: sectors, + }]; + let mut disc = aacs_session_disc(title.clone()); + disc.aacs.as_mut().unwrap().bus_encryption = true; + disc.capacity_sectors = sectors + 16; + let set = crate::keys::ResolvedKeySet::keyed_for_test(&disc, key, &[(0, sectors)]); + let mut image = encrypted_audio_unit(&key).repeat(units as usize); + damage(&mut image); + image.resize((sectors + 16) as usize * 2048, 0); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("staged.iso"); + std::fs::write(&path, &image).unwrap(); + (dir, path, title, set) + } + + fn mux_damaged_iso( + path: &std::path::Path, + title: DiscTitle, + set: &crate::keys::ResolvedKeySet, + dest: &str, + ) -> std::io::Result { + mux_with_keys( + MuxSource::Iso { + path, + title, + format: crate::disc::ContentFormat::BdTs, + }, + Some(set), + dest, + &MuxOptions { + batch_sectors: 64, + ..keyed_opts() + }, + &Halt::new(), + Arc::new(NoopEvents), + ) + } + + /// "We rip bad discs": a sweep hole (whole zero units) mid-title muxes through, as before + /// KU. KS-5 [BD] §3.10.2: CPI "shall be set to 00₂ if the data is not encrypted" — a zero + /// unit is not flagged, so no key is applied to it and none is judged by it. + #[test] + fn iso_mux_through_a_zero_filled_run_mid_title() { + assert!( + crate::spec::keys::KS_5_CPI + .text + .contains("00₂ if the data is not encrypted") + ); + let _serial = crate::sector::prefetched::holder_test_lock(); + let key = [0x5A; 16]; + let (dir, path, title, set) = + damaged_uhd_image(key, 9, |im| im[3 * 6144..6 * 6144].fill(0)); + let out_path = dir.path().join("hole.mkv"); + let out = mux_damaged_iso(&path, title, &set, &format!("mkv://{}", out_path.display())) + .expect("a zero-filled run is read damage, never E7013"); + assert!(out.completed); + assert!(holds_plain_es(&out_path), "units around the hole decrypt"); + } + + /// Regression (b4d322e): a unit on the edge of a sweep hole whose first sector read back as + /// garbage (CPI 11₂, no TS sync) is read damage — a hole — not E7013. KS-4 [BD] §3.10.1: + /// "The first 16 bytes of each Aligned Unit is used as the seed for calculating the Block Key." + #[test] + fn iso_mux_through_a_damaged_unit_seed_is_a_hole_not_e7013() { + assert!( + crate::spec::keys::KS_4_SEED + .text + .contains("used as the seed") + ); + let _serial = crate::sector::prefetched::holder_test_lock(); + let key = [0x5A; 16]; + let (dir, path, title, set) = damaged_uhd_image(key, 9, |im| { + im[3 * 6144..5 * 6144].fill(0); + crate::test_util::damage_unit_seed(&mut im[5 * 6144..6 * 6144]); + }); + let out_path = dir.path().join("hole.mkv"); + let out = mux_damaged_iso(&path, title, &set, &format!("mkv://{}", out_path.display())) + .expect("a damaged unit seed is read damage, never E7013"); + assert!(out.completed); + assert_eq!(out.lost_bytes, 6144, "the blanked unit is counted as loss"); + assert!(holds_plain_es(&out_path), "units around the hole decrypt"); + } + + /// The real sweep holes are not unit-aligned (Dunkirk: runs of 260, 611, 352 sectors): a + /// run that starts in one unit's tail and ends in another's head muxes through as holes. + /// KS-3 [BD] §3.10.1: "A new CBC cipher chain is started for each Aligned Unit". + #[test] + fn iso_mux_through_a_zero_run_cutting_units_at_both_edges() { + assert!( + crate::spec::keys::KS_3_CBC_PER_UNIT + .text + .contains("new CBC cipher chain") + ); + let _serial = crate::sector::prefetched::holder_test_lock(); + let key = [0x5A; 16]; + let (dir, path, title, set) = + damaged_uhd_image(key, 9, |im| im[7 * 2048..16 * 2048].fill(0)); + let out_path = dir.path().join("hole.mkv"); + let out = mux_damaged_iso(&path, title, &set, &format!("mkv://{}", out_path.display())) + .expect("an unaligned zero run is read damage, never E7013"); + assert!(out.completed); + assert!(holds_plain_es(&out_path), "units around the hole decrypt"); + } + + // A random-access reader over an in-memory image (zeros past its end). + struct ImageReader(Vec); + impl crate::sector::SectorSource for ImageReader { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + let (at, n) = (lba as usize * 2048, count as usize * 2048); + buf[..n].fill(0); + let have = self.0.len().saturating_sub(at).min(n); + buf[..have].copy_from_slice(&self.0[at..at + have]); + Ok(n) + } + fn capacity_sectors(&self) -> u32 { + (self.0.len() / 2048) as u32 + } + fn random_access(&self) -> bool { + true + } + } + + // `damaged_uhd_image`'s title, re-cut into `extents`, muxed from the image (ISO) and from + // a live reader over the same bytes; `batch` sectors per read. + fn mux_both( + units: u32, + extents: &[(u32, u32)], + batch: u16, + damage: impl Fn(&mut [u8]), + ) -> [std::io::Result; 2] { + let _serial = crate::sector::prefetched::holder_test_lock(); + let key = [0x5A; 16]; + let (_dir, path, mut title, set) = damaged_uhd_image(key, units, damage); + title.extents = extents + .iter() + .map(|&(start_lba, sector_count)| crate::disc::Extent { + start_lba, + sector_count, + }) + .collect(); + let opts = MuxOptions { + batch_sectors: batch, + ..keyed_opts() + }; + let iso = mux_with_keys( + MuxSource::Iso { + path: &path, + title: title.clone(), + format: crate::disc::ContentFormat::BdTs, + }, + Some(&set), + "null://", + &opts, + &Halt::new(), + Arc::new(NoopEvents), + ); + let live = mux_with_keys( + MuxSource::Live { + reader: Box::new(ImageReader(std::fs::read(&path).unwrap())), + title, + format: crate::disc::ContentFormat::BdTs, + }, + Some(&set), + "null://", + &opts, + &Halt::new(), + Arc::new(NoopEvents), + ); + [iso, live] + } + + /// An extent that starts inside a cluster of damaged units (its first reads show no intact + /// unit) is holes, not E7013: the grid verdict waits for the extent's intact units. KS-4 + /// [BD] §3.10.1: "The first 16 bytes of each Aligned Unit is used as the seed". + #[test] + fn an_extent_starting_inside_a_damage_cluster_muxes_through() { + let [iso, live] = mux_both(9, &[(0, 12), (12, 15)], 6, |im| { + (3..6).for_each(|u| { + crate::test_util::damage_unit_seed(&mut im[u * 6144..(u + 1) * 6144]) + }); + }); + let (iso, live) = ( + iso.expect("iso: damage is blanked"), + live.expect("live: blanked"), + ); + assert!(iso.completed && live.completed); + assert_eq!( + (iso.lost_bytes, live.lost_bytes), + (3 * 6144, 3 * 6144), + "3 units counted" + ); + assert!( + iso.errors >= 3 && live.errors >= 3, + "a damaged rip never looks clean" + ); + } + + /// Units read off their file's grid look like damage (a flagged seed without TS sync): as + /// in 1.7.7 the mux carries on, never E7013 — here blanked and counted as loss, on ISO and + /// live alike. The second extent starts 2 sectors off the grid. + #[test] + fn an_off_grid_extent_is_blanked_never_e7013_on_iso_and_live() { + let [iso, live] = mux_both(9, &[(0, 9), (11, 15)], 64, |_| {}); + let (iso, live) = ( + iso.expect("iso: never E7013"), + live.expect("live: never E7013"), + ); + assert!( + iso.lost_bytes > 0, + "the flagged off-grid chunks are counted" + ); + assert_eq!( + iso.lost_bytes, live.lost_bytes, + "ISO and live never deviate" + ); + } + + /// Dunkirk (AACS 2.0, bus encryption, one 55.5 GB clip): five unaligned zero runs of + /// 260, 611, 352, 192 and 546 sectors, laid out as on the real image, plus a unit on a run's + /// edge whose head read back as garbage. Every grid phase muxes through on ISO and live. + #[test] + fn a_dunkirk_damage_pattern_muxes_through_on_iso_and_live() { + const RUNS: [(u32, u32); 5] = + [(96, 260), (928, 611), (8899, 352), (9280, 192), (9633, 546)]; + for phase in 0..3u32 { + let [iso, live] = mux_both(3400, &[(0, 10200)], 64, |im| { + for (at, n) in RUNS { + let a = (at - phase) as usize * 2048; + im[a..a + n as usize * 2048].fill(0); + } + let u = (928 - phase as usize + 611) / 3 + 1; + crate::test_util::damage_unit_seed(&mut im[u * 6144..(u + 1) * 6144]); + }); + let (iso, live) = (iso.expect("iso"), live.expect("live")); + assert!(iso.completed && live.completed, "phase {phase}"); + // Blanked: the garbage head, and each run end that zero-filled a unit's head sector + // but not its tail (a lost seed over ciphertext). A run start keeps its head. + let lost_seeds = RUNS + .iter() + .filter(|&&(at, n)| (at - phase + n) % 3 != 0) + .count(); + let want = (1 + lost_seeds as u64) * 6144; + assert_eq!( + iso.lost_bytes, want, + "phase {phase}: blanked units are counted" + ); + assert_eq!(live.lost_bytes, iso.lost_bytes, "phase {phase}"); + } + } } diff --git a/tests/fvi_pipeline.rs b/src/mux/fvi_pipeline_tests.rs similarity index 96% rename from tests/fvi_pipeline.rs rename to src/mux/fvi_pipeline_tests.rs index b1c57a09..9e83b001 100644 --- a/tests/fvi_pipeline.rs +++ b/src/mux/fvi_pipeline_tests.rs @@ -1,17 +1,19 @@ //! End-to-end `fvi://` tests: drive a REAL MPEG-2 Program-Stream image through -//! the public highway (`build_iso_pipeline`, MpegPs → PS demux → `Mpeg2Parser`) +//! the highway (`build_iso_pipeline`, MpegPs → PS demux → `Mpeg2Parser`) //! and into the `fvi://` sink built by `output()`, then parse the `.fvi` back //! and assert the per-picture index is correct. //! -//! These tests deliberately use only the public API and the real parser / -//! pipeline — no stubbed frames that bypass the demuxer or the codec parse. +//! These tests use the real parser / pipeline — no stubbed frames that bypass the +//! demuxer or the codec parse. `build_iso_pipeline` is crate-private (KU §3.1). -use libfreemkv::disc::{ +use crate::decrypt::DecryptKeys; +use crate::disc::{ Codec, ColorSpace, ContentFormat, DiscTitle, Extent, FrameRate, HdrFormat, Resolution, Stream, VideoStream, }; -use libfreemkv::pes::Stream as PesStream; -use libfreemkv::{DecryptKeys, Medium, SectorSource, SourceInfo, build_iso_pipeline, output}; +use crate::mux::resolve::build_iso_pipeline; +use crate::pes::Stream as PesStream; +use crate::{Medium, SectorSource, SourceInfo, output}; use std::path::PathBuf; /// DVD video PES stream_id (0xE0). @@ -107,7 +109,7 @@ impl SectorSource for MemSource { count: u16, buf: &mut [u8], _recovery: bool, - ) -> libfreemkv::error::Result { + ) -> crate::error::Result { let start = lba as usize * 2048; let want = count as usize * 2048; for (i, b) in buf[..want].iter_mut().enumerate() { @@ -169,6 +171,7 @@ fn two_gop_image() -> Vec { /// Drive the real highway and write every frame into the `fvi://` sink. fn run_to_fvi(image: Vec, title: DiscTitle, path: &std::path::Path) { + let _drive = crate::sector::prefetched::holder_test_lock(); let mut input = build_iso_pipeline( MemSource { data: image }, title.clone(), @@ -178,7 +181,6 @@ fn run_to_fvi(image: Vec, title: DiscTitle, path: &std::path::Path) { false, None, None, - None, ) .expect("pipeline builds"); @@ -318,7 +320,7 @@ fn fvi_sink_indexes_real_mpeg2_pipeline_output() { // `type:"?"`/`src:null` just because `PictureInfo` is MPEG-2-specific. #[test] fn fvi_sink_indexes_non_mpeg2_frames_codec_agnostically() { - use libfreemkv::pes::{PesFrame, SourcePos}; + use crate::pes::{PesFrame, SourcePos}; let dir = tempdir(); let path = dir.join("nocoding.fvi"); diff --git a/src/mux/interop_tests.rs b/src/mux/interop_tests.rs index 4a33e35a..bb617dc4 100644 --- a/src/mux/interop_tests.rs +++ b/src/mux/interop_tests.rs @@ -2,7 +2,7 @@ //! QA explicitly runs ignored `ffmpeg_` tests with software codecs installed. //! Fixtures are generated locally and never downloaded from a media corpus. -use super::driver::{MuxInput, MuxOptions, NoopEvents, mux_stream}; +use super::driver::{MuxOptions, MuxSource, NoopEvents, mux_with_keys}; use crate::halt::Halt; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -192,11 +192,12 @@ fn generate_with( fn remux(source: &Path, output: &Path, transport: bool) { let scheme = if transport { "m2ts" } else { "mkv" }; let url = format!("{scheme}://{}", source.display()); - let result = mux_stream( - MuxInput::Url { + let result = mux_with_keys( + MuxSource::Url { url: &url, opts: Default::default(), }, + None, &format!("mkv://{}", output.display()), &MuxOptions::default(), &Halt::new(), diff --git a/src/mux/mod.rs b/src/mux/mod.rs index b721155d..b128d54d 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -84,9 +84,7 @@ pub(crate) mod videomap; // The provenance types ARE public: `output()` takes a `SourceInfo` so an `fvi://` // destination records the INPUT it was built from (§6.2), not the file written. pub use disc::DiscStream; -pub use driver::{ - MuxEvents, MuxInput, MuxOptions, MuxOutcome, MuxSource, mux_stream, mux_with_keys, -}; +pub use driver::{MuxEvents, MuxOptions, MuxOutcome, MuxSource, mux_with_keys}; pub use fit::{FitReport, SkipReason, fit_report}; pub use m2ts::M2tsStream; pub use mkvstream::{ @@ -102,8 +100,6 @@ pub use mpg::MpgSink; pub use network::NetworkStream; pub use null::NullStream; pub use pipelined_stream::PipelinedPesStream; -pub use resolve::build_iso_pipeline; -pub use resolve::resolve_mux_key_map; pub use resolve::{InputOptions, StreamUrl, input, output, parse_url}; pub use stdio::StdioStream; @@ -120,6 +116,8 @@ use std::io::{Seek, Write}; pub trait WriteSeek: Write + Seek {} impl WriteSeek for T {} +#[cfg(test)] +mod fvi_pipeline_tests; #[cfg(test)] pub(crate) mod interop_tests; diff --git a/src/mux/mpg/mod.rs b/src/mux/mpg/mod.rs index 56115ef1..677922b3 100644 --- a/src/mux/mpg/mod.rs +++ b/src/mux/mpg/mod.rs @@ -442,7 +442,7 @@ impl MpgSink { return Ok(()); } let spanned = self.span.is_some_and(|(lo, hi)| hi - lo >= WINDOW_TICKS); - if !eof && !(spanned && !self.deriver.pending()) { + if !eof && (!spanned || self.deriver.pending()) { return Ok(()); } // Design §2.3 (MPG3-8): the lowest first DTS/PTS over ALL tracks maps to 1.5 s. diff --git a/src/mux/mpg/tests.rs b/src/mux/mpg/tests.rs index 3c56d7a6..7fd31583 100644 --- a/src/mux/mpg/tests.rs +++ b/src/mux/mpg/tests.rs @@ -2014,7 +2014,7 @@ fn a_map_with_a_bad_crc_is_ignored() { // offset); the extension is the mkv sink's to exclude (M1, J23). #[test] fn an_mpg_source_remuxes_to_mkv() { - use crate::mux::driver::{MuxInput, MuxOptions, NoopEvents, mux_stream}; + use crate::mux::driver::{MuxOptions, MuxSource, NoopEvents, mux_with_keys}; let fx = fixture(&Opts { spu_tracks: 0, ..Opts::default() @@ -2033,11 +2033,12 @@ fn an_mpg_source_remuxes_to_mkv() { }; let out = { let _g = crate::sector::prefetched::holder_test_lock(); - mux_stream( - MuxInput::Url { + mux_with_keys( + MuxSource::Url { url: &url, opts: Default::default(), }, + None, &format!("mkv://{}", mkv.display()), &opts, &crate::halt::Halt::new(), diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 8460ab2b..0a996b03 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -68,6 +68,8 @@ pub struct PipelinedPesStream { header_gate: super::header_gate::HeaderGate, /// The op's stop token; a never-cancelled stand-in when there is none. halt: crate::halt::Halt, + /// Damaged AACS units the decrypting reader blanked (reported as loss). + blanked: std::sync::Arc, /// `mpg://` input: the one video `stream_id` routed; other video ids are left out. video_stream_id: Option, /// Packets of a video `stream_id` other than `video_stream_id`, dropped. @@ -133,6 +135,7 @@ impl PipelinedPesStream { field_merge, header_gate: super::header_gate::HeaderGate::default(), halt: crate::halt::Halt::new(), + blanked: std::sync::Arc::default(), video_stream_id: None, other_video_packets: 0, } @@ -144,6 +147,15 @@ impl PipelinedPesStream { self } + // The decrypting reader's blanked-unit counter, reported as `errors` / `lost_bytes`. + pub(crate) fn with_blanked( + mut self, + blanked: std::sync::Arc, + ) -> Self { + self.blanked = blanked; + self + } + // The op's stop token: a cancel ends a blocked `read` with `Halted` (LP11). pub(crate) fn with_halt(mut self, halt: Option) -> Self { self.halt = halt.unwrap_or_default(); @@ -458,6 +470,15 @@ impl Stream for PipelinedPesStream { &self.title } + fn errors(&self) -> u64 { + self.blanked.load(std::sync::atomic::Ordering::Relaxed) + } + + fn lost_bytes(&self) -> u64 { + // Each blanked unit is one whole aligned unit of zeros (KS-2: 6144 bytes). + self.errors() * crate::aacs::content::ALIGNED_UNIT_LEN as u64 + } + fn config_changes(&self) -> Vec<(usize, u64)> { self.pid_to_track .iter() diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index d523e06f..dfa10057 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -22,6 +22,11 @@ use std::path::{Path, PathBuf}; /// I/O buffer size for file streams. const IO_BUF_SIZE: usize = 4 * 1024 * 1024; +/// File-backed ISO mux read size. Empirically optimal for the CLI: 16 MiB; +/// 32 MiB regressed from cache pressure and longer per-batch latency. +/// Image remux uses the same reader pipeline and should use this value too. +pub const ISO_MUX_BATCH_SECTORS: u16 = 8192; + /// Parsed stream URL. #[derive(Debug, Clone)] pub enum StreamUrl { @@ -325,44 +330,28 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { /// Options for opening an input stream. #[derive(Clone, Default)] pub struct InputOptions { - /// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc - /// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the - /// caller has no key. The library does no lookup — a key source resolves - /// these and the caller passes them here. - pub unit_keys: Vec<(u32, [u8; 16])>, /// 0-based title index to open; `None` selects title 0. An /// out-of-range index yields [`crate::error::Error::DiscTitleRange`]. pub title_index: Option, /// Skip decryption — return raw encrypted bytes. pub raw: bool, - /// Optional fresh-key-on-failure closure (a shared [`crate::sector::KeyFetch`]). - /// `None` (default) keeps the prior behaviour: a unit no held key decrypts is - /// counted as decrypt loss. When set, the mux installs it (cloned `Arc`) so a - /// still-scrambled unit is re-tried via the application's key source. - /// Application seam only; the library makes no network call. - pub key_fetch: Option, /// Which audio/subtitle streams to keep. `input()` scans the source and /// picks the title internally, so the caller can't prune the `DiscTitle` /// itself — it passes the selection here and `input()` applies it right /// after the title-index bounds check. Default keeps every stream (video is /// always kept). See [`crate::StreamSelection`]. pub selection: crate::StreamSelection, - /// The rip's up-front key set (KU §3.1). For an AACS image it replaces `unit_keys` and - /// `key_fetch`: the title is read through the set's reader, with no lookup here. - /// `None` keeps the legacy disc-banked keys (until KU-X2). + /// The rip's up-front key set (KU §3.1). An AACS image is read through the set's + /// reader, with no lookup here; `None` refuses an AACS image before any output. pub keys: Option, } -// `KeyFetchFactory` holds a trait object that is not `Debug`; hand-roll the -// impl (the prior derive is preserved for every other field) so `InputOptions` -// stays printable without dumping key material. +// Hand-rolled so `InputOptions` stays printable without dumping key material. impl std::fmt::Debug for InputOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InputOptions") - .field("unit_keys", &self.unit_keys.len()) .field("title_index", &self.title_index) .field("raw", &self.raw) - .field("key_fetch", &self.key_fetch.is_some()) .field("selection", &self.selection) .field("keys", &self.keys.is_some()) .finish() @@ -495,20 +484,9 @@ where if let Some(set) = opts.keys.as_ref().filter(|s| s.is_aacs() && !opts.raw) { return keyed_image_input(reader, disc, opts, set, reopen); } - // Apply the caller-resolved keys (lookup-free); decrypt_keys() then - // yields them for the stream below. Propagate a failed application - // rather than silently muxing an undecryptable stream. - if !opts.unit_keys.is_empty() { - // These UKs were already resolved AND validated by the caller - // (the CLI's keydb loop), so no re-validation sample is needed. - disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[]) - .map_err(|e| -> io::Error { e.into() })?; - } - // Pre-flight decrypt gate (see `Disc::ensure_decryptable`): fails fast - // on a scrambled-but-uncracked CSS disc or keyless AACS disc (else - // muxes garbage). Disc-wide only; per-title CSS check is below. - disc.ensure_decryptable(opts.raw) - .map_err(|e| -> io::Error { e.into() })?; + // Pre-flight decrypt gate: fails fast on a scrambled-but-uncracked CSS disc or an + // AACS disc with no key set (else muxes garbage). Per-title CSS is checked below. + crate::keys::disc_gate(&disc, opts.raw).map_err(|e| -> io::Error { e.into() })?; if disc.titles.is_empty() { return Err(crate::error::Error::NoStreams.into()); } @@ -527,60 +505,19 @@ where .apply(&mut disc.titles[idx]) .map_err(|e| -> io::Error { e.into() })?; // DVD CSS resolves at exactly one site, `build_iso_pipeline`'s per-title - // crack below, so a DVD passes `None` here. AACS/unencrypted resolve - // via `decrypt_keys()`; `--raw` is deliberate passthrough — also `None`. + // crack below, so a DVD passes `None` here, as does `--raw` (passthrough). let is_dvd = disc.format == crate::disc::DiscFormat::Dvd; - let (keys, title_is_clear) = if opts.raw || is_dvd { - (crate::decrypt::DecryptKeys::None, false) + let keys = if opts.raw || is_dvd { + crate::decrypt::DecryptKeys::None } else { - (disc.decrypt_keys(), false) + disc.decrypt_keys() }; - // Decrypt gate for AACS/non-DVD: a None key means no usable disc key, - // which would mux ciphertext verbatim — fail loudly (NoDiscKey). DVD - // is gated inside `build_iso_pipeline`'s CSS hard-fail; `--raw` passes. - if !is_dvd { - disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear) - .map_err(|e| -> io::Error { e.into() })?; - } - // FMTS (AACS 2.1) is checked downstream by resolve_mux_key_map/resolve_fmts_key_map. // TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) are corrected by // probing decrypted units via a fresh reader; skipped in --raw (no point). if !opts.raw { match reopen() { - Ok(mut probe) => { - // The probe decrypts the title head, so it needs the same - // key map the mux read installs below, or an AACS source - // fails loud on the first unit. `.ok()`: non-fatal. - let mut probe_keys = keys.clone(); - let probe_title = disc.titles[idx].clone(); - let probe_map = match &probe_keys { - crate::decrypt::DecryptKeys::Aacs { .. } => resolve_mux_key_map( - &mut probe, - &probe_title, - &mut probe_keys, - opts.key_fetch.as_ref(), - disc.content_format, - // Bounded, file-backed probe; no live drive here. - None, - ) - // Non-fatal: a failed key-map resolution leaves the probe without - // FMTS ranges, so TrueHD channel correction is skipped (MPLS 7.1/Atmos - // stays understated as 5.1). Log the actual error (parity with the sibling re-open failure below), not a bare `.ok()`. - .inspect_err(|e| { - tracing::debug!( - target: "mux", - error = %e, - "TrueHD channel-correction key-map resolution failed; skipping" - ); - }) - .ok() - .map(std::sync::Arc::new), - _ => None, - }; - let mut dec = crate::sector::DecryptingSectorSource::new(probe, probe_keys); - if let Some(map) = probe_map { - dec = dec.with_key_map(map); - } + Ok(probe) => { + let mut dec = crate::sector::DecryptingSectorSource::new(probe, keys.clone()); crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]); } Err(e) => { @@ -597,10 +534,7 @@ where } let title = disc.titles[idx].clone(); let format = disc.content_format; - // ISO file: 8192-sector batch (16 MiB) — sequential read from fast - // storage, no bad sectors. Empirically optimal; 16384 sectors regressed - // (more cache pressure, longer per-batch latency starves the consumer). - const ISO_MUX_BATCH_SECTORS: u16 = 8192; + // ISO file: use the same tuned batch as the other file-backed mux path. // Pass `DecryptKeys::None` to the decrypt decorator when --raw is set — // the read stack still flows through the same producer+demux+parse @@ -610,13 +544,6 @@ where } else { keys }; - // Install the shared fetch closure (if supplied) so a unit no held key - // decrypts is re-tried via the app's key source. Suppressed in --raw. - let fetch = if opts.raw { - None - } else { - opts.key_fetch.clone() - }; let stream = build_iso_pipeline( reader, title, @@ -626,7 +553,6 @@ where opts.raw, None, None, - fetch, )?; Ok(stream) } @@ -670,7 +596,6 @@ where tracing::debug!(target: "mux", error = %e, "TrueHD channel-correction probe re-open failed") } } - const ISO_MUX_BATCH_SECTORS: u16 = 8192; let title = disc.titles[idx].clone(); build_iso_pipeline_keyed(reader, title, set, ISO_MUX_BATCH_SECTORS, None, None) } @@ -900,501 +825,6 @@ pub(crate) fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> Dem // BASE CPS unit keys (< this) from forensic ones (>= this). pub(crate) const FMTS_POOL_TAG_BASE: u32 = 1 << 24; -// Pool slot of the disc's ONE base CPS Unit Key, or None for a genuine multi-CPS disc; excludes -// forensic index keys. -fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option { - match base_key_slots(unit_keys)[..] { - [only] => Some(only), - _ => None, - } -} - -/// The pool slots of the disc's BASE CPS Unit Keys, in pool order — i.e. every -/// entry that is not a forensic index key banked by [`resolve_fmts_key_map`] -/// ([`FMTS_POOL_TAG_BASE`]). One element per CPS unit whose key is held. -pub(crate) fn base_key_slots(unit_keys: &[(u32, [u8; 16])]) -> Vec { - unit_keys - .iter() - .enumerate() - .filter(|(_, (cps_id, _))| *cps_id < FMTS_POOL_TAG_BASE) - .map(|(slot, _)| slot) - .collect() -} - -/// Read a spread of real encrypted aligned units from `[start, start + sectors)`. -/// Only units that are genuinely AACS-encrypted are returned, so a caller can -/// treat a decrypt-to-clean as proof that the key it used is this extent's. -fn sample_encrypted_units( - reader: &mut dyn SectorSource, - start: u32, - sectors: u32, - format: ContentFormat, -) -> Vec> { - use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; - let total_units = sectors / ALIGNED_UNIT_SECTORS; - let mut out = Vec::new(); - if total_units == 0 { - return out; - } - const PROBES: u32 = 8; - for p in 1..=PROBES { - let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32; - if unit >= total_units { - continue; - } - let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); - let mut buf = vec![0u8; ALIGNED_UNIT_LEN]; - if reader - .read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false) - .is_ok() - && aacs_unit_encrypted(&buf, format) - { - out.push(buf); - } - } - out -} - -// The FIRST pool slot whose key decrypts one of `samples` to clean content. -// `slots` restricts the search (and order) to candidate pool entries — the -// whole pool for multi-CPS, or just base keys for "which CPS unit is this". -fn pick_pool_slot( - samples: &[Vec], - pool: &[(u32, [u8; 16])], - slots: &[usize], - format: ContentFormat, -) -> Option { - use crate::aacs::content::{decrypt_unit, is_clean}; - slots.iter().copied().find(|&slot| { - let Some((_, k)) = pool.get(slot) else { - return false; - }; - samples.iter().any(|s| { - let mut u = s.clone(); - decrypt_unit(&mut u, k); - is_clean(&u, format) - }) - }) -} - -// Which CPS unit's BASE Unit Key opens `ext`, as a pool slot; memoised per-disc. Only base keys -// are considered (forensic index keys are mapped by tag earlier). -fn base_slot_for_extent( - reader: &mut dyn SectorSource, - ext: &crate::disc::Extent, - keys: &mut crate::decrypt::DecryptKeys, - fetch: Option<&crate::sector::KeyFetch>, - format: ContentFormat, - cps: &mut CpsUnitCache, - last_idx: usize, -) -> io::Result { - let ck = (format, ext.start_lba, ext.sector_count); - if let Some(&hit) = cps.get(&ck) { - return Ok(hit); - } - let samples = sample_encrypted_units(reader, ext.start_lba, ext.sector_count, format); - let pool: Vec<(u32, [u8; 16])> = match keys { - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(), - _ => Vec::new(), - }; - let mut idx = pick_pool_slot(&samples, &pool, &base_key_slots(&pool), format); - if idx.is_none() - && let Some(f) = fetch - && !samples.is_empty() - { - // A key-source failure is reported as itself, not as "no key" (DecryptFailed). - let fresh = f.unit_keys(&samples)?; - if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { - for k in fresh { - if !unit_keys.iter().any(|(_, h)| *h == k) { - let i = unit_keys.len() as u32; - unit_keys.push((i, k)); - } - } - idx = pick_pool_slot(&samples, unit_keys, &base_key_slots(unit_keys), format); - } - } - match idx { - // A real decision from this extent's own ciphertext — memoise it. - Some(i) => { - cps.insert(ck, i); - Ok(i) - } - // Inherited from the PRECEDING extent of THIS title, so it is not a - // property of this extent: never cache it. - None if samples.is_empty() => Ok(last_idx), - None => Err(crate::error::Error::DecryptFailed.into()), - } -} - -// FMTS (AACS 2.1) branch of resolve_mux_key_map: resolves per-segment index keys (from the -// configured source) plus base-key gap fill, memoised per disc. -#[allow(clippy::too_many_arguments)] -fn resolve_fmts_key_map( - reader: &mut dyn SectorSource, - title: &DiscTitle, - keys: &mut crate::decrypt::DecryptKeys, - fetch: Option<&crate::sector::KeyFetch>, - format: ContentFormat, - halt: Option<&crate::halt::Halt>, - cache: &mut FmtsCache, - cps: &mut CpsUnitCache, -) -> io::Result> { - let FmtsCache { - table, - clip, - keys: memo, - } = cache; - use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments}; - - // Cooperative cancel: probes the LIVE drive across up to a few hundred - // `read_sectors`, each able to stall to the SCSI timeout. `/api/stop` - // must be honored at each loop boundary, not just after completion. - let check_halt = || -> io::Result<()> { - if halt.is_some_and(|h| h.is_cancelled()) { - return Err(crate::error::Error::Halted.into()); - } - Ok(()) - }; - // Poll on ENTRY too: with both memos warm a title can reach the finished - // map without touching a loop, so a 60-playlist sweep would otherwise - // run to completion after an operator Stop. - check_halt()?; - - // Load the segment map. Distinguish "not FMTS" (`UdfNotFilesystem`/ - // `UdfNotFound` → Ok(None)) from a transient I/O fault (propagates, else - // silently loses forensic content). Only the two negatives are memoised. - if table.is_none() { - let udf = match crate::udf::read_filesystem(reader) { - Ok(u) => Some(u), - Err(crate::error::Error::UdfNotFilesystem) => None, - Err(e) => return Err(e.into()), - }; - let tbl = match udf.as_ref() { - None => None, - Some(u) => match u.read_file(reader, "/AACS/IndividualSegment.tbl") { - Ok(t) => Some(t), - Err(crate::error::Error::UdfNotFound { .. }) => None, - Err(e) => return Err(e.into()), - }, - }; - let parsed = tbl - .as_deref() - .and_then(parse_individual_segments) - .filter(|s| !s.is_empty()); - // Locate the forensic feature clip in the SAME walk — the byte space - // segment SPNs are relative to (`FmtsClipCache`). Assigned before - // `*table` so a read fault leaves NEITHER memo filled and retries. - *clip = Some(match (&parsed, udf.as_ref()) { - (Some(_), Some(u)) => forensic_clip_extents(u, reader)?, - _ => None, - }); - *table = Some(parsed); - } - let Some(Some(segments)) = table.as_ref() else { - return Ok(None); // not an FMTS disc - }; - let segments = segments.clone(); - // ANCHOR the segment byte space: SPNs are offsets into the FORENSIC clip, - // not this title's extents (a trailer-first playlist would else map - // segments to the wrong clip). Anchored on the clip's own UDF extents. - let Some(Some(clip_extents)) = clip.as_ref() else { - // Disc has a non-empty `IndividualSegment.tbl` but its clip could not - // be identified, so there's no defensible SPN anchor. Fail loud: a - // retryable error beats silently mapping keys onto wrong sectors. - tracing::warn!(target: "freemkv::keysource", segments = segments.len(), "fmts: forensic feature clip not identifiable — refusing an unanchored segment map"); - return Err(crate::error::Error::FmtsKeyMissing.into()); - }; - let clip_extents = clip_extents.clone(); - // A title not overlapping the forensic clip (menu/extras, or a - // different clip) has nothing forensic to resolve — fall back to - // `Ok(None)` rather than hard-failing every non-main title's mux. - if !extents_overlap(&title.extents, &clip_extents) { - return Ok(None); - } - // Keep only segments the clip actually contains: bytes running past the - // clip's end belong to a stale/foreign table entry, must not be mapped. - let segments = filter_addressable_segments(segments, &clip_extents); - if segments.is_empty() { - return Ok(None); - } - // This title HAS forensic content, so the index keys are REQUIRED, like - // a Unit Key. No key source means no complete rip: fail loud rather than - // silently drop segments (`--raw` never reaches this path). - let Some(fetch) = fetch else { - return Err(crate::error::Error::FmtsKeyMissing.into()); - }; - - // The expensive half (anchor + phase probes, ONE key-service round trip) - // reads only the forensic clip's sectors, resolved at most once per disc - // via `FmtsKeyCache` — else a 60-playlist disc repeats it 60x. - let ek = extent_key(format, title); - let (index_keys, phase_of_index) = match memo.get(&ek) { - Some((k, p)) => (k.clone(), p.clone()), - None => { - let probed = - probe_fmts_index_keys(reader, &clip_extents, &segments, fetch, format, halt)?; - // Only memoise a run whose every index reached a DEFINITE phase — - // a read-faulted index falling back to `Phase::Verify` is a transient - // fault, not a property of these extents; caching it would spread. - if probed.all_phases_definite { - memo.insert(ek, (probed.keys.clone(), probed.phases.clone())); - } - (probed.keys, probed.phases) - } - }; - - // Map array position → forensic index (element i = index i+1); add each - // key to the pool and remember its slot by tag. Per TITLE, deliberately: - // the pool grows across titles, so already-banked keys reuse their slots. - let mut tag_slot: std::collections::HashMap = std::collections::HashMap::new(); - if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { - for (i, k) in index_keys.iter().enumerate() { - let tag = (i + 1) as u16; - let slot = match unit_keys.iter().position(|(_, h)| h == k) { - Some(s) => s, - None => { - let s = unit_keys.len(); - // CPS-unit id is cosmetic here (mapped decrypt indexes by - // slot), so it carries the forensic TAG instead, telling - // the single-CPS short-circuit these aren't base keys. - unit_keys.push((FMTS_POOL_TAG_BASE.saturating_add(s as u32), *k)); - s - } - }; - tag_slot.insert(tag, slot); - } - } - - // Build per-segment LBA ranges: each segment → its tag's key and index's - // phase, leaving the alternate as ciphertext. A boundary-straddling - // segment is unmapped and tallied (hard failure below). - let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = - Vec::with_capacity(segments.len()); - let mut unresolved = 0usize; - for seg in &segments { - // SPNs are untrusted; an inverted record (start_spn > end_spn) would - // underflow `end_byte - 1 - start_byte` below (mirrors the guard in - // `aacs::segment::fmts_key_ranges`). - if seg.start_spn > seg.end_spn { - unresolved += 1; - continue; - } - let Some(&slot) = tag_slot.get(&seg.index) else { - unresolved += 1; - continue; - }; - // No resolved phase → `Verify` (KU design §5.3, K-5), never an unverified `All`. - let phase = phase_of_index - .get(&seg.index) - .copied() - .unwrap_or(crate::decrypt::Phase::Verify); - let start_byte = seg.start_spn as u64 * 192; - let end_byte = (seg.end_spn as u64 + 1) * 192; - // Clip bytes → LBAs through the FORENSIC CLIP's extents, never the - // title's. - let (Some(a), Some(b)) = ( - clip_byte_to_lba(&clip_extents, start_byte), - clip_byte_to_lba(&clip_extents, end_byte - 1), - ) else { - unresolved += 1; - continue; - }; - // Only emit a contiguous within-extent range (segments are ~480 KB; - // a rare extent-straddle is left unresolved, not given a wrong span). - if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 { - ranges.push((a, b + 1, slot, phase)); - } else { - unresolved += 1; - } - } - // Every forensic segment must map to an index key. A hole here should - // never happen with the full 32-key set — treat it as a hard failure. - if unresolved != 0 { - return Err(crate::error::Error::FmtsKeyMissing.into()); - } - - // Cover NON-segment content with each LBA's CPS unit's base key. "The" - // base key is only well defined with ONE base unit — hardcoding slot 0 - // would garble others, since this runs before `single_base_key_slot`. - let base_slots: Vec = match keys { - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => base_key_slots(unit_keys), - _ => Vec::new(), - }; - if base_slots.len() <= 1 { - // One base CPS unit (the majority, incl. single-key UHD): its key - // covers every non-forensic LBA, no sampling needed. Empty pool - // keeps slot 0, which an empty map keys nothing with anyway. - let base_idx = base_slots.first().copied().unwrap_or(0); - let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx); - ranges.extend(base_gaps); - } else { - let mut last_idx = base_slots[0]; - let mut gaps = Vec::new(); - for ext in &title.extents { - // Cooperative cancel between extents: this samples real content units - // off the live drive, exactly like the multi-CPS loop. - if halt.is_some_and(|h| h.is_cancelled()) { - return Err(crate::error::Error::Halted.into()); - } - let idx = base_slot_for_extent(reader, ext, keys, Some(fetch), format, cps, last_idx)?; - last_idx = idx; - gaps.extend(fill_base_key_gaps(std::slice::from_ref(ext), &ranges, idx)); - } - ranges.extend(gaps); - } - - Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(ranges))) -} - -/// The disc-wide forensic answer [`probe_fmts_index_keys`] resolves: the ordered -/// index keys (element `i` = forensic index `i + 1`) and each index's decrypt phase. -struct FmtsIndexKeys { - keys: Vec<[u8; 16]>, - phases: std::collections::HashMap, - /// Every index reached a DEFINITE phase — no index fell back to `Phase::Verify` - /// after [`IndexProbe::ReadFault`]. Only such a run is safe to memoise; see - /// [`resolve_mux_key_map_cached`]. - all_phases_definite: bool, -} - -// Drive/key-service-hitting half of resolve_fmts_key_map: anchor the disc's forensic index-key -// set from one round trip, then probe each index's phase. -fn probe_fmts_index_keys( - reader: &mut dyn SectorSource, - clip_extents: &[crate::disc::Extent], - segments: &[crate::aacs::segment::Segment], - fetch: &crate::sector::KeyFetch, - format: ContentFormat, - halt: Option<&crate::halt::Halt>, -) -> io::Result { - use crate::aacs::content::ALIGNED_UNIT_LEN; - use crate::aacs::segment::clip_byte_to_lba; - - let check_halt = || -> io::Result<()> { - if halt.is_some_and(|h| h.is_cancelled()) { - return Err(crate::error::Error::Halted.into()); - } - Ok(()) - }; - tracing::info!(target: "freemkv::keysource", segments = segments.len(), extents = clip_extents.len(), "fmts: begin index-key resolution"); - - // Read aligned unit `index` of `seg`: clip byte `start_spn*192 + index*6144`. - let read_unit = - |reader: &mut dyn SectorSource, seg: &crate::aacs::segment::Segment, index: usize| { - let clip_byte = seg.start_spn as u64 * 192 + index as u64 * ALIGNED_UNIT_LEN as u64; - let lba = clip_byte_to_lba(clip_extents, clip_byte)?; - let mut c = vec![0u8; ALIGNED_UNIT_LEN]; - reader.read_sectors(lba, 3, &mut c, false).ok()?; - Some(c) - }; - // ANCHOR — fetch the whole key set from ONE index-1 batch (phase - // unknown a priori, so try PHASE A then B). Size is whatever the source - // returns (≥ 1, not assumed 32). Batch = key service's samples floor. - const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS; - // Read-fault fallback: how many index-1 segments to attempt if the leading one - // is unreadable. The 2 phase requests happen per readable segment. - const MAX_ANCHOR_ATTEMPTS: usize = 16; - // Even units = p*2; odd units = p*2 + 1. - let read_phase_batch = |reader: &mut dyn SectorSource, - seg: &crate::aacs::segment::Segment, - phase_off: usize| - -> Option>> { - let mut batch: Vec> = Vec::with_capacity(BATCH_UNITS); - for p in 0..BATCH_UNITS { - batch.push(read_unit(reader, seg, p * 2 + phase_off)?); - } - Some(batch) - }; - let mut index_keys: Vec<[u8; 16]> = Vec::new(); - // First key-source failure: reported instead of FmtsKeyMissing if nothing anchors. - let mut fetch_err: Option = None; - 'anchor: for seg in segments - .iter() - .filter(|s| s.index == 1) - .take(MAX_ANCHOR_ATTEMPTS) - { - check_halt()?; - for phase_off in [0usize, 1usize] { - let Some(batch) = read_phase_batch(reader, seg, phase_off) else { - continue; // read fault on this phase — try the other / next segment - }; - let fresh = match fetch.fmts_indexes(&batch) { - Ok(f) => f, - Err(e) => { - fetch_err.get_or_insert(e); - continue; - } - }; - // Any non-empty reply is the source's COMPLETE ordered forensic set; - // trust it and stop. An empty reply = this phase/segment did not anchor. - if !fresh.is_empty() { - index_keys = fresh; - break 'anchor; - } - } - } - // The count is whatever the source returned — not a fixed 32. Sized here, used - // everywhere below. - let n_index = index_keys.len(); - tracing::info!(target: "freemkv::keysource", held = n_index, "fmts: collection done"); - // At least one forensic index key is required. None ⇒ no FMTS key for this - // disc from any source — fail loud like a missing Unit Key rather than emit - // forensic-holed output. - if index_keys.is_empty() { - return Err(fetch_err - .unwrap_or(crate::error::Error::FmtsKeyMissing) - .into()); - } - - // PROBE each index's phase: only ONE parity is real content, found by - // counting clean EVEN vs ODD decrypts under its key. The ONE place - // `is_clean` runs, so the mux decrypt can trust the map afterward. - let mut phase_of_index: std::collections::HashMap = - std::collections::HashMap::new(); - let mut all_phases_definite = true; - for (i, k) in index_keys.iter().enumerate() { - check_halt()?; - let tag = (i + 1) as u16; - // Read-fault-tolerant across up to MAX_ANCHOR_ATTEMPTS segments; - // distinguishes a wrong key (no clean parity) from a transient - // fault (zero evidence), so a recoverable fault never aborts. - 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: the key - // is wrong (or the sample isn't this index's content). Fail - // loud rather than emit a broken segment. - 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 faulted (zero evidence ⇒ not a wrong key; never - // abort). KU design §5.3 (evidence KS-25/KS-26): `Phase::Verify`. - tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: index phase probe read-faulted on every segment — Phase::Verify: each unit kept only if it verifies (recoverable read fault, not a wrong key)"); - phase_of_index.insert(tag, crate::decrypt::Phase::Verify); - all_phases_definite = false; - } - } - } - - Ok(FmtsIndexKeys { - keys: index_keys, - phases: phase_of_index, - all_phases_definite, - }) -} - // FMTS forensic feature clip's own extents — the byte space every `IndividualSegment.tbl` SPN // is relative to — or None if not exactly one such clip. pub(crate) fn forensic_clip_extents( @@ -1432,19 +862,6 @@ pub(crate) fn forensic_clip_extents( Ok((!exts.is_empty()).then_some(exts)) } -/// True when any extent of `a` shares a sector with any extent of `b`. Used to decide -/// whether a title actually reads the forensic clip (and so carries forensic content) -/// without assuming the two extent lists are byte-identical. -fn extents_overlap(a: &[crate::disc::Extent], b: &[crate::disc::Extent]) -> bool { - a.iter().any(|x| { - let x_end = x.start_lba.saturating_add(x.sector_count); - b.iter().any(|y| { - let y_end = y.start_lba.saturating_add(y.sector_count); - x.start_lba < y_end && y.start_lba < x_end - }) - }) -} - // Keep only forensic segments addressable within the FORENSIC CLIP's extents; stale/foreign // records past the clip's end are dropped. Extracted from resolve_fmts_key_map for direct // testing. @@ -1460,85 +877,6 @@ pub(crate) fn filter_addressable_segments( .collect() } -// Decide a forensic index's decrypt phase from clean-sample counts of its EVEN vs ODD aligned -// units under that index's key. Extracted for unit-testing. -fn resolve_tie_phase(even_clean: usize, odd_clean: usize) -> io::Result { - match even_clean.cmp(&odd_clean) { - std::cmp::Ordering::Greater => Ok(crate::decrypt::Phase::Even), - std::cmp::Ordering::Less => Ok(crate::decrypt::Phase::Odd), - std::cmp::Ordering::Equal if even_clean == 0 => { - Err(crate::error::Error::FmtsKeyMissing.into()) - } - std::cmp::Ordering::Equal => Ok(crate::decrypt::Phase::Even), - } -} - -// Outcome of probing ONE forensic index's decrypt phase; the load-bearing split is WrongKey vs -// ReadFault — only the former is a real FmtsKeyMissing. -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum IndexProbe { - /// A parity decrypted clean under this index's key (or a padding tie) → its phase. - Phase(crate::decrypt::Phase), - /// At least one unit was READ and decrypt-attempted, yet NEITHER parity came up - /// clean under this index's key on any same-index segment → genuine wrong key. - WrongKey, - /// EVERY probe read of every same-index segment faulted (`read` returned `None` - /// for all attempts) → zero decrypt evidence. A recoverable read fault, NOT a - /// wrong key: there is no data to conclude the key is bad. - ReadFault, -} - -// Probe one forensic index's decrypt phase (EVEN vs ODD aligned units) under `key`, tolerating -// read faults without masking a genuine wrong key. -pub(crate) fn probe_index_phase( - segments: &[crate::aacs::segment::Segment], - tag: u16, - batch_units: usize, - max_segments: usize, - format: ContentFormat, - key: &[u8; 16], - mut read: impl FnMut(&crate::aacs::segment::Segment, usize) -> 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 or 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, so keep trying. - if let Ok(phase) = resolve_tie_phase(even, odd) { - return IndexProbe::Phase(phase); - } - } - if any_read { - IndexProbe::WrongKey - } else { - IndexProbe::ReadFault - } -} - // Back-fill LBA gaps NOT covered by forensic segment ranges with the base Unit Key, so the map // is a COMPLETE positive list over the title's content extents. pub(crate) fn fill_base_key_gaps( @@ -1571,243 +909,12 @@ pub(crate) fn fill_base_key_gaps( fills } -/// A single-key content map: every content extent → `idx`; everything else passes -/// through. The positive-map replacement for the old "one key everywhere" default. -fn content_map(title: &DiscTitle, idx: usize) -> crate::decrypt::AacsKeyMap { - let ranges = title - .extents - .iter() - .map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count), idx)) - .collect(); - crate::decrypt::AacsKeyMap::from_ranges(ranges) -} - -/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title -/// before muxing. It decides which held unit key decrypts each of the title's -/// LBA ranges and secures any key the pool is missing through the app's -/// configured source (`fetch`) up front, never reactively per unit at mux time. -/// -/// Single-CPS (the overwhelming majority) keys every extent with one index; multi-CPS keys each -/// with the key opening a real sample; FMTS layers per-segment keys on top. LBAs outside the -/// title's content pass through. -pub fn resolve_mux_key_map( - reader: &mut dyn SectorSource, - title: &DiscTitle, - keys: &mut crate::decrypt::DecryptKeys, - fetch: Option<&crate::sector::KeyFetch>, - format: ContentFormat, - halt: Option<&crate::halt::Halt>, -) -> io::Result { - // One-shot: a single title shares no extents with anything, so a fresh cache - // never hits. Multi-title callers use `resolve_mux_key_map_cached` instead. - resolve_mux_key_map_cached( - reader, - title, - keys, - fetch, - format, - halt, - &mut DiscKeyCache::new(), - ) -} - -// Memoises the multi-CPS "which held unit key opens this extent" decision across the titles of -// ONE disc, for resolve_mux_key_map_cached. Keyed by content format plus the extent's exact -// (start_lba, sector_count). -pub(crate) type CpsUnitCache = std::collections::HashMap<(ContentFormat, u32, u32), usize>; - -// The disc's forensic segment table (/AACS/IndividualSegment.tbl), resolved at most ONCE per -// disc: None = not looked for yet; Some(None) = not FMTS; Some(Some(v)) = parsed segments. -pub(crate) type FmtsTableCache = Option>>; - -// Memoises the FMTS forensic index-key set + per-index phase (the expensive half of -// resolve_fmts_key_map) across the titles of ONE disc; key material, never logged. -pub(crate) type FmtsKeyCache = std::collections::HashMap< - (ContentFormat, Vec<(u32, u32)>), - ( - Vec<[u8; 16]>, - std::collections::HashMap, - ), ->; - -// The disc's forensic feature clip extents, resolved in the SAME UDF walk as FmtsTableCache, at -// most once per disc (None/Some(None)/Some(Some(v)) as there). -pub(crate) type FmtsClipCache = Option>>; - -/// The three FMTS memos, bundled so [`resolve_fmts_key_map`] takes one argument for -/// all of them and the multi-CPS memo can be borrowed independently. -#[derive(Default)] -pub(crate) struct FmtsCache { - /// The disc's forensic segment table — see [`FmtsTableCache`]. - table: FmtsTableCache, - /// The disc's forensic feature clip extents — see [`FmtsClipCache`]. - clip: FmtsClipCache, - /// The forensic index keys + phases — see [`FmtsKeyCache`]. - keys: FmtsKeyCache, -} - -/// Everything [`resolve_mux_key_map_cached`] memoises across the titles of ONE -/// disc. One value threaded through `resolve_content_key_map`'s per-title loop; the -/// fields are independent and are borrowed disjointly. -#[derive(Default)] -pub(crate) struct DiscKeyCache { - /// Multi-CPS "which held key opens this extent" — see [`CpsUnitCache`]. - pub(crate) cps: CpsUnitCache, - /// The FMTS segment table and index-key memos — see [`FmtsCache`]. - pub(crate) fmts: FmtsCache, -} - -impl DiscKeyCache { - pub(crate) fn new() -> Self { - Self::default() - } -} - -/// The `(format, extents)` cache key used by [`FmtsKeyCache`]. -fn extent_key(format: ContentFormat, title: &DiscTitle) -> (ContentFormat, Vec<(u32, u32)>) { - ( - format, - title - .extents - .iter() - .map(|e| (e.start_lba, e.sector_count)) - .collect(), - ) -} - -// resolve_mux_key_map with a caller-owned per-disc cache (DiscKeyCache) shared across the -// titles of one disc. Safe because every memoised value is a disc fact, not a title fact. -pub(crate) fn resolve_mux_key_map_cached( - reader: &mut dyn SectorSource, - title: &DiscTitle, - keys: &mut crate::decrypt::DecryptKeys, - fetch: Option<&crate::sector::KeyFetch>, - format: ContentFormat, - halt: Option<&crate::halt::Halt>, - cache: &mut DiscKeyCache, -) -> io::Result { - // Borrow the memos disjointly: the FMTS branch needs its three mutably while the - // multi-CPS loop below needs the CPS one. - let DiscKeyCache { cps: cache, fmts } = cache; - - // The base Unit Key pool is always resolved and banked before mux - // (autorip's pre-rip gate; the ISO path's `decrypt_keys()`), so an AACS - // title reaches here with a non-empty pool. - if !matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }) { - // CSS / clear: the AACS map keys nothing here — an empty map passes every - // unit through (CSS self-descrambles on its own path). - return Ok(crate::decrypt::AacsKeyMap::from_ranges(Vec::new())); - } - // FMTS (AACS 2.1): forensic segments need per-index keys the base Unit - // Key can't open. `None` if not FMTS or no key source (base UK path - // below applies and forensic units garble/get dropped). - if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt, fmts, cache)? - { - return Ok(map); - } - // Single-CPS short-circuit asks about BASE keys only, and must not read - // pool LENGTH: FMTS appends forensic keys, making length order-dependent. - // Forensic keys are tagged ([`FMTS_POOL_TAG_BASE`]), so count stays exact. - if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys - && let Some(slot) = single_base_key_slot(unit_keys) - { - // One CPS unit → its key over every content extent; nav passes through. - return Ok(content_map(title, slot)); - } - - // Multi-CPS: read a spread of real encrypted units from each extent and pick - // the held key that opens one (the `is_clean` proof is sound HERE — samples - // are guaranteed real content, not the authored-bad units that trip the mux). - let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec> { - sample_encrypted_units(reader, start, sectors, format) - }; - // Here the question is "which HELD key opens this extent", so every pool entry - // is a candidate, in pool order. - let pick = |samples: &[Vec], pool: &[(u32, [u8; 16])]| -> Option { - let all: Vec = (0..pool.len()).collect(); - pick_pool_slot(samples, pool, &all, format) - }; - - let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len()); - let mut last_idx = 0usize; - for ext in &title.extents { - // Cooperative cancel between extents: multi-CPS sampling reads real - // content units off the live drive, so honor an operator stop here too. - if halt.is_some_and(|h| h.is_cancelled()) { - return Err(crate::error::Error::Halted.into()); - } - // Already resolved for this exact extent (a clip another playlist shares): - // reuse the index instead of re-sampling the same physical units. - let ck = (format, ext.start_lba, ext.sector_count); - if let Some(&hit) = cache.get(&ck) { - last_idx = hit; - ranges.push(( - ext.start_lba, - ext.start_lba.saturating_add(ext.sector_count), - hit, - )); - continue; - } - let samples = sample_units(reader, ext.start_lba, ext.sector_count); - // Snapshot the current pool for the pure `pick` closure. - let pool: Vec<(u32, [u8; 16])> = match keys { - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(), - _ => Vec::new(), - }; - let mut idx = pick(&samples, &pool); - if idx.is_none() - && let Some(f) = fetch - && !samples.is_empty() - { - let fresh = f.unit_keys(&samples)?; // a source failure reports as itself - if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { - for k in fresh { - if !unit_keys.iter().any(|(_, h)| *h == k) { - let i = unit_keys.len() as u32; - unit_keys.push((i, k)); - } - } - idx = pick(&samples, unit_keys); - } - } - // `sample_units` draws REAL content, so no key decrypting clean - // means the key is genuinely absent — fail loud rather than - // silently assign a WRONG key (corrupts with lost_bytes==0). - let idx = match idx { - // A real decision from this extent's own ciphertext — memoise it. - Some(i) => { - cache.insert(ck, i); - i - } - // Inherited from the PRECEDING extent of THIS title, so it is not a - // property of this extent: never cache it (another title reaching the - // same extent may carry a different previous index). - None if samples.is_empty() => last_idx, - // Fail-loud, and not cached: a later extent's fetch may bank the key - // that opens this one, so a retry must re-sample rather than inherit a - // stale verdict. - None => return Err(crate::error::Error::DecryptFailed.into()), - }; - last_idx = idx; - ranges.push(( - ext.start_lba, - ext.start_lba.saturating_add(ext.sector_count), - idx, - )); - } - Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges)) -} - -/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for -/// a `FileSectorSource`-backed reader. Returns the resulting -/// `PipelinedPesStream`. -/// -/// `keys` pass [`crate::decrypt::DecryptKeys::None`] for raw/unencrypted reads. `raw` skips the -/// per-title CSS crack entirely (ciphertext passthrough). `halt` is a cooperative cancel token, -/// not a timeout. `fetch` is used UP FRONT by [`resolve_mux_key_map`], never per-unit at mux -/// time. +/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for a title with no +/// AACS key set (clear, CSS, or `raw`); an AACS title goes through +/// [`build_iso_pipeline_keyed`]. `keys` is `DecryptKeys::None` for raw/unencrypted +/// reads; `raw` skips the per-title CSS crack. `halt` is a cooperative cancel token. #[allow(clippy::too_many_arguments)] -pub fn build_iso_pipeline( +pub(crate) fn build_iso_pipeline( mut reader: S, title: DiscTitle, mut keys: crate::decrypt::DecryptKeys, @@ -1816,12 +923,11 @@ pub fn build_iso_pipeline( raw: bool, halt: Option, event_fn: Option, - fetch: Option, ) -> io::Result { let extents = title.extents.clone(); // CSS (DVD) key resolution — shared per-title step. A `None`/MPEG-PS - // title cracks its own key; AACS arrives as `Aacs` untouched; `raw` - // skips it. `halt` lets /api/stop interrupt the crack scan. + // title cracks its own key; `raw` skips it. `halt` lets /api/stop + // interrupt the crack scan. crate::css::resolve_dvd_title_key( &mut reader, &extents, @@ -1838,33 +944,9 @@ pub fn build_iso_pipeline( crate::decrypt::DecryptKeys::Aacs { .. } => 3, _ => 1, }; - // MUX path: resolve the AACS key map UP FRONT — one key per CPS unit/ - // segment against its LBA ranges — so the mux decrypts with a KNOWN key, - // no per-unit verdict or key-server storm. AACS-only; CSS self-cracks. - let key_map = match &keys { - crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(resolve_mux_key_map( - &mut reader, - &title, - &mut keys, - fetch.as_ref(), - format, - halt.as_ref(), - )?)), - _ => None, - }; - // The map IS the title's read plan. Walk ONLY units it marks as ours — - // inside an FMTS segment, only our-phase units (alternate phase is a - // different device group's variant). A non-forensic map is unchanged. let full_extents = extents.clone(); - let extents = match &key_map { - Some(map) => map.read_plan(&extents, unit_align as u32), - None => extents, - }; - let mut decrypting = + let decrypting = crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys); - if let Some(map) = key_map { - decrypting = decrypting.with_key_map(map); - } let plan = IsoPlan { extents, full_extents, @@ -1964,6 +1046,7 @@ fn iso_pipeline_tail( } crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title); decrypting.clear_unit_base(); + let blanked = decrypting.blanked_counter(); let prefetched = crate::sector::PrefetchedSectorSource::new_with_events( decrypting, @@ -1988,7 +1071,8 @@ fn iso_pipeline_tail( .map_err(|e| -> io::Error { e.into() })?; Ok( PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track) - .with_halt(halt), + .with_halt(halt) + .with_blanked(blanked), ) } @@ -2102,7 +1186,6 @@ fn build_ps_pipeline( true, halt.cloned(), None, - None, ) .map(|p| p.with_video_stream_id(scan.video_id)) } @@ -2338,7 +1421,7 @@ mod tests { assert!(validate_network_addr("host:65535").is_ok()); } - // Decrypt-verdict matrix is owned/tested by `Disc::ensure_decryptable[_keys]`. + // Decrypt-verdict matrix is owned/tested by `keys::check_decryptable`. // Box isn't Debug, so unwrap_err() won't compile — these // helpers extract io::ErrorKind from the Err arm (panic on Ok) instead. fn input_err_kind(url: &str) -> std::io::ErrorKind { @@ -2666,113 +1749,6 @@ mod tests { assert!(ps.is_none()); } - // ── Fix 1: halt threading into live-drive key resolution ─────────────── - - // A counting SectorSource over zeros; touched_extent flags a read landing in the extent - // region (LBA >= 1000). - struct HaltCountSource { - reads: u32, - touched_extent: bool, - } - impl SectorSource for HaltCountSource { - fn capacity_sectors(&self) -> u32 { - 512 // keeps the UDF secondary anchor well below the extent region - } - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::error::Result { - self.reads += 1; - if lba >= 1000 { - self.touched_extent = true; - } - let want = count as usize * 2048; - buf[..want].fill(0); - Ok(want) - } - } - - // resolve_mux_key_map (multi-CPS live path) must honor a pre-cancelled halt PROMPTLY, at - // the first extent boundary, before sampling any ciphertext. - #[test] - fn resolve_mux_key_map_honors_pre_cancelled_halt() { - use crate::halt::Halt; - let mut title = DiscTitle::empty(); - title.extents = vec![ - Extent { - start_lba: 1000, - sector_count: 300, - }, - Extent { - start_lba: 5000, - sector_count: 300, - }, - ]; - // Multi-CPS (pool_len = 2) → the extent-sampling loop is the resolve path - // (pool_len == 1 would short-circuit to content_map before any read). - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])], - format: ContentFormat::BdTs, - }; - let mut reader = HaltCountSource { - reads: 0, - touched_extent: false, - }; - let halt = Halt::new(); - halt.cancel(); // pre-cancelled: the very first extent boundary must bail - - let err = super::resolve_mux_key_map( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - Some(&halt), - ) - .expect_err("a pre-cancelled halt must abort key resolution"); - assert!(crate::error::is_halt(&err), "expected Halted, got: {err}"); - assert!( - !reader.touched_extent, - "extent sampling must be skipped on a pre-cancelled halt (a read landed \ - in the extent region — the halt check was not honored)" - ); - } - - /// A `None` halt (no token) must NOT abort — the resolve runs to completion. - /// Guards against a mutation that treats `None` as cancelled. - #[test] - fn resolve_mux_key_map_none_halt_does_not_abort() { - let mut title = DiscTitle::empty(); - title.extents = vec![Extent { - start_lba: 1000, - sector_count: 300, - }]; - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])], - format: ContentFormat::BdTs, - }; - let mut reader = HaltCountSource { - reads: 0, - touched_extent: false, - }; - // No halt token → resolution proceeds and samples the extent (zeros → no - // encrypted unit → carries key 0), returning Ok. - let map = super::resolve_mux_key_map( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - ) - .expect("no halt → resolution completes"); - assert!(reader.touched_extent, "the extent WAS sampled with no halt"); - assert!(!map.ranges().is_empty(), "a map is produced for the extent"); - } - // ── build_iso_pipeline: end-to-end highway wiring ────────────────────── /// An in-memory SectorSource that serves a fixed byte image. Reads beyond @@ -2845,7 +1821,6 @@ mod tests { false, None, None, - None, ) .expect("pipeline builds"); let first = stream.read().expect("read must not error on clean EOF"); @@ -2887,7 +1862,6 @@ mod tests { false, None, None, - None, ) .expect("pipeline builds"); @@ -2980,7 +1954,6 @@ mod tests { false, None, None, - None, ) .expect("pipeline builds"); @@ -3021,7 +1994,6 @@ mod tests { false, None, None, - None, ); assert!(res.is_err(), "zero batch_sectors must be rejected"); } @@ -3062,7 +2034,6 @@ mod tests { false, None, None, - None, ); assert!( res.is_err(), @@ -3070,61 +2041,6 @@ mod tests { ); } - // ── content_map: single-CPS positive range building ──────────────────── - - // content_map(title, idx): each extent -> [start, start+count) at idx, phase All. - #[test] - fn content_map_builds_exact_ranges_from_extents() { - use crate::decrypt::Phase; - let mut t = DiscTitle::empty(); - t.extents = vec![ - Extent { - start_lba: 100, - sector_count: 50, - }, - Extent { - start_lba: 1000, - sector_count: 200, - }, - ]; - let map = super::content_map(&t, 3); - // end = start + count (exclusive), idx = 3, phase = All, for each extent. - assert_eq!( - map.ranges(), - &[ - (100u32, 150u32, 3usize, Phase::All), - (1000u32, 1200u32, 3usize, Phase::All), - ], - "each extent maps to [start, start+count) at the given idx" - ); - // Spot-check the derived lookups: inside → idx 3, the exclusive end and - // the inter-extent gap → no key (pass-through). - assert_eq!(map.key_idx_for(100), Some(3), "range start is inclusive"); - assert_eq!(map.key_idx_for(149), Some(3), "last sector of extent 0"); - assert_eq!(map.key_idx_for(150), None, "extent end is exclusive"); - assert_eq!(map.key_idx_for(500), None, "gap between extents → no key"); - assert_eq!(map.key_idx_for(1199), Some(3), "last sector of extent 1"); - } - - /// A single-extent title still produces exactly one range with the correct - /// end (`saturating_add`), and a `sector_count` that would overflow u32 - /// saturates rather than wrapping past `u32::MAX`. - #[test] - fn content_map_single_extent_end_saturates() { - use crate::decrypt::Phase; - let mut t = DiscTitle::empty(); - t.extents = vec![Extent { - start_lba: u32::MAX - 10, - sector_count: 100, // (MAX-10)+100 would overflow → saturate to MAX - }]; - let map = super::content_map(&t, 0); - assert_eq!( - map.ranges(), - &[(u32::MAX - 10, u32::MAX, 0usize, Phase::All)], - "range end saturates at u32::MAX, no wrap" - ); - } - // ── resolve_fmts_key_map decision helpers (behaviors flagged by audit) ── // BEHAVIOR 1 — segment filter: a segment mapping inside the title's extents is kept, @@ -3175,38 +2091,6 @@ mod tests { ); } - // BEHAVIOR 2 — phase-tie default: all four arms of the even/odd clean-count decision. - #[test] - fn resolve_tie_phase_covers_all_arms() { - use crate::decrypt::Phase; - // Non-tie: the clean half is the index's real variant. - assert_eq!( - super::resolve_tie_phase(5, 2).unwrap(), - Phase::Even, - "even majority → Even" - ); - assert_eq!( - super::resolve_tie_phase(2, 5).unwrap(), - Phase::Odd, - "odd majority → Odd" - ); - // Padding tie (both halves clean, > 0): parity immaterial → default Even. - assert_eq!( - super::resolve_tie_phase(3, 3).unwrap(), - Phase::Even, - "even == odd > 0 → default Even" - ); - assert_eq!(super::resolve_tie_phase(1, 1).unwrap(), Phase::Even); - // Neither half clean (even == odd == 0): fail loud with FmtsKeyMissing. - let err = super::resolve_tie_phase(0, 0).unwrap_err(); - let expected = std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string(); - assert_eq!( - err.to_string(), - expected, - "even == odd == 0 → FmtsKeyMissing" - ); - } - // forensic + fills must cover every LBA of every extent EXACTLY once: no gap, no overlap. fn assert_gapless( extents: &[Extent], @@ -3323,1801 +2207,6 @@ 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 then AACS-encrypt it under key. - 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; - } - // Flag encrypted BEFORE encrypting: bytes 0..16 are the key seed. - u[0] |= 0xC0; - assert!( - crate::aacs::content::encrypt_unit(&mut u, key), - "a full-length unit must encrypt" - ); - 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 must classify as ReadFault, NOT WrongKey. - #[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: first same-index segment faults every read, second decrypts clean - // -> probe falls through. - #[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) ─ - - // Tiles a fixed ciphertext unit across each registered extent range, zeros elsewhere; low - // LBAs zero so FMTS misses and multi-CPS runs. - 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 that opens the extent's real ciphertext (idx 2, not 0). - #[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)], - 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: no held/fetched key opening a sample must surface DecryptFailed, never - // mis-key. - #[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)], - 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: pool missing the key, injected KeyFetch recovers it, map succeeds. - #[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)], - 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]| { - Ok(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" - ); - } - - // ── Multi-CPS extent cache: shared clips are sampled ONCE per disc ─────── - - /// LBA below which a read is disc metadata, not content sampling: the FMTS - /// branch probes the UDF anchor/metadata before concluding "not an FMTS disc", - /// and every content extent in these tests starts at or above this. - const CONTENT_LBA_FLOOR: u32 = 1000; - - // CipherSource plus a counter of CONTENT reads, excluding low-LBA UDF metadata probes. - struct CountingCipherSource { - inner: CipherSource, - probes: u32, - /// Reads BELOW `CONTENT_LBA_FLOOR` — the FMTS branch's UDF walk and - /// `IndividualSegment.tbl` load, whose per-title repetition the disc-wide - /// `FmtsTableCache` exists to remove. - meta: u32, - } - impl CountingCipherSource { - fn new(units: Vec<(u32, u32, Vec)>) -> Self { - Self { - inner: CipherSource { units }, - probes: 0, - meta: 0, - } - } - } - impl SectorSource for CountingCipherSource { - fn capacity_sectors(&self) -> u32 { - self.inner.capacity_sectors() - } - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - recovery: bool, - ) -> crate::error::Result { - if lba >= CONTENT_LBA_FLOOR { - self.probes += 1; - } else { - self.meta += 1; - } - self.inner.read_sectors(lba, count, buf, recovery) - } - } - - // A second title over the same extent must cost ZERO further reads and resolve the SAME - // index; a different extent must still be sampled. - #[test] - fn multi_cps_shared_extent_is_served_from_cache_not_resampled() { - let key_a = [0x01u8; 16]; - let key_b = [0x02u8; 16]; - let key_c = [0x03u8; 16]; - let shared_start = 1000u32; // ciphertext under key_c → pool index 2 - let other_start = 9000u32; // ciphertext under key_b → pool index 1 - let sectors = 30u32; // 10 aligned units → all 8 probes land - let mut reader = CountingCipherSource::new(vec![ - ( - shared_start, - shared_start + sectors, - encrypted_clean_unit(&key_c), - ), - ( - other_start, - other_start + sectors, - encrypted_clean_unit(&key_b), - ), - ]); - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)], - format: ContentFormat::BdTs, - }; - let mut cache = super::DiscKeyCache::new(); - - let title = multi_cps_title(shared_start, sectors); - let first = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("first title resolves"); - let after_first = reader.probes; - assert_eq!( - after_first, 8, - "the first title must actually sample the extent (8 probes)" - ); - assert_eq!(first.key_idx_for(shared_start), Some(2)); - - // Same clip referenced by another playlist → cache hit, no further reads. - let second = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("second title resolves"); - assert_eq!( - reader.probes, after_first, - "an identical extent must be served from cache with no further reads" - ); - assert_eq!( - second.key_idx_for(shared_start), - first.key_idx_for(shared_start), - "the cached index must equal the one originally resolved" - ); - - // A DIFFERENT extent is a miss: it must still be sampled, and must get its - // OWN index (1), never the cached neighbour's (2). - let third = multi_cps_title(other_start, sectors); - let map = super::resolve_mux_key_map_cached( - &mut reader, - &third, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("a different extent resolves"); - assert_eq!( - reader.probes, - after_first + 8, - "a different extent must not hit the cache" - ); - assert_eq!( - map.key_idx_for(other_start), - Some(1), - "each distinct extent keeps its own resolved index" - ); - } - - // A cache hit must equal a full recompute range-for-range (warm vs cold reader). - #[test] - fn multi_cps_cache_hit_matches_a_full_recompute() { - let key_a = [0x01u8; 16]; - let key_b = [0x02u8; 16]; - let key_c = [0x03u8; 16]; - let units = vec![ - (1000u32, 1030u32, encrypted_clean_unit(&key_c)), - (9000u32, 9030u32, encrypted_clean_unit(&key_b)), - ]; - let mut title = DiscTitle::empty(); - title.extents = vec![ - Extent { - start_lba: 1000, - sector_count: 30, - }, - Extent { - start_lba: 9000, - sector_count: 30, - }, - ]; - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)], - format: ContentFormat::BdTs, - }; - - // Warm a cache, then serve the whole title from it. - let mut warm = CountingCipherSource::new(units.clone()); - let mut cache = super::DiscKeyCache::new(); - for _ in 0..2 { - super::resolve_mux_key_map_cached( - &mut warm, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("warm resolve"); - } - let cached = super::resolve_mux_key_map_cached( - &mut warm, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("cached resolve"); - - // The same title resolved from scratch, re-reading every unit. - let mut cold = CountingCipherSource::new(units); - let recomputed = super::resolve_mux_key_map_cached( - &mut cold, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut super::DiscKeyCache::new(), - ) - .expect("cold resolve"); - - assert_eq!( - cached.ranges(), - recomputed.ranges(), - "a cache hit must produce byte-for-byte the same map as a recompute" - ); - assert_eq!(cold.probes, 16, "the cold resolve samples both extents"); - assert_eq!( - warm.probes, 16, - "the warm reader samples each extent exactly once across three resolves" - ); - } - - // An extent with no sampleable units inherits the PRECEDING extent's index (a per-title - // fact) and must never be memoised. - #[test] - fn multi_cps_inherited_index_is_not_cached() { - let key_a = [0x01u8; 16]; - let key_b = [0x02u8; 16]; - let key_c = [0x03u8; 16]; - let clear = 5000u32; // registered nowhere → reads as zeros → no samples - let mut reader = CountingCipherSource::new(vec![ - (1000, 1030, encrypted_clean_unit(&key_c)), // → index 2 - (9000, 9030, encrypted_clean_unit(&key_b)), // → index 1 - ]); - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)], - format: ContentFormat::BdTs, - }; - let mut cache = super::DiscKeyCache::new(); - - let mut with_c = DiscTitle::empty(); - with_c.extents = vec![ - Extent { - start_lba: 1000, - sector_count: 30, - }, - Extent { - start_lba: clear, - sector_count: 30, - }, - ]; - let a = super::resolve_mux_key_map_cached( - &mut reader, - &with_c, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("title A resolves"); - assert_eq!( - a.key_idx_for(clear), - Some(2), - "a clear extent inherits the preceding extent's index" - ); - - let mut with_b = DiscTitle::empty(); - with_b.extents = vec![ - Extent { - start_lba: 9000, - sector_count: 30, - }, - Extent { - start_lba: clear, - sector_count: 30, - }, - ]; - let b = super::resolve_mux_key_map_cached( - &mut reader, - &with_b, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("title B resolves"); - assert_eq!( - b.key_idx_for(clear), - Some(1), - "the inherited index must not be served from another title's cache entry" - ); - } - - // A fail-loud extent must not be memoised: a later retry (key now banked) must re-sample - // and succeed. - #[test] - fn multi_cps_failed_extent_is_not_cached() { - let key_a = [0x01u8; 16]; - let key_x = [0x09u8; 16]; - let start = 1000u32; - let mut reader = - CountingCipherSource::new(vec![(start, start + 30, encrypted_clean_unit(&key_x))]); - let title = multi_cps_title(start, 30); - let mut cache = super::DiscKeyCache::new(); - - // Two held keys → the multi-CPS sampling path (a one-key pool short-circuits - // to index 0 without sampling); neither opens the extent → fail loud. - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, key_a), (1, [0x02u8; 16])], - format: ContentFormat::BdTs, - }; - super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect_err("no held key opens the extent → fail loud"); - assert!(cache.cps.is_empty(), "a failed extent must not be memoised"); - - // The operator supplies the missing key; the retry must resolve it. - if let DecryptKeys::Aacs { unit_keys, .. } = &mut keys { - unit_keys.push((2, key_x)); - } - let map = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("the retry resolves once the key is banked"); - assert_eq!( - map.key_idx_for(start), - Some(2), - "the retry re-samples and resolves to the newly banked key" - ); - } - - // ── Fix 1: read-fault vs genuinely-not-FMTS in resolve_fmts_key_map ────── - - // Every read is a transient DiscRead fault (marginal live drive). - struct FaultSource; - impl SectorSource for FaultSource { - 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 { - Err(crate::error::Error::DiscRead { - sector: lba as u64, - status: None, - sense: None, - }) - } - } - - // A transient DiscRead fault reading UDF metadata must PROPAGATE, not be swallowed into - // not-FMTS Ok(None). - #[test] - fn resolve_fmts_key_map_read_fault_propagates() { - let mut reader = FaultSource; - let title = multi_cps_title(1000, 30); - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, [0x01u8; 16])], - format: ContentFormat::BdTs, - }; - let got = super::resolve_fmts_key_map( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut super::FmtsCache::default(), - &mut super::CpsUnitCache::default(), - ); - let err = got.expect_err("a transient DiscRead must fail loud, never Ok(None)"); - let expected = std::io::Error::from(crate::error::Error::DiscRead { - sector: 256, - status: None, - sense: None, - }) - .to_string(); - assert_eq!( - err.to_string(), - expected, - "the DiscRead fault must propagate" - ); - } - - // A structurally non-UDF reader is genuinely not FMTS: clean Ok(None), not fail-loud. - #[test] - fn resolve_fmts_key_map_not_udf_is_clean_none() { - // CipherSource with no registered units reads as all zeros everywhere, so - // read_filesystem sees tag_id 0 at sector 256 → UdfNotFilesystem. - let mut reader = CipherSource { units: Vec::new() }; - let title = multi_cps_title(1000, 30); - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, [0x01u8; 16])], - format: ContentFormat::BdTs, - }; - let got = super::resolve_fmts_key_map( - &mut reader, - &title, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut super::FmtsCache::default(), - &mut super::CpsUnitCache::default(), - ) - .expect("a structurally non-UDF disc is a clean not-FMTS negative"); - assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)"); - } - - // FMTS index-key resolution is memoised per DISC, not per title: EVEN - // units under the index key, ODD under the alternate; the doubles - // COUNT calls so tests can state exact cost. - - /// First LBA of the synthetic disc's content. Everything below is UDF metadata. - const FMTS_CONTENT_LBA: u32 = 10_000; - /// Sectors in the content extent — enough to cover both forensic segments. - const FMTS_CONTENT_SECTORS: u32 = 2_000; - /// The base CPS Unit Key (pool slot 0) of the synthetic disc. - const FMTS_BASE_KEY: [u8; 16] = [0x01u8; 16]; - /// The base Unit Key of a SECOND CPS unit (pool slot 1) on the multi-CPS - /// variant of the synthetic disc — the key of every content sector in - /// [`FMTS_CPS2_LBA`]'s extent. - const FMTS_CPS2_KEY: [u8; 16] = [0x02u8; 16]; - /// First LBA of the second CPS unit's extent, immediately after the - /// forensic clip. - const FMTS_CPS2_LBA: u32 = FMTS_CONTENT_LBA + FMTS_CONTENT_SECTORS; - /// Sectors in the second CPS unit's extent (200 aligned units). - const FMTS_CPS2_SECTORS: u32 = 600; - /// The disc's forensic index keys: element i = forensic index i+1. Two, not 32 — - /// the resolver sizes itself to whatever the source returns. - const FMTS_INDEX_KEYS: [[u8; 16]; 2] = [[0x21u8; 16], [0x22u8; 16]]; - /// The alternate interleaved variant's key: the ODD units of every segment are - /// encrypted under it, so they do NOT open under the segment's index key. - const FMTS_ALT_KEY: [u8; 16] = [0x99u8; 16]; - - /// The disc's forensic segments: one per index, 2560 packets each (the retail - /// size), on a 6144-byte-aligned start so units line up with sectors. - fn fmts_segments() -> Vec { - use crate::aacs::segment::Segment; - vec![ - Segment { - index: 1, - start_spn: 3200, - end_spn: 3200 + 2559, - }, - Segment { - index: 2, - start_spn: 6400, - end_spn: 6400 + 2559, - }, - ] - } - - /// Serialise `segs` as an `IndividualSegment.tbl` image (8-byte header: - /// type, record count, record size; then 16-byte records). - fn fmts_tbl(segs: &[crate::aacs::segment::Segment]) -> Vec { - let mut v = Vec::new(); - v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); - v.extend_from_slice(&(segs.len() as u16).to_be_bytes()); - v.extend_from_slice(&(crate::aacs::segment::SEGMENT_RECORD_LEN as u16).to_be_bytes()); - for s in segs { - v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); - v.extend_from_slice(&s.index.to_be_bytes()); - v.extend_from_slice(&1u16.to_be_bytes()); - v.extend_from_slice(&s.start_spn.to_be_bytes()); - v.extend_from_slice(&s.end_spn.to_be_bytes()); - } - v - } - - /// The synthetic FMTS disc. Counts UDF-metadata reads and content-probe reads - /// separately, and can be told to fault every read of one LBA span (a marginal - /// live drive) to exercise the read-fault path. - struct FmtsDisc { - meta_disc: crate::udf::fixture::MemDisc, - segs: Vec, - /// Reads below `FMTS_CONTENT_LBA`: the UDF walk + segment-table load. - meta_reads: u32, - /// Reads at/above `FMTS_CONTENT_LBA`: the anchor + phase probes. - probe_reads: u32, - /// `[start, end)` LBA span whose every read returns `DiscRead`. - fault_span: Option<(u32, u32)>, - /// When set, LBAs at/above [`FMTS_CPS2_LBA`] belong to a SECOND CPS - /// unit and are encrypted under [`FMTS_CPS2_KEY`], not the base key. - second_cps: bool, - /// `[start, end)` LBA span that reads back as zeros — CLEAR content, with - /// no encrypted unit to sample and so no CPS-unit evidence of its own. - clear_span: Option<(u32, u32)>, - /// An operator Stop that lands MID-resolve: the token is cancelled as soon - /// as this many reads of the given kind have been served. - cancel_after: Option<(crate::halt::Halt, CancelWhen, u32)>, - } - - /// Which read counter [`FmtsDisc::cancel_after`] watches. - #[derive(Clone, Copy, PartialEq, Eq)] - enum CancelWhen { - /// UDF-metadata reads — the Stop lands after the segment table is loaded and - /// before a single content sector has been touched. - Meta, - /// Content-probe reads — the Stop lands with the drive already working. - Probe, - } - - // Synthetic disc with extra IndividualSegment.tbl records appended; content region - // unchanged. - fn fmts_disc_with_extra_records(extra: &[crate::aacs::segment::Segment]) -> FmtsDisc { - let mut all = fmts_segments(); - all.extend_from_slice(extra); - let mut d = FmtsDisc::new(); - d.meta_disc = FmtsDisc::rebuild_meta(&all); - d - } - - impl FmtsDisc { - fn new() -> Self { - Self::with_forensic_clip(true) - } - - // UDF metadata image alone, with tbl_segs as the segment table. - fn rebuild_meta( - tbl_segs: &[crate::aacs::segment::Segment], - ) -> crate::udf::fixture::MemDisc { - let mut d = Self::build(true, tbl_segs); - std::mem::replace(&mut d.meta_disc, crate::udf::fixture::MemDisc::new()) - } - - fn with_forensic_clip(clip: bool) -> Self { - Self::build(clip, &fmts_segments()) - } - - fn build(clip: bool, tbl_segs: &[crate::aacs::segment::Segment]) -> Self { - use crate::udf::fixture::{ - DirSpec, PART_START, build_udf_skeleton, file, file_with, lay_dir, - }; - let segs = fmts_segments(); - let tbl_image = fmts_tbl(tbl_segs); - let mut meta_disc = crate::udf::fixture::MemDisc::new(); - build_udf_skeleton(&mut meta_disc, 10); - let mut subdirs = vec![DirSpec { - name: "AACS".to_string(), - icb_lba: 12, - dir_data_lba: 13, - files: vec![file_with("IndividualSegment.tbl", 14, 15, tbl_image, true)], - subdirs: Vec::new(), - }]; - if clip { - // The forensic FEATURE clip: `BDMV/STREAM/.fmts`, whose - // extents are the byte space segment SPNs are relative to - // (UDF-declared, at partition-relative LBA = FMTS_CONTENT_LBA). - subdirs.push(DirSpec { - name: "BDMV".to_string(), - icb_lba: 16, - dir_data_lba: 17, - files: Vec::new(), - subdirs: vec![DirSpec { - name: "STREAM".to_string(), - icb_lba: 18, - dir_data_lba: 19, - files: vec![file( - "00001.fmts", - 20, - FMTS_CONTENT_LBA - PART_START, - u64::from(FMTS_CONTENT_SECTORS) * 2048, - true, - )], - subdirs: Vec::new(), - }], - }); - } - lay_dir( - &mut meta_disc, - &DirSpec { - name: String::new(), - icb_lba: 10, - dir_data_lba: 11, - files: Vec::new(), - subdirs, - }, - ); - Self { - meta_disc, - segs, - meta_reads: 0, - probe_reads: 0, - fault_span: None, - second_cps: false, - clear_span: None, - cancel_after: None, - } - } - - // Same disc + a second CPS unit at FMTS_CPS2_LBA (ambiguous "base key"). - fn with_second_cps_unit() -> Self { - Self { - second_cps: true, - ..Self::new() - } - } - - // Flip the operator's Stop once the watched counter reaches its threshold. - fn maybe_cancel(&mut self) { - if let Some((h, when, n)) = &self.cancel_after { - let seen = match when { - CancelWhen::Meta => self.meta_reads, - CancelWhen::Probe => self.probe_reads, - }; - if seen >= *n { - h.cancel(); - } - } - } - - fn unit_at(&self, unit_byte: u64) -> Vec { - for s in &self.segs { - let sb = s.start_byte(); - if unit_byte >= sb && unit_byte < sb + s.byte_len() { - let n = (unit_byte - sb) / crate::aacs::content::ALIGNED_UNIT_LEN as u64; - let key = if n.is_multiple_of(2) { - FMTS_INDEX_KEYS[(s.index - 1) as usize] - } else { - FMTS_ALT_KEY - }; - return encrypted_clean_unit(&key); - } - } - encrypted_clean_unit(&FMTS_BASE_KEY) - } - } - - impl SectorSource for FmtsDisc { - 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 { - if let Some((a, b)) = self.fault_span - && lba >= a - && lba < b - { - self.probe_reads += 1; - self.maybe_cancel(); - return Err(crate::error::Error::DiscRead { - sector: lba as u64, - status: None, - sense: None, - }); - } - if lba < FMTS_CONTENT_LBA { - self.meta_reads += 1; - self.maybe_cancel(); - return self.meta_disc.read_sectors(lba, count, buf, recovery); - } - let want = count as usize * 2048; - // A clear span: readable, but carrying no encrypted unit at all. - if let Some((a, b)) = self.clear_span - && lba >= a - && lba < b - { - self.probe_reads += 1; - self.maybe_cancel(); - buf[..want].fill(0); - return Ok(want); - } - // The SECOND CPS unit's extent: ordinary (non-forensic) content, - // encrypted under that unit's own base Unit Key. - if self.second_cps && lba >= FMTS_CPS2_LBA { - self.probe_reads += 1; - self.maybe_cancel(); - let unit = encrypted_clean_unit(&FMTS_CPS2_KEY); - for s in 0..count as usize { - let within = ((lba as usize + s - FMTS_CPS2_LBA as usize) % 3) * 2048; - buf[s * 2048..(s + 1) * 2048].copy_from_slice(&unit[within..within + 2048]); - } - return Ok(want); - } - self.probe_reads += 1; - self.maybe_cancel(); - buf[..want].fill(0); - for s in 0..count as u32 { - let off = (lba + s - FMTS_CONTENT_LBA) as u64; - let unit_byte = (off / 3) * crate::aacs::content::ALIGNED_UNIT_LEN as u64; - let within = (off % 3) as usize * 2048; - let unit = self.unit_at(unit_byte); - let dst = s as usize * 2048; - buf[dst..dst + 2048].copy_from_slice(&unit[within..within + 2048]); - } - Ok(want) - } - } - - // A KeyFetch whose fmts_indexes counts calls, replying with the full index-key set only for - // a genuine index-1 anchor batch. - fn counting_fmts_fetch( - calls: std::sync::Arc, - ) -> crate::sector::KeyFetch { - crate::sector::KeyFetch::new( - std::sync::Arc::new(|_| Ok(Vec::new())), - std::sync::Arc::new(move |batch: &[Vec]| { - calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let Some(first) = batch.first() else { - return Ok(Vec::new()); - }; - let mut u = first.clone(); - crate::aacs::content::decrypt_unit(&mut u, &FMTS_INDEX_KEYS[0]); - Ok(if crate::aacs::content::is_clean(&u, ContentFormat::BdTs) { - FMTS_INDEX_KEYS.to_vec() - } else { - Vec::new() - }) - }), - ) - } - - /// per evidence (no public spec); do not change without evidence proving otherwise — - /// KS-26: "the decode server returns the whole set only for an index-1 anchor". - #[test] - fn fmts_anchor_is_index_1() { - use crate::spec::keys::KS_26_FMTS_ANCHOR_EVIDENCE as KS_26; - assert_eq!(KS_26.kind, crate::spec::QuoteKind::Evidence); - assert!(KS_26.text.contains("only for an index-1 anchor")); - // Per batch sent: (opens under the index-1 key, opens under the index-2 key). - let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let log = sent.clone(); - let opens = |batch: &[Vec], key: &[u8; 16]| { - let mut u = batch[0].clone(); - crate::aacs::content::decrypt_unit(&mut u, key); - crate::aacs::content::is_clean(&u, ContentFormat::BdTs) - }; - let fetch = crate::sector::KeyFetch::new( - std::sync::Arc::new(|_| Ok(Vec::new())), - std::sync::Arc::new(move |batch: &[Vec]| { - let hit = ( - opens(batch, &FMTS_INDEX_KEYS[0]), - opens(batch, &FMTS_INDEX_KEYS[1]), - ); - log.lock().unwrap().push(hit); - Ok(if hit.0 { - FMTS_INDEX_KEYS.to_vec() - } else { - Vec::new() - }) - }), - ); - let clip = [Extent { - start_lba: FMTS_CONTENT_LBA, - sector_count: FMTS_CONTENT_SECTORS, - }]; - let mut segs = fmts_segments(); - segs.reverse(); // the index-2 segment is listed (and readable) first - let mut disc = FmtsDisc::new(); - super::probe_fmts_index_keys(&mut disc, &clip, &segs, &fetch, ContentFormat::BdTs, None) - .expect("anchors from the index-1 segment"); - let sent = sent.lock().unwrap(); - assert_eq!( - sent.first(), - Some(&(true, false)), - "the first batch is index 1's" - ); - assert!( - sent.iter().all(|&(_, idx2)| !idx2), - "no index-2 batch is sent: {sent:?}" - ); - } - - fn fmts_title(sectors: u32) -> DiscTitle { - multi_cps_title(FMTS_CONTENT_LBA, sectors) - } - - fn fmts_keys() -> DecryptKeys { - DecryptKeys::Aacs { - unit_keys: vec![(0, FMTS_BASE_KEY)], - format: ContentFormat::BdTs, - } - } - - // A play-all title over BOTH CPS units. - fn fmts_two_cps_title() -> DiscTitle { - let mut t = DiscTitle::empty(); - t.extents = vec![ - Extent { - start_lba: FMTS_CONTENT_LBA, - sector_count: FMTS_CONTENT_SECTORS, - }, - Extent { - start_lba: FMTS_CPS2_LBA, - sector_count: FMTS_CPS2_SECTORS, - }, - ]; - t - } - - // The disc's two BASE CPS Unit Keys, in Unit_Key_RO.inf order. - fn fmts_two_cps_keys() -> DecryptKeys { - DecryptKeys::Aacs { - unit_keys: vec![(1, FMTS_BASE_KEY), (2, FMTS_CPS2_KEY)], - format: ContentFormat::BdTs, - } - } - - // The non-forensic gap fill must use each LBA's own CPS unit key, not hardcode pool slot 0. - #[test] - fn fmts_gap_fill_uses_each_lbas_own_cps_unit_key_not_pool_slot_zero() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls.clone()); - let mut reader = FmtsDisc::with_second_cps_unit(); - let mut keys = fmts_two_cps_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_two_cps_title(); - - let map = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("a two-CPS-unit FMTS disc resolves"); - - // CPS unit 1 (the forensic clip): the forensic segments keep their own - // index-key slots, and the gaps between them take unit 1's key, which - // IS pool slot 0 here. - assert_eq!( - map.key_idx_for(10_300), - Some(2), - "segment index 1 keys to its own pool slot (appended after the 2 base keys)" - ); - assert_eq!( - map.key_idx_for(10_600), - Some(3), - "segment index 2 keys to its own pool slot" - ); - assert_eq!( - map.key_idx_for(10_000), - Some(0), - "a non-segment LBA of CPS unit 1 takes CPS unit 1's key" - ); - - // CPS unit 2: every LBA of this extent must take the SECOND base Unit - // Key (pool slot 1). Slot 0 here is CPS unit 1's key — the defect. - for lba in [FMTS_CPS2_LBA, FMTS_CPS2_LBA + 300, FMTS_CPS2_LBA + 599] { - assert_eq!( - map.key_idx_for(lba), - Some(1), - "LBA {lba} is in CPS unit 2 and must take CPS unit 2's key, not unit 1's" - ); - } - - // Nothing outside the title's extents is keyed at all. - assert_eq!( - map.key_idx_for(FMTS_CPS2_LBA + FMTS_CPS2_SECTORS), - None, - "past the last extent is nav/filesystem and passes through" - ); - } - - fn pool_of(keys: &DecryptKeys) -> Vec<(u32, [u8; 16])> { - match keys { - DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(), - _ => Vec::new(), - } - } - - // The FMTS branch must resolve UDF walk + probes + key service ONCE per disc, not once per - // playlist. - #[test] - fn fmts_index_keys_resolved_once_per_disc_not_once_per_title() { - use std::sync::atomic::Ordering; - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls.clone()); - let mut reader = FmtsDisc::new(); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - - let first = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("the synthetic FMTS disc resolves"); - let (meta1, probe1) = (reader.meta_reads, reader.probe_reads); - let calls1 = calls.load(Ordering::SeqCst); - assert!(meta1 > 0, "the first title really does walk the UDF tree"); - assert_eq!( - probe1, 40, - "the first title pays the anchor (8 reads) plus a 16-read phase probe per index" - ); - assert_eq!(calls1, 1, "ONE anchor batch resolves the whole index set"); - // The map really is the forensic one: two segment ranges on their index-key - // slots with the probed phase, and the base key over the rest. - assert_eq!( - first.key_idx_for(10_300), - Some(1), - "segment index 1 keys to its own pool slot" - ); - assert_eq!( - first.key_idx_for(10_600), - Some(2), - "segment index 2 keys to its own pool slot" - ); - assert_eq!( - first.key_idx_for(10_000), - Some(0), - "base key outside segments" - ); - - // 59 more playlists of the same clip — the shape of a real BD. - for _ in 0..59 { - let again = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("a later title resolves from the memo"); - assert_eq!( - again.ranges(), - first.ranges(), - "a memoised title must produce the byte-identical map" - ); - } - assert_eq!( - reader.meta_reads, meta1, - "the UDF walk / segment table is a DISC fact: exactly one walk for 60 titles" - ); - assert_eq!( - reader.probe_reads, probe1, - "the anchor and phase probes must not re-run per title" - ); - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "the key service must be asked ONCE per disc, not once per playlist" - ); - // And the pool grew exactly once — the index keys were banked, not duplicated. - assert_eq!( - pool_of(&keys).len(), - 1 + FMTS_INDEX_KEYS.len(), - "the base key plus one slot per forensic index, appended once" - ); - } - - // Same three titles via a SHARED memo must equal a FRESH-memo-per-title recomputation. - #[test] - fn fmts_memoised_map_equals_per_title_recomputation() { - let titles = [ - fmts_title(FMTS_CONTENT_SECTORS), - fmts_title(FMTS_CONTENT_SECTORS), - // A DIFFERENT extent list (a longer clip): a memo miss that must be - // recomputed, not served the first title's answer. - fmts_title(FMTS_CONTENT_SECTORS + 500), - ]; - - let mut shared_cache = super::DiscKeyCache::new(); - let mut shared_keys = fmts_keys(); - let mut reader = FmtsDisc::new(); - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls.clone()); - let shared: Vec<_> = titles - .iter() - .map(|t| { - super::resolve_mux_key_map_cached( - &mut reader, - t, - &mut shared_keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut shared_cache, - ) - .expect("shared-memo resolve") - }) - .collect(); - - let mut fresh_keys = fmts_keys(); - let mut reader2 = FmtsDisc::new(); - let calls2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch2 = counting_fmts_fetch(calls2.clone()); - let fresh: Vec<_> = titles - .iter() - .map(|t| { - super::resolve_mux_key_map_cached( - &mut reader2, - t, - &mut fresh_keys, - Some(&fetch2), - ContentFormat::BdTs, - None, - // A fresh memo per title == the unmemoised behaviour. - &mut super::DiscKeyCache::new(), - ) - .expect("per-title recompute") - }) - .collect(); - - for (i, (a, b)) in shared.iter().zip(fresh.iter()).enumerate() { - assert_eq!( - a.ranges(), - b.ranges(), - "title {i}: the memoised map must equal the per-title recomputation" - ); - } - assert_eq!( - pool_of(&shared_keys), - pool_of(&fresh_keys), - "the key pool must end in the same state — same keys, same slots, same order" - ); - // And the memo really was doing work: the unmemoised run paid 3 walks and 3 - // key-service calls where the memoised one paid 1 walk and 2 calls (title 3's - // extent list is a genuine miss). - assert!( - reader2.meta_reads > reader.meta_reads, - "the per-title recomputation re-walks the UDF tree; the memoised run does not" - ); - assert_eq!( - ( - calls.load(std::sync::atomic::Ordering::SeqCst), - calls2.load(std::sync::atomic::Ordering::SeqCst) - ), - (2, 3), - "memoised: one call per DISTINCT extent list; unmemoised: one per title" - ); - } - - // A DIFFERENT extent list is a genuine memo miss and must re-probe. - #[test] - fn fmts_different_extent_list_is_a_miss_and_reprobes() { - use std::sync::atomic::Ordering; - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls.clone()); - let mut reader = FmtsDisc::new(); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - - let a = fmts_title(FMTS_CONTENT_SECTORS); - super::resolve_mux_key_map_cached( - &mut reader, - &a, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("first title resolves"); - let (meta1, probe1) = (reader.meta_reads, reader.probe_reads); - - let b = fmts_title(FMTS_CONTENT_SECTORS + 500); - super::resolve_mux_key_map_cached( - &mut reader, - &b, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("second title resolves"); - assert_eq!( - reader.meta_reads, meta1, - "the segment table is disc-wide: still no second UDF walk" - ); - assert_eq!( - reader.probe_reads, - probe1 * 2, - "a different extent list must re-probe from its own bytes" - ); - assert_eq!( - calls.load(Ordering::SeqCst), - 2, - "and re-ask the key service for that extent list" - ); - } - - // A read-faulted phase (Phase::Verify) must NOT be memoised; the next title re-probes. - #[test] - fn fmts_read_faulted_phase_is_not_memoised() { - use std::sync::atomic::Ordering; - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls.clone()); - let mut reader = FmtsDisc::new(); - // Fault every read of the index-2 segment (clip bytes 6400*192.. → LBAs - // 10600..10840): the anchor (index 1) still succeeds, so the keys are in - // hand, but index 2's phase probe has zero decrypt evidence. - reader.fault_span = Some((10_600, 10_840)); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - - let first = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("a read fault must NOT abort a rip whose index keys are good"); - assert_eq!( - first.entry_for(10_600).map(|(_, p, _)| p), - Some(crate::decrypt::Phase::Verify), - "the read-faulted index falls back to Phase::Verify (K-5)" - ); - assert_eq!( - first.entry_for(10_300).map(|(_, p, _)| p), - Some(crate::decrypt::Phase::Even), - "the index that DID probe keeps its resolved phase" - ); - let probe1 = reader.probe_reads; - - super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("second title resolves"); - assert!( - reader.probe_reads > probe1, - "a read-faulted run must not be memoised — the next title re-probes" - ); - assert_eq!( - calls.load(Ordering::SeqCst), - 2, - "and re-anchors rather than inheriting a degraded answer" - ); - } - - /// LK14 (K-5), per evidence (no public FMTS spec) — KS-25, KS-26: an index whose every - /// phase probe faulted is decrypted unit by unit, each kept only if it verifies; per spec - /// KS-3 [BD] §3.10.1 "A new CBC cipher chain is started for each Aligned Unit". - #[test] - fn fmts_verify_phase_drops_wrong_half() { - use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, is_clean}; - use crate::spec::keys::{ - KS_3_CBC_PER_UNIT, KS_25_AACS2_EVIDENCE, KS_26_FMTS_ANCHOR_EVIDENCE, - }; - assert!( - KS_3_CBC_PER_UNIT - .text - .contains("new CBC cipher chain is started for each") - ); - assert_eq!(KS_25_AACS2_EVIDENCE.kind, crate::spec::QuoteKind::Evidence); - assert_eq!( - KS_26_FMTS_ANCHOR_EVIDENCE.kind, - crate::spec::QuoteKind::Evidence - ); - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - // Every read of the index-2 segment (LBAs 10600..10840) faults: its phase is unknown. - let mut reader = FmtsDisc::new(); - reader.fault_span = Some((10_600, 10_840)); - let mut keys = fmts_keys(); - let map = super::resolve_mux_key_map( - &mut reader, - &fmts_title(FMTS_CONTENT_SECTORS), - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - ) - .expect("a read-faulted phase probe never aborts the rip"); - // Later the segment reads: unit 0 is index 2's half, unit 1 the other variant's. - let mut buf = vec![0u8; 2 * ALIGNED_UNIT_LEN]; - FmtsDisc::new() - .read_sectors(10_600, 6, &mut buf, false) - .unwrap(); - let other_half = buf[ALIGNED_UNIT_LEN..].to_vec(); - crate::decrypt::decrypt_sectors_mapped(&mut buf, &keys, 10_600, &map) - .expect("a unit that fails the verify is left as ciphertext, not an error"); - let (ours, theirs) = buf.split_at(ALIGNED_UNIT_LEN); - assert!(is_clean(ours, ContentFormat::BdTs), "our half decrypts"); - assert!( - !aacs_unit_encrypted(ours, ContentFormat::BdTs), - "and reads clear" - ); - assert!(theirs == other_half, "the wrong half is left as ciphertext"); - assert!( - aacs_unit_encrypted(theirs, ContentFormat::BdTs), - "still flagged" - ); - } - - // The UDF walk deciding FMTS-or-not must be attempted ONCE per disc, not once per playlist. - #[test] - fn non_fmts_disc_walks_the_filesystem_once_for_every_title() { - let key_a = [0x01u8; 16]; - let key_b = [0x02u8; 16]; - // All-zero metadata → no AVDP at sector 256 → `UdfNotFilesystem`, the - // deterministic "not an FMTS disc" negative that IS memoised. - let mut reader = CountingCipherSource::new(vec![ - (1000, 1030, encrypted_clean_unit(&key_b)), - (9000, 9030, encrypted_clean_unit(&key_b)), - ]); - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, key_a), (1, key_b)], - format: ContentFormat::BdTs, - }; - let mut cache = super::DiscKeyCache::new(); - - super::resolve_mux_key_map_cached( - &mut reader, - &multi_cps_title(1000, 30), - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("first title resolves"); - let meta1 = reader.meta; - assert!( - meta1 > 0, - "the first title really does attempt the UDF walk" - ); - - // A second title with DIFFERENT extents (so the CPS memo misses and the - // title is genuinely resolved) must still not re-walk the filesystem. - super::resolve_mux_key_map_cached( - &mut reader, - &multi_cps_title(9000, 30), - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("second title resolves"); - assert_eq!( - reader.meta, meta1, - "the not-FMTS verdict is a disc fact: one filesystem walk for the disc" - ); - } - - // Halt must stay responsive even when both FMTS memos are warm and a title does no I/O. - #[test] - fn fmts_memoised_title_still_honors_halt() { - use crate::halt::Halt; - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = FmtsDisc::new(); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - let halt = Halt::new(); - - // Warm both memos. - super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - Some(&halt), - &mut cache, - ) - .expect("first title resolves"); - - halt.cancel(); - let err = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - Some(&halt), - &mut cache, - ) - .expect_err("a cancelled halt must abort even a fully-memoised title"); - assert!(crate::error::is_halt(&err), "expected Halted, got: {err}"); - } - - // The single-CPS short-circuit must depend on the count of BASE CPS Unit Keys, NOT - // whole-pool length (FMTS appends forensic keys). - #[test] - fn single_cps_short_circuit_survives_a_forensic_title_resolving_first() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = FmtsDisc::new(); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - - // 1) The FORENSIC title resolves FIRST and banks the disc's index keys into - // the shared pool. - let forensic = fmts_title(FMTS_CONTENT_SECTORS); - super::resolve_mux_key_map_cached( - &mut reader, - &forensic, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("the forensic title resolves"); - assert_eq!( - pool_of(&keys).len(), - 1 + FMTS_INDEX_KEYS.len(), - "the forensic index keys really are banked into the shared pool" - ); - let probes_after_forensic = reader.probe_reads; - - // 2) A NON-forensic title (menu playlist, extents outside the forensic - // clip) still has ONE base CPS Unit Key, so it takes the single-CPS - // short-circuit — no sampling reads (multi-CPS WOULD succeed if run). - let menu = multi_cps_title(FMTS_CONTENT_LBA + 9_000, 300); - let map = super::resolve_mux_key_map_cached( - &mut reader, - &menu, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("a single-CPS menu title must not need the sampling path"); - assert_eq!( - reader.probe_reads, probes_after_forensic, - "single base CPS key → the short-circuit fires, so NO multi-CPS sampling" - ); - assert_eq!( - map.ranges(), - super::content_map(&menu, 0).ranges(), - "one base CPS key → key 0 over every content extent" - ); - } - - /// First extent of the play-all playlist's TRAILER clip in the defect-2 test. - /// Placed AFTER the forensic clip on disc but FIRST in the extent list — the - /// authoring order that broke the segment byte-space anchor. - const TRAILER_LBA: u32 = FMTS_CONTENT_LBA + FMTS_CONTENT_SECTORS + 1_000; - /// Sectors in the trailer clip — exactly the byte offset of forensic segment 1 - /// (3200 * 192 = 614_400 = 300 * 2048), so a wrong anchor is off by exactly the - /// trailer's length and the two candidate LBAs are unambiguous. - const TRAILER_SECTORS: u32 = 300; - - // Forensic segment SPNs live in the FORENSIC CLIP's byte space, not the title's first - // extent (a trailer-first play-all playlist). - #[test] - fn forensic_segments_anchor_to_the_forensic_clip_not_the_titles_first_extent() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = FmtsDisc::new(); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - - // A play-all playlist: the trailer clip FIRST, the forensic feature second. - let mut title = DiscTitle::empty(); - title.extents = vec![ - Extent { - start_lba: TRAILER_LBA, - sector_count: TRAILER_SECTORS, - }, - Extent { - start_lba: FMTS_CONTENT_LBA, - sector_count: FMTS_CONTENT_SECTORS, - }, - ]; - - let map = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("a play-all playlist carrying the forensic clip must resolve"); - - // Segment index 1 starts at clip byte 3200 * 192 = 614_400 → sector 300 OF - // THE FORENSIC CLIP → LBA 10_300. Segment index 2: 6400 * 192 = 1_228_800 → - // sector 600 → LBA 10_600. - assert_eq!( - map.key_idx_for(FMTS_CONTENT_LBA + 300), - Some(1), - "segment index 1 maps to its own key at the FORENSIC clip's offset" - ); - assert_eq!( - map.key_idx_for(FMTS_CONTENT_LBA + 600), - Some(2), - "segment index 2 maps to its own key at the FORENSIC clip's offset" - ); - assert_eq!( - map.key_idx_for(FMTS_CONTENT_LBA), - Some(0), - "the forensic clip's first sectors are ordinary base-key content" - ); - assert_eq!( - map.key_idx_for(TRAILER_LBA), - Some(0), - "the trailer clip is base-key content — never a forensic index key" - ); - assert_eq!( - map.key_idx_for(TRAILER_LBA + TRAILER_SECTORS - 1), - Some(0), - "and the trailer's last sector too" - ); - } - - // A disc with a segment table but no identifiable forensic clip must fail LOUD - // (FmtsKeyMissing), never guess an anchor. - #[test] - fn unidentifiable_forensic_clip_fails_loud_rather_than_guessing_an_anchor() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = FmtsDisc::with_forensic_clip(false); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - - let err = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect_err("an unanchorable segment map must never be built"); - assert_eq!( - err.to_string(), - std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string(), - "the forensic-content-without-an-anchor verdict is FmtsKeyMissing" - ); - assert_eq!( - pool_of(&keys).len(), - 1, - "and nothing was banked into the pool" - ); - } - - // The base-CPS count must exclude banked forensic index keys (tagged FMTS_POOL_TAG_BASE+n). - #[test] - fn single_base_key_slot_counts_cps_units_not_banked_forensic_keys() { - let base = [0x01u8; 16]; - let idx1 = [0x21u8; 16]; - assert_eq!( - super::single_base_key_slot(&[(1, base)]), - Some(0), - "one base CPS key → its slot" - ); - assert_eq!( - super::single_base_key_slot(&[ - (1, base), - (super::FMTS_POOL_TAG_BASE + 1, idx1), - (super::FMTS_POOL_TAG_BASE + 2, [0x22u8; 16]), - ]), - Some(0), - "banked forensic index keys must not turn a single-CPS disc into multi-CPS" - ); - assert_eq!( - super::single_base_key_slot(&[(1, base), (2, idx1)]), - None, - "two real CPS units → the multi-CPS sampling path" - ); - assert_eq!( - super::single_base_key_slot(&[]), - None, - "an empty pool has no base key" - ); - assert_eq!( - super::single_base_key_slot(&[(super::FMTS_POOL_TAG_BASE, idx1), (7, base)]), - Some(1), - "the slot is the BASE key's, wherever it sits in the pool" - ); - } - // Forensic ranges arrive in table RECORD order, not LBA order; the gap walk's forward sweep // needs them sorted. #[test] @@ -5139,603 +2228,4 @@ mod tests { // The load-bearing invariant: exactly one range covers every content LBA. assert_gapless(&ext, &forensic, &fills); } - - // Records every (lba, count) requested and serves a caller-chosen aligned unit. - struct RecordingSource { - reads: Vec<(u32, u16)>, - unit: Vec, - } - impl SectorSource for RecordingSource { - 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 { - self.reads.push((lba, count)); - let want = count as usize * 2048; - buf[..want].copy_from_slice(&self.unit[..want]); - Ok(want) - } - } - - // A read-time key-source FAILURE must surface as the source's own error, not - // be flattened into DecryptFailed ("no key for this unit"). - #[test] - fn base_slot_reports_a_key_source_failure_as_itself() { - struct Down; - impl crate::keysource::KeySource for Down { - fn get_unit_keys( - &self, - _ctx: &dyn crate::keysource::ResolveCtx, - ) -> Result, crate::error::Error> { - Err(crate::error::Error::KeyServiceUnavailable) - } - } - let mut src = RecordingSource { - reads: Vec::new(), - unit: encrypted_clean_unit(&[0x5Au8; 16]), - }; - let inputs = crate::keysource::DiscInputs { - disc_hash: String::new(), - volume_id: [0u8; 16], - version: 2, - mkb: Vec::new(), - unit_key_ro: Vec::new(), - samples: Vec::new(), - volume_label: None, - }; - let fetch = crate::keysource::key_fetch( - inputs, - std::sync::Arc::new(|| vec![Box::new(Down) as Box]), - ); - let mut keys = crate::decrypt::DecryptKeys::Aacs { - unit_keys: Vec::new(), - format: ContentFormat::BdTs, - }; - let ext = crate::disc::Extent { - start_lba: 3000, - sector_count: 270, - }; - let err = super::base_slot_for_extent( - &mut src, - &ext, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - &mut super::CpsUnitCache::new(), - 0, - ) - .expect_err("no key resolved"); - assert_eq!( - err.to_string(), - std::io::Error::from(crate::error::Error::KeyServiceUnavailable).to_string() - ); - } - - // sample_encrypted_units: 8 probes at total*p/9 for p in 1..=8, pinned exactly. - #[test] - fn sample_encrypted_units_probes_eight_aligned_units_spread_across_the_extent() { - let unit = encrypted_clean_unit(&[0x5Au8; 16]); - let mut src = RecordingSource { - reads: Vec::new(), - unit: unit.clone(), - }; - // 90 aligned units (270 sectors) from LBA 3000. - let got = super::sample_encrypted_units(&mut src, 3000, 270, ContentFormat::BdTs); - assert_eq!( - src.reads, - vec![ - (3000 + 10 * 3, 3), - (3000 + 20 * 3, 3), - (3000 + 30 * 3, 3), - (3000 + 40 * 3, 3), - (3000 + 50 * 3, 3), - (3000 + 60 * 3, 3), - (3000 + 70 * 3, 3), - (3000 + 80 * 3, 3), - ], - "8 probes at total*p/9 aligned units, each a whole 3-sector unit, \ - anchored on the extent start" - ); - assert_eq!(got.len(), 8, "every encrypted probe is returned"); - assert!( - got.iter().all(|s| *s == unit), - "the returned samples are the units as read" - ); - } - - // Only genuinely AACS-encrypted units come back; a clear extent yields NO samples. - #[test] - fn sample_encrypted_units_drops_clear_units_and_reads_nothing_below_one_unit() { - // A clear unit: the AACS scrambling bits in byte 0 are zero. - let mut clear = encrypted_clean_unit(&[0x5Au8; 16]); - clear[0] &= 0x3F; - let mut src = RecordingSource { - reads: Vec::new(), - unit: clear, - }; - let got = super::sample_encrypted_units(&mut src, 3000, 270, ContentFormat::BdTs); - assert_eq!(src.reads.len(), 8, "the probes are still attempted"); - assert!(got.is_empty(), "a clear unit is not evidence of any key"); - - // Under one whole aligned unit there is nothing to sample: no read at all. - let mut src = RecordingSource { - reads: Vec::new(), - unit: encrypted_clean_unit(&[0x5Au8; 16]), - }; - let got = super::sample_encrypted_units(&mut src, 3000, 2, ContentFormat::BdTs); - assert!( - src.reads.is_empty(), - "an extent shorter than one aligned unit must not touch the drive" - ); - assert!(got.is_empty()); - } - - // pick_pool_slot answers "which of THESE slots, in THIS order, opens the extent" — both - // restriction and order are load-bearing. - #[test] - fn pick_pool_slot_honours_the_caller_s_slot_list_and_its_order() { - let k0 = [0x01u8; 16]; - let k1 = [0x02u8; 16]; - let k2 = [0x03u8; 16]; - let pool = vec![(0u32, k0), (1, k1), (2, k2)]; - // One sample opens under slot 0, another under slot 2. Slot 1 opens neither. - let samples = vec![encrypted_clean_unit(&k0), encrypted_clean_unit(&k2)]; - - assert_eq!( - super::pick_pool_slot(&samples, &pool, &[2, 0], ContentFormat::BdTs), - Some(2), - "the FIRST slot in the caller's order that opens a sample wins" - ); - assert_eq!( - super::pick_pool_slot(&samples, &pool, &[0, 2], ContentFormat::BdTs), - Some(0), - "reversing the caller's order reverses the answer" - ); - assert_eq!( - super::pick_pool_slot(&samples, &pool, &[1], ContentFormat::BdTs), - None, - "a slot list that excludes every opening key resolves to nothing" - ); - assert_eq!( - super::pick_pool_slot(&samples, &pool, &[7], ContentFormat::BdTs), - None, - "an out-of-range slot is skipped, not panicked on" - ); - assert_eq!( - super::pick_pool_slot(&[], &pool, &[0, 1, 2], ContentFormat::BdTs), - None, - "no samples is no evidence" - ); - } - - // Extents are [start_lba, start_lba + sector_count) — half open, both ends. - #[test] - fn extents_overlap_is_half_open_at_both_ends() { - let at = |start_lba, sector_count| { - vec![Extent { - start_lba, - sector_count, - }] - }; - assert!( - !super::extents_overlap(&at(100, 10), &at(110, 10)), - "b starts exactly where a ends → no shared sector" - ); - assert!( - !super::extents_overlap(&at(110, 10), &at(100, 10)), - "and the same the other way round" - ); - assert!( - super::extents_overlap(&at(100, 11), &at(110, 10)), - "one shared sector (110) IS an overlap" - ); - assert!( - super::extents_overlap(&at(110, 10), &at(100, 11)), - "and the same the other way round" - ); - assert!( - super::extents_overlap(&at(100, 100), &at(120, 5)), - "wholly contained is an overlap" - ); - assert!( - !super::extents_overlap(&at(100, 10), &[]), - "nothing overlaps an empty extent list" - ); - assert!( - !super::extents_overlap(&[], &at(100, 10)), - "in either position" - ); - // A LATER extent of `a` matching is still an overlap — the scan must not - // stop at the first extent of either list. - assert!( - super::extents_overlap(&[at(0, 10)[0], at(500, 10)[0]], &at(505, 10)), - "any extent pair sharing a sector is an overlap" - ); - } - - // ── An unmappable forensic record is a HOLE, and a hole is a hard failure ── - // Four arms refuse to emit a range and tally `unresolved`; drop the tally - // and those LBAs decrypt as garbage under the wrong key, `lost_bytes == 0`. - - fn fmts_missing_err() -> String { - std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string() - } - - // Resolve the synthetic FMTS disc with extra bogus records appended, return the error text. - fn fmts_resolve_err_with(extra: &[crate::aacs::segment::Segment]) -> String { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = fmts_disc_with_extra_records(extra); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect_err("a forensic record that maps to no range must abort the disc") - .to_string() - } - - // Control: the SAME resolve with no extra records succeeds (guards the four tests below). - #[test] - fn fmts_baseline_table_resolves_so_the_unmappable_record_tests_mean_something() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = fmts_disc_with_extra_records(&[]); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - let map = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("the well-formed synthetic FMTS disc resolves"); - // And it really is the forensic map: the segments carry their own index-key - // slots (appended after the single base key), the gaps carry the base key. - assert_eq!(map.key_idx_for(10_300), Some(1), "segment 1 → index key 1"); - assert_eq!(map.key_idx_for(10_600), Some(2), "segment 2 → index key 2"); - assert_eq!( - map.key_idx_for(10_000), - Some(0), - "a gap → the base Unit Key" - ); - } - - // Arm 1 — an INVERTED record (start_spn > end_spn) is refused, not base-keyed. - #[test] - fn fmts_inverted_segment_record_aborts_rather_than_base_keying_the_hole() { - let bad = crate::aacs::segment::Segment { - index: 1, - start_spn: 12_000, - end_spn: 11_000, - }; - assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); - } - - // Arm 2 — a record whose forensic INDEX has no key must abort, not base-key. - #[test] - fn fmts_record_with_no_index_key_aborts_rather_than_base_keying_the_hole() { - let bad = crate::aacs::segment::Segment { - index: 3, - start_spn: 12_000, - end_spn: 12_000 + 2559, - }; - assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); - } - - // Arm 3 — record START addressable but END runs past the clip's last byte. - #[test] - fn fmts_record_running_past_the_clips_end_aborts_rather_than_base_keying_it() { - // The clip is FMTS_CONTENT_SECTORS * 2048 bytes = 21_333 whole packets. - let bad = crate::aacs::segment::Segment { - index: 2, - start_spn: 21_000, // byte 4_032_000 — inside the clip - end_spn: 22_000, // byte 4_224_191 — past its 4_096_000-byte end - }; - assert!( - crate::aacs::segment::clip_byte_to_lba( - &[Extent { - start_lba: FMTS_CONTENT_LBA, - sector_count: FMTS_CONTENT_SECTORS, - }], - bad.start_byte(), - ) - .is_some(), - "the fixture must reach the END check — its START has to be addressable" - ); - assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); - } - - // Arm 4 — a record off the aligned-unit grid (sector-crossing count disagrees with - // byte-length count) must abort. - #[test] - fn fmts_record_off_the_aligned_unit_grid_aborts_rather_than_spanning_wrongly() { - let bad = crate::aacs::segment::Segment { - index: 2, - start_spn: 10, // clip byte 1920 — 1920 % 2048 != 0 - end_spn: 19, // last byte 3839 — a different sector, but < 2048 long - }; - assert_eq!(fmts_resolve_err_with(&[bad]), fmts_missing_err()); - } - - // The multi-CPS inheritance chain must run THROUGH a cache hit (hit arm feeds last_idx), - // else a later unsampleable extent inherits slot 0. - #[test] - fn multi_cps_cache_hit_still_feeds_the_next_extents_inheritance() { - let key_a = [0x01u8; 16]; - let key_b = [0x02u8; 16]; - let key_c = [0x03u8; 16]; - let shared = 1000u32; - let clear = 5000u32; // no registered ciphertext → zeros → no samples - let sectors = 30u32; - let mut reader = CountingCipherSource::new(vec![( - shared, - shared + sectors, - encrypted_clean_unit(&key_c), - )]); - let mut keys = DecryptKeys::Aacs { - unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)], - format: ContentFormat::BdTs, - }; - let mut cache = super::DiscKeyCache::new(); - - // Title 1 fills the memo for `shared` (index 2) by really sampling it. - let first = super::resolve_mux_key_map_cached( - &mut reader, - &multi_cps_title(shared, sectors), - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("title 1 resolves"); - assert_eq!(first.key_idx_for(shared), Some(2), "sampled to its own key"); - let after_first = reader.probes; - - // Title 2: the same clip (a cache HIT — assert that below) followed by an - // extent with nothing to sample. - let mut title2 = DiscTitle::empty(); - title2.extents = vec![ - Extent { - start_lba: shared, - sector_count: sectors, - }, - Extent { - start_lba: clear, - sector_count: sectors, - }, - ]; - let second = super::resolve_mux_key_map_cached( - &mut reader, - &title2, - &mut keys, - None, - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("title 2 resolves"); - assert_eq!( - reader.probes - after_first, - 8, - "only the CLEAR extent is sampled — the shared one must be a cache hit, \ - which is the path under test" - ); - assert_eq!(second.key_idx_for(shared), Some(2), "the hit's own index"); - assert_eq!( - second.key_idx_for(clear), - Some(2), - "an unsampleable extent inherits the PRECEDING extent's index even when \ - that index came from the cache, not from a fresh sample" - ); - } - - // FMTS gap fill on a multi-CPS disc: a clear tail must inherit the preceding extent's CPS - // unit, not base_slots[0]. - #[test] - fn fmts_multi_cps_gap_fill_carries_the_preceding_extents_cps_unit_to_a_clear_tail() { - const CLEAR_LBA: u32 = FMTS_CPS2_LBA + FMTS_CPS2_SECTORS; - const CLEAR_SECTORS: u32 = 300; - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = FmtsDisc::with_second_cps_unit(); - reader.clear_span = Some((CLEAR_LBA, CLEAR_LBA + CLEAR_SECTORS)); - let mut keys = fmts_two_cps_keys(); - let mut cache = super::DiscKeyCache::new(); - let mut title = fmts_two_cps_title(); - title.extents.push(Extent { - start_lba: CLEAR_LBA, - sector_count: CLEAR_SECTORS, - }); - - let map = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("a two-CPS FMTS disc with a clear tail resolves"); - assert_eq!( - map.key_idx_for(FMTS_CPS2_LBA), - Some(1), - "the preceding extent is CPS unit 2 (pool slot 1)" - ); - for lba in [CLEAR_LBA, CLEAR_LBA + CLEAR_SECTORS - 1] { - assert_eq!( - map.key_idx_for(lba), - Some(1), - "LBA {lba} has nothing to sample and must inherit CPS unit 2 from its \ - neighbour, not fall back to the first CPS unit's key" - ); - } - } - - // FMTS gap fill samples extents through the same per-disc CpsUnitCache; a second title over - // the same extents costs ZERO further reads. - #[test] - fn fmts_multi_cps_gap_fill_samples_each_extent_once_per_disc() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls); - let mut reader = FmtsDisc::with_second_cps_unit(); - let mut keys = fmts_two_cps_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_two_cps_title(); - - let first = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("first title resolves"); - let after_first = reader.probe_reads; - assert!( - after_first >= 16, - "the first title really samples both extents (8 probes each), saw \ - {after_first} content reads" - ); - - let second = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - None, - &mut cache, - ) - .expect("second title resolves"); - assert_eq!( - reader.probe_reads, after_first, - "a second title over the same extents must touch the drive ZERO more \ - times — the CPS verdict is a property of the extent's own bytes" - ); - for lba in [ - 10_000u32, - 10_300, - 10_600, - FMTS_CPS2_LBA, - FMTS_CPS2_LBA + 599, - ] { - assert_eq!( - second.key_idx_for(lba), - first.key_idx_for(lba), - "and the cached map must be identical at LBA {lba}" - ); - } - } - - // An operator Stop mid-probe: `/api/stop` must be honored at every loop - // boundary since FMTS probes are the heaviest thing this crate does to a - // live drive; these tests count reads since outcome alone can't tell. - - fn halted_err() -> String { - std::io::Error::from(crate::error::Error::Halted).to_string() - } - - // Stop lands during the UDF walk; the anchor loop's own poll must catch it so no CONTENT - // sector is ever requested. - #[test] - fn fmts_stop_during_the_udf_walk_touches_no_content_sector() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls.clone()); - let halt = crate::halt::Halt::new(); - let mut reader = FmtsDisc::new(); - reader.cancel_after = Some((halt.clone(), CancelWhen::Meta, 1)); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - - let err = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - Some(&halt), - &mut cache, - ) - .expect_err("a Stop during the UDF walk must abort the resolve"); - assert_eq!(err.to_string(), halted_err(), "the verdict is Halted"); - assert!( - reader.meta_reads > 0, - "the fixture must really have reached the UDF walk" - ); - assert_eq!( - reader.probe_reads, 0, - "a stopped drive must not be asked for a single content sector" - ); - assert_eq!( - calls.load(std::sync::atomic::Ordering::SeqCst), - 0, - "nor the key service asked for anything" - ); - } - - // Stop lands during the anchor batch; the PHASE loop's poll must catch it before probing - // index 1's parity. - #[test] - fn fmts_stop_during_the_anchor_batch_stops_before_the_phase_probes() { - let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetch = counting_fmts_fetch(calls.clone()); - let halt = crate::halt::Halt::new(); - let mut reader = FmtsDisc::new(); - // One content read in, i.e. inside the very first anchor phase batch: the - // anchor loop's poll has already run and passed for this segment. - reader.cancel_after = Some((halt.clone(), CancelWhen::Probe, 1)); - let mut keys = fmts_keys(); - let mut cache = super::DiscKeyCache::new(); - let title = fmts_title(FMTS_CONTENT_SECTORS); - - let err = super::resolve_mux_key_map_cached( - &mut reader, - &title, - &mut keys, - Some(&fetch), - ContentFormat::BdTs, - Some(&halt), - &mut cache, - ) - .expect_err("a Stop during the anchor batch must abort the resolve"); - assert_eq!(err.to_string(), halted_err(), "the verdict is Halted"); - // The anchor completed (uninterruptible by construction); phase - // probes must NOT have started — any reads beyond the anchor's is a - // Stop overrun (each probe reads 2*MIN_SAMPLE_UNITS per segment). - let anchor_cost = crate::keysource::MIN_SAMPLE_UNITS as u32; - assert!( - reader.probe_reads > 0, - "the fixture must really have reached the anchor batch" - ); - assert!( - reader.probe_reads <= 2 * anchor_cost, - "the phase probes must not run after the Stop — saw {} content reads, \ - at most {} belong to the anchor", - reader.probe_reads, - 2 * anchor_cost - ); - } } diff --git a/src/mux/videomap.rs b/src/mux/videomap.rs index 3f86680b..c8473bf9 100644 --- a/src/mux/videomap.rs +++ b/src/mux/videomap.rs @@ -109,7 +109,7 @@ impl Scan { /// Source `medium` for the header `source.medium` member. Describes the physical/logical input /// the index was built from — never the destination the index is written to. The driver derives -/// it from the `MuxInput` arm; [`Medium::File`] is the default only for a caller that declares +/// it from the `MuxSource` arm; [`Medium::File`] is the default only for a caller that declares /// no provenance at all. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Medium { diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 070f4628..00968e6b 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -14,55 +14,6 @@ use std::sync::Arc; use super::SectorSource; -/// A closure resolving keys from encrypted-content samples — the shape of both -/// [`KeyFetch`] operations. `Ok(empty)` = no key; `Err` = the key source failed -/// (reported as itself, not as a missing key). -pub type KeyFetchFn = std::sync::Arc]) -> Result> + Send + Sync>; - -/// Resolves keys from encrypted-content samples for [`DecryptingSectorSource`]. -/// -/// Two operations: [`unit_keys`](Self::unit_keys) resolves the base Unit -/// Key(s) for a CPS unit from real encrypted samples; [`fmts_indexes`](Self::fmts_indexes) -/// resolves the disc's AACS 2.1 forensic index key set from an index-1 -/// anchor batch. Both return additional keys to add to the pool and retry -/// with; empty if the source can't help, `Err` if it failed. The library does no key lookup or -/// network I/O itself — this is the caller's seam to its key source. -#[derive(Clone)] -pub struct KeyFetch { - unit: KeyFetchFn, - fmts: KeyFetchFn, -} - -impl KeyFetch { - /// Build a resolver from its two operations: `unit` resolves base Unit Keys - /// from a CPS unit's samples; `fmts` resolves the forensic index set from an - /// index-1 anchor batch. - pub fn new(unit: KeyFetchFn, fmts: KeyFetchFn) -> Self { - Self { unit, fmts } - } - - /// A resolver that serves ONLY base Unit Keys; [`fmts_indexes`](Self::fmts_indexes) - /// is always empty. For read paths that never resolve forensic keys — the - /// sweep/patch recovery decorator, which handles CPS units only. - pub fn unit_only(unit: KeyFetchFn) -> Self { - Self::new(unit, std::sync::Arc::new(|_| Ok(Vec::new()))) - } - - /// Resolve the base Unit Key(s) for a CPS unit from `samples` (real encrypted - /// units drawn from it). Normally one key; the caller adds whatever it returns - /// to the pool. - pub fn unit_keys(&self, samples: &[Vec]) -> Result> { - (self.unit)(samples) - } - - /// Resolve the disc's AACS 2.1 forensic index keys from an index-1 single- - /// phase `anchor` batch. The source returns the COMPLETE ordered set (index i - /// = element i); the caller trusts any non-empty result as all of them. - pub fn fmts_indexes(&self, anchor: &[Vec]) -> Result> { - (self.fmts)(anchor) - } -} - /// Decorator: read from `inner`, then run the configured /// AACS / CSS decrypt over the bytes that landed in `buf`. /// @@ -98,6 +49,21 @@ pub struct DecryptingSectorSource { /// The key set's on-arrival proof for pieces `resolve` could not prove up front /// (KU §2.4). `None` for every reader not built by a `ResolvedKeySet`. arrival: Option>, + /// Damaged AACS units blanked so far (see [`blanked_units`](Self::blanked_units)). + blanked: BlankTally, +} + +// The blanked-unit count, shared with the stream that reports it as loss; logged once when +// the reader is dropped, so a damaged rip never reads as clean. +struct BlankTally(Arc); + +impl Drop for BlankTally { + fn drop(&mut self) { + let n = self.0.load(std::sync::atomic::Ordering::Relaxed); + if n > 0 { + tracing::warn!(target: "freemkv::decrypt", units = n, "{n} damaged AACS units blanked"); + } + } } /// Does the sector span `[lba, lba+count)` intersect any encrypted-content range? @@ -110,9 +76,9 @@ fn span_touches_content(content: Option<&[(u32, u32)]>, lba: u32, count: u16) -> } impl DecryptingSectorSource { - /// Wrap `inner` with the given keys. For an AACS source, install a key map - /// via [`with_key_map`](Self::with_key_map) before reading — AACS decrypts - /// only through the map and fails loud without one. + /// Wrap `inner` with the given keys. AACS decrypts only through a key map, which + /// only a [`ResolvedKeySet`](crate::keys::ResolvedKeySet) reader installs; an AACS + /// source built here fails loud on its first encrypted unit. pub fn new(inner: S, keys: DecryptKeys) -> Self { Self { inner, @@ -121,6 +87,7 @@ impl DecryptingSectorSource { content_ranges: None, key_map: None, arrival: None, + blanked: BlankTally(Arc::default()), } } @@ -140,7 +107,7 @@ impl DecryptingSectorSource { /// resolved one key per CPS unit / segment up front, so every aligned unit is /// decrypted with its MAPPED key and trusted — no per-unit `is_clean` check. /// AACS-only; a CSS / clear disc ignores it. - pub fn with_key_map(mut self, map: Arc) -> Self { + pub(crate) fn with_key_map(mut self, map: Arc) -> Self { self.key_map = Some(map); self } @@ -149,7 +116,7 @@ impl DecryptingSectorSource { /// proactive map on an already-constructed source (the inline live-drive /// [`DiscStream`](crate::mux::DiscStream) builds the decorator first, then /// installs the map via its own `with_key_map`). - pub fn set_key_map(&mut self, map: Arc) { + pub(crate) fn set_key_map(&mut self, map: Arc) { self.key_map = Some(map); } @@ -179,6 +146,17 @@ impl DecryptingSectorSource { self.unit_base = None; } + /// Damaged AACS units this reader blanked (zero-filled) so far: flagged units no key can + /// open, read damage the rip carries on past. + pub fn blanked_units(&self) -> u64 { + self.blanked.0.load(std::sync::atomic::Ordering::Relaxed) + } + + /// The shared blanked-unit counter, for a stream that reports it after this reader moves. + pub(crate) fn blanked_counter(&self) -> Arc { + self.blanked.0.clone() + } + /// Borrow the inner source. Useful for tests and for adapters /// that want to introspect the underlying drive / file without /// unwrapping the decorator. @@ -268,24 +246,30 @@ impl SectorSource for DecryptingSectorSource { .inner .read_sectors_fua(lba, count, buf, recovery, fua)?; + // Read damage before any key sees the units: a damaged unit is blanked and counted, + // never a key verdict (E7013 here, E7022 on arrival). "We rip bad discs." + self.blank_damage(lba, &mut buf[..n], content_ref); + // KU §2.4: units of a piece left unproven are proven now, from the held keys only. if let (Some(arrival), DecryptKeys::Aacs { unit_keys, .. }) = (self.arrival.as_deref(), &self.keys) { - arrival.process(&mut self.inner, lba, &mut buf[..n], unit_keys)?; + let blanked = arrival.process(&mut self.inner, lba, &mut buf[..n], unit_keys)?; + self.count_blanked(blanked); } // Proactive map path (storm-free mux): keys were resolved per unit up front, // so decrypt with the mapped key and trust it (no per-unit `is_clean`). A // resolver gap fails loud; bad TS and units outside content extents pass through. if let Some(map) = self.key_map.clone() { - crate::decrypt::decrypt_sectors_mapped_in_content( + let blanked = crate::decrypt::decrypt_sectors_mapped_in_content( &mut buf[..n], &self.keys, lba, &map, content_ref, )?; + self.count_blanked(blanked); return Ok(n); } @@ -305,6 +289,30 @@ impl SectorSource for DecryptingSectorSource { } } +impl DecryptingSectorSource { + // Blank and count the damaged AACS units of a read at `lba` (see + // `decrypt::blank_damaged_units`): judged are content units the map keys or arrival covers. + fn blank_damage(&mut self, lba: u32, buf: &mut [u8], content: Option<&[(u32, u32)]>) { + let DecryptKeys::Aacs { format, .. } = self.keys else { + return; + }; + let (map, arrival) = (self.key_map.as_deref(), self.arrival.as_deref()); + let covered = |at: u32| { + content.is_none_or(|r| crate::decrypt::span_in_content_ranges(at, 1, r)) + && (map.is_some_and(|m| m.entry_for(at).is_some()) + || arrival.is_some_and(|a| a.covers(at))) + }; + let n = crate::decrypt::blank_damaged_units(buf, lba, format, &covered); + self.count_blanked(n); + } + + fn count_blanked(&self, n: usize) { + self.blanked + .0 + .fetch_add(n as u64, std::sync::atomic::Ordering::Relaxed); + } +} + #[cfg(test)] mod tests { use super::*; @@ -981,18 +989,32 @@ mod tests { ); } - // A wrong explicit base (disc grid) cuts chunks across real units: their seed has - // no TS sync at byte 4, so the decrypt refuses instead of returning garbage. + // A wrong explicit base (disc grid) cuts chunks across real units: a flagged chunk's seed + // has no TS sync at byte 4, so no key opens it. Like any damaged unit it is blanked and + // counted, never E7013 (1.7.7 muxed through; "multi pass shouldn't error"). #[test] - fn aacs_read_on_wrong_unit_grid_fails_loud() { + fn aacs_read_on_wrong_unit_grid_is_blanked_not_e7013() { let mut dec = misaligned_file_source(); let mut buf = vec![0u8; 2 * crate::aacs::content::ALIGNED_UNIT_LEN]; dec.set_unit_base(FILE_LBA + 1); - let r = dec.read_sectors(FILE_LBA + 1, 6, &mut buf, false); + let mut raw = vec![0u8; buf.len()]; + dec.inner_mut() + .read_sectors(FILE_LBA + 1, 6, &mut raw, false) + .unwrap(); + let flagged: Vec = (0..2).filter(|&u| raw[u * 6144] & 0xC0 != 0).collect(); assert!( - matches!(r, Err(crate::error::Error::DecryptFailed)), - "an off-grid read must be DecryptFailed, got Ok" + !flagged.is_empty(), + "the fixture cuts at least one flagged chunk" ); + dec.read_sectors(FILE_LBA + 1, 6, &mut buf, false) + .expect("an off-grid read is blanked, never E7013"); + for &u in &flagged { + assert!( + buf[u * 6144..(u + 1) * 6144].iter().all(|&b| b == 0), + "unit {u} blanked" + ); + } + assert_eq!(dec.blanked_units(), flagged.len() as u64); // The file's own grid decrypts both units byte-exact. dec.set_unit_base(FILE_LBA); @@ -1002,4 +1024,180 @@ mod tests { want.extend(clear_aacs_unit()); assert_eq!(buf, crate::aacs::content::cpi_cleared(want)); } + + // A file at FILE_LBA of `units` units under one key; `damaged` units have a garbage seed. + fn damaged_file_source(units: u32, damaged: &[u32]) -> DecryptingSectorSource { + let key = [0x5Au8; 16]; + let mut data = Vec::new(); + for u in 0..units { + let mut unit = encrypt_aacs_unit(&key); + if damaged.contains(&u) { + crate::test_util::damage_unit_seed(&mut unit); + } + data.extend(unit); + } + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + format: crate::disc::ContentFormat::BdTs, + }; + let end = FILE_LBA + units * 3; + let map = Arc::new(crate::decrypt::AacsKeyMap::from_ranges(vec![( + FILE_LBA, end, 0, + )])); + let mut dec = DecryptingSectorSource::new(MisalignedFile(data), keys).with_key_map(map); + dec.set_unit_base(FILE_LBA); + dec + } + + // Read `units` units from unit `from` of the damaged file. + fn read_units( + dec: &mut DecryptingSectorSource, + from: u32, + units: u32, + ) -> Result> { + let mut buf = vec![0u8; units as usize * crate::aacs::content::ALIGNED_UNIT_LEN]; + dec.read_sectors(FILE_LBA + from * 3, (units * 3) as u16, &mut buf, false)?; + Ok(buf) + } + + /// A lone unit whose seed is damaged, among units that decrypt on the grid, is a hole + /// (zeros), never E7013. KS-4 [BD] §3.10.1: "The first 16 bytes of each Aligned Unit is + /// used as the seed for calculating the Block Key." — no key opens a damaged seed. + #[test] + fn a_damaged_unit_seed_on_the_grid_is_a_hole() { + assert!( + crate::spec::keys::KS_4_SEED + .text + .contains("used as the seed") + ); + let mut dec = damaged_file_source(3, &[1]); + let buf = read_units(&mut dec, 0, 3).expect("read damage is not a key failure"); + let clear = crate::aacs::content::cpi_cleared(clear_aacs_unit()); + let ul = crate::aacs::content::ALIGNED_UNIT_LEN; + assert_eq!(&buf[..ul], &clear[..]); + assert!( + buf[ul..2 * ul].iter().all(|&b| b == 0), + "the damaged unit is a hole" + ); + assert_eq!(&buf[2 * ul..], &clear[..]); + assert_eq!(dec.blanked_units(), 1); + } + + /// A cluster of damaged units is blanked and counted however it is read: alone, first, + /// with no intact unit around it. KS-2 [BD] §3.10.1: "Each MPEG source packet consists of + /// the TP_extra_header (4 bytes) and an MPEG Transport packet". + #[test] + fn a_cluster_of_damaged_units_is_blanked_and_counted() { + assert!( + crate::spec::keys::KS_2_ALIGNED_UNIT + .text + .contains("TP_extra_header (4 bytes)") + ); + let mut dec = damaged_file_source(4, &[1, 2]); + let buf = read_units(&mut dec, 1, 2).expect("a damage cluster is blanked, never E7013"); + assert_eq!(dec.blanked_units(), 2); + assert!(buf.iter().all(|&b| b == 0), "both damaged units are holes"); + } + + /// A sweep's zero run need not be unit-aligned. A unit whose tail sectors are zero keeps + /// its seed: its head sector decrypts and the zeros stay zeros. A unit whose head sector is + /// zero lost its seed (KS-4 [BD] §3.10.1: "The first 16 bytes of each Aligned Unit is used + /// as the seed"), so its ciphertext rest is a hole too — never E7013, never ciphertext out. + #[test] + fn partial_zero_units_at_both_edges_of_a_run_are_holes() { + let mut dec = damaged_file_source(4, &[]); + dec.inner_mut().0[4 * 2048..7 * 2048].fill(0); // unit 1 sectors 1-2, unit 2 sector 0 + let buf = read_units(&mut dec, 0, 4).expect("a partial zero run is read damage"); + let clear = crate::aacs::content::cpi_cleared(clear_aacs_unit()); + let ul = crate::aacs::content::ALIGNED_UNIT_LEN; + assert_eq!(&buf[..ul], &clear[..]); + // Packets 0-9 lie wholly in unit 1's intact head sector (10 × 192 = 1920 bytes). + assert_eq!( + &buf[ul..ul + 1920], + &clear[..1920], + "the kept head decrypts" + ); + assert!( + buf[ul + 2112..2 * ul].iter().all(|&b| b == 0), + "its zero tail stays zero" + ); + assert!( + buf[2 * ul..3 * ul].iter().all(|&b| b == 0), + "a lost seed is a hole" + ); + assert_eq!(&buf[3 * ul..], &clear[..]); + assert_eq!( + dec.blanked_units(), + 1, + "the lost-seed unit; the kept head is not blanked" + ); + } + + // An FMTS Even-phase segment of `units` units under `key`, the map pointing at `map_key`; + // units in `garbled` read back with a garbage head that kept the CPI flag and TS sync. + fn fmts_file_source( + units: u32, + garbled: &[u32], + key: [u8; 16], + map_key: [u8; 16], + ) -> DecryptingSectorSource { + let mut data = Vec::new(); + for u in 0..units { + let mut unit = encrypt_aacs_unit(&key); + if garbled.contains(&u) { + crate::test_util::damage_unit_seed(&mut unit); + unit[4] = 0x47; + } + data.extend(unit); + } + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, map_key)], + format: crate::disc::ContentFormat::BdTs, + }; + let end = FILE_LBA + units * 3; + let map = crate::decrypt::AacsKeyMap::from_ranges_phased(vec![( + FILE_LBA, + end, + 0, + crate::decrypt::Phase::Even, + )]); + let mut dec = + DecryptingSectorSource::new(MisalignedFile(data), keys).with_key_map(Arc::new(map)); + dec.set_unit_base(FILE_LBA); + dec + } + + /// A lone FMTS unit that fails the correct-phase verify (a garbled head that kept its + /// sync) is damage: blanked and counted, never E7013, read with its segment or alone. + /// KS-3 [BD] §3.10.1: "A new CBC cipher chain is started for each Aligned Unit". + #[test] + fn a_lone_fmts_verify_failure_is_blanked_never_e7013() { + let key = [0x5Au8; 16]; + let mut dec = fmts_file_source(4, &[2], key, key); + let buf = read_units(&mut dec, 0, 4).expect("damage, not E7013"); + let ul = crate::aacs::content::ALIGNED_UNIT_LEN; + let fmt = crate::disc::ContentFormat::BdTs; + assert!( + crate::aacs::content::is_clean(&buf[..ul], fmt), + "unit 0 decrypts" + ); + assert!( + buf[2 * ul..3 * ul].iter().all(|&b| b == 0), + "unit 2 blanked" + ); + assert_eq!(dec.blanked_units(), 1); + let alone = read_units(&mut dec, 2, 1).expect("alone, still damage"); + assert!(alone.iter().all(|&b| b == 0)); + assert_eq!(dec.blanked_units(), 2); + } + + /// A wrong FMTS key fails every unit it keys: that stops E7013, never a blank. + #[test] + fn a_wrong_fmts_key_still_stops_e7013() { + let mut dec = fmts_file_source(4, &[], [0x5Au8; 16], [0xCCu8; 16]); + assert!(matches!( + read_units(&mut dec, 0, 4), + Err(crate::error::Error::DecryptFailed) + )); + } } diff --git a/src/sector/mod.rs b/src/sector/mod.rs index c8cfb504..c9d7e15a 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -190,7 +190,7 @@ impl SectorSource for &mut (dyn SectorSource + '_) { } pub use crate::io::file_sector_source::FileSectorSource; -pub use decrypting::{DecryptingSectorSource, KeyFetch, KeyFetchFn}; +pub use decrypting::DecryptingSectorSource; pub use prefetched::PrefetchedSectorSource; #[cfg(test)] diff --git a/src/session.rs b/src/session.rs index f004dbb1..d0f46e7f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -7,13 +7,12 @@ //! libfreemkv resolves no keys and reads no keydb: the consumer supplies its //! own key material via [`KeySpec`], forwarded to [`ScanOptions`] at scan time. -use crate::aacs::trace::ResolutionTrace; use crate::disc::{Disc, DiscId, DriveCredentials, ScanOptions}; use crate::drive::{Drive, find_drive}; use crate::error::{Error, Result}; use crate::halt::{Halt, Progress}; -use crate::keysource::{KeySource, MIN_SAMPLE_UNITS, key_fetch, resolve_and_apply_traced}; -use crate::sector::{FileSectorSource, KeyFetch, SectorSource}; +use crate::keysource::KeySource; +use crate::sector::{FileSectorSource, SectorSource}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -21,72 +20,13 @@ use std::sync::Arc; /// /// libfreemkv builds no key sources itself (the `freemkv_keysources` crate that /// implements [`KeySource`] depends on libfreemkv, not the other way round), so -/// the consumer hands in a way to (re)build its sources. It is invoked once for -/// the up-front resolve and again per on-decrypt-miss fetch (the cold path), so -/// it stays `Send + Sync` without requiring `KeySource: Send`. Mirrors the -/// `make_sources` argument [`key_fetch`] already takes. +/// the consumer hands in a way to build its sources. [`ResolvedKeySet::resolve`] +/// calls it once and keeps nothing; it stays `Send + Sync` without requiring +/// `KeySource: Send`. +/// +/// [`ResolvedKeySet::resolve`]: crate::keys::ResolvedKeySet::resolve pub type KeySourceFactory = Arc Vec> + Send + Sync>; -/// The outcome of resolving a disc's base AACS unit keys: the structured -/// per-source [`ResolutionTrace`] (for the consumer to render) plus the -/// read-time [`KeyFetch`] built from the disc's public inputs. -/// -/// `key_fetch` is `None` only for a disc that carries no AACS inputs (an -/// unencrypted / CSS / non-AACS disc); it is `Some` whenever the disc is AACS, -/// independent of whether a key actually resolved — the on-decrypt-miss fetch is -/// wired the same way regardless. -pub struct ResolvedKeys { - /// Per-source walk of the resolve, for the consumer to render (English-free - /// typed enums only; the app layer maps them to text). - pub trace: ResolutionTrace, - /// The read-time fetch closure, or `None` for a non-AACS disc. - pub key_fetch: Option, -} - -/// Resolve and bank a keyless-scanned disc's BASE AACS unit keys, and build the -/// read-time [`KeyFetch`] — the one place the sampling / ordered-apply / banking -/// / fetch-construction glue lives, so the CLI and autorip stop hand-rolling it. -/// -/// Samples ciphertext from the disc via `reader` (a live [`Drive`] or a -/// file-backed [`SectorSource`] from [`scan_iso`]), runs the ordered `sources` -/// first-valid-wins, banks any winning unit keys onto `disc`, and builds the -/// read-time [`KeyFetch`] used for later on-decrypt-miss fetches. -pub fn resolve_keys_for( - reader: &mut dyn SectorSource, - disc: &mut Disc, - sources: KeySourceFactory, -) -> ResolvedKeys { - // A disc with no captured AACS inputs is unencrypted / CSS / non-AACS — - // nothing to resolve, nothing to fetch. - let Some(mut inputs) = disc.inputs() else { - return ResolvedKeys { - trace: ResolutionTrace::new(), - key_fetch: None, - }; - }; - - // Build the ordered sources once for the up-front resolve. Skip the disc-reading - // sample step when there is no source to validate against (a dropped / SSRF- - // rejected online-only source) — resolution is a miss regardless; the read is waste. - let src_vec = sources(); - if !src_vec.is_empty() { - inputs.samples = disc.content_samples(reader, MIN_SAMPLE_UNITS); - } - - // Ordered, first-valid-wins; banks the winning unit keys onto `disc`. - let (_resolved, trace) = resolve_and_apply_traced(&src_vec, &inputs, disc); - - // Build the read-time fetch from the disc's public inputs (fresh, so it - // reflects any banked state); its per-fetch `samples` are filled by the - // closure. `inputs()` is still `Some` here (the disc is AACS). - let fetch_inputs = disc.inputs().unwrap_or(inputs); - let fetch = key_fetch(fetch_inputs, sources); - ResolvedKeys { - trace, - key_fetch: Some(fetch), - } -} - /// Which optical device a [`DiscSession`] should open. pub enum DeviceTarget { /// Open this exact device path (e.g. `/dev/sg0`). @@ -154,10 +94,6 @@ pub struct DiscSession { /// file path stages a `FileSectorSource`; the live-drive path stages the /// drive itself via [`Self::stage_drive_as_reader`]. reader: Option>, - /// The read-time AACS fetch closure, built by [`Self::resolve_keys`] and - /// retained so a later mux (step 4) can install it into the decrypt - /// decorator. `None` until keys are resolved / for a non-AACS disc. - key_fetch: Option, /// The op token from [`Self::open_with`] (stop design §2.2): every CDB, the scan and /// the key resolution observe it. `None` for [`Self::open`]. halt: Option, @@ -262,7 +198,6 @@ impl DiscSession { spec, disc: None, reader: None, - key_fetch: None, halt, progress: None, }) @@ -285,7 +220,7 @@ impl DiscSession { /// Fast disc identification — name/format only, no playlist parse. Wraps /// [`Disc::identify`]. pub fn identify(&mut self) -> Result { - // Same reachability as `scan`/`resolve_keys`: public `stage_drive_as_reader`/ + // Same reachability as `scan`/`resolve_key_set`: public `stage_drive_as_reader`/ // `into_drive` move the drive out, so this slot can legitimately be empty. // A library must not panic from public API, so return typed `DeviceNotReady`. let drive = self.drive.as_mut().ok_or_else(|| Error::DeviceNotReady { @@ -357,41 +292,6 @@ impl DiscSession { } } - /// Resolve and bank the scanned disc's base AACS unit keys from the - /// consumer-supplied `sources`, and retain the read-time [`KeyFetch`] on the - /// session (see [`Self::key_fetch`]) for a later mux. - /// - /// Samples ciphertext through the session's own reader — the staged file - /// reader if one is present, otherwise the live drive — so it works for both - /// a live-drive session and a file-backed one. Returns the structured - /// [`ResolutionTrace`] for the consumer to render; a non-AACS disc resolves - /// to an empty trace with no error. Requires [`Self::scan`] to have run. - pub fn resolve_keys(&mut self, sources: KeySourceFactory) -> Result { - // The disc must have been scanned so its AACS inputs are captured. - if self.disc.is_none() { - return Err(Error::DeviceNotReady { - path: self.device.clone(), - }); - } - // Sample through the staged reader when present (file-backed), else the - // live drive. `self.reader` / `self.disc` / `self.drive` are disjoint - // fields, so the borrows below don't conflict. - let resolved = if let Some(reader) = self.reader.as_mut() { - let disc = self.disc.as_mut().expect("disc present (checked above)"); - resolve_keys_for(reader.as_mut(), disc, sources) - } else { - // Same reachability as `scan` above: the drive may have been staged - // into the reader slot by the public `stage_drive_as_reader`. - let drive = self.drive.as_mut().ok_or_else(|| Error::DeviceNotReady { - path: self.device.clone(), - })?; - let disc = self.disc.as_mut().expect("disc present (checked above)"); - resolve_keys_for(drive, disc, sources) - }; - self.key_fetch = resolved.key_fetch; - Ok(resolved.trace) - } - /// Resolve the rip's key set for `scope` up front (KU §3.1): one /// [`ResolvedKeySet::resolve`](crate::keys::ResolvedKeySet::resolve) through the /// session's staged reader, else its drive. The session keeps nothing: the set is the @@ -444,13 +344,6 @@ impl DiscSession { } } - /// The read-time AACS fetch closure retained by [`Self::resolve_keys`], for a - /// later mux (step 4) to install into the decrypt decorator. `None` before - /// keys are resolved, or for a non-AACS disc. - pub fn key_fetch(&self) -> Option<&KeyFetch> { - self.key_fetch.as_ref() - } - /// The scanned disc, if [`Self::scan`] has run. pub fn disc(&self) -> Option<&Disc> { self.disc.as_ref() @@ -498,7 +391,7 @@ impl DiscSession { /// Stage the owned drive as the session's boxed sector source so a live /// single-pass mux can drive it through - /// [`MuxInput::Session`](crate::mux::MuxInput::Session). Moves the `Drive` + /// [`MuxSource::Session`](crate::mux::MuxSource::Session). Moves the `Drive` /// (itself a [`SectorSource`]) into the `reader` slot; the cached /// [`Self::device_path`] keeps the device name available afterward. A no-op /// if the drive was already staged or moved out. @@ -515,8 +408,8 @@ impl DiscSession { } /// Take the staged sector source out of the session by mutable borrow, - /// leaving `None` behind. Used by [`crate::mux::mux_stream`]'s - /// [`MuxInput::Session`](crate::mux::MuxInput::Session) arm, which drives + /// leaving `None` behind. Used by [`crate::mux::mux_with_keys`]'s + /// [`MuxSource::Session`](crate::mux::MuxSource::Session) arm, which drives /// the mux from `&mut DiscSession` and so cannot consume the whole session. /// A second call (or a call before the reader is staged) returns `None`, and /// the driver maps that to a clean error rather than a panic (see Q2 of the @@ -526,12 +419,11 @@ impl DiscSession { } // Test-only: build a session over an injected reader + already-scanned disc without opening - // a live Drive, to exercise the mux/resolve_keys test paths. + // a live Drive, to exercise the mux test paths. #[cfg(test)] pub(crate) fn from_parts_for_test( disc: Option, reader: Option>, - key_fetch: Option, ) -> DiscSession { DiscSession { drive: None, @@ -539,7 +431,6 @@ impl DiscSession { spec: KeySpec::default(), disc, reader, - key_fetch, halt: None, progress: None, } @@ -557,7 +448,6 @@ impl DiscSession { spec: KeySpec::default(), disc: None, reader: None, - key_fetch: None, halt: None, progress: None, } @@ -778,44 +668,7 @@ mod tests { assert!(spec.key_sources.is_empty()); } - // ── resolve_keys_for: sampling → ordered apply → bank → fetch ───────────── - - /// A no-op reader — the resolve tests use discs with no titles, so no - /// sampling read fires; this satisfies the `&mut dyn SectorSource` seam. - struct NullReader; - impl SectorSource for NullReader { - fn capacity_sectors(&self) -> u32 { - 0 - } - fn read_sectors(&mut self, _: u32, _: u16, _: &mut [u8], _: bool) -> Result { - Ok(0) - } - } - - /// A source that hands back one terminal Unit Key. - struct HasUnitKey([u8; 16]); - impl KeySource for HasUnitKey { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result> { - Ok(vec![UnitKey::new(0, self.0)]) - } - fn label(&self) -> &'static str { - "has-key" - } - } - - /// A source with no key for this disc. - struct NoUnitKey; - impl KeySource for NoUnitKey { - fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result> { - Ok(Vec::new()) - } - fn label(&self) -> &'static str { - "empty" - } - } - - /// A minimal keyless AACS `Disc` — `inputs()` returns `Some`, so - /// `resolve_keys_for` proceeds to the sources. No titles (no sampling read). + /// A minimal keyless AACS `Disc`: `inputs()` returns `Some`. No titles. fn aacs_disc() -> Disc { Disc { volume_id: "TEST".into(), @@ -831,9 +684,6 @@ mod tests { bus_encryption: false, mkb_version: None, disc_hash: "0xabc".into(), - key_source: crate::disc::KeyOrigin::KeyDb, - vuk: None, - unit_keys: Vec::new(), volume_id: [0u8; 16], uk_ro: Vec::new(), mkb: Vec::new(), @@ -846,121 +696,6 @@ mod tests { } } - fn factory_of(make: fn() -> S) -> KeySourceFactory { - Arc::new(move || vec![Box::new(make()) as Box]) - } - - // The happy path: a source's Unit Key is banked onto the disc's AACS state - // and a `KeyFetch` is retained. Guards against dropping the banking step - // (`resolve_and_apply_traced`), which would leave `decrypt_keys()` `None`. - #[test] - fn resolve_keys_for_banks_unit_key_and_builds_fetch() { - use crate::decrypt::DecryptKeys; - const K: [u8; 16] = [0x5A; 16]; - let mut disc = aacs_disc(); - let mut reader = NullReader; - - let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey(K))); - - match disc.decrypt_keys() { - DecryptKeys::Aacs { unit_keys, .. } => { - // CPS-unit number is positional index + 1 (idx 0 → unit 1). - assert_eq!(unit_keys, vec![(1u32, K)], "the source's key is banked"); - } - _ => panic!("expected banked AACS keys"), - } - assert!( - resolved.key_fetch.is_some(), - "an AACS disc always retains a read-time fetch" - ); - // The trace recorded exactly one source, which resolved. - assert_eq!(resolved.trace.keys.len(), 1); - } - - /// A source with no key: nothing is banked (`decrypt_keys()` stays `None`), - /// but a `KeyFetch` is STILL built (the on-decrypt-miss path is wired - /// regardless of the up-front resolve succeeding). - #[test] - fn resolve_keys_for_no_key_leaves_disc_unkeyed_but_builds_fetch() { - use crate::decrypt::DecryptKeys; - let mut disc = aacs_disc(); - let mut reader = NullReader; - - let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| NoUnitKey)); - - assert!( - matches!(disc.decrypt_keys(), DecryptKeys::None), - "no source key ⇒ disc stays unkeyed" - ); - assert!( - resolved.key_fetch.is_some(), - "an AACS disc retains a fetch even when the up-front resolve misses" - ); - } - - /// A counting reader over zeros — records the highest LBA sampled so the test - /// can prove the LARGEST title's extent (not the small one) was read. - struct SamplingReader { - reads: u32, - max_lba: u32, - } - impl SectorSource for SamplingReader { - fn capacity_sectors(&self) -> u32 { - 100_000 - } - fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result { - self.reads += 1; - self.max_lba = self.max_lba.max(lba); - let want = count as usize * 2048; - buf[..want].fill(0); - Ok(want) - } - } - - // `resolve_keys_for` samples the LARGEST title's ciphertext through the - // reader when a source is configured. Other tests use a title-less disc - // (no sampling read); this one covers the sampling branch with two titles. - #[test] - fn resolve_keys_for_samples_largest_title_through_reader() { - use crate::disc::{DiscTitle, Extent}; - let mut disc = aacs_disc(); - let mut small = DiscTitle::empty(); - small.size_bytes = 1_000; - small.extents = vec![Extent { - start_lba: 100, - sector_count: 300, - }]; - let mut large = DiscTitle::empty(); - large.size_bytes = 9_000_000; - large.extents = vec![Extent { - start_lba: 9_000, - sector_count: 300, - }]; - disc.titles = vec![small, large]; - let mut reader = SamplingReader { - reads: 0, - max_lba: 0, - }; - - // Non-empty source ⇒ the sampling read is NOT skipped. - let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16]))); - - assert!( - reader.reads > 0, - "the largest title was sampled via the reader" - ); - assert!( - reader.max_lba >= 9_000, - "sampling read the LARGER title's extent (lba>=9000), not the small one \ - (max_lba={})", - reader.max_lba - ); - assert!( - resolved.key_fetch.is_some(), - "an AACS disc still retains a read-time fetch" - ); - } - // `inputs_with_samples` carries real encrypted units from the main feature, so // a source's key is validated at disc open (bare `inputs()` has none). #[test] @@ -984,59 +719,17 @@ mod tests { disc.titles = vec![t]; assert!(disc.inputs().expect("aacs").samples.is_empty()); let inputs = disc - .inputs_with_samples(&mut Encrypted, MIN_SAMPLE_UNITS) + .inputs_with_samples(&mut Encrypted, crate::keysource::MIN_SAMPLE_UNITS) .expect("aacs"); - assert_eq!(inputs.samples.len(), MIN_SAMPLE_UNITS); - } - - /// A non-AACS disc (CSS / unencrypted — `inputs()` is `None`): resolution is a - /// no-op. Empty trace, NO fetch, disc untouched. This is the out-of-the-box - /// CSS/None path that must keep working with no keydb. - #[test] - fn resolve_keys_for_non_aacs_disc_is_a_noop() { - use crate::decrypt::DecryptKeys; - let mut disc = aacs_disc(); - disc.aacs = None; // now carries no AACS inputs - disc.encrypted = false; - let mut reader = NullReader; - - let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16]))); - - assert!( - resolved.trace.keys.is_empty() && resolved.trace.unlock.is_empty(), - "a non-AACS disc yields an empty trace" - ); - assert!( - resolved.key_fetch.is_none(), - "a non-AACS disc has nothing to fetch" - ); - assert!( - matches!(disc.decrypt_keys(), DecryptKeys::None), - "the disc is left untouched" - ); - } - - // `resolve_keys` called before `scan` (disc slot still `None`) must return - // the typed `DeviceNotReady` guard, never reach the `.expect(...)` below it - // and panic. Guards against dropping the `if self.disc.is_none()` early return. - #[test] - fn resolve_keys_before_scan_is_clean_device_not_ready() { - let mut session = DiscSession::from_parts_for_test(None, None, None); - let err = session - .resolve_keys(factory_of(|| HasUnitKey([1; 16]))) - .expect_err("resolve_keys before scan must error, not panic"); - assert!( - matches!(err, Error::DeviceNotReady { .. }), - "expected DeviceNotReady, got {err:?}" - ); + assert_eq!(inputs.samples.len(), crate::keysource::MIN_SAMPLE_UNITS); } // `identify` after the drive has left the session (both `stage_drive_as_reader` // and `into_drive` permit that ordering) must return `DeviceNotReady`, not - // reach `drive_mut`'s `.expect(...)` and panic. Sibling of scan/resolve_keys. + // reach `drive_mut`'s `.expect(...)` and panic. Sibling of scan. #[test] fn identify_without_a_drive_is_clean_device_not_ready() { - let mut session = DiscSession::from_parts_for_test(None, None, None); + let mut session = DiscSession::from_parts_for_test(None, None); let err = session .identify() .expect_err("identify without a drive must error, not panic"); @@ -1134,7 +827,7 @@ mod tests { /// (already staged or moved out): the reader slot stays empty. #[test] fn stage_drive_as_reader_is_noop_without_a_drive() { - let mut session = DiscSession::from_parts_for_test(None, None, None); + let mut session = DiscSession::from_parts_for_test(None, None); session.stage_drive_as_reader(); assert!( session.into_reader().is_none(), diff --git a/src/session/stop_tests.rs b/src/session/stop_tests.rs index 5da52481..e04731b8 100644 --- a/src/session/stop_tests.rs +++ b/src/session/stop_tests.rs @@ -101,10 +101,10 @@ fn finish_release_and_unlock() { /// typed error, `Release` is a no-op. #[test] fn finish_without_a_drive() { - let s = DiscSession::from_parts_for_test(None, None, None); + let s = DiscSession::from_parts_for_test(None, None); assert!(s.finish(Finish::Release).is_ok()); for how in [Finish::Unlock, Finish::Eject] { - let s = DiscSession::from_parts_for_test(None, None, None); + let s = DiscSession::from_parts_for_test(None, None); assert!( matches!(s.finish(how), Err(Error::DeviceNotReady { .. })), "{how:?}" diff --git a/src/test_util.rs b/src/test_util.rs index 26d3dbc1..9555fdee 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -8,12 +8,13 @@ //! per aligned unit on each file's own grid (KS-1, KS-2, KS-3). //! - [`CountingSource`]: a [`SectorSource`] wrapper that logs every read. //! - [`decrypt_unit`]: the aligned-unit decrypt, for tests that check ciphertext. +//! - [`damage_unit_seed`]: an aligned unit whose first sector read back as garbage. //! - [`FakeTransport`]: a scripted drive with a state model for Stop tests (stop §5.0). use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit}; use crate::aacs::mkb::AacsVersion; use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES}; -use crate::disc::{AacsState, KeyOrigin}; +use crate::disc::AacsState; use crate::error::Result; use crate::sector::SectorSource; use std::sync::{Arc, Mutex}; @@ -64,8 +65,7 @@ pub fn unit_key_ro( /// Builds an [`AacsState`] for a test: start from [`aacs_state`], set what the test /// needs, then [`AacsStateBuilder::build`]. Defaults: AACS 1.0, no bus encryption, -/// no MKB version, empty disc hash, [`KeyOrigin::ExternalUk`], no VUK, no unit keys, -/// a zero Volume ID, and empty `uk_ro` / `mkb`. +/// no MKB version, empty disc hash, a zero Volume ID, and empty `uk_ro` / `mkb`. #[must_use] pub struct AacsStateBuilder { state: AacsState, @@ -79,9 +79,6 @@ pub fn aacs_state() -> AacsStateBuilder { bus_encryption: false, mkb_version: None, disc_hash: String::new(), - key_source: KeyOrigin::ExternalUk, - vuk: None, - unit_keys: Vec::new(), volume_id: [0u8; 16], uk_ro: Vec::new(), mkb: Vec::new(), @@ -106,18 +103,6 @@ impl AacsStateBuilder { self.state.disc_hash = hash.into(); self } - pub fn key_source(mut self, origin: KeyOrigin) -> Self { - self.state.key_source = origin; - self - } - pub fn vuk(mut self, vuk: Option<[u8; 16]>) -> Self { - self.state.vuk = vuk; - self - } - pub fn unit_keys(mut self, keys: Vec<(u32, [u8; 16])>) -> Self { - self.state.unit_keys = keys; - self - } pub fn volume_id(mut self, vid: [u8; 16]) -> Self { self.state.volume_id = vid; self @@ -380,6 +365,17 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { crate::aacs::content::decrypt_unit(unit, unit_key) } +/// Overwrite the first sector of an aligned unit with garbage that keeps the encrypted flag +/// (CPI 11₂, KS-5) but not the seed's TS sync at byte 4 (KS-2, KS-4): read damage that no +/// key opens, as a drive returning the wrong bytes leaves it. +pub fn damage_unit_seed(unit: &mut [u8]) { + for (i, b) in unit[..SECTOR_BYTES].iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(37) ^ 0xA5; + } + unit[0] |= 0xC0; + unit[4] = 0x00; +} + #[path = "test_util_fake.rs"] mod fake; pub use fake::{FakeCdb, FakeHandle, FakeMode, FakeTransport}; diff --git a/src/test_util_tests.rs b/src/test_util_tests.rs index c04cfca2..91e06e5e 100644 --- a/src/test_util_tests.rs +++ b/src/test_util_tests.rs @@ -38,8 +38,7 @@ fn aacs_state_defaults_and_every_setter() { (d.version, d.bus_encryption, d.mkb_version), (1, false, None) ); - assert!(d.disc_hash.is_empty() && d.unit_keys.is_empty() && d.vuk.is_none()); - assert_eq!(d.key_source, KeyOrigin::ExternalUk); + assert!(d.disc_hash.is_empty()); assert_eq!(d.volume_id, [0u8; 16]); assert!(d.uk_ro.is_empty() && d.mkb.is_empty()); let s = aacs_state() @@ -47,9 +46,6 @@ fn aacs_state_defaults_and_every_setter() { .bus_encryption(true) .mkb_version(Some(77)) .disc_hash("0xABC") - .key_source(KeyOrigin::KeyDb) - .vuk(Some([7; 16])) - .unit_keys(vec![(1, K1)]) .volume_id([9; 16]) .uk_ro(vec![1, 2]) .mkb(vec![3]) @@ -59,9 +55,7 @@ fn aacs_state_defaults_and_every_setter() { (2, true, Some(77)) ); assert_eq!(s.disc_hash, "0xABC"); - assert_eq!(s.key_source, KeyOrigin::KeyDb); - assert_eq!((s.vuk, s.volume_id), (Some([7; 16]), [9; 16])); - assert_eq!(s.unit_keys, vec![(1, K1)]); + assert_eq!(s.volume_id, [9; 16]); assert_eq!((s.uk_ro, s.mkb), (vec![1, 2], vec![3])); } @@ -102,7 +96,7 @@ fn encrypted_bd_image_encrypts_every_unit_on_its_files_grid() { assert!(aacs_unit_encrypted(&enc, ContentFormat::BdTs)); assert_ne!(enc, plain, "file {i} unit {u} is ciphertext"); let mut d = enc.clone(); - decrypt_unit(&mut d, &k); + crate::test_util::decrypt_unit(&mut d, &k); assert_eq!(d, plain, "file {i} unit {u} opens with its key"); } None => assert_eq!(enc, plain, "a clear file stays clear"), @@ -111,7 +105,7 @@ fn encrypted_bd_image_encrypts_every_unit_on_its_files_grid() { } // The other key does not open file 0. let mut d = unit(&fx.image, fx.files[0].0); - decrypt_unit(&mut d, &K2); + crate::test_util::decrypt_unit(&mut d, &K2); assert!(!is_clean(&d, ContentFormat::BdTs)); } @@ -159,6 +153,6 @@ fn decrypt_unit_inverts_encrypt_unit() { let plain = u.clone(); assert!(encrypt_unit(&mut u, &K1)); assert_ne!(u[16..], plain[16..]); - decrypt_unit(&mut u, &K1); + crate::test_util::decrypt_unit(&mut u, &K1); assert_eq!(u, plain); } diff --git a/src/whole_disc.rs b/src/whole_disc.rs index 5d559429..7e34b800 100644 --- a/src/whole_disc.rs +++ b/src/whole_disc.rs @@ -1,16 +1,12 @@ -//! The whole-disc decrypting reader behind every decrypted disc/image → ISO copy, so the -//! GUI, the CLI and the image path share one set of rules. AACS content is every content -//! file (`/BDMV/STREAM`, HD DVD `/HVDVD_TS/*.EVO`), not just the kept titles. Each file no -//! title plays is keyed on its own, probed at up to 32 units across it: a held key must -//! open TWO probed units before it keys the file (one chance TS-sync pass is no proof); -//! ciphertext no held key opens refuses up front ([`Error::WholeDiscKeyMissing`]); a file -//! with no proven key is keyed only on a provably single-CPS disc, else left unkeyed so an -//! encrypted unit there stops the pass with the same error (never ships as ciphertext). -//! Reads follow each file's own 3-sector unit grid ([`UnitAligned`]). +//! The pieces of the whole-disc decrypting reader +//! ([`ResolvedKeySet::whole_disc_reader`](crate::keys::ResolvedKeySet::whole_disc_reader)) +//! behind every decrypted disc/image → ISO copy, so the GUI, the CLI and the image path +//! share one set of rules. AACS content is every content file (`/BDMV/STREAM`, HD DVD +//! `/HVDVD_TS/*.EVO`), not just the kept titles. Reads follow each file's own 3-sector +//! unit grid ([`UnitAligned`]). -use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase}; use crate::error::{Error, Result}; -use crate::sector::{DecryptingSectorSource, KeyFetch, SectorSource}; +use crate::sector::{DecryptingSectorSource, SectorSource}; /// Sectors in one AACS aligned unit (6144 bytes). pub(crate) const UNIT: u64 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u64; @@ -18,75 +14,17 @@ pub(crate) const UNIT: u64 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as /// Units probed per unplayed content file: its first unit, then evenly across it. const PROBES: u64 = 32; -/// Probed units a key must open before it keys a file. -const PROOFS: u8 = 2; - /// The decrypting reader a whole-disc copy reads through. pub type WholeDiscReader = UnitAligned>; -/// Build the whole-disc reader over `reader`. For AACS every content file is keyed here, so -/// a refusal (or a stop during keying) comes before the caller creates any output; the one -/// exception is a file with no provable key on a multi-key disc, which stops the pass with -/// E7032 at its first encrypted unit. `fetch` goes to [`crate::mux::resolve_mux_key_map`], -/// which asks it only when the pool holds several base keys and none opens a file's -/// samples. CSS is descrambled; `decrypt == false` (raw copy) and clear discs pass through. -pub fn whole_disc_reader( - disc: &crate::Disc, - mut reader: S, - decrypt: bool, - fetch: Option<&KeyFetch>, - halt: Option<&crate::halt::Halt>, -) -> Result> { - let mut keys = if decrypt { - disc.decrypt_keys() - } else { - DecryptKeys::None - }; - let mut content = disc.encrypted_content_ranges(); - let mut spans = Vec::new(); - let mut unproven = Vec::new(); - let mut key_map = None; - if matches!(keys, DecryptKeys::Aacs { .. }) { - let r: &mut dyn SectorSource = &mut reader; - let map = disc.resolve_content_key_map(r, &mut keys, fetch, halt)?; - // freemkv#55: the key map alone is no gate; clear UDF/nav outside it would refuse. - let files = content_files(r)?; - if files.is_empty() && !content.is_empty() { - tracing::warn!(target: "freemkv::scan", "titles but no AACS content files"); - let path = match disc.format { - crate::DiscFormat::HdDvd => "/HVDVD_TS", - _ => "/BDMV/STREAM", - }; - return Err(Error::UdfNotFound { path: path.into() }); - } - spans = unit_spans(&files, &content); - let rule = KeyRule { - format: disc.content_format, - single: single_cps_key_slot(disc, &keys, &map), - fetch, - halt, - }; - let keyed = key_content_files(r, &rule, &mut keys, map, &files, &spans)?; - key_map = Some(std::sync::Arc::new(keyed.map)); - unproven = keyed.unproven; - content.extend(files.iter().flatten()); - content = merge_ranges(content); - // A stop that landed during the last key lookup still ends before any output. - if halt.is_some_and(|h| h.is_cancelled()) { - return Err(Error::Halted); - } - } - let mut dec = DecryptingSectorSource::new(reader, keys); - if let Some(map) = key_map { - dec = dec.with_key_map(map); - } - // CSS self-descrambles and `None` decrypts nothing: the gate only matters for AACS. - if decrypt && !content.is_empty() { - dec = dec.with_content_ranges(std::sync::Arc::from(content)); - } - let mut out = UnitAligned::new(dec, spans); - out.unproven = unproven; - Ok(out) +/// The whole-disc reader for a raw (`--raw`) copy. It never decrypts: AACS ciphertext and +/// CSS-scrambled sectors pass through byte for byte. A decrypting copy reads through +/// [`ResolvedKeySet::whole_disc_reader`](crate::keys::ResolvedKeySet::whole_disc_reader). +pub fn raw_whole_disc_reader(reader: S) -> WholeDiscReader { + UnitAligned::new( + DecryptingSectorSource::new(reader, crate::decrypt::DecryptKeys::None), + Vec::new(), + ) } // Every AACS content file's extents, one entry per file (contiguous extents joined): @@ -147,37 +85,6 @@ pub(crate) fn content_files_in( /// owning file's first sector, carried across extents by file offset. pub(crate) type UnitSpan = (u32, u32, u64); -// The unit grid of every content extent: per file by file offset (never a merged run: -// adjacent files each start their own grid), then title extents no file covers (anchored -// at the extent start). Sorted, disjoint; on overlap the earlier-listed source wins. -pub(crate) fn unit_spans(files: &[Vec<(u32, u32)>], title_extents: &[(u32, u32)]) -> Vec { - let mut raw: Vec<(UnitSpan, usize)> = Vec::new(); - for (i, file) in files.iter().enumerate() { - let mut off = 0u64; - for &(lba, n) in file { - raw.push(((lba, n, (lba as u64).saturating_sub(off % UNIT)), i)); - off += n as u64; - } - } - for &(lba, n) in title_extents { - raw.push(((lba, n, lba as u64), files.len())); - } - raw.retain(|&((_, n, _), _)| n > 0); - raw.sort_by_key(|&((lba, _, _), i)| (lba, i)); - let mut spans: Vec = Vec::with_capacity(raw.len()); - let mut covered = 0u64; - for ((lba, n, anchor), _) in raw { - let end = lba as u64 + n as u64; - if end <= covered { - continue; - } - let start = (lba as u64).max(covered); - spans.push((start as u32, (end - start) as u32, anchor)); - covered = end; - } - spans -} - // The span holding `lba`, if any. pub(crate) fn span_at(spans: &[UnitSpan], lba: u64) -> Option { let i = spans @@ -192,213 +99,6 @@ pub(crate) fn unit_head(lba: u32, anchor: u64) -> u64 { lba as u64 + (UNIT - (lba as u64).saturating_sub(anchor) % UNIT) % UNIT } -// How content files are keyed: the disc's content format, the single-CPS slot an -// unproven file may take, and the key source + stop token. -struct KeyRule<'a> { - format: crate::ContentFormat, - single: Option, - fetch: Option<&'a KeyFetch>, - halt: Option<&'a crate::halt::Halt>, -} - -// The keyed content map plus the `[start, end)` pieces left unkeyed (no proven key). -struct ContentKeys { - map: AacsKeyMap, - unproven: Vec<(u32, u32)>, -} - -// Key every content file no kept title plays, ONE FILE AT A TIME (CPS units sit back to -// back), with a held key proven on its ciphertext; ciphertext none opens refuses now. -fn key_content_files( - reader: &mut dyn SectorSource, - rule: &KeyRule, - keys: &mut DecryptKeys, - map: AacsKeyMap, - files: &[Vec<(u32, u32)>], - spans: &[UnitSpan], -) -> Result { - let mut keyed: Vec<(u32, u32)> = map.ranges().iter().map(|&(s, e, _, _)| (s, e)).collect(); - let mut ranges = map.ranges().to_vec(); - let mut unproven = Vec::new(); - for file in files { - let orphans = subtract_ranges(file, &keyed); - if orphans.is_empty() { - continue; - } - // Each piece from its first unit head, so key resolution samples on the file grid. - let heads: Vec<(u32, u32, u64)> = orphans - .iter() - .map(|&(s, e)| { - let anchor = span_at(spans, s as u64).map_or(s as u64, |sp| sp.2); - (unit_head(s, anchor).min(e as u64) as u32, e, anchor) - }) - .collect(); - let mut title = crate::DiscTitle::empty(); - title.content_format = rule.format; - title.extents = heads - .iter() - .filter(|&&(h, e, _)| h < e) - .map(|&(h, e, _)| crate::Extent { - start_lba: h, - sector_count: e - h, - }) - .collect(); - let fmap = crate::mux::resolve_mux_key_map( - reader, - &title, - keys, - rule.fetch, - rule.format, - rule.halt, - ) - .map_err(|e| unkeyable(Error::from(e), file))?; - for (&(s, e), &(_, _, anchor)) in orphans.iter().zip(&heads) { - let probe = Probe { - map: &fmap, - keys, - format: rule.format, - halt: rule.halt, - }; - match probe - .run(reader, (s, e), anchor) - .map_err(|e| unkeyable(e, file))? - { - Proof::Forensic => { - ranges.extend(fmap.ranges().iter().filter_map(|&(rs, re, i, p)| { - (rs < e && re > s).then_some((rs.max(s), re.min(e), i, p)) - })); - } - Proof::Key(slot) => ranges.push((s, e, slot, Phase::All)), - Proof::Unproven => match rule.single { - Some(slot) => ranges.push((s, e, slot, Phase::All)), - None => { - tracing::warn!( - target: "freemkv::scan", - start = s, - end = e, - probes = PROBES, - "unplayed stream file: no key opened two readable encrypted \ - probes (damaged area, or too little ciphertext), so its key \ - is unproven on this multi-key disc. Left unkeyed: if the copy \ - meets an encrypted unit here it stops with E7032. An MKV rip \ - or a raw copy avoids this." - ); - unproven.push((s, e)); - } - }, - } - } - // Settled either way: a file re-listing these sectors (SSIF) is not re-keyed. - keyed.extend(orphans); - keyed.sort_unstable(); - } - Ok(ContentKeys { - map: AacsKeyMap::from_ranges_phased(merge_key_ranges(ranges)), - unproven: merge_spans(unproven), - }) -} - -// A key-resolution refusal for an unplayed file: "no held key opens it" is -// [`Error::WholeDiscKeyMissing`] (the fix is an MKV rip or a raw copy). -fn unkeyable(e: Error, file: &[(u32, u32)]) -> Error { - match e { - Error::DecryptFailed | Error::WholeDiscKeyMissing => { - tracing::error!( - target: "freemkv::scan", - lba = file.first().map_or(0, |f| f.0), - code = crate::error::E_WHOLE_DISC_KEY_MISSING, - "unplayed stream file is encrypted and no held key opens it: a decrypted \ - image would keep encrypted pieces. Refusing before the copy starts." - ); - Error::WholeDiscKeyMissing - } - other => other, - } -} - -// What probing an unplayed file's ciphertext proved. -#[derive(Debug, PartialEq)] -enum Proof { - /// A forensic (FMTS) segment entry: libfreemkv verifies each such unit as it decrypts. - Forensic, - /// This pool slot opened [`PROOFS`] probed units. - Key(usize), - /// No key opened enough readable encrypted units, and none was unopenable. - Unproven, -} - -// Inputs for proving an unplayed file's key. -struct Probe<'a> { - map: &'a AacsKeyMap, - keys: &'a DecryptKeys, - format: crate::ContentFormat, - halt: Option<&'a crate::halt::Halt>, -} - -impl Probe<'_> { - // Probe `[start, end)` on the file's grid (`anchor`): its first unit, then evenly - // across it. Each encrypted unit is tried with the mapped key, then every held base - // key (never forensic ones). Err(WholeDiscKeyMissing): a unit no held key opens. - fn run( - &self, - reader: &mut dyn SectorSource, - (start, end): (u32, u32), - anchor: u64, - ) -> Result { - use crate::aacs::content::{aacs_unit_encrypted, decrypt_unit, is_clean}; - let DecryptKeys::Aacs { unit_keys, .. } = self.keys else { - return Ok(Proof::Unproven); - }; - let base = crate::mux::resolve::base_key_slots(unit_keys); - let head = unit_head(start, anchor); - let units = (end as u64).saturating_sub(head) / UNIT; - let mut opened = vec![0u8; unit_keys.len()]; - let mut unopened = false; - let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; - let opens = |buf: &[u8], slot: usize| { - unit_keys.get(slot).is_some_and(|(_, key)| { - let mut u = buf.to_vec(); - decrypt_unit(&mut u, key); - is_clean(&u, self.format) - }) - }; - for unit in probe_units(units) { - if self.halt.is_some_and(|h| h.is_cancelled()) { - return Err(Error::Halted); - } - let Ok(lba) = u32::try_from(head + unit * UNIT) else { - continue; - }; - if !matches!(reader.read_sectors(lba, UNIT as u16, &mut buf, false), - Ok(n) if n == buf.len()) - || !aacs_unit_encrypted(&buf, self.format) - { - continue; - } - let mapped = match self.map.entry_for(lba) { - Some((_, p, _)) if p != Phase::All => return Ok(Proof::Forensic), - entry => entry.map(|(slot, _, _)| slot), - }; - let mut candidates = mapped - .into_iter() - .chain(base.iter().copied().filter(|&s| Some(s) != mapped)); - match candidates.find(|&slot| opens(&buf, slot)) { - Some(slot) => { - opened[slot] = opened[slot].saturating_add(1); - if opened[slot] >= PROOFS { - return Ok(Proof::Key(slot)); - } - } - None => unopened = true, - } - } - if unopened { - return Err(Error::WholeDiscKeyMissing); - } - Ok(Proof::Unproven) - } -} - // Which of a piece's `units` to probe, in order: all of them when few, else the // first unit, then `PROBES - 1` more spread evenly to the end. pub(crate) fn probe_units(units: u64) -> Vec { @@ -408,44 +108,6 @@ pub(crate) fn probe_units(units: u64) -> Vec { (0..PROBES).map(|p| units * p / PROBES).collect() } -/// The key-pool slot of the disc's only CPS unit, when provably single-CPS: -/// `Unit_Key_RO.inf` declares exactly one unit, the disc is not FMTS, and `map` -/// uses at most one key. Never the pool size: one held key on a two-unit disc is -/// not single-CPS. -fn single_cps_key_slot(disc: &crate::Disc, keys: &DecryptKeys, map: &AacsKeyMap) -> Option { - if disc.format == crate::DiscFormat::Fmts || map.ranges().iter().any(|r| r.3 != Phase::All) { - return None; - } - // KS-14 [BD] §3.9.3: "Num_of_CPS_Unit … indicates the number of CPS Units on the - // disc"; K-8: via `parse_title_keys`, so an HD DVD VTKF counts too (KS-27, evidence). - if disc.declared_cps_units()? != 1 { - return None; - } - match (map.key_indices(), keys) { - ([only], _) => Some(*only), - ([], DecryptKeys::Aacs { unit_keys, .. }) if unit_keys.len() == 1 => Some(0), - _ => None, - } -} - -// Sorted, disjoint key ranges (`AacsKeyMap::entry_for` checks one neighbour), merged as -// the per-disc map is: same key + phase overlap unions; a conflicting overlap drops. -fn merge_key_ranges(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Vec<(u32, u32, usize, Phase)> { - ranges.sort_by_key(|r| r.0); - let mut merged: Vec<(u32, u32, usize, Phase)> = Vec::with_capacity(ranges.len()); - for r in ranges { - match merged.last_mut() { - Some(last) if r.0 < last.1 => { - if r.2 == last.2 && r.3 == last.3 { - last.1 = last.1.max(r.1); - } - } - _ => merged.push(r), - } - } - merged -} - // `content` `(start, count)` ranges minus the sorted, disjoint `[start, end)` `keyed` // ranges, as `[start, end)` pieces. pub(crate) fn subtract_ranges(content: &[(u32, u32)], keyed: &[(u32, u32)]) -> Vec<(u32, u32)> { @@ -490,29 +152,14 @@ pub(crate) fn merge_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> { out } -// Sort + coalesce `[start, end)` ranges. -fn merge_spans(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> { - v.sort_unstable(); - let mut out: Vec<(u32, u32)> = Vec::with_capacity(v.len()); - for (s, e) in v { - match out.last_mut() { - Some(last) if s <= last.1 => last.1 = last.1.max(e), - _ => out.push((s, e)), - } - } - out -} - /// Reads through `inner` on each content file's AACS unit grid: a read touching a span /// is widened to whole units of that span (sweep batches, `write_image` batches and /// patch's single-sector reads are not unit multiples). A unit whose head lies outside -/// its span fails loud; a decrypt refusal inside an unproven file is E7032. +/// its span fails loud. pub struct UnitAligned { inner: S, spans: Vec, scratch: Vec, - /// Unplayed-file pieces left unkeyed (no proven key), `[start, end)`. - unproven: Vec<(u32, u32)>, } impl UnitAligned { @@ -521,7 +168,6 @@ impl UnitAligned { inner, spans, scratch: Vec::new(), - unproven: Vec::new(), } } @@ -541,27 +187,13 @@ impl UnitAligned { _ => end, } } +} - // A decrypt refusal inside an unproven piece is the key it could not prove up front. - fn refusal(&self, err: Error, a0: u64, a1: u64) -> Error { - let hit = self - .unproven - .iter() - .any(|&(s, e)| (s as u64) < a1 && (e as u64) > a0); - match err { - Error::DecryptFailed if hit => { - tracing::error!( - target: "freemkv::disc", - lba = a0, - code = crate::error::E_WHOLE_DISC_KEY_MISSING, - "encrypted unit in an unplayed stream file whose key could not be \ - proven before the copy (no readable probe); stopping rather than \ - writing ciphertext into a decrypted image" - ); - Error::WholeDiscKeyMissing - } - other => other, - } +impl UnitAligned> { + /// Damaged AACS units the decrypting reader blanked so far (see + /// [`DecryptingSectorSource::blanked_units`](crate::sector::DecryptingSectorSource::blanked_units)). + pub fn blanked_units(&self) -> u64 { + self.inner.blanked_units() } } @@ -634,16 +266,13 @@ impl SectorSource for UnitAligned { let len = (a1 - a0) as usize * SECTOR; self.scratch.resize(len, 0); self.inner.set_unit_base(a0 as u32); - let got = self - .inner - .read_sectors_fua( - a0 as u32, - (a1 - a0) as u16, - &mut self.scratch[..len], - recovery, - fua, - ) - .map_err(|e| self.refusal(e, a0, a1))?; + let got = self.inner.read_sectors_fua( + a0 as u32, + (a1 - a0) as u16, + &mut self.scratch[..len], + recovery, + fua, + )?; let skip = (cur - a0) as usize * SECTOR; let want = (piece_end - cur) as usize * SECTOR; let have = got.saturating_sub(skip).min(want); diff --git a/src/whole_disc_tests.rs b/src/whole_disc_tests.rs index e12d8ddd..b1b0dc98 100644 --- a/src/whole_disc_tests.rs +++ b/src/whole_disc_tests.rs @@ -1,341 +1,40 @@ use super::*; -use crate::aacs::content::encrypt_unit; -const SECTOR: usize = 2048; -const K0: [u8; 16] = [0x11; 16]; -const K1: [u8; 16] = [0x22; 16]; -const K2: [u8; 16] = [0x33; 16]; -const STRANGER: [u8; 16] = [0x77; 16]; - -// A clear content unit for the unit starting at `lba`: varied payload, TS sync at -// offset 4 of every 192-byte source packet, CPI bits clear. -fn clear_unit(lba: u32) -> Vec { - let mut u: Vec = (0..6144u32) - .map(|i| (lba.wrapping_mul(7919).wrapping_add(i) % 251) as u8 | 1) - .collect(); - for off in (4..6144).step_by(192) { - u[off] = 0x47; - } - u[0] &= 0x3F; - u -} - -/// An in-memory image: stream files laid out on their own unit grid, the listed -/// units encrypted under the file's key; sectors outside every file are -/// CPI-flagged-looking clear data (the freemkv#55 shape). -struct Img { - data: Vec, - plain: Vec, - /// One-unit reads starting in `[start, end)` fail (probes of a damaged area). - probe_fail: Option<(u32, u32)>, - reads: Vec<(u32, u16)>, -} - -// One file: start, sectors, key, and which of its units are encrypted. -type FileSpec = (u32, u32, [u8; 16], &'static [u32]); - -// Every unit of a 30-sector file. -const ALL: &[u32] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; - -fn img(capacity: u32, files: &[FileSpec]) -> Img { - let mut data = vec![0u8; capacity as usize * SECTOR]; - for lba in 0..capacity { - let o = lba as usize * SECTOR; - for (i, b) in data[o..o + SECTOR].iter_mut().enumerate() { - *b = (lba as usize + i) as u8; - } - data[o] = 0xC0; - } - let mut plain = data.clone(); - for (start, n, key, enc) in files { - for u in 0..n / 3 { - let lba = start + u * 3; - let o = lba as usize * SECTOR; - let mut unit = clear_unit(lba); - if enc.contains(&u) { - unit[0] |= 0xC0; - plain[o..o + 6144].copy_from_slice(&unit); - assert!(encrypt_unit(&mut unit, key)); - } else { - plain[o..o + 6144].copy_from_slice(&unit); - } - data[o..o + 6144].copy_from_slice(&unit); - } - } - Img { - data, - plain, - probe_fail: None, - reads: Vec::new(), - } -} - -impl SectorSource for Img { - fn capacity_sectors(&self) -> u32 { - (self.data.len() / SECTOR) as u32 - } - fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result { - self.reads.push((lba, count)); - if count == 3 && self.probe_fail.is_some_and(|(s, e)| (s..e).contains(&lba)) { - return Err(Error::DiscRead { - sector: lba as u64, - status: None, - sense: None, - }); - } - let (o, n) = (lba as usize * SECTOR, count as usize * SECTOR); - buf[..n].copy_from_slice(&self.data[o..o + n]); - Ok(n) - } -} - -fn pool(keys: &[[u8; 16]]) -> DecryptKeys { - DecryptKeys::Aacs { - unit_keys: keys - .iter() - .enumerate() - .map(|(i, k)| (i as u32, *k)) - .collect(), - format: crate::ContentFormat::BdTs, - } -} - -fn rule(single: Option) -> KeyRule<'static> { - KeyRule { - format: crate::ContentFormat::BdTs, - single, - fetch: None, - halt: None, - } -} - -/// Key `files` the way [`whole_disc_reader`] does; the kept title plays file 0 with slot 0. -fn plan( - src: &mut Img, - keys: &mut DecryptKeys, - files: &[Vec<(u32, u32)>], - rule: &KeyRule, -) -> Result { - let (s, n) = files[0][0]; - let title = AacsKeyMap::from_ranges(vec![(s, s + n, 0)]); - let spans = unit_spans(files, &[]); - key_content_files(src, rule, keys, title, files, &spans) -} - -fn one_file_each(specs: &[FileSpec]) -> Vec> { - specs.iter().map(|f| vec![(f.0, f.1)]).collect() -} - -/// Wire the reader over `src` from a plan and copy the whole image with `write_image`. -fn write_through( - src: Img, - keys: DecryptKeys, - files: &[Vec<(u32, u32)>], - planned: ContentKeys, -) -> Result> { - let content = merge_ranges(files.iter().flatten().copied().collect()); - let cap = src.capacity_sectors(); - let dec = DecryptingSectorSource::new(src, keys) - .with_key_map(std::sync::Arc::new(planned.map)) - .with_content_ranges(std::sync::Arc::from(content)); - let mut r = UnitAligned::new(dec, unit_spans(files, &[])); - r.unproven = planned.unproven; - let dir = tempfile::tempdir().map_err(|e| Error::IoError { source: e })?; - let dest = dir.path().join("out.iso"); - crate::write_image(&mut r, &dest, cap, &crate::halt::Halt::new(), |_| {})?; - std::fs::read(&dest).map_err(|e| Error::IoError { source: e }) -} - -// `bytes` equals the image's plaintext, where each decrypted unit (`specs`) reads with -// CPI 00₂ in every source packet (KS-5; KU design §5.4) and every other sector is verbatim. -fn assert_plain(img_plain: &[u8], bytes: &[u8], specs: &[FileSpec]) { - assert_eq!(bytes.len(), img_plain.len()); - let mut want = img_plain.to_vec(); - for (start, n, _, enc) in specs { - for u in enc.iter().filter(|&&u| u < n / 3) { - let o = (start + u * 3) as usize * SECTOR; - let unit = crate::aacs::content::cpi_cleared(want[o..o + 6144].to_vec()); - want[o..o + 6144].copy_from_slice(&unit); - } - } - for (lba, (got, want)) in bytes.chunks(SECTOR).zip(want.chunks(SECTOR)).enumerate() { - assert!(got == want, "sector {lba} not decrypted as expected"); - } -} - -#[test] -fn every_unplayed_file_is_keyed_and_the_image_decrypts_across_batches() { - // Second file misaligned (start % 3 != 0) and crossing a 2048-sector batch edge. - let specs = [(300, 30, K0, ALL), (2041, 30, K0, ALL)]; - let files = one_file_each(&specs); - let mut src = img(2200, &specs); - let mut keys = pool(&[K0]); - let planned = plan(&mut src, &mut keys, &files, &rule(Some(0))).unwrap(); - assert!(planned.unproven.is_empty()); - let want = src.plain.clone(); - let bytes = write_through(img(2200, &specs), keys, &files, planned).unwrap(); - assert_plain(&want, &bytes, &specs); -} - -/// Back-to-back unplayed files in different CPS units each get their own key. -#[test] -fn adjacent_files_in_different_cps_units_each_get_their_own_key() { - let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL), (630, 30, K2, ALL)]; - let files = one_file_each(&specs); - let mut keys = pool(&[K0, K1, K2]); - let planned = plan(&mut img(1200, &specs), &mut keys, &files, &rule(None)).unwrap(); - assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(1)); - assert_eq!(planned.map.entry_for(630).map(|e| e.0), Some(2)); - let bytes = write_through(img(1200, &specs), keys, &files, planned).unwrap(); - assert_plain(&img(1200, &specs).plain, &bytes, &specs); -} - -/// Ciphertext no held key opens refuses the plan: multi-CPS pool, and a single key. -#[test] -fn a_file_no_held_key_opens_is_refused_up_front() { - let specs = [(300, 30, K0, ALL), (600, 30, STRANGER, ALL)]; - let files = one_file_each(&specs); - for (p, single) in [(&[K0, K1][..], None), (&[K0][..], Some(0))] { - let r = plan(&mut img(1200, &specs), &mut pool(p), &files, &rule(single)); - assert!( - matches!(r, Err(Error::WholeDiscKeyMissing)), - "pool {}: {:?}", - p.len(), - r.err() - ); - } -} - -/// The resolver's evenly spaced samples miss a file encrypted only at its ends; the -/// probes cover every unit of a short file and prove the other held key twice. -#[test] -fn another_held_key_opening_two_probes_keys_the_file() { - let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0, 9][..])]; - let files = one_file_each(&specs); - let mut src = img(1200, &specs); - let planned = plan(&mut src, &mut pool(&[K0, K1]), &files, &rule(None)).unwrap(); - assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(1)); - assert!(planned.unproven.is_empty()); -} - -/// One opened probe is no proof: a chance TS-sync pass must not key a whole file. -/// With no unit left unopened the file is unproven (keyed only on single-CPS). -#[test] -fn a_key_opening_a_single_probe_is_not_trusted() { - let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0])]; - let files = one_file_each(&specs); - let planned = plan( - &mut img(1200, &specs), - &mut pool(&[K0, K1]), - &files, - &rule(None), - ) - .unwrap(); - assert_eq!(planned.map.entry_for(600), None); - assert_eq!(planned.unproven, vec![(600, 630)]); - let planned = plan( - &mut img(1200, &specs), - &mut pool(&[K0, K1]), - &files, - &rule(Some(0)), - ) - .unwrap(); - assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(0)); -} - -/// A key that opens one probe while another encrypted unit opens under none is -/// demonstrably unkeyable: refused, not trusted on the one pass. -#[test] -fn a_single_opened_probe_beside_an_unopenable_unit_is_refused() { - let mut src = img(1200, &[(300, 30, K0, ALL), (600, 30, K1, &[0])]); - let other = img(1200, &[(600, 30, STRANGER, &[5])]); - let (a, b) = (615 * SECTOR, 618 * SECTOR); - src.data[a..b].copy_from_slice(&other.data[a..b]); - let files = vec![vec![(300, 30)], vec![(600, 30)]]; - let r = plan(&mut src, &mut pool(&[K0, K1]), &files, &rule(None)); - assert!( - matches!(r, Err(Error::WholeDiscKeyMissing)), - "{:?}", - r.err() - ); -} - -/// Alternates are the held BASE keys only: a forensic (FMTS-tagged) pool key never -/// keys a whole file, while another base key still does. -#[test] -fn alternates_never_use_forensic_pool_keys() { - // Only the ends encrypted: the resolver's samples miss the file. - let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0, 9][..])]; - let files = one_file_each(&specs); - let mut src = img(1200, &specs); - let tagged = |extra: &[(u32, [u8; 16])]| { - let mut unit_keys = vec![(0u32, K0)]; - unit_keys.extend_from_slice(extra); - DecryptKeys::Aacs { - unit_keys, - format: crate::ContentFormat::BdTs, - } - }; - let mut forensic = tagged(&[(1 << 24, K1)]); - let r = plan(&mut src, &mut forensic, &files, &rule(None)); - assert!( - matches!(r, Err(Error::WholeDiscKeyMissing)), - "{:?}", - r.err() - ); - let mut base = tagged(&[(1 << 24, STRANGER), (1, K1)]); - let planned = plan(&mut src, &mut base, &files, &rule(None)).unwrap(); - assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(2)); -} - -/// Last resort: no probe is readable, so the file stays unkeyed and the copy stops -/// at its first encrypted unit with E7032, never writing ciphertext. -#[test] -fn an_unreadable_probe_leaves_the_file_unproven_and_the_copy_stops_with_e7032() { - let specs = [(300, 30, K0, ALL), (600, 30, STRANGER, ALL)]; - let files = one_file_each(&specs); - let mut src = img(1200, &specs); - src.probe_fail = Some((600, 630)); - let mut keys = pool(&[K0, K1]); - let planned = plan(&mut src, &mut keys, &files, &rule(None)).unwrap(); - assert_eq!(planned.unproven, vec![(600, 630)]); - let r = write_through(img(1200, &specs), keys, &files, planned); - assert!( - matches!(r, Err(Error::WholeDiscKeyMissing)), - "{:?}", - r.err() - ); -} - -/// A stop request is honoured between probes. -#[test] -fn probing_honours_a_stop() { - let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL)]; - let files = one_file_each(&specs); - let halt = crate::halt::Halt::new(); - halt.cancel(); - let rule = KeyRule { - halt: Some(&halt), - ..rule(None) - }; - let r = plan(&mut img(1200, &specs), &mut pool(&[K0, K1]), &files, &rule); - assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err()); -} - -/// SSIF re-lists an m2ts's extents: that file is probed once, the map stays disjoint. -#[test] -fn a_relisted_file_is_keyed_once_into_a_disjoint_map() { - let files = vec![vec![(300, 30)], vec![(600, 30)], vec![(600, 30)]]; - let mut src = img(1200, &[(300, 30, K0, ALL), (600, 30, K0, ALL)]); - let planned = plan(&mut src, &mut pool(&[K0]), &files, &rule(Some(0))).unwrap(); - let r = planned.map.ranges(); - assert!(r.windows(2).all(|w| w[0].1 <= w[1].0), "disjoint: {r:?}"); - let probes = src - .reads +// The key set's whole-disc pieces (`keys::whole_disc_pieces`) over `files`, with one +// title playing `title_extents`: each piece's unit spans. +fn pieces(files: &[Vec<(u32, u32)>], title_extents: &[(u32, u32)]) -> Vec> { + let mut title = crate::DiscTitle::empty(); + title.extents = title_extents .iter() - .filter(|&&(l, n)| l >= 600 && n == 3) - .count(); - assert!(probes <= PROBES as usize + 8, "probed twice: {probes}"); + .map(|&(start_lba, sector_count)| crate::Extent { + start_lba, + sector_count, + }) + .collect(); + let disc = crate::Disc { + volume_id: String::new(), + meta_title: None, + format: crate::DiscFormat::BluRay, + capacity_sectors: 1000, + capacity_bytes: 1000 * 2048, + layers: 1, + titles: vec![title], + region: crate::disc::DiscRegion::Free, + aacs: None, + css: None, + encrypted: true, + aacs_error: None, + css_error: None, + content_format: crate::ContentFormat::BdTs, + }; + crate::keys::whole_disc_pieces(&disc, files) +} + +// The unit spans the key set's whole-disc reader holds: every piece's, sorted. +fn unit_spans(files: &[Vec<(u32, u32)>], title_extents: &[(u32, u32)]) -> Vec { + let mut spans: Vec = pieces(files, title_extents).into_iter().flatten().collect(); + spans.sort_unstable_by_key(|s| s.0); + spans } #[test] @@ -348,56 +47,6 @@ fn probes_start_at_the_first_unit_and_spread_to_the_end() { assert_eq!((long[0], long[1], long[31]), (0, 100, 3100)); } -// A minimal AACS 1.0 `Unit_Key_RO.inf` declaring `units` CPS units. -fn unit_key_ro(units: u16) -> Vec { - let mut v = vec![0u8; 96 + 48 * units as usize]; - v[..4].copy_from_slice(&48u32.to_be_bytes()); - v[48..50].copy_from_slice(&units.to_be_bytes()); - v -} - -fn aacs_disc(declared: u16) -> crate::Disc { - crate::Disc { - volume_id: String::new(), - meta_title: None, - format: crate::DiscFormat::BluRay, - capacity_sectors: 1000, - capacity_bytes: 1000 * 2048, - layers: 1, - titles: Vec::new(), - region: crate::disc::DiscRegion::Free, - aacs: Some(crate::disc::AacsState { - version: 1, - bus_encryption: false, - mkb_version: None, - disc_hash: String::new(), - key_source: crate::disc::KeyOrigin::DeviceKey, - vuk: None, - unit_keys: vec![(1, K0)], - volume_id: [0; 16], - uk_ro: unit_key_ro(declared), - mkb: Vec::new(), - }), - css: None, - encrypted: true, - aacs_error: None, - css_error: None, - content_format: crate::ContentFormat::BdTs, - } -} - -/// Single-CPS is the DECLARED unit count, never the pool size. -#[test] -fn single_cps_follows_the_declared_unit_count_not_the_pool() { - let empty = AacsKeyMap::from_ranges(Vec::new()); - let keys = pool(&[K0]); - assert_eq!(single_cps_key_slot(&aacs_disc(1), &keys, &empty), Some(0)); - assert_eq!(single_cps_key_slot(&aacs_disc(2), &keys, &empty), None); - let mut fmts = aacs_disc(1); - fmts.format = crate::DiscFormat::Fmts; - assert_eq!(single_cps_key_slot(&fmts, &keys, &empty), None); -} - #[test] fn subtract_leaves_only_the_unkeyed_pieces() { let got = subtract_ranges(&[(10, 30), (50, 10)], &[(0, 12), (20, 25), (38, 55)]); @@ -534,22 +183,6 @@ fn unit_aligned_refuses_a_unit_split_across_extents() { )); } -/// A decrypt refusal is E7032 only inside an unproven piece. -#[test] -fn a_refusal_in_an_unproven_piece_is_the_mkv_or_raw_error() { - let mut r = reader(vec![(100, 30, 100)]); - r.unproven = vec![(110, 130)]; - r.inner.fail = Some(|| Error::DecryptFailed); - let mut buf = vec![0u8; 2048]; - let got = r.read_sectors(111, 1, &mut buf, false); - assert!(matches!(got, Err(Error::WholeDiscKeyMissing)), "{got:?}"); - let got = r.read_sectors(101, 1, &mut buf, false); - assert!(matches!(got, Err(Error::DecryptFailed)), "{got:?}"); - r.inner.fail = Some(|| Error::Halted); - let got = r.read_sectors(111, 1, &mut buf, false); - assert!(matches!(got, Err(Error::Halted)), "{got:?}"); -} - #[test] fn unit_block_end_pulls_back_to_the_straddling_units_head() { let r = reader(vec![(100, 30, 100)]); @@ -559,27 +192,6 @@ fn unit_block_end_pulls_back_to_the_straddling_units_head() { assert_eq!(r.unit_block_end(0, 50), 50); } -/// An AACS disc with titles but no stream folder names the missing folder (E6003), -/// not a key failure: no key refresh can fix a tree with no content files. -#[test] -fn titles_without_a_stream_folder_name_the_missing_folder() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("BDMV")).unwrap(); - std::fs::write(dir.path().join("BDMV/index.bdmv"), b"INDX0200").unwrap(); - let img = crate::DirImage::open(dir.path()).unwrap(); - let mut d = aacs_disc(1); - let mut title = crate::DiscTitle::empty(); - title.extents = vec![crate::Extent { - start_lba: 300, - sector_count: 30, - }]; - d.titles = vec![title]; - match whole_disc_reader(&d, img, true, None, None) { - Err(Error::UdfNotFound { path }) => assert_eq!(path, "/BDMV/STREAM"), - other => panic!("expected UdfNotFound, got {:?}", other.err()), - } -} - // Spec guards (keys-upfront-design §7.8) on the per-file unit grid. mod spec_guards { use super::*; @@ -691,8 +303,7 @@ mod spec_guards { .text .contains("aligned to Aligned Unit boundary") ); - let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL)]; - let m2ts = one_file_each(&specs); + let m2ts = vec![vec![(300, 30)], vec![(600, 30)]]; // The SSIF re-lists the second clip from sector 601: its own grid would be 601+3k. let mut with_ssif = m2ts.clone(); with_ssif.push(vec![(601, 29)]); @@ -701,18 +312,11 @@ mod spec_guards { unit_spans(&m2ts, &[]), "not re-gridded" ); - let run = |files: &[Vec<(u32, u32)>]| { - let mut src = img(1200, &specs); - let planned = plan(&mut src, &mut pool(&[K0, K1]), files, &rule(None)).unwrap(); - (planned.map.ranges().to_vec(), src.reads) - }; - let (map, reads) = run(&m2ts); assert_eq!( - run(&with_ssif), - (map.clone(), reads), - "not re-keyed, not re-probed" + pieces(&with_ssif, &[]), + pieces(&m2ts, &[]), + "no piece of its own: not re-keyed, not re-probed" ); - assert!(map.iter().any(|&(s, _, slot, _)| s <= 600 && slot == 1)); } } @@ -724,75 +328,28 @@ fn unit_aligned_forwards_unmapped_stream_files() { assert_forwards(UnitAligned::new(Reports(vec![m2ts1()]), Vec::new())); } -/// LK21 (K-13), per spec — KS-5 [BD] §3.10.2: CPI "shall be set to 00₂ if the data is not -/// encrypted"; corroborated by KS-22 (libaacs clears it per source packet). A decrypted -/// image says so in every packet, so re-scanning it finds only clear pieces and asks nothing. +/// The raw (`--raw`) whole-disc reader never decrypts: AACS ciphertext and a CSS-scrambled +/// pack pass through byte for byte, as the pre-KU-X2 reader did with `decrypt: false`. #[test] -fn decrypt_clears_cpi_on_every_source_packet() { - use crate::spec::keys::{KS_5_CPI, KS_22_LIBAACS_VERIFY_TS}; - assert!(KS_5_CPI.text.contains("00₂ if the data is not encrypted")); - assert!(KS_22_LIBAACS_VERIFY_TS.text.contains("buf[i] &= ~0xc0;")); - let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL)]; - let files = one_file_each(&specs); - let mut keys = pool(&[K0, K1]); - let planned = plan(&mut img(1200, &specs), &mut keys, &files, &rule(None)).unwrap(); - let bytes = write_through(img(1200, &specs), keys, &files, planned).unwrap(); - for &(start, n, _, _) in &specs { - for lba in (start..start + n).step_by(3) { - let o = lba as usize * SECTOR; - for i in (0..6144).step_by(192) { - assert_eq!(bytes[o + i] & 0xC0, 0, "LBA {lba}, packet byte {i}: CPI"); - } - } +fn raw_whole_disc_reader_passes_every_sector_through() { + use crate::test_util::{BdFile, MemSource, encrypted_bd_image, unit_key_ro}; + let uk_ro = unit_key_ro(crate::aacs::mkb::AacsVersion::V10, &[[0xEE; 16]], &[1]); + let files = [BdFile::new("BDMV/STREAM/00001.m2ts", 30, Some([0x11; 16]))]; + let mut image = encrypted_bd_image(&files, &uk_ro).image; + // A CSS-scrambled MPEG-2 pack (bits 4-5 of byte 0x14 set) in sector 1. + image[2048..2052].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + image[2048 + 4] = 0x44; + image[2048 + 0x14] |= 0x30; + let sectors = (image.len() / 2048) as u32; + let mut r = raw_whole_disc_reader(MemSource::new(image.clone())); + let mut buf = vec![0u8; image.len()]; + let mut lba = 0; + while lba < sectors { + let n = (sectors - lba).min(30); + let at = lba as usize * 2048; + let got = r.read_sectors(lba, n as u16, &mut buf[at..at + n as usize * 2048], false); + assert_eq!(got.unwrap(), n as usize * 2048); + lba += n; } - // Re-scan the decrypted image: every piece reads Clear, and no source is asked. - let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen = asked.clone(); - let fetch = KeyFetch::unit_only(std::sync::Arc::new(move |_| { - seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(Vec::new()) - })); - let rescan_rule = KeyRule { - format: crate::ContentFormat::BdTs, - single: None, - fetch: Some(&fetch), - halt: None, - }; - let mut decrypted = Img { - plain: bytes.clone(), - data: bytes, - probe_fail: None, - reads: Vec::new(), - }; - let replanned = plan(&mut decrypted, &mut pool(&[K0, K1]), &files, &rescan_rule); - assert!(replanned.is_ok(), "{:?}", replanned.err()); - assert_eq!( - asked.load(std::sync::atomic::Ordering::SeqCst), - 0, - "requests" - ); -} - -/// K-8: the declared count is read through `parse_title_keys`, so an HD DVD title-key -/// file counts too. Per spec KS-14 ("Num_of_CPS_Unit … the number of CPS Units on the -/// disc") for BD; HD DVD has no public book, per evidence KS-27. -#[test] -fn single_cps_reads_hd_dvd_title_keys_too() { - use crate::spec::keys::{KS_14_UNIT_KEY_BLOCK, KS_27_HDDVD_EVIDENCE}; - assert!( - KS_14_UNIT_KEY_BLOCK - .text - .contains("indicates the number of CPS Units") - ); - assert_eq!(KS_27_HDDVD_EVIDENCE.kind, crate::spec::QuoteKind::Evidence); - // A VTKF with one available Title Key Entry (AV_FLG set in slot 0). - let mut vtkf = vec![0u8; 2480]; - vtkf[..12].copy_from_slice(crate::aacs::inf::VTKF_MAGIC); - vtkf[0x80] = 0x80; - let mut disc = aacs_disc(1); - disc.format = crate::DiscFormat::HdDvd; - disc.content_format = crate::ContentFormat::MpegPs; - disc.aacs.as_mut().unwrap().uk_ro = vtkf; - let empty = AacsKeyMap::from_ranges(Vec::new()); - assert_eq!(single_cps_key_slot(&disc, &pool(&[K0]), &empty), Some(0)); + assert!(buf == image, "a raw read returns the image unchanged"); } diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index 9bcd225e..33129a9f 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -147,7 +147,7 @@ fn aacs_decrypt_unit_roundtrip() { )); // Now decrypt - aacs::content::decrypt_unit(&mut plain, &unit_key); + libfreemkv::test_util::decrypt_unit(&mut plain, &unit_key); assert!( aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs), "decrypted unit should read as clear (TS syncs restored)" @@ -424,7 +424,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() { ); // -- Decrypt with the library -- - aacs::content::decrypt_unit(&mut plaintext, &unit_key); + libfreemkv::test_util::decrypt_unit(&mut plaintext, &unit_key); // Decryption clears no flag, so the unit round-trips byte-for-byte. assert_eq!( @@ -465,7 +465,7 @@ fn aacs_cross_validation_alternate_key() { &mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN], ); - aacs::content::decrypt_unit(&mut plaintext, &unit_key); + libfreemkv::test_util::decrypt_unit(&mut plaintext, &unit_key); // Decryption clears no flag, so the unit round-trips byte-for-byte. assert_eq!(&plaintext[..], &expected[..]); diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 73817362..1c86dd56 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -545,21 +545,6 @@ fn batch_count_max_batch_sizes() { } } -#[test] -fn scan_encrypted_resolves_no_keys() { - // A UDF image with an /AACS directory: the lookup-free scan detects - // encryption and captures inputs, but resolves NO key on its own — a - // caller applies one later via Disc::decrypt_with. - let mut reader = MockSectorReader::new(); - build_udf_with_aacs_dir(&mut reader); - - let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap(); - - // No unit keys without an external key (the mock has no Unit_Key_RO.inf to - // capture, so the keyless state isn't even built) — either way, no keys. - assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None)); -} - #[test] fn aacs_dir_alone_marks_the_disc_encrypted_and_reports_the_capture_error() { // Detection is an OR over `/AACS` and `/BDMV/AACS`. This fixture carries diff --git a/tests/legacy_key_api_guard.rs b/tests/legacy_key_api_guard.rs new file mode 100644 index 00000000..35457886 --- /dev/null +++ b/tests/legacy_key_api_guard.rs @@ -0,0 +1,275 @@ +//! The structural key guard (keys-upfront design v3.4 §2.2, LK20, KU-X2). +//! +//! §2.2: "One test per repo; any match outside the allow-path fails." Every Rust file under +//! `src/` (`#[cfg(test)]` included) and `tests/` is scanned, comments skipped, for the +//! libfreemkv banned list. The allow-path is §2.2's "`src/keys/**`, `src/decrypt.rs`, +//! `src/sector/decrypting.rs`, `src/aacs/**` (definitions and internal use)". +//! +//! The sanctioned test helper `test_util::decrypt_unit` (§2.2: "Both move to +//! `libfreemkv::test_util::decrypt_unit`") is not the library door: its definition and its +//! callers are exempt. So is `KeySource::resolve_unit_keys`'s default, the trait op's own +//! forward to `get_unit_keys` (§2.2: the "public trait ops ... Stay"). +//! Per spec; do not change without a spec citation proving otherwise. + +use std::path::{Path, PathBuf}; + +/// §2.2, the libfreemkv row of the structural guards. +const BANNED: &[&str] = &[ + ".get_unit_keys(", + ".get_fmts_indexes(", + ".resolve_unit_keys(", + "decrypt_with(", + "AacsKeyMap::from_ranges", + "with_key_map(", + "set_key_map(", + "decrypt_unit(", +]; + +/// §2.2: "`src/keys/**`, `src/decrypt.rs`, `src/sector/decrypting.rs`, `src/aacs/**`". +fn allowed(rel: &str) -> bool { + rel.starts_with("src/keys/") + || rel.starts_with("src/aacs/") + || rel == "src/decrypt.rs" + || rel == "src/sector/decrypting.rs" +} + +/// `src` with every comment blanked (newlines kept), and a copy with string and char +/// literal contents blanked too; both keep `src`'s char positions. +fn lex(src: &str) -> (Vec, Vec) { + let s: Vec = src.chars().collect(); + let mut code = s.clone(); + let mut bare = s.clone(); + let blank = |v: &mut Vec, from: usize, to: usize| { + for c in &mut v[from..to] { + if *c != '\n' { + *c = ' '; + } + } + }; + let word = |c: char| c.is_alphanumeric() || c == '_'; + let (n, mut i) = (s.len(), 0); + while i < n { + let at = |k: usize| s.get(k).copied().unwrap_or('\0'); + if at(i) == '/' && at(i + 1) == '/' { + let end = (i..n).find(|&k| s[k] == '\n').unwrap_or(n); + blank(&mut code, i, end); + blank(&mut bare, i, end); + i = end; + } else if at(i) == '/' && at(i + 1) == '*' { + let (mut depth, mut k) = (1, i + 2); + while k < n && depth > 0 { + if at(k) == '/' && at(k + 1) == '*' { + depth += 1; + k += 2; + } else if at(k) == '*' && at(k + 1) == '/' { + depth -= 1; + k += 2; + } else { + k += 1; + } + } + blank(&mut code, i, k); + blank(&mut bare, i, k); + i = k; + } else if at(i) == 'r' + && (i == 0 || !word(s[i - 1]) || (s[i - 1] == 'b' && (i < 2 || !word(s[i - 2])))) + && { + let h = (i + 1..n).take_while(|&k| s[k] == '#').count(); + at(i + 1 + h) == '"' + } + { + let h = (i + 1..n).take_while(|&k| s[k] == '#').count(); + let open = i + 2 + h; + let close: String = std::iter::once('"') + .chain(std::iter::repeat_n('#', h)) + .collect(); + let close: Vec = close.chars().collect(); + let end = (open..n).find(|&k| s[k..].starts_with(&close)).unwrap_or(n); + blank(&mut bare, open, end); + i = (end + close.len()).min(n); + } else if at(i) == '"' { + let mut k = i + 1; + while k < n && s[k] != '"' { + k += if s[k] == '\\' { 2 } else { 1 }; + } + blank(&mut bare, i + 1, k.min(n)); + i = k + 1; + } else if at(i) == '\'' && (at(i + 1) == '\\' || at(i + 2) == '\'') { + let end = (i + 2..n).find(|&k| s[k] == '\'').unwrap_or(n); + blank(&mut bare, i + 1, end); + i = end + 1; + } else { + i += 1; + } + } + (code, bare) +} + +/// Char spans of the bodies of the `fn`s in `bare` whose name satisfies `pick`. +fn fn_spans(bare: &[char], pick: impl Fn(&str) -> bool) -> Vec<(usize, usize)> { + let text: String = bare.iter().collect(); + let chars: Vec<(usize, char)> = text.char_indices().collect(); + let to_char = |byte: usize| chars.partition_point(|&(b, _)| b < byte); + let mut spans = Vec::new(); + for (at, _) in text.match_indices("fn ") { + let name: String = text[at + 3..] + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if name.is_empty() || !pick(&name) { + continue; + } + let start = to_char(at); + let Some(open) = (start..bare.len()).find(|&k| bare[k] == '{') else { + continue; + }; + let mut depth = 0; + for (k, &c) in bare.iter().enumerate().skip(open) { + depth += (c == '{') as i32 - (c == '}') as i32; + if depth == 0 { + spans.push((start, k + 1)); + break; + } + } + } + spans +} + +/// `(line, token)` of every banned match in `src` (file `rel`) outside the allow-path. +fn hits(rel: &str, src: &str) -> Vec<(usize, &'static str)> { + if allowed(rel) { + return Vec::new(); + } + let (code, bare) = lex(src); + let mut exempt = fn_spans(&bare, |n| rel == "src/test_util.rs" && n == "decrypt_unit"); + exempt.extend(fn_spans(&bare, |n| { + rel == "src/keysource.rs" && n == "resolve_unit_keys" + })); + let inside = |k: usize| exempt.iter().any(|&(a, b)| a <= k && k < b); + let helper: Vec = "test_util::".chars().collect(); + let mut out = Vec::new(); + for &tok in BANNED { + let t: Vec = tok.chars().collect(); + for k in 0..code.len().saturating_sub(t.len() - 1) { + if code[k..k + t.len()] != t[..] || inside(k) { + continue; + } + let via_helper = k >= helper.len() && code[k - helper.len()..k] == helper[..]; + if tok == "decrypt_unit(" && via_helper { + continue; + } + out.push((code[..k].iter().filter(|&&c| c == '\n').count() + 1, tok)); + } + } + out.sort(); + out +} + +fn rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + rs_files(&p, out); + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } +} + +/// LK20 (KU §2.2, KU-X2): no AACS decrypt door in `src/` or `tests/` outside the allow-path. +#[test] +fn no_legacy_key_api_outside_the_allow_path() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files = Vec::new(); + rs_files(&root.join("src"), &mut files); + rs_files(&root.join("tests"), &mut files); + assert!(files.len() > 50, "the walk lost files: {}", files.len()); + let mut found = Vec::new(); + for f in &files { + let rel = f + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel == "tests/legacy_key_api_guard.rs" { + continue; + } + let src = std::fs::read_to_string(f).expect("read source"); + for (line, tok) in hits(&rel, &src) { + found.push(format!("{rel}:{line}: {tok}")); + } + } + assert!( + found.is_empty(), + "legacy key API outside the allow-path:\n{}", + found.join("\n") + ); +} + +/// Self-test: comments are skipped, code and strings are not, the allow-path is exactly +/// §2.2's four, and only the sanctioned helper is exempt. +#[test] +fn the_structural_guard_skips_comments_and_keeps_its_allow_path() { + let code = "fn f() { m.set_key_map(v); }\n"; + assert_eq!(hits("src/mux/x.rs", code), [(1, "set_key_map(")]); + let commented = + "// m.with_key_map(v)\n/* decrypt_with( /* nested */ */\n/// x.get_unit_keys(\n"; + assert!( + hits("src/x.rs", commented).is_empty(), + "comments are skipped" + ); + let url = "let u = \"http://x\"; AacsKeyMap::from_ranges_phased(r);\n"; + assert_eq!(hits("src/x.rs", url), [(1, "AacsKeyMap::from_ranges")]); + let raw = "let s = r#\"x.get_fmts_indexes( // \"#; s.resolve_unit_keys(c);\n"; + assert_eq!( + hits("src/x.rs", raw).len(), + 2, + "raw strings are data, not comments" + ); + + for rel in [ + "src/keys/resolve.rs", + "src/aacs/content.rs", + "src/decrypt.rs", + "src/sector/decrypting.rs", + ] { + assert!(hits(rel, code).is_empty(), "{rel} is on the allow-path"); + } + for rel in [ + "src/keysource.rs", + "src/sector/mod.rs", + "src/decrypt_extra.rs", + "tests/x.rs", + ] { + assert_eq!(hits(rel, code).len(), 1, "{rel} is not on the allow-path"); + } + + let helper = "test_util::decrypt_unit(&mut u, k); content::decrypt_unit(&mut u, k);"; + assert_eq!(hits("tests/x.rs", helper).len(), 1, "only the library door"); + let def = "pub fn decrypt_unit(u: &mut [u8]) { crate::aacs::content::decrypt_unit(u) }\n"; + assert!( + hits("src/test_util.rs", def).is_empty(), + "the helper's definition" + ); + assert_eq!( + hits("src/other.rs", def).len(), + 2, + "only in src/test_util.rs" + ); + + let forward = "fn resolve_unit_keys(&self, c: &C) -> R { self.get_unit_keys(c) }\n"; + assert!( + hits("src/keysource.rs", forward).is_empty(), + "the trait op's own default" + ); + let other = "fn other(&self, c: &C) -> R { self.get_unit_keys(c) }\n"; + assert_eq!(hits("src/keysource.rs", other).len(), 1, "only that fn"); + assert_eq!( + hits("src/session.rs", forward).len(), + 1, + "only in src/keysource.rs" + ); +} diff --git a/tests/lint_stop.rs b/tests/lint_stop.rs index f332747e..5f528c3a 100644 --- a/tests/lint_stop.rs +++ b/tests/lint_stop.rs @@ -559,7 +559,7 @@ const ALLOW_BASELINE: &[(&str, &str, usize)] = &[ ( "src/mux/resolve.rs", r#"allow(clippy::too_many_arguments)"#, - 2, + 1, ), ( "src/platform/fs_type/linux.rs", diff --git a/tests/macos_drive_tests.rs b/tests/macos_drive_tests.rs index 385f5959..203035e8 100644 --- a/tests/macos_drive_tests.rs +++ b/tests/macos_drive_tests.rs @@ -245,10 +245,6 @@ fn inserted_uhd_disc_reports_full_aacs2_state() { aacs.volume_id, [0u8; 16], "VID must be populated after a successful AACS 2.0 handshake" ); - assert!( - !aacs.unit_keys.is_empty(), - "at least one CPS unit key expected for a scannable UHD disc" - ); } /// Scan is idempotent: rescanning the same session yields the same disc diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 80406fc5..2ca83636 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -88,12 +88,6 @@ fn decrypt_keys_is_encrypted_variants() { let none = DecryptKeys::None; assert!(!none.is_encrypted()); - let aacs = DecryptKeys::Aacs { - unit_keys: vec![], - format: libfreemkv::disc::ContentFormat::BdTs, - }; - assert!(aacs.is_encrypted()); - let css = DecryptKeys::Css { title_key: [0u8; 5], }; diff --git a/tests/spec_sites.txt b/tests/spec_sites.txt index 8f377e3f..c4193b7e 100644 --- a/tests/spec_sites.txt +++ b/tests/spec_sites.txt @@ -8,11 +8,12 @@ src/decrypt.rs apply_aacs_map KS-3 src/decrypt.rs apply_aacs_map KS-5 src/decrypt.rs apply_aacs_map KS-25 src/decrypt.rs apply_aacs_map KS-26 -src/mux/resolve.rs probe_fmts_index_keys KS-25 -src/mux/resolve.rs probe_fmts_index_keys KS-26 +src/decrypt.rs blank_damaged_units KS-2 +src/decrypt.rs blank_damaged_units KS-3 +src/decrypt.rs blank_damaged_units KS-4 +src/decrypt.rs blank_damaged_units KS-22 src/disc/mod.rs declared_cps_units KS-14 -src/whole_disc.rs single_cps_key_slot KS-14 -src/whole_disc.rs single_cps_key_slot KS-27 +src/disc/mod.rs declared_cps_units KS-27 src/sector/bus_removal.rs debus_read KS-18 src/scsi/mod.rs parse_sense_progress SS-1 src/scsi/mod.rs parse_sense_progress SS-2 @@ -37,6 +38,7 @@ src/keys/arrival.rs decrypt_proven KS-5 src/keys/arrival.rs side_partners KS-1 src/keys/arrival.rs side_partners KS-2 src/keys/arrival.rs side_partners KS-7 +src/keys/arrival.rs is_partner KS-4 src/keys/fmts.rs anchor KS-26 src/keys/fmts.rs phases KS-25 src/keys/fmts.rs phases KS-26