Decrypt is keymap-only: sweep/patch/extract, no AACS trial-decrypt

Every AACS decrypt now goes through the resolved key map (decrypt_sectors_
mapped): the map keys each content unit up front and a missing key fails at
resolve time. The old trial-decrypt path — try each held key per unit, keep
the first-tried plaintext on a miss — is gone; decrypt_sectors_impl's AACS
arm now fails loud (reaching it means a reader was built without its map,
which would silently apply a wrong key). CSS (self-descramble) and the clear
no-op path are unchanged.

Disc::sweep and Disc::patch resolve a whole-disc key map up front for a
decrypting pass (the fetch secures any missing CPS-unit key, fail-loud) and
decrypt via the map — clear nav/filesystem sectors are in no range and pass
through, so the separate content-range gate and the reactive per-unit
key-fetch recovery are no longer needed. extract_tree keys every unit with
the base Unit Key through the map (its encrypted-flag gate skips clear
files). Multipass sweeps stay --raw.

Removes the obsolete non-mapped-AACS trial/gate/recovery tests (the mapped
path and resolve fail-loud are tested directly).
This commit is contained in:
Matthew Jackson
2026-07-23 13:24:36 -07:00
parent 279ba0dd7c
commit 88e58bfc95
6 changed files with 89 additions and 1545 deletions
+13 -760
View File
@@ -523,273 +523,25 @@ pub fn decrypt_sectors_in_content(
decrypt_sectors_impl(buf, keys, unit_key_idx, Some((base_lba, content_ranges)))
}
/// True if `lba` falls inside one of the sorted, merged, disjoint
/// `(start, count)` ranges (same representation as [`crate::udf::merge_ranges`]
/// and `Extent`). O(log n) binary search — cheap enough to run per unit.
pub(crate) fn lba_in_ranges(lba: u32, ranges: &[(u32, u32)]) -> bool {
match ranges.binary_search_by(|&(start, _)| start.cmp(&lba)) {
Ok(_) => true, // lba is exactly a range start
Err(0) => false, // before the first range
Err(i) => {
let (start, count) = ranges[i - 1];
lba < start.saturating_add(count) // inside the range that starts before lba?
}
}
}
fn decrypt_sectors_impl(
buf: &mut [u8],
keys: &mut DecryptKeys,
unit_key_idx: usize,
content: Option<(u32, &[(u32, u32)])>,
// Unused now that AACS decrypts via the key map only; the CSS arm self-gates on
// its per-sector scramble flag and `None` is a no-op. Kept so the wrapper
// signatures (decrypt_sectors / _in_content) stay stable for CSS/None callers.
_unit_key_idx: usize,
_content: Option<(u32, &[(u32, u32)])>,
) -> Result<usize, crate::error::Error> {
let dropped: usize = match keys {
DecryptKeys::None => 0,
DecryptKeys::Aacs {
unit_keys,
read_data_key,
format,
} => {
// Validate that unit_key_idx is in-range before doing anything else.
// This preserves the existing contract: an out-of-range explicit index
// is always an error (tested by `aacs_out_of_range_unit_key_idx_errors`).
if unit_keys.get(unit_key_idx).is_none() {
return Err(crate::error::Error::DecryptFailed);
}
// Container of this disc's content — the key SELECTOR (`is_clean`)
// checks the decrypted plaintext against the right structure (TS vs PS).
let format = *format;
// Index `unit_keys` directly for the raw key bytes (the `.1` of each
// `(cps_id, key)`); no per-call `Vec` of stripped keys — the decrypt
// closures only ever need `len()` / `[idx].1`, so collecting one would
// just be a heap alloc/free on every batch of the mux hot path.
let rdk: Option<[u8; 16]> = *read_data_key;
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
// AACS decrypts whole 6144-byte aligned units. The live mux path
// (mux/disc.rs::fill_extents) issues 1- or 2-sector reads at every
// extent tail, so a buffer is commonly NOT a multiple of the unit
// length. We process the whole leading units exactly as a fully
// aligned buffer would be, then make a deliberate decision about any
// trailing partial unit.
//
// Trailing-partial contract:
// * A clear partial (incomplete final unit / clear nav-TS tail) is
// what AACS legitimately leaves in the clear on disc, so we leave
// it untouched and return Ok. This is the proven, shipped
// behavior every production UHD MKV was made with — no regression
// on conformant discs.
// * A *scrambled* partial can only arise from a structurally
// malformed UDF layout that splits an encrypted unit across an
// extent boundary. Those bytes are encrypted content that cannot
// be decrypted standalone; passing them through as clear would be
// silent corruption. We fail loud (Error::DecryptFailed), matching
// the highway path's Error::ExtentNotUnitAligned policy.
//
// Detection: `is_clean` cannot judge a partial (it reports a
// shorter-than-a-full-unit buffer as clean, having no full encrypted
// packet to check), so we apply the same TS-sync-intactness test it
// uses internally (ts_sync_count vs ts_packet_total) directly to the
// available partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte
// stride (> half the packets) → intact → not scrambled → tolerate. An
// encrypted tail has those syncs destroyed (≤ half) → scrambled →
// reject. If the partial is too short to hold even one TS packet
// (< 192 bytes, ts_packet_total == 0) we cannot judge confidently and
// tolerate rather than risk a false positive on conformant tails.
let partial_len = buf.len() % unit_len;
if partial_len != 0 {
// Gate the trailing partial on content too: a scrambled partial
// OUTSIDE the encrypted m2ts extents is just clear non-TS bytes
// (filesystem tail), not a malformed encrypted unit, so it must
// not hard-fail. `nfull * 3` is the partial's absolute LBA.
let nfull = (buf.len() / unit_len) as u32;
let partial_in_content = match content {
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
None => true,
};
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
// unit) can't be unit-decrypted, so fail loud. Validity is the SAME
// `is_clean` proof floor used everywhere — a clear TS tail passes it,
// a scrambled one fails. PS (`.evo`) partials lack the TS structure,
// so this stays TS-only (HD-DVD partial-scramble is not yet wired).
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
let partial = &buf[buf.len() - partial_len..];
if !aacs::content::is_clean(partial, format) {
return Err(crate::error::Error::DecryptFailed);
}
}
}
let nthreads = decrypt_threads();
let nunits = buf.len() / unit_len;
// Cache the last successfully-validated key index so that runs of
// units under the same CPS unit hit on the first try. Initialised to
// unit_key_idx (the caller's hint — 0 for almost all discs). An
// AtomicUsize lets the parallel path share it cheaply; relaxed
// ordering is fine because a stale read just causes one extra try,
// never a wrong result (TS-sync verify gates correctness).
let last_key_idx = AtomicUsize::new(unit_key_idx);
// Count bytes of scrambled units that NO key could decrypt. Shared
// across the rayon workers (relaxed is fine — it's a pure tally, not
// a synchronisation point). A non-zero total is silent decrypt loss:
// the bytes pass downstream still encrypted and the TS assembler
// drops them without a sync. The caller folds this into mux loss
// accounting so a partial key failure isn't reported as a clean rip.
let dropped_bytes = AtomicUsize::new(0);
// Per-unit PURE decrypt closure. For a scrambled full aligned unit:
// 1. Try the cached key index first (avoids scanning all keys on the
// common case where a disc run uses one CPS unit throughout).
// 2. On miss, try every key in order (multi-CPS-unit discs).
// 3. Select the first key whose output passes the TS-sync verify.
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
// tally the unit as unverified. Never restore ciphertext / null.
// Nav protection is the caller's content gate, not a restore here.
//
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
// must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| {
// Gate on `aacs_unit_needs_decrypt` (encrypted-flag set AND structure
// not yet restored): the flag alone isn't enough because it lives in
// the plaintext header and survives decryption, so an already-decrypted
// unit would be decrypted a SECOND time (scrambling it) on any re-run of
// this pass. The structure-restored half makes it idempotent. This is
// ALSO the sole gate protecting the now-pure `decrypt_unit` from
// decrypting a clear unit.
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk, format)
{
return;
}
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
if let Some(ref rdk_key) = rdk {
aacs::content::decrypt_bus(chunk, rdk_key);
}
// Reorder the key iterator: try the cached hint first, then fall
// back to the full list skipping the hint.
let hint = last_key_idx.load(Ordering::Relaxed);
let try_order =
std::iter::once(hint).chain((0..unit_keys.len()).filter(move |&i| i != hint));
// Compose the two SEGREGATED primitives explicitly. `decrypt_unit`
// is the decrypt (apply the key, leave the plaintext). `is_clean`
// is a SEPARATE structural question used here ONLY as a multi-CPS-unit
// key SELECTOR — the first key whose output is clean for the disc's
// container (`format`: TS or PS) is the match. "Did a key produce
// clean structure?" is NOT "did we decrypt?": a correct key can
// decrypt content whose encoding is broken (a muxer concern). When
// NO key yields clean structure we STILL decrypted (the cached-hint
// key is applied): keep those bytes and report the unit UNVERIFIED.
// This function applies no policy; the caller decides what unverified
// means (mux passes it to the muxer; sweep/patch recover or fail).
// Single-key fast path (the vast majority of titles): with no
// alternate key to fall back on there is nothing to try/rollback,
// so decrypt in place — no per-unit scratch alloc or copy-back.
// Clean → cache the hint; unclean → keep the applied bytes and
// tally unverified, exactly as the loop below would with one key.
if unit_keys.len() == 1 {
aacs::content::decrypt_unit(chunk, &unit_keys[0].1);
if aacs::content::is_clean(chunk, format) {
last_key_idx.store(0, Ordering::Relaxed);
} else {
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
}
return;
}
// Trial each key against a STACK scratch (unit_len is always
// ALIGNED_UNIT_LEN and the guard above proved chunk.len() == unit_len)
// so a failing attempt doesn't clobber the bus-decrypted base in
// `chunk` that the next key retries on — with no per-key heap Vec.
// `chunk` is NOT mutated in this loop, so on total miss we simply
// re-apply the first key in place (decrypt_unit is pure), which
// reproduces the first attempt without stashing its bytes.
let mut scratch = [0u8; aacs::content::ALIGNED_UNIT_LEN];
let scratch = &mut scratch[..chunk.len()];
let mut first_idx: Option<usize> = None;
for idx in try_order {
if let Some((_, key)) = unit_keys.get(idx) {
scratch.copy_from_slice(chunk);
aacs::content::decrypt_unit(scratch, key);
if aacs::content::is_clean(scratch, format) {
chunk.copy_from_slice(scratch);
last_key_idx.store(idx, Ordering::Relaxed);
return;
}
if first_idx.is_none() {
first_idx = Some(idx);
}
}
}
// No key yielded clean structure. Keep the first-tried key's
// plaintext (the pool is non-empty past the guard, so `first_idx` is
// always `Some`) and tally the unit as unverified. Never restore
// ciphertext; that is a caller concern, threaded through the recovery
// ciphertext, not this seam.
if let Some(idx) = first_idx {
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
}
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
};
// Content gate wrapper: when a gate is supplied, skip any unit whose
// absolute LBA lies OUTSIDE the encrypted-content extents — it is
// clear non-TS data (filesystem / nav) and must never be decrypted,
// verified, or counted as loss. Each aligned unit is 3 sectors.
let unit_sectors = (unit_len / 2048) as u32;
let process = |idx: usize, chunk: &mut [u8]| {
if let Some((base, ranges)) = content {
let unit_lba = base.saturating_add((idx as u32) * unit_sectors);
if !lba_in_ranges(unit_lba, ranges) {
return;
}
}
decrypt_one(chunk);
};
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
// Serial path: avoids thread-pool overhead for tiny
// buffers; also the only path when caller pinned
// single-threaded via FREEMKV_THREADS=1. Iterate the
// chunks directly — no Vec of slice pointers needed.
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
process(idx, chunk);
}
} else {
// Parallel path via rayon's persistent thread pool.
// The pool is built once on first use and reused across
// every decrypt_sectors call — no per-call OS thread
// spawn. Each unit decrypts independently (own key
// derivation), so par_iter is sound. On a pool-build
// failure (e.g. thread/pid-limit exhaustion) we fall
// back to the serial path rather than panic.
match decrypt_pool() {
Some(pool) => {
// `par_chunks_mut` iterates the units in place — no
// intermediate `Vec<&mut [u8]>` allocation per batch.
pool.install(|| {
buf.par_chunks_mut(unit_len)
.enumerate()
.for_each(|(idx, chunk)| {
process(idx, chunk);
});
});
}
None => {
for (idx, chunk) in buf.chunks_mut(unit_len).enumerate() {
process(idx, chunk);
}
}
}
}
dropped_bytes.into_inner()
DecryptKeys::Aacs { .. } => {
// AACS decrypts EXCLUSIVELY through the resolved key map
// (`decrypt_sectors_mapped`): the map keys every content unit up front,
// and a missing key fails at RESOLVE time. The old trial-decrypt path
// (try each held key, keep the first-tried plaintext on a miss) is gone
// — reaching it means an AACS reader was built without installing its
// key map, which would silently apply a wrong key. Fail loud instead.
return Err(crate::error::Error::DecryptFailed);
}
DecryptKeys::Css { title_key } => {
// CSS SELF-recovers: the title key changes per VOB region and is
@@ -809,39 +561,6 @@ fn decrypt_sectors_impl(
mod tests {
use super::*;
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
/// nav protection is the CALLER's content gate: a real read (sweep/patch) is
/// content-gated, and every whole-disc caller passes the encrypted-content
/// extents so nav LBAs are skipped entirely and left untouched.
#[test]
fn nav_file_unit_survives_when_gated_out_of_content() {
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
unit[0] = b'M';
unit[1] = b'P';
unit[2] = b'L';
unit[3] = b'S';
for (i, b) in unit.iter_mut().enumerate().skip(4) {
*b = (i as u8).wrapping_mul(31);
}
let snapshot = unit.clone();
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
// so this nav unit is OUTSIDE content and the gate skips it untouched.
decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap();
assert_eq!(
unit, snapshot,
"a nav unit outside the content extents must be left untouched by the gate"
);
}
/// Build a clear-TS region: a 0x47 sync byte at offset 4 of every 192-byte
/// BD-TS packet (matching `ts_sync_count`'s probe stride), filler elsewhere.
/// Reads as NOT scrambled.
@@ -878,163 +597,6 @@ mod tests {
// ── Content-extent gate (`decrypt_sectors_in_content` / `lba_in_ranges`) ──
#[test]
fn lba_in_ranges_membership() {
// (start, count) ⇒ [10,15) and [100,110).
let r = &[(10u32, 5u32), (100, 10)];
assert!(!lba_in_ranges(0, r), "before first range");
assert!(!lba_in_ranges(9, r), "just before first range");
assert!(lba_in_ranges(10, r), "at first range start");
assert!(lba_in_ranges(14, r), "inside first range");
assert!(!lba_in_ranges(15, r), "first range end is exclusive");
assert!(!lba_in_ranges(50, r), "in the gap between ranges");
assert!(lba_in_ranges(100, r), "at second range start");
assert!(lba_in_ranges(109, r), "inside second range");
assert!(!lba_in_ranges(110, r), "second range end is exclusive");
assert!(!lba_in_ranges(5, &[]), "empty set has no members");
}
/// The content gate at the decrypt primitive: a scrambled-LOOKING unit
/// OUTSIDE the content extents (e.g. UDF filesystem) must be SKIPPED — never
/// decrypted, never counted as loss. The SAME bytes INSIDE content are
/// checked and counted. This is the first-2 GB false-positive fix.
#[test]
fn content_gate_skips_non_content_units() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
// base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content.
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(100, 10)]).unwrap();
assert_eq!(
dropped, 0,
"a non-content unit must not count as decrypt loss"
);
assert_eq!(
buf, original,
"a non-content unit must be left byte-for-byte untouched"
);
// Same bytes INSIDE content (base_lba 100, range covers LBA 100..103).
let mut buf2 = original.clone();
let dropped2 =
decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap();
assert_eq!(
dropped2,
aacs::content::ALIGNED_UNIT_LEN,
"an undecryptable CONTENT unit IS counted as loss"
);
}
/// Per-unit gating across a content boundary: in a 2-unit buffer where only
/// the second unit (LBA 3..6) is content, only the second is decrypt-checked.
#[test]
fn content_gate_is_per_unit_across_a_boundary() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap();
assert_eq!(
dropped,
aacs::content::ALIGNED_UNIT_LEN,
"only the in-content unit (unit1) is checked; clear unit0 is skipped"
);
}
/// A content range covering the whole buffer must behave EXACTLY like the
/// ungated `decrypt_sectors` — the gate adds nothing when everything is content.
#[test]
fn content_gate_covering_whole_buffer_matches_ungated() {
let mut keys_g = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut keys_u = keys_g.clone();
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut g = original.clone();
let mut u = original.clone();
let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap();
let ungated = decrypt_sectors(&mut u, &mut keys_u, 0).unwrap();
assert_eq!(
gated, ungated,
"gated-covering-all == ungated dropped count"
);
assert_eq!(g, u, "gated-covering-all == ungated bytes");
}
#[test]
fn lba_in_ranges_more_edges() {
// Single range [5,8).
assert!(!lba_in_ranges(4, &[(5, 3)]), "just before single range");
assert!(lba_in_ranges(5, &[(5, 3)]), "at single range start");
assert!(lba_in_ranges(7, &[(5, 3)]), "inside single range");
assert!(
!lba_in_ranges(8, &[(5, 3)]),
"single range end is exclusive"
);
// After the last range.
assert!(
!lba_in_ranges(200, &[(10, 5), (100, 10)]),
"past the last range"
);
// Saturating: a range whose start+count overflows u32 must not panic. The
// end saturates to u32::MAX, so the very top LBA is excluded — a harmless
// edge (real disc LBAs never reach u32::MAX). The range start is still in.
assert!(
lba_in_ranges(u32::MAX - 1, &[(u32::MAX - 1, 5)]),
"saturating range start is in"
);
assert!(
!lba_in_ranges(u32::MAX, &[(u32::MAX - 1, 5)]),
"saturated end excludes the top"
);
}
/// An EMPTY content map gates EVERYTHING out — even a scrambled unit is
/// skipped (treated as non-content). This is the no-titles fallback at the
/// primitive level.
#[test]
fn content_gate_empty_ranges_skips_everything() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap();
assert_eq!(
dropped, 0,
"empty content map ⇒ nothing is content ⇒ no loss"
);
assert_eq!(buf, original, "empty content map ⇒ buffer untouched");
}
/// A CLEAR (sync-intact) unit INSIDE content is not ciphertext, so even though
/// it is in-content it is skipped by the ts-sync check and never counted.
#[test]
fn content_gate_clear_unit_in_content_not_counted() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext");
assert_eq!(buf, original, "a clear in-content unit is left untouched");
}
/// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes.
#[test]
fn content_gate_none_keys_is_noop() {
@@ -1138,91 +700,6 @@ mod tests {
);
}
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
#[test]
fn content_gate_mixed_three_units() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let u = aacs::content::ALIGNED_UNIT_LEN;
let mut buf = vec![0u8; 3 * u];
buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled
buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear
buf[2 * u..].copy_from_slice(&scrambled_region(u)); // unit2 @ LBA6 scrambled
// Content = LBA 0..6 (units 0 and 1); unit2 (LBA6) is out of content.
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 6)]).unwrap();
assert_eq!(dropped, u, "only unit0 (in-content + scrambled) counts");
}
/// Mirror of the boundary test: content covers the FIRST unit only.
#[test]
fn content_gate_covers_first_unit_only() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(
dropped,
aacs::content::ALIGNED_UNIT_LEN,
"only unit0 counts"
);
}
/// The trailing-partial reject is ALSO content-gated: a scrambled partial
/// OUTSIDE content is clear filesystem tail, not a malformed encrypted unit,
/// so it must NOT hard-fail.
#[test]
fn content_gate_scrambled_partial_outside_content_is_tolerated() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
// content → the partial must be tolerated (Ok), not DecryptFailed.
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
buf.extend_from_slice(&scrambled_region(2048));
// content far away → both the full unit and the partial are non-content.
let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]);
assert!(
res.is_ok(),
"a scrambled partial outside content must not hard-fail"
);
}
/// Whole leading units plus a CLEAR trailing partial (the benign,
/// conformant case): AACS leaves an incomplete final unit / clear nav-TS
/// tail in the clear on disc. We must return `Ok` and leave the partial
/// bytes byte-for-byte unchanged — no regression on real discs.
#[test]
fn aacs_clear_trailing_partial_is_tolerated_unchanged() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let tail = clear_ts_region(2048);
let mut buf = unit;
buf.extend_from_slice(&tail);
decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
assert_eq!(
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&tail[..],
"clear trailing partial unit must be left unchanged"
);
}
/// Whole leading units plus a SCRAMBLED trailing partial (the malformed
/// danger case): an encrypted unit split across an extent boundary cannot be
/// decrypted standalone. Passing it through as clear would be silent
@@ -1249,39 +726,6 @@ mod tests {
);
}
/// An empty buffer is a valid no-op (zero units), not an error.
#[test]
fn aacs_empty_buffer_is_ok() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf: Vec<u8> = Vec::new();
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
}
/// An exact multiple of the unit length has no trailing partial: behavior
/// is unchanged — clear units stay clear, scrambled units are decrypt-
/// attempted. Two clear units must round-trip untouched and return `Ok`.
#[test]
fn aacs_exact_multiple_unchanged() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
let snapshot = buf.clone();
decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
assert_eq!(
buf, snapshot,
"clear exact-multiple buffer must be left unchanged"
);
}
// ── DecryptKeys::None and is_encrypted ─────────────────────────────────
/// DecryptKeys::None is a pure no-op: the buffer must be returned
@@ -1806,197 +1250,6 @@ mod tests {
}
}
/// A unit encrypted under unit_keys[1] (the second CPS unit) on a
/// two-key disc must be correctly decrypted — not left as garbage —
/// when `decrypt_sectors` is called with unit_key_idx=0 (the default).
///
/// Before the fix, `decrypt_one` used only `unit_keys[unit_key_idx]`
/// (i.e. always key 0). On a multi-CPS-unit disc this produced silent
/// garbage for content under key ≥ 1. The fix tries every key and
/// accepts the one whose output passes the TS-sync verify.
///
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
/// garbled bytes that still look scrambled, failing the `is_clean`
/// assert.
#[test]
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
let key0 = [0x11u8; 16]; // CPS unit 0 key — NOT the correct key for this unit
let key1 = [0x22u8; 16]; // CPS unit 1 key — the correct key
// Build and encrypt a clear unit under key1 (the non-default CPS unit).
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key1);
assert!(
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled before decrypt"
);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Call with the default hint (idx 0) — the fix must fall back to key1.
let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
assert!(
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
);
// Every sync position must carry 0x47.
assert_eq!(
aacs::content::ts_sync_count(&buf),
aacs::content::ts_packet_total(&buf),
"all TS sync bytes must be restored after decrypting under key1"
);
}
/// Single-key disc: the common case is unaffected — the single key is
/// tried first (via the hint) and validates, so no second-pass overhead.
///
/// Grounding: the `hint = last_key_idx.load(…)` path returns on the first
/// `try_order` iteration. A regression that always tried all keys (instead
/// of accepting the first hit) would still pass this test — correctness is
/// the invariant here, not the performance shortcut.
#[test]
fn aacs_single_key_disc_still_decrypts_correctly() {
let key = [0x55u8; 16];
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
assert!(
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"single-key disc: TS syncs must be restored"
);
assert_eq!(
aacs::content::ts_sync_count(&buf),
aacs::content::ts_packet_total(&buf),
"all TS sync bytes must be restored for single-key disc"
);
}
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
/// muxer). This is the single decrypt authority applying no policy.
///
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
/// the removal of the `copy_from_slice(&original)` restore.
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
#[test]
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16]; // not the encrypting key
// Encrypt a clear unit under real_key, then offer ONLY the wrong key.
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &real_key);
let ciphertext = unit.clone();
assert!(
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled going in"
);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
let unverified =
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
assert_eq!(
unverified,
aacs::content::ALIGNED_UNIT_LEN,
"a unit that did not reach clean TS is reported unverified"
);
assert_ne!(
buf, ciphertext,
"the unit must be DECRYPTED in place (key applied), never restored to ciphertext"
);
}
/// The dropped-byte tally accumulates across a multi-unit buffer where some
/// units decrypt and others don't: a 2-unit buffer with one good and one
/// bad unit reports exactly one unit's worth of loss, and the good unit is
/// fully decrypted. Confirms the count is per-unit, not all-or-nothing.
///
/// Grounding: the per-chunk `decrypt_one` closure tallies only the units
/// that fail; the good unit takes the `return` before the tally.
#[test]
fn aacs_mixed_buffer_tallies_only_failed_units() {
let key = [0x55u8; 16];
let wrong = [0x66u8; 16];
// Unit A: encrypted under `key` (decryptable). Unit B: encrypted under
// `wrong` (NOT in the key list → undecryptable).
let mut unit_a = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit_a, &key);
let mut unit_b = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
let unit_b_ciphertext = unit_b.clone();
let mut buf = Vec::with_capacity(2 * aacs::content::ALIGNED_UNIT_LEN);
buf.extend_from_slice(&unit_a);
buf.extend_from_slice(&unit_b);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
assert_eq!(
dropped,
aacs::content::ALIGNED_UNIT_LEN,
"exactly one unit's worth of bytes must be reported unverified"
);
assert!(
crate::aacs::content::is_clean(
&buf[..aacs::content::ALIGNED_UNIT_LEN],
crate::disc::ContentFormat::BdTs
),
"the decryptable unit must come out clear"
);
assert_ne!(
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&unit_b_ciphertext[..],
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
);
}
/// A fully-decryptable single-key buffer reports zero dropped bytes — the
/// loss tally must not fire on the clean path.
#[test]
fn aacs_all_units_decrypt_reports_zero_dropped() {
let key = [0x77u8; 16];
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
assert_eq!(dropped, 0, "a fully-decrypted buffer must report no loss");
}
// ── decrypt_threads resolution (read-only; no global mutation) ─────────
/// The default (auto) decrypt thread count is always a usable pool size:
+10
View File
@@ -192,6 +192,16 @@ impl Disc {
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
// AACS decrypts via the key map. Extract reads arbitrary files (not resolved
// title extents), so key every unit with the disc's base Unit Key: the mapped
// decrypt applies it to encrypted units and passes clear filesystem/nav
// through (its encrypted-flag gate). Single-CPS is exact; a multi-CPS disc's
// secondary units are not separately keyed here (extract is not the mux path).
if matches!(base_keys, DecryptKeys::Aacs { .. }) {
dec = dec.with_key_map(std::sync::Arc::new(
crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]),
));
}
let mut result = ExtractResult::default();
let total_bytes = required;
+50 -9
View File
@@ -2357,6 +2357,37 @@ 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(crate) fn resolve_content_key_map(
&self,
reader: &mut dyn SectorSource,
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
) -> Result<crate::decrypt::AacsKeyMap> {
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
for title in &self.titles {
let map =
crate::mux::resolve_mux_key_map(reader, title, keys, fetch, self.content_format)?;
ranges.extend_from_slice(map.ranges());
}
ranges.sort_by_key(|&(s, _, _, _)| s);
let mut merged: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
for r in ranges {
// Drop a range that overlaps one already kept (a clip shared by two
// titles resolves the same span twice) — entry_for needs disjoint ranges.
if merged.last().is_none_or(|&(_, e, _, _)| r.0 >= e) {
merged.push(r);
}
}
Ok(crate::decrypt::AacsKeyMap::from_ranges_phased(merged))
}
/// The disc's AACS-encrypted content as a sorted, merged, disjoint set of
/// `(start_lba, sector_count)` ranges — the union of every title's m2ts
/// stream extents.
@@ -3138,27 +3169,37 @@ impl Disc {
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
// verify and no post-sweep clip-anchored pass; decryptability is proven at
// mux time, not at capture time.)
let keys = if opts.decrypt {
let mut keys = if opts.decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
// Content extent map — only the in-place decrypt path (`opts.decrypt`) gates
// on it so clear filesystem / nav sectors pass through untouched.
// AACS decrypting sweep: resolve a WHOLE-DISC key map up front (the fetch
// secures any missing CPS-unit key, fail-loud) and decrypt via the map —
// a clear nav/filesystem sector is in no range and passes through, so no
// separate content gate is needed. CSS keeps the content-gated
// self-descramble path (the map path is AACS-only).
let key_map = if opts.decrypt && decrypt_is_aacs {
Some(std::sync::Arc::new(self.resolve_content_key_map(
reader,
&mut keys,
opts.key_fetch.as_ref(),
)?))
} else {
None
};
let content_ranges = self.encrypted_content_ranges();
let can_gate = !content_ranges.is_empty();
let mut reader = {
let mut dec = DecryptingSectorSource::new(reader, keys);
if opts.decrypt && can_gate {
if let Some(map) = key_map {
dec = dec.with_key_map(map);
} else if opts.decrypt && can_gate {
// CSS / clear decrypt: content-gate the self-descramble path.
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
}
if decrypt_is_aacs && opts.decrypt {
if let Some(cb) = &opts.key_fetch {
dec = dec.with_key_fetch(cb.clone());
}
}
dec
};
let reader = &mut reader;
+16 -7
View File
@@ -1329,24 +1329,33 @@ impl Disc {
// PHYSICAL read success, not by decrypt structure: a re-read that returns
// good bytes recovers the range; a read that errors leaves it NonTrimmed
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
let keys = if opts.decrypt {
let mut keys = if opts.decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
// AACS decrypting patch: resolve the whole-disc key map up front and decrypt
// via the map (identical to `Disc::sweep`). CSS keeps the content-gated
// self-descramble path. (Multipass patch is `--raw`, so decrypt is a no-op.)
let key_map = if opts.decrypt && decrypt_is_aacs {
Some(std::sync::Arc::new(self.resolve_content_key_map(
reader,
&mut keys,
opts.key_fetch.as_ref(),
)?))
} else {
None
};
let content_ranges = self.encrypted_content_ranges();
let can_gate = !content_ranges.is_empty();
let mut reader = {
let mut dec = DecryptingSectorSource::new(reader, keys);
if opts.decrypt && can_gate {
if let Some(map) = key_map {
dec = dec.with_key_map(map);
} else if opts.decrypt && can_gate {
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
}
if decrypt_is_aacs && opts.decrypt {
if let Some(cb) = &opts.key_fetch {
dec = dec.with_key_fetch(cb.clone());
}
}
dec
};
let reader = &mut reader;
-712
View File
@@ -730,43 +730,6 @@ mod tests {
}
}
/// `with_unit_key_idx` selects which unit key the AACS path uses.
/// idx=2 against a single populated key is out of range → the
/// `unit_keys.get(idx)` lookup returns None → DecryptFailed. idx=0
/// is in range → the lookup succeeds, and on a clear (TS-sync
/// intact) full unit the cipher is a no-op, so the read returns Ok
/// with the bytes unchanged. Grounding: `decrypt_sectors`'
/// `unit_keys.get(unit_key_idx)`.
#[test]
fn with_unit_key_idx_selects_key() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// 3 sectors = one 6144-byte aligned unit (so partial_len == 0).
let mut buf = vec![0u8; 3 * 2048];
// idx=2 out of range → lookup fails.
let mut bad =
DecryptingSectorSource::new(ClearUnitSource, keys.clone()).with_unit_key_idx(2);
assert!(
bad.read_sectors(0, 3, &mut buf, false).is_err(),
"out-of-range unit_key_idx must fail the lookup"
);
// idx=0 in range → lookup ok, clear unit left untouched.
let mut good = DecryptingSectorSource::new(ClearUnitSource, keys).with_unit_key_idx(0);
let mut buf2 = vec![0u8; 3 * 2048];
let n = good.read_sectors(0, 3, &mut buf2, false).unwrap();
assert_eq!(n, 3 * 2048);
// Clear unit: sync byte preserved at offset 4.
assert_eq!(
buf2[4], 0x47,
"clear unit must be left intact under valid idx"
);
}
/// `set_keys` must replace the active keys mid-life. We use a
/// CSS-SCRAMBLED-flagged sector (byte 0x14 scramble bits set) so the
/// effect of the active key is observable: under a CSS key the
@@ -839,100 +802,6 @@ mod tests {
);
}
/// Defense-in-depth: an AACS decrypting read whose START LBA is not
/// unit-aligned (lba % 3 != 0) must be rejected with DecryptFailed BEFORE
/// touching the cipher — a mid-unit start would decrypt every unit under the
/// wrong CBC/unit alignment and silently mis-decrypt. A unit-aligned start
/// (lba % 3 == 0) must pass the guard and proceed normally.
///
/// Grounding: the `lba % UNIT_SECTORS != 0` guard in `read_sectors`.
#[test]
fn aacs_unaligned_start_lba_rejected() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Unaligned starts (1, 2, 4, 5, 32 — note 32 % 3 == 2) must all reject.
for lba in [1u32, 2, 4, 5, 32, 64] {
let mut wrapped = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
let mut buf = vec![0u8; 3 * 2048];
let r = wrapped.read_sectors(lba, 3, &mut buf, false);
let err = r.expect_err("unaligned AACS start LBA must reject");
assert_eq!(
err.code(),
crate::error::Error::DecryptFailed.code(),
"lba {lba} (% 3 = {}) must reject with DecryptFailed",
lba % 3
);
}
// Unit-aligned starts (0, 3, 33, 66) must pass the guard. ClearUnitSource
// yields TS-clear units, so decrypt is a no-op and the read succeeds.
for lba in [0u32, 3, 33, 66] {
let mut wrapped = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
let mut buf = vec![0u8; 3 * 2048];
let n = wrapped
.read_sectors(lba, 3, &mut buf, false)
.unwrap_or_else(|_| panic!("aligned lba {lba} must pass the guard"));
assert_eq!(n, 3 * 2048);
}
}
/// Clip-anchored gate (the Watership Down "Decryption failed" regression):
/// AACS aligned units are anchored at the clip's encrypted-region start
/// (`unit_base`), NOT absolute disc LBA 0. A clip whose `start_lba` is not
/// itself 3-aligned must gate on ITS OWN units, so the clip's base LBA
/// (which the old `lba % 3` gate wrongly rejected) now passes, and only
/// reads off the clip-relative unit grid reject.
#[test]
fn aacs_gate_is_clip_anchored_not_absolute() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// base = 64 (abs % 3 == 1): the non-3-aligned clip start that triggered
// the bug. The old absolute gate rejected every read here; the clip-
// anchored gate must accept the clip's own unit grid.
let base = 64u32;
// Clip-relative aligned starts (base + {0,3,6,30}) pass.
for off in [0u32, 3, 6, 30] {
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
let n = w
.read_sectors(base + off, 3, &mut buf, false)
.unwrap_or_else(|_| panic!("clip-relative aligned lba {} must pass", base + off));
assert_eq!(n, 3 * 2048);
}
// The clip's base LBA itself (abs % 3 == 1) — the exact read the old gate
// wrongly rejected — must now decrypt.
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
assert!(
w.read_sectors(base, 3, &mut buf, false).is_ok(),
"a clip starting at a non-3-aligned LBA must decrypt from its own base"
);
// Clip-relative MISaligned starts (base + {1,2,4,5}) still reject.
for off in [1u32, 2, 4, 5] {
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
let err = w
.read_sectors(base + off, 3, &mut buf, false)
.expect_err("clip-relative unaligned start must reject");
assert_eq!(
err.code(),
crate::error::Error::DecryptFailed.code(),
"base+{off} is off the clip-relative unit grid"
);
}
}
/// The unit-alignment guard is AACS-only. A CSS decrypting read (per-sector,
/// stateless — DVDs) must NOT be gated on a 3-sector boundary: a single
/// sector at lba 1 must read fine. Grounding: the guard is inside
@@ -1028,424 +897,6 @@ mod tests {
unit
}
/// MUX (read > decrypt > mux): an undecryptable AACS content unit must NOT
/// fail the read and must NOT be nulled. The best key is applied and the (bad)
/// bytes pass through to the muxer; broken TS is a muxer concern. The read only
/// hard-fails on a genuine can't-decrypt (no key at all / misaligned unit).
#[test]
fn mux_passes_undecryptable_unit_through_without_nulling() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16];
// One unit encrypted under real_key, plus one trailing CLEAR (TS-sync)
// unit so we can confirm conceal touches ONLY the undecryptable unit.
let enc = encrypt_aacs_unit(&real_key);
let mut clear = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut o = 4;
while o < clear.len() {
clear[o] = 0x47;
o += 192;
}
let mut two_units = enc;
two_units.extend_from_slice(&clear);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)], // can't open the encrypted unit
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
let mut buf = vec![0u8; 6 * 2048];
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// Unit 0 is passed through DECRYPTED (the wrong key was applied), NOT
// null-TS concealed: it is not the all-0x47/PID-0x1FFF null pattern.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
assert!(
!all_null,
"the undecryptable unit is passed through, never null-TS concealed"
);
// Unit 1 (clear) passed through untouched.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
/// MUX pass-through, mixed buffer: a unit the pool CAN decrypt (a
/// content-fragment TAIL — a few real packets + source-zero padding, the 1.2.0
/// shape, <16 TS syncs) comes out byte-for-byte correct, and a unit it CANNOT
/// (encrypted under an absent key) is passed through best-effort — never
/// null-TS filled, never counted as loss. The old path nulled the good tail
/// (silent data loss) whenever it shared a buffer with an undecryptable unit.
#[test]
fn mux_passes_both_decryptable_and_undecryptable_units_through() {
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
// Unit A: a full content unit encrypted under `bad_key` — with only
// `good_key` in the pool it cannot be decrypted → restored to ciphertext.
let bad_unit = encrypt_aacs_unit(&bad_key);
// Unit B: a SHORT-PADDING-TAIL unit — encrypt a full clear unit under
// `good_key`, then zero the trailing source packets (from packet 11 on) so
// they decrypt back to clean zero padding. Only 11 of 32 packets are real
// content → 11 TS syncs after decrypt (well under the majority-vote 16).
const KEEP: usize = 11;
let mut good_tail = encrypt_aacs_unit(&good_key);
for b in good_tail[KEEP * 192..].iter_mut() {
*b = 0;
}
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key);
let mut two_units = bad_unit;
two_units.extend_from_slice(&good_tail);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
let mut buf = vec![0u8; 6 * 2048];
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// Unit A (absent key) → passed through best-effort, NOT null-TS concealed.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
assert!(
!all_null,
"the undecryptable unit is passed through, never null-TS concealed"
);
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
"the decryptable padding-tail unit comes out byte-for-byte correct"
);
// Sanity: its real content packets carry their TS sync; its padding is zero.
for p in 0..KEEP {
assert_eq!(unit1[p * 192 + 4], 0x47, "content pkt {p} sync preserved");
}
for p in KEEP..32 {
let o = p * 192;
assert!(
unit1[o..o + 192].iter().all(|&b| b == 0),
"padding pkt {p} stayed zero (not NULL-TS-filled)"
);
}
}
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set
/// would normally count as decrypt loss. With a [`with_key_fetch`] callback
/// that returns that key, the decorator must hand the still-scrambled unit to
/// the callback, add the returned key, re-decrypt, and register ZERO loss.
/// Without the callback the same read accumulates loss (the baseline).
///
/// Grounding: `read_sectors` invokes `fetch_failed_units` when
/// `decrypt_sectors` leaves a scrambled unit and a callback is installed.
#[test]
fn key_fetch_recovers_unit_with_a_fresh_key() {
let real_key = [0x5au8; 16]; // the key the unit is actually under
let wrong_key = [0x11u8; 16]; // the only key we start with
struct EncUnitSource {
unit: Vec<u8>,
}
impl SectorSource for EncUnitSource {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.unit);
Ok(bytes)
}
}
let unit = encrypt_aacs_unit(&real_key);
// Capture what the callback was handed, and how many times it fired.
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
vec![real_key]
}));
let mut wrapped = DecryptingSectorSource::new(
EncUnitSource { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 3 * 2048];
wrapped.read_sectors(0, 3, &mut buf, false).unwrap();
// The recovered key decrypts the unit: it is now clean TS in `buf`.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
assert!(
crate::aacs::content::is_clean(unit0, crate::disc::ContentFormat::BdTs),
"fetch supplied the key → the unit decrypts to clean TS"
);
let got = seen.lock().unwrap();
assert_eq!(
got.len(),
1,
"callback must be invoked once with the failing unit"
);
assert!(
!crate::aacs::content::is_clean(&got[0], crate::disc::ContentFormat::BdTs),
"the sample handed to the callback is the still-scrambled ciphertext"
);
assert_eq!(
got[0], unit,
"the exact on-disc unit is forwarded for fetch"
);
}
/// THE MUX-STORM REGRESSION. A unit the held key OPENS (>= the 4-packet proof
/// floor) but that carries many authored-bad packets (< half synced) must
/// NEVER be handed to the key-fetch closure — its key is already in hand. Only
/// a GENUINE miss (no held key opens it) is sampled. Before the min(E,4)
/// unification, the bad-encoded unit tripped the old >50% majority in
/// `aacs_unit_needs_decrypt`, so every batch re-sampled it to the key service
/// (the Jason Bourne / Stand By Me stall). This drives the REAL
/// `DecryptingSectorSource` recovery path, not a synthetic check.
#[test]
fn bad_encoded_opened_unit_is_never_sampled_to_the_key_service() {
use crate::aacs::content::ALIGNED_UNIT_LEN;
let held = [0x5au8; 16]; // opens the bad-encoded unit
let orphan = [0x77u8; 16]; // opens the genuine-miss unit (NOT held)
// Knock out packets 1..27 (26 authored-bad) → ~5 synced: >= the 4-packet
// floor (OPENED) yet < half (what the old >50% majority false-flagged).
let bad_pkts: Vec<usize> = (1..27).collect();
let bad_encoded = encrypt_aacs_unit_bad(&held, &bad_pkts);
let genuine_miss = encrypt_aacs_unit(&orphan);
// One 6-sector read spans both units: bad-encoded at [0,3), miss at [3,6).
struct TwoUnits {
a: Vec<u8>,
b: Vec<u8>,
}
impl SectorSource for TwoUnits {
fn capacity_sectors(&self) -> u32 {
6
}
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let n = crate::aacs::content::ALIGNED_UNIT_LEN;
buf[..n].copy_from_slice(&self.a);
buf[n..2 * n].copy_from_slice(&self.b);
Ok(2 * n)
}
}
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
Vec::new() // service has nothing for the orphan — forces the sampling path
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]);
let mut dec = DecryptingSectorSource::new(
TwoUnits {
a: bad_encoded.clone(),
b: genuine_miss.clone(),
},
DecryptKeys::Aacs {
unit_keys: vec![(0, held)], // opens bad_encoded, NOT genuine_miss
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 6 * 2048];
let _ = dec.read_sectors(0, 6, &mut buf, false);
let got = seen.lock().unwrap();
assert!(
!got.is_empty(),
"the genuine orphan-key miss must trigger a fetch"
);
for s in got.iter() {
assert_ne!(
&s[..ALIGNED_UNIT_LEN.min(s.len())],
&bad_encoded[..],
"a bad-encoded unit the key OPENED must NEVER be sampled (the storm)"
);
}
assert!(
got.iter().any(|s| s.as_slice() == genuine_miss.as_slice()),
"only the genuine miss is sampled to the key service"
);
}
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
/// be asked for after unit A came back dry.
#[test]
fn fetch_dry_does_not_block_a_distinct_later_unit() {
let key_a = [0x5au8; 16];
let key_b = [0x77u8; 16];
let unit_a = encrypt_aacs_unit(&key_a);
let unit_b = encrypt_aacs_unit(&key_b);
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
struct AltSource {
units: Vec<Vec<u8>>,
}
impl SectorSource for AltSource {
fn capacity_sectors(&self) -> u32 {
6
}
// LBA-addressable (like a real File/drive): unit A at LBA 0..3, unit B
// at LBA 3..6. Re-reading the same LBA returns the same ciphertext — the
// key-fetch recovery re-reads on a miss, so a call-order-stateful mock
// would hand it the wrong unit.
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
let u = if lba < 3 {
&self.units[0]
} else {
&self.units[1]
};
buf[..bytes].copy_from_slice(u);
Ok(bytes)
}
}
// Callback serves key_b only when asked about unit B; nothing for A.
let unit_b_cb = unit_b.clone();
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
if samples.iter().any(|s| *s == unit_b_cb) {
vec![key_b]
} else {
vec![]
}
}));
let mut wrapped = DecryptingSectorSource::new(
AltSource {
units: vec![unit_a, unit_b],
},
DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_key_fetch(fetch);
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
let mut buf = vec![0u8; 3 * 2048];
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
// recover key_b → B decrypts cleanly.
let mut buf2 = vec![0u8; 3 * 2048];
wrapped
.read_sectors(3, 3, &mut buf2, false)
.expect("unit B recovers via its own fetch");
assert_eq!(
*calls.lock().unwrap(),
2,
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
crate::aacs::content::is_clean(&buf2, crate::disc::ContentFormat::BdTs),
"unit B is decrypted after its on-demand fetch"
);
}
/// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods.
#[test]
@@ -1476,43 +927,6 @@ mod tests {
}
}
/// In-place decrypt + content map: a NON-content read passes through unchanged
/// (ciphertext, not decrypted); an in-content read is decrypted IN PLACE.
#[test]
fn inplace_decrypt_content_gate_passes_clear_decrypts_content() {
let key = [0x5a; 16];
let cipher_unit = encrypt_aacs_unit(&key);
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); // content @ 1002..
let mut dec = DecryptingSectorSource::new(
FixedUnit {
unit: cipher_unit.clone(),
},
DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges); // decrypt in place, content-gated
// Non-content read (LBA 0): not decrypted → buf stays ciphertext.
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(
buf, cipher_unit,
"a non-content read is passed through, not decrypted"
);
// In-content read (LBA 1002): decrypted in place → TS sync restored.
let mut buf2 = vec![0u8; 3 * 2048];
dec.read_sectors(1002, 3, &mut buf2, false).unwrap();
assert_ne!(
buf2, cipher_unit,
"an in-content read is decrypted in place"
);
assert_eq!(buf2[4], 0x47, "decrypted content carries the TS sync byte");
}
/// A source that returns a fixed encrypted unit for ANY read — used to drive
/// the verify-only fetch + cache tests below.
struct AnyLbaUnit {
@@ -1531,130 +945,4 @@ mod tests {
Ok(b)
}
}
/// CPS-2 key recovery at the read level: a content unit no HELD key opens hands
/// its on-disc ciphertext to the fetch closure, the returned key is added to the
/// pool (the CACHE) and the read is re-decrypted IN PLACE. The cached key then
/// serves the NEXT unit WITHOUT another callback (≈one fetch per CPS unit) —
/// what stops an orphan CPS unit from producing garbage.
#[test]
fn fetch_recovers_and_caches_the_cps_key() {
let real_key = [0x5au8; 16]; // the key the unit is actually under
let wrong_key = [0x11u8; 16]; // the only key we start with
let unit = encrypt_aacs_unit(&real_key);
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
// The closure is handed the still-scrambled on-disc ciphertext.
assert!(!samples.is_empty(), "fetch receives the failing units");
assert_eq!(samples[0].len(), crate::aacs::content::ALIGNED_UNIT_LEN);
vec![real_key]
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]); // LBA 0..6 content
let mut dec = DecryptingSectorSource::new(
AnyLbaUnit { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
// First read (LBA 0): wrong key fails → fetch supplies real_key → the read
// is re-decrypted IN PLACE, so buf comes out clean TS (not the ciphertext).
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("fetch recovers the orphan unit's key");
assert_ne!(buf, unit, "the fetched key decrypts the unit in place");
assert!(
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"the recovered read is clean TS"
);
assert_eq!(*calls.lock().unwrap(), 1, "fetch called exactly once");
// Second read (LBA 3): real_key now CACHED → decrypts with no new callback.
let mut buf2 = vec![0u8; 3 * 2048];
dec.read_sectors(3, 3, &mut buf2, false)
.expect("cached key serves the next unit");
assert_eq!(
*calls.lock().unwrap(),
1,
"cache hit — the fetch callback must NOT fire again"
);
}
/// Bad-encoding pass-through: a unit the held key OPENS (the proof floor is >=4
/// good packets) but that carries many authored-bad packets reads Ok and is
/// DECRYPTED in place — never fails loud, never grinds on a physically fine
/// read. The old 75% proportion false-failed this exact unit.
#[test]
fn bad_encoded_unit_the_key_opened_passes_through_decrypted() {
let key = [0x5au8; 16];
// 20 authored-bad packets (1..21); packets 0 + 21..31 stay clean → 11 good
// encrypted packets ≥ the 4-packet proof floor, so the key OPENED the unit.
let bad: Vec<usize> = (1..21).collect();
let unit = encrypt_aacs_unit_bad(&key, &bad);
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 3u32)]);
let mut dec = DecryptingSectorSource::new(
FixedUnit { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges);
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("a bad-encoded unit the key OPENED reads Ok, never fail-loud");
assert_ne!(
buf, unit,
"the unit is decrypted in place, not left ciphertext"
);
// The 11 good packets recovered their TS sync (the muxer drops the bad ones).
assert_eq!(buf[21 * 192 + 4], 0x47, "a good packet restored its sync");
}
/// The fetch is content-gated: a scrambled unit OUTSIDE the content extents
/// is clear filesystem, not ciphertext, so the read succeeds and the fetch
/// callback is never consulted (no wasted key-server traffic on nav/UDF).
#[test]
fn fetch_not_called_outside_content() {
let real_key = [0x5au8; 16];
let wrong = [0x11u8; 16];
let unit = encrypt_aacs_unit(&real_key);
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |_: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
vec![real_key]
}));
// Content lives far away; LBA 0 is "filesystem".
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]);
let mut dec = DecryptingSectorSource::new(
AnyLbaUnit { unit },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("non-content scrambled-looking bytes read OK (gated out)");
assert_eq!(
*calls.lock().unwrap(),
0,
"fetch must NOT fire for a non-content unit"
);
}
}
-57
View File
@@ -6,63 +6,6 @@
use libfreemkv::{aacs, decrypt::DecryptKeys};
/// Test: decrypt_sectors with AACS keys actually decrypts units.
#[test]
fn decrypt_sectors_with_aacs_keys_works() {
// Build an encrypted aligned unit
let mut unit = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
// Set encryption flag (bits 6-7 of byte 0)
unit[0] |= 0xC0;
// Fill with recognizable pattern
for (i, byte) in unit
.iter_mut()
.enumerate()
.take(aacs::content::ALIGNED_UNIT_LEN)
.skip(1)
{
*byte = ((i * 3 + 7) & 0xFF) as u8;
}
let unit_key: [u8; 16] = [0xAAu8; 16];
// Apply the key to the pattern to produce ciphertext-shaped bytes for the
// call below. (decrypt_unit is now PURE — it applies the key unconditionally,
// so it is NOT idempotent; never call it twice on the same unit.)
aacs::content::decrypt_unit(&mut unit, &unit_key);
// (byte 0 keeps its CPI bits set from above, so `decrypt_sectors` recognises
// this as encrypted content and actually applies the key.)
let mut aacs_keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, unit_key)],
read_data_key: None,
format: libfreemkv::disc::ContentFormat::BdTs,
};
let mut none_keys = DecryptKeys::None;
// The regression this guards is passing `DecryptKeys::None` where AACS keys
// were meant. Prove the two DIVERGE: AACS applies the key (bytes change), None
// leaves the unit byte-for-byte untouched. is_ok alone can't catch that —
// both variants return Ok.
let mut with_aacs = unit.clone();
let mut with_none = unit.clone();
libfreemkv::decrypt::decrypt_sectors(&mut with_aacs, &mut aacs_keys, 0)
.expect("AACS decrypt must not error");
libfreemkv::decrypt::decrypt_sectors(&mut with_none, &mut none_keys, 0)
.expect("None decrypt must not error");
assert_ne!(
with_aacs, unit,
"AACS keys must actually transform the unit"
);
assert_eq!(with_none, unit, "None keys must leave the unit untouched");
assert_ne!(
with_aacs, with_none,
"AACS decrypt must differ from the None no-op (the None-vs-Aacs regression)"
);
}
/// Test: decrypt_sectors with DecryptKeys::None is a no-op.
#[test]
fn decrypt_sectors_with_none_keys_is_noop() {