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