From 830d1e360c0bacc18829504ea17b21185870261d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:35:12 -0700 Subject: [PATCH] Mux decrypt/verify redesign, HD DVD first-class, MVC 3D decrypt: - decrypt_sectors is now a pure decrypt (apply key, leave plaintext, report unverified bytes); TS-structure is a separate primitive (is_clean_ts/ps) used only for key selection and read-verify. The mux passes decrypted bytes through (the demuxer drops non-conforming packets), ending the NULL-TS conceal loop and the per-unit key-server refetch storm. Key-proof floor replaces the 75% supermajority. recovery: - Removed the post-read decrypt-verify gate (verify.rs) that mis-aligned the disc-absolute unit grid against clip-anchored AACS units and false-failed good clips (e.g. Dunkirk's orphan-CPS clip). Bad sectors are marked by physical read result; decryptability is proven at scan + mux time. HD DVD (first-class AACS): - Role-based candidate-list file sourcing so an HD DVD's /ANY!/ files (MKBROM.AACS, VTKF000.AACS, CONTENT_CERT.AACS) are found with no disc-type branch. parse_vtkf parses VTKF000.AACS into the same UnitKeyFile as a BD Unit_Key_RO.inf, so the shared VUK unwrap applies unchanged. set_unit_base clip-anchoring. Two decrypt-axis assumptions remain UNVERIFIED-HDDVD-DECRYPT (no encrypted disc to test). mux: - MVC (Blu-ray 3D) track signals unified into one MVCDecoderConfigurationRecord; release-safe track_vint (3-byte VINT) and pid_index (i32) guards. hardening: - Container-aware is_clean / encryption detection; bytes_bad_in_title fail-safe on a corrupt mapfile; CSS crack gated on DiscFormat::Dvd (HD DVD excluded); non-vacuous CSS tests; patch NOT_READY/HARDWARE/ILLEGAL_REQUEST/ABORTED sense-path tests. --- CHANGELOG.md | 87 ++- src/aacs/content.rs | 1067 +++++++++++------------------ src/aacs/derive.rs | 7 +- src/aacs/inf.rs | 172 +++++ src/aacs/mod.rs | 58 +- src/aacs/resolve.rs | 7 +- src/decrypt.rs | 153 +++-- src/disc/encrypt.rs | 10 +- src/disc/extract.rs | 57 -- src/disc/mod.rs | 200 +++--- src/disc/patch.rs | 118 +--- src/disc/sweep.rs | 14 - src/disc/verify.rs | 1085 ------------------------------ src/io/file_sector_source/mod.rs | 4 +- src/keysource.rs | 6 +- src/mux/demux_sink.rs | 22 +- src/mux/demux_thread.rs | 2 +- src/mux/disc.rs | 25 +- src/mux/mkv.rs | 44 +- src/mux/mkvstream.rs | 6 +- src/mux/pipelined_stream.rs | 37 +- src/mux/resolve.rs | 54 +- src/mux/ts.rs | 46 +- src/sector/decrypting.rs | 926 +++++-------------------- src/sector/mod.rs | 74 +- src/sector/prefetched.rs | 13 +- src/sector/recovery.rs | 12 +- src/udf.rs | 51 ++ tests/crypto_tests.rs | 104 +-- tests/pass_n_patch_fix.rs | 37 +- tests/pass_n_size_aware_skip.rs | 1 - tests/passn_handler_ab.rs | 216 +++++- 32 files changed, 1589 insertions(+), 3126 deletions(-) delete mode 100644 src/disc/verify.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4281c20..4865d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,64 +6,57 @@ - **Mux no longer nulls decryptable video or storms the key server on a bad-encoded region.** 1.4.1 relaxed the decrypt gate but left the surrounding - machinery in place. On a unit that a key *decrypted* but that did not - reassemble to clean MPEG-TS, the read path still restored the ciphertext, - tallied it as loss, re-asked the online key server (which returned the same - correct key, forever), and the mux concealed the unit as NULL TS. On a UHD - title with an authored bad-encoded run this stalled each region for 30–90 s per - unit — the key server brute-forcing and re-returning the one right key — while - nulling video that had, in fact, already decrypted. The root cause was one - conflation duplicated across several sites: *"did a key produce clean TS?"* was - treated as the verdict *"did we decrypt?"*. They are not the same — a correct - key can decrypt content whose underlying encoding is broken, and broken TS is a - muxer concern (the demuxer drops the packet and resyncs), never a decrypt - verdict. + machinery in place. On a unit whose key *decrypted* but whose plaintext didn't + reassemble to clean MPEG-TS, the read path still restored ciphertext, tallied + loss, and re-asked the online key server (forever returning the same correct key) + while the mux concealed the unit as NULL TS. The root cause: *"did a key produce + clean TS?"* was used as the verdict *"did we decrypt?"* — they are not the same. + A correct key can decrypt content with broken encoding; broken TS is a muxer + concern, never a decrypt verdict. ### Changed - **One decrypt authority; policy at the caller.** `decrypt_sectors` is now a - pure decrypt: it applies the CPS unit key to every encrypted unit in place, - leaves the plaintext, and reports how many bytes did not reach clean TS - ("unverified"). It never restores ciphertext, nulls, or re-fetches a key. - Clean TS is used only as a multi-key *selection* hint and a read *verify* - signal. The callers decide what an unverified unit means: - - **mux** (`read → decrypt → mux`): pass the decrypted bytes to the muxer, - whatever they are; the muxer handles bad TS. The mux never conceals, - re-fetches, or counts broken TS as loss — it fails loud only when it - genuinely cannot decrypt (no key / misaligned unit), since a mux over - already-captured data must otherwise always succeed. - - **sweep / patch** (reading from a disc): an unverified unit means the read - did not prove out; recover a fresh key and retry, or fail the read so the - disc-recovery path re-reads it. + pure decrypt: applies the CPS unit key in place, leaves plaintext, and reports + unverified bytes. It never restores ciphertext, nulls, or re-fetches a key. + Clean-TS status is only a key-*selection* hint (multi-CPS) or a read-*verify* + signal (sweep/patch). Callers own the policy: the mux passes decrypted bytes + through unconditionally (the demuxer handles bad TS); sweep/patch treat an + unverified unit as a failed read and re-read it. Removes the decrypt-time + ciphertext restore, the mux NULL-TS conceal loop, and the per-unit key-server + refetch, plus the dead `aacs_unit_still_ciphertext` predicate. - This removes three duplicated decisions — the decrypt-time ciphertext restore, - the mux NULL-TS conceal loop, and the per-unit key-server refetch — and the - dead `aacs_unit_still_ciphertext` predicate. The key-fetch recovery now samples - the on-disc ciphertext explicitly (a pure decrypt leaves the buffer plaintext) - and lives only on the rip/verify path, never the mux. +- **Decrypt and TS-structure are now separate primitives.** AACS has no MAC; + the only "did it decrypt?" signal is whether plaintext looks like MPEG-TS — + a data-quality / key-selection question, not a decrypt verdict. The old + `decrypt_unit(...) -> bool` is split into `decrypt_unit_raw` (pure crypto) and + `is_clean_ts` (structural check), composed explicitly only where needed. The + mux calls only `decrypt_unit_raw`. + +- **Key-proof floor replaces the 75% supermajority.** The old proportion + (≥75% of content packets synced) conflated *the key worked* with *the content + is well-encoded*. `is_clean_ts` now requires `synced >= min(E, 4)` on + **encrypted** packets (skipping packet 0 whose `0x47` is in the clear seed): + four synced packets ≈ 1-in-4-billion false-positive; `min(E, 4)` scales to + short fragment tails so they're never false-rejected. A unit is "opened" when + a handful of packets prove the key — bad-encoded packets are the muxer's job. ## [1.4.1] — 2026-07-14 ### Fixed - **Mux no longer discards good video over a single defective packet.** AACS - content decryption judged a 6144-byte aligned unit "undecryptable" unless - **every** content packet was conformant MPEG-TS. A single authored-bad packet - — a pressing/encoding defect, or an AACS 2.1 forensic-variant frame — made the - mux conceal the **whole** unit as NULL TS, destroying up to 31 of 32 good - packets and tallying them as loss. On discs carrying such packets this - surfaced as false "corruption" over large runs of otherwise-perfect video - (observed across two UHD titles: ~466 MB concealed, every unit decryptable). - The decrypt path now asks only *"did a key OPEN this unit?"* — a padding-aware - **≥75% supermajority** of content packets restoring their `0x47` sync, a gate - no wrong key can reach (uniform-AES noise floor ≈ 256⁻ⁿ) yet one that tolerates - a minority of authored-bad packets. Opened units pass through **verbatim**; a - non-conforming packet is left for the demuxer to drop on sync-loss and resync - past — TS-sync conformance is a muxer concern, never a decryption verdict. The - read/decrypt path no longer rewrites content bytes. The post-read verify/sweep - gate now shares the exact same primitive (`decrypt_unit` for TS), so verify can - never disagree with the mux decrypt and never false-marks a defect unit as a - bad read. + decryption required **every** content packet to be conformant MPEG-TS: one + authored-bad packet (encoding defect, AACS 2.1 forensic-variant frame) made + the mux conceal the **whole** 6144-byte aligned unit as NULL TS (up to 31/32 + good packets discarded, tallied as loss). On affected discs this produced + false "corruption" over otherwise-perfect video (~466 MB concealed across two + UHD titles). The gate is now a padding-aware **≥75% supermajority** of content + packets restoring their `0x47` sync — no wrong key reaches this threshold + (uniform-AES noise floor ≈ 256⁻ⁿ), but a minority of authored-bad packets + still passes. Opened units flow through verbatim; the demuxer drops + non-conforming packets on sync-loss. TS-sync conformance is a muxer concern, + never a decrypt verdict. (The supermajority threshold is tightened in 1.4.2.) - **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC` `CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's `BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord` diff --git a/src/aacs/content.rs b/src/aacs/content.rs index d541b90..1cb027a 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -1,8 +1,10 @@ //! AACS content decryption — aligned-unit / bus decryption and TS verification. //! The low-level AES primitives it uses live in [`super::crypto`]. +#[cfg(test)] use aes::Aes128; -use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray}; +#[cfg(test)] +use aes::cipher::{KeyInit, generic_array::GenericArray}; use super::crypto::{aes_cbc_decrypt, aes_ecb_encrypt}; // Available at module scope for this module's test fixtures (they reference @@ -76,83 +78,132 @@ pub fn ts_sync_destroyed(unit: &[u8]) -> bool { unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit) } -/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy -/// Permission Indicator (CPI) in the top 2 bits of byte 0. [BD] §3.10.2. Byte 0 is the first -/// byte of the first source packet's `TP_extra_header`, which AACS always leaves -/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So -/// this is readable WITHOUT a key: -/// * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through. -/// * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt. +/// HD-DVD `.evo` (MPEG-2 Program Stream) AACS-encrypted-unit flag offset & mask. /// -/// This is exactly the spec CPI test (`buf[0] & 0xc0 == 0` means clear) -/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte -/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct -/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A -/// disc-absolute / mis-aligned read makes byte 0 arbitrary mid-stream data, so -/// the CPI bits are meaningless (which is precisely why per-unit verify must run +/// BD/UHD/FMTS flag encryption with the Copy Permission Indicator in the top 2 +/// bits of byte 0 (the M2TS `TP_extra_header`). HD-DVD `.evo` is Program Stream — +/// byte 0 is a `00 00 01 BA` pack_start_code, NOT a CPI — so AACS reuses the +/// MPEG-2 `PES_scrambling_control` field instead: pack_header (14 bytes) + PES +/// start-code/`stream_id` (4) + `PES_packet_length` (2) puts the PES flags byte at +/// offset 20, with `PES_scrambling_control` in bits 5-4 (`& 0x30`). Non-zero = +/// encrypted. Derived from BackupHDDVD (`Header[20] & 0x30`) cross-checked against +/// MPEG-2 systems (ISO/IEC 13818-1). +/// +/// UNVERIFIED against a real ENCRYPTED HD-DVD disc — none available to confirm +/// byte-exactly. TWO open questions a real disc must settle: +/// 1. A pack carrying an MPEG `system_header` (`00 00 01 BB`) before the PES +/// packet shifts this offset past 20. +/// 2. Whether offset 20 is even readable pre-decrypt. BD keeps only the first 16 +/// bytes clear (the seed) and AES-CBC-encrypts 16..6144 — under that model +/// offset 20 is ciphertext. BackupHDDVD reading `Header[20]` pre-decrypt +/// implies HD-DVD instead encrypts PES *payloads* with *clear* pack/PES +/// headers (per-PES model). If so, `decrypt_unit`'s 16-byte-seed model also +/// would not fit HD-DVD and needs its own path. TS is unaffected either way. +const PS_SCRAMBLE_OFF: usize = 20; +const PS_SCRAMBLE_MASK: u8 = 0x30; + +/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal, per container. +/// +/// - `BdTs` (BD / UHD / FMTS Transport Stream): the Copy Permission Indicator in +/// the top 2 bits of byte 0 — the first `TP_extra_header` byte, always left +/// clear (the first 16 bytes of every unit are the unencrypted SEED). [BD] +/// §3.10.2. `(buf[0] & 0xC0) == 0` → clear; non-zero → bytes `16..6144` are +/// AES-CBC encrypted. +/// - `MpegPs` (HD-DVD `.evo`): the MPEG-2 `PES_scrambling_control` flag — see +/// [`PS_SCRAMBLE_OFF`] (UNVERIFIED against a real encrypted disc). +/// +/// Readable WITHOUT a key. CRITICAL: only meaningful when `unit` is read at the +/// correct clip-FILE-anchored boundary — a disc-absolute / mis-aligned read makes +/// the flag byte arbitrary mid-stream data (which is why per-unit checks run /// clip-anchored, not in the whole-disc sweep). -pub fn aacs_unit_encrypted(unit: &[u8]) -> bool { - unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0 +pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool { + use crate::disc::ContentFormat; + if unit.len() < ALIGNED_UNIT_LEN { + return false; + } + match format { + ContentFormat::BdTs => (unit[0] & 0xC0) != 0, + // UNVERIFIED-HDDVD-DECRYPT (1 of 2): the PES_scrambling_control location + // for HD-DVD `.evo` is derived from spec, never confirmed against a real + // ENCRYPTED HD DVD (we only have decrypted rips). If HD-DVD ripping ever + // misbehaves, verify this flag byte/mask against a genuine encrypted unit. + ContentFormat::MpegPs => (unit[PS_SCRAMBLE_OFF] & PS_SCRAMBLE_MASK) != 0, + } } -/// True when an aligned unit is flagged encrypted (CPI set) AND still looks -/// scrambled (TS syncs not yet restored) — i.e. genuine encrypted content that -/// has NOT been decrypted yet. +/// True when an aligned unit is flagged encrypted AND still looks scrambled +/// (structure not yet restored) — i.e. genuine encrypted content NOT yet decrypted. /// -/// [`aacs_unit_encrypted`] is the authoritative spec gate, but the CPI bits live -/// in the plaintext header (bytes `0..16`) which decryption never rewrites, so a -/// successfully decrypted unit still reports CPI-set. Buffer-iterating sites that -/// may run twice over the same `buf` (the post-fetch re-decrypt, sample -/// collection, failure diagnosis) need an IDEMPOTENT "does this still need work?" -/// test, so they compose CPI with the sync-restored check: once a unit decrypts, -/// its syncs come back and it drops out. Single-shot callers that always operate -/// on fresh ciphertext (`decrypt_unit`) gate on [`aacs_unit_encrypted`] alone. +/// [`aacs_unit_encrypted`] is the authoritative gate, but the flag lives in the +/// clear header, which decryption never rewrites, so a successfully decrypted unit +/// still reports the flag. Buffer-iterating sites that may run twice over the same +/// `buf` (the post-fetch re-decrypt, sample collection, failure diagnosis) need an +/// IDEMPOTENT "does this still need work?" test, so they compose the flag with a +/// "structure restored?" check that flips once decrypted: TS syncs come back for +/// `BdTs`; valid `00 00 01 BA` packs come back for `MpegPs`. /// -/// Like CPI itself this is only meaningful at the clip-FILE-anchored boundary. -pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool { - aacs_unit_encrypted(unit) && ts_sync_destroyed(unit) +/// Like the flag itself this is only meaningful at the clip-FILE-anchored boundary. +pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool { + use crate::disc::ContentFormat; + aacs_unit_encrypted(unit, format) + && match format { + ContentFormat::BdTs => ts_sync_destroyed(unit), + ContentFormat::MpegPs => !is_clean_ps(unit), + } } -/// The one canonical "did a key OPEN this unit?" signal — a padding-aware, -/// defect-TOLERANT structural check on the POST-decrypt bytes. +/// Minimum synced content packets that PROVE a key opened a unit. Four `0x47` +/// syncs ≈ 32 bits of MPEG-TS structure ≈ 1-in-4-billion that a wrong key (uniform +/// AES noise, `0x47` at 1/256 per packet) fakes it. It is an ABSOLUTE proof floor, +/// NOT a proportion — a unit the key opened but whose content is bad-encoded +/// (many non-conforming packets) is proven by ANY four good packets, not rejected +/// for the bad ones. +const KEY_PROOF_PACKETS: usize = 4; + +/// Structural "did a key open this content unit?" — the pure, NO-CRYPTO signal +/// for key SELECTION (pick the right key among multiple on a multi-CPS disc) and +/// read VERIFY. AACS has no cryptographic "did the key work" answer (no MAC), so +/// a key is proven STRUCTURALLY: its plaintext must look like valid content for +/// the disc's container. This dispatches to the right container check by +/// `format` — BD/UHD/FMTS are Transport Stream ([`is_clean_ts`]); HD-DVD `.evo` +/// is Program Stream ([`is_clean_ps`]). NOT a decryption verdict: a correct key +/// can decrypt structurally-broken content, which is the muxer's concern. +pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool { + match format { + crate::disc::ContentFormat::BdTs => is_clean_ts(unit), + crate::disc::ContentFormat::MpegPs => is_clean_ps(unit), + } +} + +/// Structural "does this unit carry enough valid MPEG-TS to prove a key opened +/// it?" — the Transport-Stream arm of [`is_clean`]. It is +/// NOT a decryption verdict: [`decrypt_unit`] applies a key (that is +/// "decrypt"); whether the plaintext is clean TS is this SEPARATE question. The +/// mux never calls this — TS validity is a muxer concern, never a decrypt result. /// -/// ARCHITECTURE (why this is the only TS-sync test the read/decrypt path may -/// use): TS-sync presence is a *muxer* concern, not a decryption verdict. The -/// read/decrypt path is not allowed to reject or conceal a unit just because a -/// packet isn't conformant MPEG-TS — a non-conforming packet inside an otherwise -/// perfectly-decrypted unit is an authoring/pressing defect (or an AACS 2.1 -/// forensic-variant frame), which the demuxer drops on sync-loss and resyncs -/// past. The ONLY thing the decrypt path legitimately needs from TS structure is -/// KEY SELECTION: "did this candidate key turn ciphertext back into MPEG-TS at -/// all?" — used to pick among held keys (multi-CPS-unit discs) and to detect a -/// genuinely-missing key. That is a coarse, all-or-nothing question, and this is -/// its answer. -/// -/// Verdict: the key opened the unit iff a SUPERMAJORITY (>= 75%) of the -/// non-padding content packets carry their `0x47` TS sync. Rationale: -/// * WRONG key → AES output is uniform random → each content packet carries -/// `0x47` at offset 4 with probability 1/256 → ~0 synced. Reaching 75% by -/// chance is cryptographically impossible (e.g. 17-of-22 ≈ 256^-17). So a -/// wrong key can NEVER pass — no silent-corruption hole. -/// * RIGHT key → every real content packet decrypts to clear TS. A handful of -/// authored-bad packets (pressing defects / variant frames) legitimately -/// lack `0x47`, but they are a small minority and MUST NOT reject the unit — -/// the decrypted bytes (defects and all) pass through verbatim; the muxer -/// handles the bad packets. This is the whole point: correctness of a -/// *packet* is not a decryption verdict. -/// -/// Padding-aware: a 192-byte packet whose 188-byte payload is all-zero is source -/// padding (or a NULL packet) and is excluded from the count, so a legitimate -/// content-fragment TAIL (a few real packets + source-zero padding) is judged on -/// its real packets only. A full content unit reduces to "nearly all 32 synced". -pub fn unit_content_decrypted(unit: &[u8]) -> bool { +/// Rule — evidence is ABSOLUTE, scaled to the packets that exist. Over the +/// ENCRYPTED packets (skip packet 0: its `0x47` sits in the clear 16-byte seed, so +/// it reads `0x47` for ANY key and is never evidence), let `E` = non-padding +/// content packets and `synced` = those carrying `0x47`. The key opened the unit +/// iff `E == 0` (nothing encrypted to prove) OR `synced >= min(E, KEY_PROOF_PACKETS)`. +/// * WRONG key → ~0 synced → fails (reaching 4 by chance ≈ 1e-5/unit, and every +/// unit of a clip would have to fluke — astronomically safe). +/// * RIGHT key, bad-encoded content → any 4 good packets pass; the bad ones are +/// the muxer's problem. (This is the false-negative the old 75% PROPORTION +/// caused — a mostly-bad unit the key opened was wrongly rejected.) +/// * `min(E, 4)` handles the end-of-clip fragment TAIL: a unit with only E=1 +/// real packet (then source-zero padding) needs just that one to sync, so a +/// sparse-but-valid tail is never false-rejected. Padding (all-zero payload) +/// is excluded throughout. +fn is_clean_ts(unit: &[u8]) -> bool { const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 let limit = ALIGNED_UNIT_LEN.min(unit.len()); let mut content = 0usize; let mut synced = 0usize; - let mut off = 0; + // Skip packet 0: its sync byte lives in the clear 16-byte seed (unencrypted), + // so it is `0x47` regardless of the key and proves nothing about decryption. + let mut off = PKT; while off + PKT <= limit { - // All-zero payload → padding / NULL packet → excluded from the verdict. let payload = &unit[off + 4..off + PKT]; if !payload.iter().all(|&b| b == 0) { content += 1; @@ -162,57 +213,7 @@ pub fn unit_content_decrypted(unit: &[u8]) -> bool { } off += PKT; } - // No content packets (all padding) → trivially "opened". Otherwise require a - // >=75% supermajority of content packets restored — the wrong-key-proof gate. - content == 0 || synced * 4 >= content * 3 -} - -/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS -/// source packets — the [A2] mux loss-concealment fill for a content unit that -/// genuinely would not decrypt. -/// -/// Zero-filling such a unit is wrong at the TS layer: a run of `0x00` bytes -/// carries no `0x47` sync, so the demuxer loses packet framing and can mis-parse -/// the *next* unit if a stray `0x47` appears mid-zero. Instead we lay down 32 -/// well-formed BD source packets, each a TS null packet (PID `0x1FFF`) carrying -/// an adaptation-field **discontinuity_indicator**: -/// -/// ```text -/// [4-byte TP_extra_header = 0][47 1F FF 20 B7 80 + 182 bytes 0xFF stuffing] -/// ^sync ^PID ^AF-only ^af_len=183 ^disc_indicator -/// ``` -/// -/// The demuxer stays byte-synced on the 192-byte stride, and because PID -/// `0x1FFF` matches no elementary stream every null packet is silently dropped. -/// The discontinuity_indicator is the B1 loss SIGNAL: `mux::ts` recognises a -/// `0x1FFF` packet with that bit set as a concealed gap and forces a discontinuity -/// on every tracked PID's next PES (the codec consumer then drops forward to the -/// next keyframe). This is CC-INDEPENDENT — unlike the real PID's continuity -/// counter it survives a loss that is an exact multiple of 16 packets, or a loss -/// at a PID's very start. NEVER emits ciphertext; lossless framing, not fabricated -/// content. -pub fn fill_null_ts_unit(unit: &mut [u8]) { - const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 - let mut off = 0; - while off + PKT <= unit.len() { - // TP_extra_header (arrival timestamp / copy-control) — zero is fine; the - // demuxer never reads it for a PID it does not track. - unit[off..off + 4].fill(0); - // 188-byte TS null packet: sync, PID 0x1FFF (no PUSI/TEI). - unit[off + 4] = TS_SYNC; // 0x47 - unit[off + 5] = 0x1F; // PID high (top 5 bits of 0x1FFF, flags clear) - unit[off + 6] = 0xFF; // PID low - // adaptation_field_control = 0b10 (AF only, no payload), CC = 0. - unit[off + 7] = 0x20; - // adaptation_field_length = 183: the AF (its flags byte + 182 stuffing) - // fills the rest of the 188-byte packet. - unit[off + 8] = 0xB7; - // AF flags: discontinuity_indicator (0x80) — the concealed-gap signal. - unit[off + 9] = 0x80; - // Stuffing: 0xFF is the conventional adaptation-field fill. - unit[off + 10..off + PKT].fill(0xFF); - off += PKT; - } + content == 0 || synced >= content.min(KEY_PROOF_PACKETS) } /// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride @@ -244,50 +245,35 @@ fn ts_syncs_intact(unit: &[u8]) -> bool { ts_sync_count(unit) > ts_packet_total(unit) / 2 } -/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check — -/// the standards-correct all-32-sync verify: EVERY one of the 32 BD source -/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first -/// miss fails. This is the authoritative gate for the POST-READ verify stage, -/// independent of (and not coupled to) `decrypt_unit`. +/// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): a pure structural +/// check that a unit is valid MPEG-2 PS — every 2048-byte pack begins with the +/// pack_start_code `00 00 01 BA`; a 6144-byte AACS unit spans three packs. /// -/// It is deliberately stricter than the majority-vote `ts_syncs_intact` -/// scramble *heuristic*: a wrong-key decrypt that coincidentally restores >16 -/// syncs passes the majority test and would silently corrupt content, but fails -/// here. Non-mutating — purely a verdict; it neither decrypts nor clears the CPI -/// bits (those stay the concern of the unchanged `decrypt_unit`). -pub fn unit_is_clean_ts(unit: &[u8]) -> bool { - if unit.len() < ALIGNED_UNIT_LEN { - return false; - } - let mut i = 0; - while i < ALIGNED_UNIT_LEN { - if unit[i + 4] != TS_SYNC { - return false; - } - i += BD_SOURCE_PACKET_BYTES; - } - true -} - -/// Structural "is this a clean MPEG-2 Program Stream aligned unit?" check — the -/// PS-container analogue of [`unit_is_clean_ts`], for AACS content carried as -/// program stream (HD-DVD `.evo`): every 2048-byte pack must begin with the -/// pack_start_code `00 00 01 BA`. A 6144-byte aligned unit spans three packs. +/// Like [`is_clean_ts`] this is a structural question, NOT a decryption verdict. +/// Pack 0's start sits in the clear 16-byte seed (present regardless of the key — +/// the freebie `is_clean_ts` skips at packet 0); packs 1 and 2 (offsets 2048 and +/// 4096) are in the encrypted region, so a wrong key garbles them and this returns +/// false (64 bits of discrimination). Validated against real decrypted HD-DVD +/// `.evo` (ANCHORMAN / SHAUN_OF_THE_DEAD): pack starts are exactly 2048-aligned, +/// three per aligned unit, at offsets 0 / 2048 / 4096. /// -/// UNVALIDATED against real HD-DVD media. It assumes (a) HD-DVD uses the -/// standard AACS 6144-byte unit, (b) `.evo` clips are 2048-pack-aligned so unit -/// boundaries fall on pack starts, and (c) byte 0 of the unit is the pack start -/// — i.e. where the AACS seed and CPI indicator sit for PS content is the same -/// as BD-TS. Each of these must be confirmed against a real HD-DVD disc before -/// the `.evo` path is turned on (see `disc::verify::ContainerKind`). It exists -/// now only so the verify gate is structurally ready for that wiring. -pub fn unit_is_clean_ps(unit: &[u8]) -> bool { +/// UNVERIFIED-HDDVD-DECRYPT (2 of 2): the pack STRUCTURE here is confirmed on +/// decrypted rips, but that a real ENCRYPTED `.evo` decrypts to it via the same +/// 6144-byte aligned unit (16-byte seed + AES-CBC over 16..6144) as BD is the +/// unverified assumption — we have no encrypted HD DVD. If HD-DVD decryption +/// yields garbage with a known-good key, the unit granularity is the suspect. +fn is_clean_ps(unit: &[u8]) -> bool { if unit.len() < ALIGNED_UNIT_LEN { return false; } const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; - let mut o = 0; - while o < ALIGNED_UNIT_LEN { + // Skip pack 0 (offset 0): its start bytes lie in the clear 16-byte seed — + // present for ANY key (and byte 0 may carry the Blu-ray-style CPI bits), so + // it proves nothing about decryption. This mirrors `is_clean_ts` skipping + // packet 0. Packs 1 and 2 (offsets 2048 / 4096) are in the encrypted region + // and are what discriminate the key (64 bits of proof). + let mut o = SECTOR_BYTES; + while o + 4 <= ALIGNED_UNIT_LEN { if unit[o..o + 4] != PACK_START { return false; } @@ -296,43 +282,34 @@ pub fn unit_is_clean_ps(unit: &[u8]) -> bool { true } -/// Decrypt one AACS aligned unit (6144 bytes) in-place. -/// Returns true if the unit is now clear MPEG-TS: either it was already -/// unscrambled (returned untouched, no key used) or it was decrypted and -/// verified by its TS sync bytes. Returns false only when the unit was -/// scrambled and this key failed verification. +/// Decrypt one AACS aligned unit (6144 bytes) IN PLACE — PURE crypto that applies +/// `unit_key` and leaves the plaintext. This is decryption and NOTHING else: it +/// makes no verdict about whether the result is clean TS. That is the SEPARATE +/// [`is_clean_ts`] question, which a caller composes only when it needs key +/// SELECTION (multi-CPS discs) or a read VERIFY — because AACS content has no MAC, +/// "did the key decrypt correctly?" is unanswerable by crypto; TS structure is a +/// data-quality signal, not a decrypt verdict. /// -/// Algorithm: -/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived -/// 2. XOR derived with original 16 bytes → unit_decrypt_key -/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV +/// PURE: this applies the key UNCONDITIONALLY (any full-length unit). It does NOT +/// check the encrypted-flag — decrypting an already-clear unit would corrupt it, +/// so the CALLER must gate on [`aacs_unit_encrypted`] (which is container-aware) +/// before calling. Lifting that gate out of the crypto keeps this function +/// container-agnostic (the flag's location differs BD-TS vs HD-DVD-PS) and true +/// to "decrypt applies the key and nothing else". The only guard kept here is the +/// length check, since the crypto is defined only over a whole 6144-byte unit. /// -/// Decryption restores the TS sync bytes, so the unit reads as clear afterward; -/// there is no flag to clear. -pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { +/// Block Key = AES-128E(Kcu, seed) ⊕ seed ([BD] §3.10.1 Fig 3-8: encrypt the clear +/// 16-byte seed under the CPS Unit Key, XOR the seed back in — the trailing ⊕seed +/// is load-bearing); then AES-128-CBC decrypt bytes 16..6144 under the AACS IV. +/// Source-zero padding packets (all 192 bytes zero on disc) are restored to zero: +/// their decrypted bytes are AES-noise from decrypting zeros, but the source WAS +/// zero, so writing the true source back is faithful and gives the demux a tidy +/// gap. Content packets are left EXACTLY as decrypted — the decrypt path never +/// rewrites content, so an authored-bad packet passes through verbatim. +pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { if unit.len() < ALIGNED_UNIT_LEN { - return false; + return; } - if !aacs_unit_encrypted(unit) { - return true; // CPI flag clear → plaintext, pass through untouched - } - - // PADDING-AWARE acceptance. The question this answers is "did we read good, - // decryptable data?" — NOT "are all 32 packets present". A content fragment - // can END mid-unit, with the disc zero-padding the rest of the aligned unit - // to the next fragment. Such a tail unit is `[real encrypted packets][source - // zeros]`: the real packets decrypt perfectly, but the strict all-32 - // `unit_is_clean_ts` would reject the whole unit over the padding tail and - // discard real video. AES ciphertext is high-entropy, so a SOURCE-zero packet - // (all 192 bytes zero before decrypt) can only be padding, never content. - // - // So: a packet whose SOURCE bytes are all zero is padding — excluded from the - // verify and emitted as clean zeros. Every other (content) packet must - // restore its TS sync. A full content unit has no zero-source packets, so this - // is byte-identical to the old all-32 check (no regression, no wrong-key - // hole). The discriminator between a legitimate short tail and a genuine - // misread is exactly this: a misread leaves the failing packets' SOURCE - // non-zero (real ciphertext that won't decrypt) → still rejected. const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 let npkt = ALIGNED_UNIT_LEN / PKT; let mut pad = [false; ALIGNED_UNIT_LEN / PKT]; @@ -341,11 +318,6 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { *slot = unit[off..off + PKT].iter().all(|&b| b == 0); } - // Save original first 16 bytes (the plaintext seed / header) and derive the - // per-unit Block Key (identical to `decrypt_unit_checked`). - // Block Key = AES-128E(Kcu, seed) ⊕ seed ([BD] §3.10.1 Fig 3-8, two-node - // construction: encrypt the clear seed under the CPS Unit Key, then XOR the - // seed back in — the trailing ⊕seed is load-bearing). let mut header = [0u8; 16]; header.copy_from_slice(&unit[..16]); let derived = aes_ecb_encrypt(unit_key, &header); @@ -353,15 +325,8 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { for i in 0..16 { decrypt_key[i] = derived[i] ^ header[i]; } - // Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1. aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); - // Restore source-zero padding packets to zero (their decrypted bytes are - // garbage from AES-decrypting zeros, but the source WAS zero, so writing the - // true source back is faithful — not concealment — and gives the demux a tidy - // gap instead of AES noise). Content packets are left EXACTLY as decrypted: - // the read/decrypt path never rewrites content, so an authored-bad packet - // (no `0x47`) passes through verbatim for the muxer to drop. for (p, &is_pad) in pad.iter().enumerate().take(npkt) { if is_pad { let off = p * PKT; @@ -370,156 +335,11 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { } } } - // KEY-SELECTION verdict only: did this key OPEN the unit (restore the TS - // structure of a supermajority of content packets)? A wrong key can't; the - // right key can even when a few content packets are authored-bad. This is NOT - // a per-packet conformance gate — that belongs to the muxer. - unit_content_decrypted(unit) -} - -/// Decrypt an AACS aligned unit in place, accepting the key only when `accept` -/// passes on the decrypted bytes. The AACS crypto is container-agnostic; the -/// post-decrypt acceptance is the only format-specific part — so this is the -/// extension seam for non-TS containers. [`decrypt_unit`] is this with the BD-TS -/// check ([`unit_is_clean_ts`]); HD-DVD PS content would pass -/// [`unit_is_clean_ps`] instead. A CPI-clear unit is plaintext and passes -/// through untouched (no key consumed), exactly as before. -pub fn decrypt_unit_checked( - unit: &mut [u8], - unit_key: &[u8; 16], - accept: fn(&[u8]) -> bool, -) -> bool { - if unit.len() < ALIGNED_UNIT_LEN { - return false; - } - if !aacs_unit_encrypted(unit) { - return true; // CPI flag clear → plaintext, pass through untouched - } - - // Save original first 16 bytes (they're the plaintext seed / header). - let mut header = [0u8; 16]; - header.copy_from_slice(&unit[..16]); - - // Step 1: Encrypt header with unit key to derive per-unit key. - let derived = aes_ecb_encrypt(unit_key, &header); - - // Step 2: XOR to get the actual decryption key. - let mut decrypt_key = [0u8; 16]; - for i in 0..16 { - decrypt_key[i] = derived[i] ^ header[i]; - } - - // Step 3: Decrypt bytes 16..6143 with AES-CBC. - aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); - - // Accept the key only if the decrypted unit passes the container's strict - // structural check. A wrong key that coincidentally restores a majority of - // markers is rejected here, not silently accepted. - accept(unit) -} - -/// Fast, NON-MUTATING unit-key validation for the brute-force key search. -/// -/// `decrypt_unit` pays a full 6128-byte (383-block) CBC decrypt before -/// `verify_ts` can reject a wrong key — but in a brute scan ~every candidate is -/// wrong. In CBC the plaintext of block *i* is `AES_dec(C_i) XOR C_{i-1}`, so -/// the FIRST restored TS sync byte (payload offset 196, which lands in CBC -/// block 11 of the `unit[16..]` region) can be recovered with a SINGLE block -/// decrypt instead of 383. A wrong key fails this 1-byte gate ~255/256 of the -/// time for the cost of one AES block; the rare survivor is then confirmed with -/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit -/// identical to the slow path. -/// -/// The caller MUST pass an aligned, already-[`ts_sync_destroyed`] unit -/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the -/// per-candidate scramble re-scan is intentionally skipped here. -/// -/// NOTE: this is a search accelerator — it never writes the input and never -/// participates in the content decrypt path. Aggregate correctness (does a key -/// validate against *any* of the disc's units) is preserved because a true key -/// restores offset-196 on every standard BD-TS unit. -pub fn unit_key_validates(unit: &[u8], unit_key: &[u8; 16]) -> bool { - if unit.len() < ALIGNED_UNIT_LEN { - return false; - } - // Per-unit decrypt key: AES-ECB-encrypt the 16-byte plaintext header with - // the unit key, XOR with the header (same derivation as `decrypt_unit`). - let mut header = [0u8; 16]; - header.copy_from_slice(&unit[..16]); - let derived = aes_ecb_encrypt(unit_key, &header); - let mut decrypt_key = [0u8; 16]; - for i in 0..16 { - decrypt_key[i] = derived[i] ^ header[i]; - } - - // Cheap gate: recover ONLY payload byte 196 (the 2nd BD-TS packet's sync). - // The CBC region is `unit[16..]`; payload offset 196 → region offset 180 = - // block 11, byte 4. P[11] = AES_dec(C[11]) XOR C[10]; C[10] is raw - // ciphertext (no decrypt needed). Constant offsets for the fixed 6144 unit. - const SYNC_PAYLOAD_OFF: usize = 196; - let region_off = SYNC_PAYLOAD_OFF - 16; // 180 - let blk = region_off / 16; // 11 - let byte = region_off % 16; // 4 - let c11 = 16 + blk * 16; // absolute offset of C[11] in `unit` (=192) - let cipher = Aes128::new(GenericArray::from_slice(&decrypt_key)); - let mut b = GenericArray::clone_from_slice(&unit[c11..c11 + 16]); - cipher.decrypt_block(&mut b); - let prev = unit[c11 - 16 + byte]; // C[10] byte (region block 10) - if b[byte] ^ prev != TS_SYNC { - return false; - } - - // Survivor (~1/256 of candidates): confirm with the authoritative full - // decrypt + verify, so the verdict matches `decrypt_unit` exactly. - let mut full = [0u8; ALIGNED_UNIT_LEN]; - full.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]); - decrypt_unit(&mut full, unit_key) -} - -/// Outcome of [`decrypt_unit_try_keys`]. -/// -/// Distinguishes "the unit was already clear, no key was consumed" from "key -/// at index `i` decrypted it" — the bare `Option` form conflated the two -/// (a clear unit reported `Some(0)`, indistinguishable from key index 0, and -/// possibly out of range when `unit_keys` is empty). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UnitKeyResult { - /// The unit was not scrambled; it was left untouched and no key was used. - AlreadyClear, - /// The unit was decrypted in place by `unit_keys[index]`. - DecryptedWith(usize), -} - -/// Decrypt one aligned unit trying multiple unit keys. -/// -/// Returns [`UnitKeyResult::AlreadyClear`] if the unit was not scrambled (no key -/// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or -/// `None` if no key worked (the unit is restored to its original bytes). -pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option { - if !aacs_unit_encrypted(unit) { - return Some(UnitKeyResult::AlreadyClear); - } - - // Save original for retry. Stack-backed buffer — no heap allocation, and the - // restore-on-failure contract holds uniformly regardless of key count. - let mut original = [0u8; ALIGNED_UNIT_LEN]; - original.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]); - - for (i, key) in unit_keys.iter().enumerate() { - unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original); - if decrypt_unit(unit, key) { - return Some(UnitKeyResult::DecryptedWith(i)); - } - } - - // Restore original on failure - unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original); - None } /// Remove bus encryption from an aligned unit (AACS 2.0 / UHD). -/// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector. -pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) { +/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector. +pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) { for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) { if sector_start + SECTOR_BYTES > unit.len() { break; @@ -532,21 +352,6 @@ pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) { } } -/// Full decrypt of an aligned unit: bus decrypt (if needed) then AACS decrypt. -pub fn decrypt_unit_full( - unit: &mut [u8], - unit_key: &[u8; 16], - read_data_key: Option<&[u8; 16]>, -) -> bool { - if !ts_sync_destroyed(unit) { - return true; - } - if let Some(rdk) = read_data_key { - decrypt_bus(unit, rdk); - } - decrypt_unit(unit, unit_key) -} - #[cfg(test)] mod tests { use super::super::crypto::aes_ecb_decrypt; @@ -603,17 +408,28 @@ mod tests { } #[test] - fn test_decrypt_unit_unencrypted() { - // A clear unit (TS syncs intact) is not scrambled → passes through. + fn clear_unit_is_not_flagged_encrypted() { + // `decrypt_unit` is now PURE (applies the key unconditionally); the + // "leave a clear unit untouched" policy lives at the caller's gate, + // `aacs_unit_encrypted` / `aacs_unit_needs_decrypt`. A clear TS unit + // (byte-0 CPI bits clear, syncs intact) must report NOT-encrypted so the + // caller never hands it to decrypt_unit. + let ts = crate::disc::ContentFormat::BdTs; let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; let mut off = 4; while off < ALIGNED_UNIT_LEN { unit[off] = TS_SYNC; off += BD_SOURCE_PACKET_BYTES; } - let key = [0u8; 16]; assert!(!ts_sync_destroyed(&unit)); - assert!(decrypt_unit(&mut unit, &key)); + assert!( + !aacs_unit_encrypted(&unit, ts), + "byte-0 CPI clear ⇒ not flagged encrypted" + ); + assert!( + !aacs_unit_needs_decrypt(&unit, ts), + "a clear unit needs no decrypt ⇒ caller skips decrypt_unit" + ); } #[test] @@ -759,7 +575,7 @@ mod tests { // Now plain contains encrypted data. Decrypt it. let mut unit = plain; assert!(ts_sync_destroyed(&unit)); - assert!(decrypt_unit(&mut unit, &unit_key)); + decrypt_unit(&mut unit, &unit_key); assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored // Verify TS sync bytes @@ -848,11 +664,12 @@ mod tests { let key = [0x5Au8; 16]; let mut unit = clear_unit(); aacs_encrypt_unit(&mut unit, &key); - assert!( - decrypt_unit(&mut unit, &key), - "full clean unit is decryptable" + decrypt_unit(&mut unit, &key); + assert_eq!( + ts_sync_count(&unit), + 32, + "the right key recovered the plaintext (all 32 syncs restored)" ); - assert_eq!(ts_sync_count(&unit), 32, "all 32 syncs restored"); } #[test] @@ -860,10 +677,7 @@ mod tests { // 11 real content packets, then source-zero padding (the Dunkirk shape). let key = [0x5Au8; 16]; let mut unit = tail_filled_unit(&key, 11, 0x00); - assert!( - decrypt_unit(&mut unit, &key), - "real prefix + source-zero pad IS decryptable" - ); + decrypt_unit(&mut unit, &key); for p in 0..11 { assert_eq!( unit[p * BD_SOURCE_PACKET_BYTES + 4], @@ -884,14 +698,22 @@ mod tests { } #[test] - fn fragment_tail_with_nonzero_garbage_is_not_decryptable() { - // Same shape, but the tail is NON-zero — a genuine misread, not padding. + fn fragment_tail_with_nonzero_garbage_decrypts_the_real_prefix() { + // Same shape, but the tail is NON-zero garbage. The crypto still recovers + // the 11 real packets; the garbage tail is whatever it is (the muxer drops + // it on sync-loss, and a genuine bad sector is caught by the physical read + // layer / mapfile — never by TS structure). Ground truth: the 11 real + // packets came back. let key = [0x5Au8; 16]; let mut unit = tail_filled_unit(&key, 11, 0xC3); - assert!( - !decrypt_unit(&mut unit, &key), - "real prefix + non-zero garbage tail is NOT decryptable (misread)" - ); + decrypt_unit(&mut unit, &key); + for p in 0..11 { + assert_eq!( + unit[p * BD_SOURCE_PACKET_BYTES + 4], + TS_SYNC, + "real content pkt {p} decrypted" + ); + } } // ── Defect-tolerant "did a key OPEN this unit?" verdict ───────────────── @@ -920,15 +742,18 @@ mod tests { unit } - /// A POST-DECRYPT-looking unit: `content` non-padding packets, `synced` of - /// them carrying `0x47`; the remaining packets are source-zero padding. CPI - /// (encrypted flag) set iff `cpi`. Feeds the key-independent predicates. - fn decrypted_shape(content: usize, synced: usize, cpi: bool) -> Vec { + /// A POST-DECRYPT-looking unit for the key-independent [`is_clean_ts`]: `e` + /// ENCRYPTED content packets carrying non-zero payload, `synced` of them with + /// `0x47`; the rest is source-zero padding. Content is placed at packets 1.. + /// because [`is_clean_ts`] SKIPS packet 0 (its sync lives in the clear seed and + /// is never evidence), so `e`/`synced` map directly to what it measures. CPI + /// (encrypted flag) set iff `cpi`. + fn decrypted_shape(e: usize, synced: usize, cpi: bool) -> Vec { let mut u = vec![0u8; ALIGNED_UNIT_LEN]; - for p in 0..content { - let off = p * BD_SOURCE_PACKET_BYTES; + for i in 0..e { + let off = (i + 1) * BD_SOURCE_PACKET_BYTES; // packets 1.. (skip seed pkt 0) u[off + 5] = 0xAB; // non-zero payload => counted as content - u[off + 4] = if p < synced { TS_SYNC } else { 0x80 }; + u[off + 4] = if i < synced { TS_SYNC } else { 0x80 }; } if cpi { u[0] |= 0xC0; @@ -941,10 +766,7 @@ mod tests { // 1 defective content packet in an otherwise-perfect unit (the real case). let key = [0x5Au8; 16]; let mut unit = unit_with_defects(&key, &[17]); - assert!( - decrypt_unit(&mut unit, &key), - "31/32 content packets synced -> the key OPENED the unit" - ); + decrypt_unit(&mut unit, &key); let off = 17 * BD_SOURCE_PACKET_BYTES; assert_eq!( unit[off + 4], @@ -965,70 +787,183 @@ mod tests { } #[test] - fn several_defect_packets_within_tolerance_still_decrypt() { - // Up to 25% authored-bad content packets are tolerated (opened + passed - // through); the muxer drops them. + fn several_defect_packets_decrypt_the_good_ones() { + // Ground truth: the right key recovers every non-defect packet's sync; the + // authored-bad packets pass through verbatim (the muxer drops them). let key = [0x33u8; 16]; - let mut unit = unit_with_defects(&key, &[3, 9, 17, 24, 30]); // 5/32 ≈ 16% - assert!( - decrypt_unit(&mut unit, &key), - "27/32 synced (>=75%) -> opened" - ); + let defects = [3usize, 9, 17, 24, 30]; + let mut unit = unit_with_defects(&key, &defects); + decrypt_unit(&mut unit, &key); + for p in 0..32 { + if defects.contains(&p) { + continue; + } + assert_eq!( + unit[p * BD_SOURCE_PACKET_BYTES + 4], + TS_SYNC, + "non-defect pkt {p} decrypted" + ); + } } #[test] - fn wrong_key_never_opens_a_unit() { - // A wrong key restores ~0 syncs -> far below the supermajority gate. - let key = [0x5Au8; 16]; - let mut unit = clear_unit(); - aacs_encrypt_unit(&mut unit, &key); - assert!( - !decrypt_unit(&mut unit, &[0x22u8; 16]), - "wrong key cannot open the unit" - ); + fn wrong_key_does_not_recover_the_plaintext() { + // Ground truth: a wrong key produces bytes that are NOT the plaintext. + let clear = clear_unit(); + let mut unit = clear.clone(); + aacs_encrypt_unit(&mut unit, &[0x5Au8; 16]); + decrypt_unit(&mut unit, &[0x22u8; 16]); + assert_ne!(unit, clear, "a wrong key does not recover the plaintext"); } #[test] - fn threshold_accepts_at_75pct_and_rejects_just_below() { - // 24/32 = exactly 75% opens; 23/32 does not. - assert!(unit_content_decrypted(&decrypted_shape(32, 24, false))); - assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false))); + fn key_proof_floor_is_four_synced_on_a_full_unit() { + // ABSOLUTE proof floor: >=4 synced ENCRYPTED packets opens a full unit; + // <4 does not — regardless of how many others are bad-encoded. + assert!( + is_clean_ts(&decrypted_shape(31, 4, false)), + "4 synced -> opened" + ); + assert!( + !is_clean_ts(&decrypted_shape(31, 3, false)), + "3 synced -> below the proof floor -> not opened" + ); } #[test] fn all_padding_unit_is_trivially_opened() { // CPI set but every packet is source-zero padding: nothing to decrypt. - assert!(unit_content_decrypted(&decrypted_shape(0, 0, true))); + assert!(is_clean_ts(&decrypted_shape(0, 0, true))); } #[test] - fn defect_count_boundary_via_real_decrypt() { - // Pin the 75% gate on the REAL decrypt path (not just the predicate): - // 8/32 defects => 24 synced = exactly 75% => opens; 9/32 => 23 synced => - // does NOT open. Packets 1.. avoid the clear-seed packet 0. - let key = [0x77u8; 16]; - let eight: Vec = (1..9).collect(); - let mut u_ok = unit_with_defects(&key, &eight); + fn is_clean_dispatches_by_container_format() { + use crate::disc::ContentFormat; + // Clean HD-DVD `.evo` Program-Stream unit: pack_start_code `00 00 01 BA` + // at each 2048-byte pack boundary (0/2048/4096) — the layout validated + // against real decrypted ANCHORMAN / SHAUN_OF_THE_DEAD `.evo`. Offset 0 is + // the clear-seed freebie; the packs at 2048/4096 are encrypted and are + // what actually discriminate a key. + let mut ps = vec![0u8; ALIGNED_UNIT_LEN]; + for off in [0usize, 2048, 4096] { + ps[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + } assert!( - decrypt_unit(&mut u_ok, &key), - "8 defects (24/32 = 75%) -> opened" + is_clean(&ps, ContentFormat::MpegPs), + "clean PS opens for MpegPs" ); - let nine: Vec = (1..10).collect(); - let mut u_no = unit_with_defects(&key, &nine); assert!( - !decrypt_unit(&mut u_no, &key), - "9 defects (23/32 < 75%) -> NOT opened (too corrupt / wrong key)" + !is_clean(&ps, ContentFormat::BdTs), + "PS content has no 0x47 TS syncs -> not clean as TS" + ); + + // Wrong key garbles the ENCRYPTED packs (2048/4096); offset 0 stays a pack + // start (it is the clear seed) but that alone proves nothing -> rejected. + let mut wrong = ps.clone(); + wrong[2048] = 0xFF; + wrong[4096] = 0xFF; + assert!( + !is_clean(&wrong, ContentFormat::MpegPs), + "garbled encrypted packs -> wrong key rejected" + ); + + // A clean BD Transport-Stream unit opens for BdTs, not MpegPs. + let ts = decrypted_shape(31, 31, false); + assert!( + is_clean(&ts, ContentFormat::BdTs), + "clean TS opens for BdTs" + ); + assert!( + !is_clean(&ts, ContentFormat::MpegPs), + "TS content has no `00 00 01 BA` packs -> not clean as PS" ); } #[test] - fn small_content_units_keep_the_wrong_key_floor() { - // Tiny content units are where a coincidental wrong-key sync matters most. - // The gate stays strict enough that a single fluke can't "open" them. - assert!(unit_content_decrypted(&decrypted_shape(2, 2, false))); // 2/2 -> open - assert!(!unit_content_decrypted(&decrypted_shape(2, 1, false))); // 1/2 -> not - assert!(unit_content_decrypted(&decrypted_shape(4, 3, false))); // 3/4=75% -> open - assert!(!unit_content_decrypted(&decrypted_shape(4, 2, false))); // 2/4 -> not + fn ps_container_encrypt_detection_and_idempotency() { + use crate::disc::ContentFormat; + let ps = ContentFormat::MpegPs; + + // Base clean PS unit: pack_start_code at each 2048 boundary. + let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; + for off in [0usize, 2048, 4096] { + clear[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + } + // PES scrambling_control (byte 20, bits 5-4) == 0 → not encrypted. + assert!( + !aacs_unit_encrypted(&clear, ps), + "scrambling_control clear ⇒ not encrypted" + ); + + // Encrypted + still-scrambled: flag set, packs 1/2 garbled (would be + // ciphertext on a real disc) → is_clean_ps false → needs decrypt. + let mut enc = clear.clone(); + enc[PS_SCRAMBLE_OFF] |= 0x10; // PES_scrambling_control = 01 + enc[2048] = 0xFF; + enc[4096] = 0xFF; + assert!( + aacs_unit_encrypted(&enc, ps), + "scrambling_control set ⇒ encrypted" + ); + assert!( + aacs_unit_needs_decrypt(&enc, ps), + "flagged + packs not restored ⇒ needs decrypt" + ); + + // Decrypted: the flag survives (it is in the preserved header) but packs + // are valid → needs_decrypt flips false (idempotent re-decrypt). + let mut dec = clear.clone(); + dec[PS_SCRAMBLE_OFF] |= 0x10; + assert!( + aacs_unit_encrypted(&dec, ps), + "flag survives decryption (header preserved)" + ); + assert!( + !aacs_unit_needs_decrypt(&dec, ps), + "valid packs restored ⇒ no re-decrypt (idempotent)" + ); + } + + #[test] + fn sparse_tail_needs_all_present_packets_min_e_4() { + // End-of-clip fragment tail: `min(E,4)` scales to the packets that exist, + // so a sparse unit needs ALL of its (few) encrypted packets — a wrong key + // gives 0, so this is a strong proof with no wrong-key hole, and a valid + // 1-packet tail is never false-rejected. + assert!( + is_clean_ts(&decrypted_shape(1, 1, false)), + "E=1: the one packet syncs -> open" + ); + assert!( + !is_clean_ts(&decrypted_shape(1, 0, false)), + "E=1: 0 synced (wrong key) -> not" + ); + assert!( + is_clean_ts(&decrypted_shape(3, 3, false)), + "E=3: all 3 sync -> open" + ); + assert!( + !is_clean_ts(&decrypted_shape(3, 2, false)), + "E=3: min(3,4)=3 -> 2 synced is not enough" + ); + // The threshold boundary: E=4 needs all 4 (min(4,4)=4); E=5 needs only 4 + // of 5 (min(5,4)=4) — the transition from "need all E" to "need exactly 4". + assert!( + is_clean_ts(&decrypted_shape(4, 4, false)), + "E=4: 4/4 -> open" + ); + assert!( + !is_clean_ts(&decrypted_shape(4, 3, false)), + "E=4: 3/4 -> below min(4,4)=4" + ); + assert!( + is_clean_ts(&decrypted_shape(5, 4, false)), + "E=5: 4/5 -> open (min(5,4)=4, the floor caps at 4)" + ); + assert!( + !is_clean_ts(&decrypted_shape(5, 3, false)), + "E=5: 3/5 -> below the 4 floor" + ); } #[test] @@ -1045,10 +980,7 @@ mod tests { *b = 0; // source-zero padding tail (packets 20..32) } aacs_encrypt_unit(&mut unit, &key); - assert!( - decrypt_unit(&mut unit, &key), - "19/20 real packets synced + zero pad -> opened" - ); + decrypt_unit(&mut unit, &key); assert_eq!( unit[off + 4], 0x80, @@ -1064,25 +996,27 @@ mod tests { } #[test] - fn wrong_key_full_unit_is_not_decryptable() { - let mut unit = clear_unit(); + fn wrong_key_full_unit_does_not_recover_plaintext() { + let clear = clear_unit(); + let mut unit = clear.clone(); aacs_encrypt_unit(&mut unit, &[0x11u8; 16]); - assert!( - !decrypt_unit(&mut unit, &[0x22u8; 16]), - "wrong key on a full content unit is rejected" + decrypt_unit(&mut unit, &[0x22u8; 16]); + assert_ne!( + unit, clear, + "a wrong key on a full content unit does not recover the plaintext" ); } #[test] - fn cpi_clear_unit_passes_through_decryptable() { - // CPI-clear (plaintext) unit: decryptable by definition, untouched. - let mut unit = clear_unit(); // byte 0 high bits clear - let before = unit.clone(); + fn cpi_clear_unit_reports_not_encrypted() { + // CPI-clear (plaintext) unit: the caller's gate `aacs_unit_encrypted` + // reports NOT-encrypted, so it is never handed to the now-pure + // `decrypt_unit` (which would otherwise corrupt it by applying a key). + let unit = clear_unit(); // byte 0 high bits clear assert!( - decrypt_unit(&mut unit, &[0u8; 16]), - "clear unit passes through as decryptable" + !aacs_unit_encrypted(&unit, crate::disc::ContentFormat::BdTs), + "CPI-clear ⇒ caller does not decrypt it" ); - assert_eq!(unit, before, "clear unit left untouched"); } // ── AES-ECB KAT (FIPS-197 Appendix C.1) ──────────────────────────────── @@ -1221,29 +1155,32 @@ mod tests { "encrypted unit must look scrambled" ); - assert!(decrypt_unit(&mut unit, &unit_key)); + decrypt_unit(&mut unit, &unit_key); // All 32 stride positions carry sync after decrypt. assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit)); assert!(!ts_sync_destroyed(&unit)); } #[test] - fn decrypt_unit_wrong_key_fails_and_does_not_falsely_clear() { - // A wrong unit key fails verify_ts (the body stays scrambled), so - // decrypt_unit returns false. Grounds the brute-force gate: a bad key - // must NOT report success. + fn decrypt_unit_wrong_key_does_not_recover_plaintext() { + // A wrong unit key leaves the body scrambled — the plaintext is NOT + // recovered. Grounds the brute-force gate: a bad key must not look right. let good = [0x11u8; 16]; let bad = [0x22u8; 16]; - let mut unit = clear_unit(); + let clear = clear_unit(); + let mut unit = clear.clone(); aacs_encrypt_unit(&mut unit, &good); - assert!(!decrypt_unit(&mut unit, &bad), "wrong key must not verify"); + decrypt_unit(&mut unit, &bad); + assert_ne!(unit, clear, "a wrong key does not recover the plaintext"); } #[test] - fn decrypt_unit_rejects_short_unit() { - // unit.len() < ALIGNED_UNIT_LEN → false (no panic on the 16.. slice). + fn decrypt_unit_ignores_short_unit() { + // unit.len() < ALIGNED_UNIT_LEN → no-op (no panic on the 16.. slice). let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1]; - assert!(!decrypt_unit(&mut short, &[0u8; 16])); + let before = short.clone(); + decrypt_unit(&mut short, &[0u8; 16]); + assert_eq!(short, before, "a short unit is left untouched (no panic)"); } #[test] @@ -1271,183 +1208,35 @@ mod tests { ); } - // ── decrypt_unit_try_keys: AlreadyClear vs DecryptedWith vs None ─────── - - #[test] - fn try_keys_reports_already_clear_without_consuming_a_key() { - // A clear unit returns AlreadyClear even with an empty key list — the - // old Option form conflated this with Some(0). Grounds the - // UnitKeyResult enum distinction. - let mut unit = clear_unit(); - assert_eq!( - decrypt_unit_try_keys(&mut unit, &[]), - Some(UnitKeyResult::AlreadyClear) - ); - } - - #[test] - fn try_keys_reports_correct_index_among_several() { - // Three keys, only the 3rd (index 2) decrypts → DecryptedWith(2). - let real = [0x44u8; 16]; - let mut unit = clear_unit(); - aacs_encrypt_unit(&mut unit, &real); - let keys = [[0x01u8; 16], [0x02u8; 16], real]; - assert_eq!( - decrypt_unit_try_keys(&mut unit, &keys), - Some(UnitKeyResult::DecryptedWith(2)) - ); - assert!( - !ts_sync_destroyed(&unit), - "unit must be clear after the hit" - ); - } - - #[test] - fn try_keys_restores_original_bytes_on_total_failure() { - // When no key works, the unit must be byte-identical to the input - // (the function CBC-mangles it per attempt, then restores). A buggy - // restore would leave the unit corrupted — silent data damage. - let real = [0x55u8; 16]; - let mut unit = clear_unit(); - aacs_encrypt_unit(&mut unit, &real); - let snapshot = unit.clone(); - let wrong = [[0xAAu8; 16], [0xBBu8; 16]]; - assert_eq!(decrypt_unit_try_keys(&mut unit, &wrong), None); - assert_eq!(unit, snapshot, "failed try must restore the original bytes"); - } - // ── CPI gate: the authoritative encrypted-vs-clear decision ──────────── - #[test] - fn cpi_gate_clear_flag_passes_through_even_when_body_looks_scrambled() { - // THE false-fail fix: a unit whose CPI bits are clear (byte 0 & 0xC0 == 0) - // is plaintext by spec, even if its body has no TS syncs (non-TS clear - // data, or a mis-probed body). `decrypt_unit` must pass it through - // untouched and report success — never attempt a decrypt that would fail. - let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; - // Body looks scrambled (no syncs at the stride) but byte 0 stays 0x00. - for (i, b) in unit.iter_mut().enumerate().skip(16) { - *b = (i as u8).wrapping_mul(31) | 1; // never 0x47 at the sync stride - } - assert!(ts_sync_destroyed(&unit), "body has no syncs"); - assert!(!aacs_unit_encrypted(&unit), "CPI clear"); - assert!( - !aacs_unit_needs_decrypt(&unit), - "CPI-clear ⇒ no decrypt attempt" - ); - let snapshot = unit.clone(); - // Any key: passthrough success, bytes untouched (no false DecryptFailed). - assert!(decrypt_unit(&mut unit, &[0xABu8; 16])); - assert_eq!(unit, snapshot, "CPI-clear unit must be left byte-identical"); - assert_eq!( - decrypt_unit_try_keys(&mut unit, &[[0xABu8; 16]]), - Some(UnitKeyResult::AlreadyClear), - "CPI-clear unit consumes no key" - ); - } - #[test] fn cpi_gate_set_flag_decrypts_and_needs_decrypt_is_idempotent() { // A CPI-set encrypted unit decrypts with the right key. CPI lives in the // plaintext header, so it survives decryption — `aacs_unit_encrypted` // still reports true afterward, but `aacs_unit_needs_decrypt` flips to // false (syncs restored), keeping the re-decrypt paths idempotent. + let ts = crate::disc::ContentFormat::BdTs; let key = [0x5au8; 16]; let mut unit = clear_unit(); aacs_encrypt_unit(&mut unit, &key); // sets CPI + scrambles body - assert!(aacs_unit_encrypted(&unit), "CPI set"); - assert!(aacs_unit_needs_decrypt(&unit), "flagged + still scrambled"); - - assert!(decrypt_unit(&mut unit, &key), "right key decrypts"); + assert!(aacs_unit_encrypted(&unit, ts), "CPI set"); assert!( - aacs_unit_encrypted(&unit), + aacs_unit_needs_decrypt(&unit, ts), + "flagged + still scrambled" + ); + + decrypt_unit(&mut unit, &key); + assert!( + aacs_unit_encrypted(&unit, ts), "CPI bits live in the preserved header ⇒ still set post-decrypt" ); assert!( - !aacs_unit_needs_decrypt(&unit), + !aacs_unit_needs_decrypt(&unit, ts), "syncs restored ⇒ no further decrypt attempt (idempotent re-decrypt)" ); } - // ── unit_key_validates: matches decrypt_unit's verdict exactly ───────── - - #[test] - fn unit_key_validates_agrees_with_decrypt_unit() { - // The fast 1-byte gate's accept/reject set must be identical to the - // authoritative decrypt_unit. Confirm: correct key → true on both; - // wrong key → false on both. - let good = [0x6Au8; 16]; - let bad = [0x6Bu8; 16]; - let mut enc = clear_unit(); - aacs_encrypt_unit(&mut enc, &good); - - assert!(unit_key_validates(&enc, &good)); - let mut probe = enc.clone(); - assert!(decrypt_unit(&mut probe, &good)); - - assert!(!unit_key_validates(&enc, &bad)); - let mut probe2 = enc.clone(); - assert!(!decrypt_unit(&mut probe2, &bad)); - } - - #[test] - fn unit_is_clean_ts_is_strict_all_32_syncs() { - // Standards-correct gate (all-32 TS syncs): EVERY one of the 32 - // packet syncs is required. A fully-synced clear unit passes. - let clear = clear_unit(); - assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean"); - - // Drop a SINGLE sync (packet 17 of 32). 31/32 remain, so the majority - // heuristic still passes — that is exactly the silent-corruption hole. - // The strict gate must REJECT it. - let mut one_missing = clear_unit(); - one_missing[17 * BD_SOURCE_PACKET_BYTES + 4] = 0x00; - assert!( - ts_syncs_intact(&one_missing), - "majority heuristic still passes one missing sync (the hole)" - ); - assert!( - !unit_is_clean_ts(&one_missing), - "strict gate rejects even one missing sync" - ); - - // A correctly decrypted unit is clean; a wrong-key decrypt is not. - let key = [0x33u8; 16]; - let mut enc = clear_unit(); - aacs_encrypt_unit(&mut enc, &key); - let mut good = enc.clone(); - decrypt_unit(&mut good, &key); - assert!(unit_is_clean_ts(&good), "right-key decrypt yields clean TS"); - let mut wrong = enc.clone(); - decrypt_unit(&mut wrong, &[0x34u8; 16]); - assert!( - !unit_is_clean_ts(&wrong), - "wrong-key decrypt must fail the strict gate" - ); - - // Short buffer is never vacuously clean. - assert!(!unit_is_clean_ts(&clear[..ALIGNED_UNIT_LEN - 1])); - } - - #[test] - fn unit_key_validates_is_non_mutating() { - // The accelerator must never write its input (it operates on the - // ciphertext and confirms on a copy). A mutation that decrypted in - // place would corrupt the caller's buffer. - let good = [0x7Cu8; 16]; - let mut enc = clear_unit(); - aacs_encrypt_unit(&mut enc, &good); - let snapshot = enc.clone(); - let _ = unit_key_validates(&enc, &good); - assert_eq!(enc, snapshot, "unit_key_validates must not mutate input"); - } - - #[test] - fn unit_key_validates_rejects_short_unit() { - let short = vec![0u8; ALIGNED_UNIT_LEN - 16]; - assert!(!unit_key_validates(&short, &[0u8; 16])); - } - // ── bus decryption (AACS 2.0 / UHD) ──────────────────────────────────── #[test] @@ -1495,52 +1284,6 @@ mod tests { } } - // ── decrypt_unit_full: bus-then-AACS ordering, and clear passthrough ─── - - #[test] - fn decrypt_unit_full_passthrough_when_already_clear() { - // A clear unit returns true and is not modified, regardless of keys. - let mut unit = clear_unit(); - let snapshot = unit.clone(); - assert!(decrypt_unit_full( - &mut unit, - &[0u8; 16], - Some(&[0xFFu8; 16]) - )); - assert_eq!(unit, snapshot, "clear unit must pass through untouched"); - } - - #[test] - fn decrypt_unit_full_applies_bus_then_aacs() { - // AACS 2.0 pipeline: content is first AACS-unit-encrypted, then - // bus-encrypted on top. Decrypt must undo bus FIRST, then AACS. - // Build that exact two-layer ciphertext and confirm full recovery. - let unit_key = [0x21u8; 16]; - let rdk = [0x84u8; 16]; - - let mut unit = clear_unit(); - // Layer 1: AACS unit-encrypt. - aacs_encrypt_unit(&mut unit, &unit_key); - // Layer 2: bus-encrypt on top (per-sector, bytes 16..2048). - let cipher = Aes128::new(GenericArray::from_slice(&rdk)); - for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) { - let mut prev = AACS_IV; - for i in 0..((SECTOR_BYTES - 16) / 16) { - let off = s + 16 + i * 16; - for j in 0..16 { - unit[off + j] ^= prev[j]; - } - let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]); - cipher.encrypt_block(&mut blk); - unit[off..off + 16].copy_from_slice(&blk); - prev.copy_from_slice(&unit[off..off + 16]); - } - } - assert!(ts_sync_destroyed(&unit)); - assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk))); - assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit)); - } - // ── ts_sync_destroyed / ts_sync_count edge cases ─────────────────────── #[test] diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index 6e01d12..cc8da51 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -463,7 +463,7 @@ pub enum KeyCandidate { /// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every /// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with /// its declared CPS-unit number); the caller runs -/// [`super::content::unit_key_validates`] to find which one actually opens the +/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the /// disc. Rungs above the candidate are `None`. #[derive(Debug, Clone)] pub struct ResolvedChain { @@ -487,7 +487,7 @@ pub struct ResolvedChain { /// /// PURE DERIVATION: no sampling, no validation, no position recovery. Validate /// `unit_keys` against a real encrypted unit with -/// [`super::content::unit_key_validates`] to prove the candidate opens the disc. +/// `decrypt_unit` + `is_clean_ts` to prove the candidate opens the disc. /// /// Returns `None` only when derivation itself cannot proceed: a PK its MKB /// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs @@ -505,7 +505,8 @@ pub fn resolve_candidate( let version = mkb_type(mkb) .map(|t| t.generation()) .unwrap_or(AacsVersion::V10); - let ukf = parse_unit_key_ro(unit_key_ro, version)?; + // BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic. + let ukf = parse_title_keys(unit_key_ro, version)?; if ukf.encrypted_keys.is_empty() { return None; } diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs index d7c46d0..df4207c 100644 --- a/src/aacs/inf.rs +++ b/src/aacs/inf.rs @@ -161,6 +161,87 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option Option { + if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC { + return None; + } + // SHA1 of the WHOLE file — the KEYDB lookup key. BackupHDDVD-family key + // databases index an HD DVD disc by SHA1(VTKF000.AACS), the same role the + // BD disc_hash plays for `Unit_Key_RO.inf`. + let hash = disc_hash(data); + + let mut encrypted_keys = Vec::new(); + let mut pos = VTKF_HEADER_LEN; + let mut cps: u32 = 1; + while pos + VTKF_ENTRY_LEN <= data.len() { + let flag = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]); + // A cleared present-bit terminates the key table. The file's trailing + // 16-byte signature then follows and must NOT be read as a key. + if flag & 0x8000_0000 == 0 { + break; + } + let mut key = [0u8; 16]; + key.copy_from_slice(&data[pos + 4..pos + 20]); + encrypted_keys.push((cps, key)); + cps += 1; + pos += VTKF_ENTRY_LEN; + } + if encrypted_keys.is_empty() { + return None; + } + + Some(UnitKeyFile { + disc_hash: hash, + app_type: 0, // HD DVD VTKF carries no BD-ROM app_type + num_bdmv_dir: 0, // BD-only concept + use_skb_mkb: false, + version: AacsVersion::V10, // HD DVD is always AACS 1.0 + encrypted_keys, + title_cps_unit: Vec::new(), + }) +} + +/// Parse a disc's title-key file, dispatching on the self-describing magic: +/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a +/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same +/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic +/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key +/// service, which classifies HD DVD by the very same magic). +pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option { + if data.len() >= 12 && &data[..12] == VTKF_MAGIC { + parse_vtkf(data) + } else { + parse_unit_key_ro(data, version) + } +} + /// MKB disc structure format code. const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83; @@ -283,3 +364,94 @@ pub fn parse_content_cert(data: &[u8]) -> Option { version, }) } + +#[cfg(test)] +mod vtkf_tests { + use super::*; + + /// Build a synthetic `VTKF000.AACS` matching the real on-disc layout + /// (Shaun of the Dead / Anchorman): magic, BE32 size, playlist name, + /// reserved to 0x80, then 32-byte present-flagged entries, a cleared-flag + /// terminator, and a 16-byte trailer. + fn synth_vtkf(keys: &[[u8; 16]]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(VTKF_MAGIC); // 0x00 + v.extend_from_slice(&0u32.to_be_bytes()); // 0x0C size (patched below) + v.extend_from_slice(b"VPLST000.XPL"); // 0x10 + v.resize(0x80, 0); // reserve to first entry + for k in keys { + v.extend_from_slice(&0x8000_0000u32.to_be_bytes()); // present flag + v.extend_from_slice(k); // 16-byte encrypted title key + v.extend_from_slice(&[0xFFu8; 12]); // 0xFF pad → 32-byte entry + } + // Cleared-flag terminator entry (must NOT be read as a key). + v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); + // 16-byte trailing signature (must NOT be read as a key). + v.extend_from_slice(&[0xABu8; 16]); + let len = v.len() as u32; + v[0x0C..0x10].copy_from_slice(&len.to_be_bytes()); + v + } + + #[test] + fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() { + let k1 = [0x11u8; 16]; + let k2 = [0x22u8; 16]; + let k3 = [0x33u8; 16]; + let data = synth_vtkf(&[k1, k2, k3]); + + let ukf = parse_vtkf(&data).expect("valid VTKF must parse"); + // Exactly the three present entries — the cleared-flag terminator and + // the 16-byte trailer are NOT mistaken for keys. + assert_eq!(ukf.encrypted_keys.len(), 3, "must stop at the cleared flag"); + assert_eq!(ukf.encrypted_keys[0], (1, k1), "CPS units number 1..=N"); + assert_eq!(ukf.encrypted_keys[1], (2, k2)); + assert_eq!(ukf.encrypted_keys[2], (3, k3)); + assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0"); + // disc_hash is SHA1 of the whole file (the KEYDB lookup key). + assert_eq!(ukf.disc_hash, disc_hash(&data)); + } + + #[test] + fn parse_vtkf_rejects_non_magic() { + let mut data = synth_vtkf(&[[0x11u8; 16]]); + data[0] = b'X'; // corrupt magic + assert!( + parse_vtkf(&data).is_none(), + "non-VTKF magic must be rejected" + ); + assert!( + parse_vtkf(&[0u8; 4]).is_none(), + "too short must be rejected" + ); + } + + #[test] + fn parse_title_keys_dispatches_by_magic() { + // VTKF magic → parse_vtkf. + let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]); + let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch"); + assert_eq!(ukf.encrypted_keys.len(), 2); + + // Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so + // this proves it ROUTED to the BD parser rather than parse_vtkf). + assert!( + parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(), + "non-magic input must route to parse_unit_key_ro" + ); + } + + /// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key + /// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would — + /// no HD-DVD-specific crypto path. + #[test] + fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() { + let enc = [0x9Au8; 16]; + let data = synth_vtkf(&[enc]); + let ukf = parse_vtkf(&data).unwrap(); + let vuk = [0x5Cu8; 16]; + let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1); + // Same as applying the shared unwrap directly to the stored enc key. + assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc)); + } +} diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index bb6402b..dfe11b9 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -40,10 +40,18 @@ pub mod types; pub mod variant; pub mod variant_select; -/// On-disc UDF paths to the AACS key-input files (with their fallbacks). -/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`, -/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding -/// or changing a fallback in one place can then never silently diverge the +/// On-disc UDF paths to the AACS key-input files. +/// +/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the +/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the +/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The +/// container difference is expressed here purely as DATA: each ROLE +/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered +/// candidate list, and every reader walks it with [`read_first`] taking the +/// first that reads. No reader ever branches on disc type — a BD/UHD disc has +/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through +/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`, +/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the /// disc_hash / MKB / VID that another reader feeds a key service. pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf"; pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf"; @@ -51,6 +59,46 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf"; pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf"; pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer"; pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer"; +/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service +/// recognises it by its `DVD_HD_V_TKF` magic. +pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS"; +/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`. +pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS"; +/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10). +pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS"; + +/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD). +pub const UNIT_KEY_RO_PATHS: &[&str] = &[ + PATH_UNIT_KEY_RO, + PATH_UNIT_KEY_RO_DUPLICATE, + PATH_VTKF_HDDVD, +]; +/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD). +pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD]; +/// Content-certificate role, in resolution order (BD/UHD, then HD DVD). +pub const CONTENT_CERT_PATHS: &[&str] = &[ + PATH_CONTENT_CERT, + PATH_CONTENT_CERT_ALT, + PATH_CONTENT_CERT_HDDVD, +]; + +/// Walk an AACS role's candidate paths and return the first that reads. +/// +/// `read` performs the actual per-path read (full file or bounded prefix), so +/// callers share the same first-present walk regardless of read style. Returns +/// [`Error::AacsNoKeys`] if no candidate is present. This is the single place +/// the `/AACS/` (BD/UHD) vs `/ANY!/` (HD DVD) layout difference is resolved. +pub(crate) fn read_first(candidates: &[&str], mut read: F) -> crate::error::Result> +where + F: FnMut(&str) -> crate::error::Result>, +{ + for path in candidates { + if let Ok(buf) = read(path) { + return Ok(buf); + } + } + Err(crate::error::Error::AacsNoKeys) +} // The module structure IS the public API — consumers import from the owning // module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`, @@ -61,7 +109,7 @@ pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer"; // content-decrypt entry points that downstream key-source crates import through // the `aacs::` path. These are the stable, load-bearing names; keeping them here // lets those crates track the module refactor without a lockstep re-pin. -pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys}; +pub use content::ALIGNED_UNIT_LEN; pub use derive::derive_vuk; pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk}; diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index 0a1eac6..48f54c6 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -156,7 +156,7 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option { /// equivalent of path 2 — there's no host-side PK derivation against a /// Variant MKB.) pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option { - let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?; + let uk_file = parse_title_keys(ctx.unit_key_ro, AacsVersion::V20)?; let hash_hex = disc_hash_hex(&uk_file.disc_hash); let bus_encryption = ctx .content_cert @@ -280,8 +280,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt .map(|cc| cc.bus_encryption) .unwrap_or(false); - // Parse Unit_Key_RO.inf at the version-appropriate stride. - let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?; + // Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the + // version-appropriate stride, or HD DVD VTKF000.AACS) → common UnitKeyFile. + let uk_file = parse_title_keys(ctx.unit_key_ro, version)?; let hash_hex = disc_hash_hex(&uk_file.disc_hash); let has_vid = *ctx.volume_id != [0u8; 16]; diff --git a/src/decrypt.rs b/src/decrypt.rs index ee6654c..bd50731 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -150,10 +150,15 @@ pub fn decrypt_threads() -> usize { pub enum DecryptKeys { /// No encryption on this disc. None, - /// AACS (Blu-ray / UHD). Unit keys + optional read data key. + /// AACS (Blu-ray / UHD / HD-DVD). Unit keys + optional read data key. 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. Aacs { unit_keys: Vec<(u32, [u8; 16])>, read_data_key: Option<[u8; 16]>, + format: crate::disc::ContentFormat, }, /// CSS (DVD). Title key for sector descrambling. Css { title_key: [u8; 5] }, @@ -206,7 +211,8 @@ pub fn decrypt_sectors( /// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is /// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors. /// -/// `content_ranges` is sorted, merged, disjoint `[start_lba, end_lba)`. +/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)` +/// tuples (each covering `[start_lba, start_lba + sector_count)`). pub fn decrypt_sectors_in_content( buf: &mut [u8], keys: &mut DecryptKeys, @@ -242,6 +248,7 @@ fn decrypt_sectors_impl( 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 @@ -250,8 +257,13 @@ fn decrypt_sectors_impl( return Err(crate::error::Error::DecryptFailed); } - // Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes. - let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect(); + // 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 @@ -295,7 +307,13 @@ fn decrypt_sectors_impl( Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges), None => true, }; - if partial_in_content { + // TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte + // unit) can't be unit-decrypted, so fail loud. The heuristic is + // MPEG-TS sync density, which a PS (`.evo`) partial lacks entirely — + // running it on PS would false-trip `DecryptFailed`. HD-DVD partial- + // scramble detection is not yet wired (consistent with the UNVERIFIED + // PS path in `aacs_unit_encrypted`). + if partial_in_content && format == crate::disc::ContentFormat::BdTs { let partial = &buf[buf.len() - partial_len..]; let packets = aacs::content::ts_packet_total(partial); if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 { @@ -336,12 +354,15 @@ fn decrypt_sectors_impl( // 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` (CPI set AND TS syncs not yet - // restored): CPI alone isn't enough because the plaintext seed keeps - // the CPI bit set after decryption, so an already-decrypted unit would - // be decrypted a SECOND time (scrambling it) on any re-run of this - // pass. The intact-TS half makes it idempotent. - if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) { + // 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; } @@ -355,41 +376,67 @@ fn decrypt_sectors_impl( // 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..raw_keys.len()).filter(move |&i| i != hint)); + std::iter::once(hint).chain((0..unit_keys.len()).filter(move |&i| i != hint)); - // DECRYPT the unit — apply a key, leave the plaintext. "Did a key - // produce clean TS?" is NOT "did we decrypt?": a correct key can - // decrypt content whose underlying encoding is broken (bad TS sync), - // which is a MUXER concern, never a decrypt verdict. Clean TS is used - // ONLY as a key-SELECTION hint on multi-CPS-unit discs — the first - // key that yields clean TS is the definite match. When none does we - // STILL decrypted (the cached-hint key is applied): keep those bytes - // and report the unit as UNVERIFIED. This function applies no policy; - // the caller decides what an unverified unit means (the mux passes it - // to the muxer; sweep/patch treat it as a read to recover or fail). - let mut applied: Option> = None; + // 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 = None; for idx in try_order { - if let Some(key) = raw_keys.get(idx) { - // Work on a per-key copy so a failing attempt doesn't - // clobber the bus-decrypted base we'll retry on. - let mut attempt: Vec = chunk.to_vec(); - if aacs::content::decrypt_unit(&mut attempt, key) { - chunk.copy_from_slice(&attempt); + 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 applied.is_none() { - applied = Some(attempt); + if first_idx.is_none() { + first_idx = Some(idx); } } } - // No key yielded clean TS. Keep the applied-key plaintext (the pool is - // non-empty past the guard, so `applied` 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(decrypted) = applied { - chunk.copy_from_slice(&decrypted); + // 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); }; @@ -427,11 +474,14 @@ fn decrypt_sectors_impl( // back to the serial path rather than panic. match decrypt_pool() { Some(pool) => { - let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect(); + // `par_chunks_mut` iterates the units in place — no + // intermediate `Vec<&mut [u8]>` allocation per batch. pool.install(|| { - chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| { - process(idx, chunk); - }); + buf.par_chunks_mut(unit_len) + .enumerate() + .for_each(|(idx, chunk)| { + process(idx, chunk); + }); }); } None => { @@ -483,6 +533,7 @@ mod tests { 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. @@ -554,6 +605,7 @@ mod tests { 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); @@ -587,6 +639,7 @@ mod tests { 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)]. @@ -605,6 +658,7 @@ mod tests { 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); @@ -655,6 +709,7 @@ mod tests { 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(); @@ -673,6 +728,7 @@ mod tests { 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(); @@ -791,6 +847,7 @@ mod tests { 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]; @@ -808,6 +865,7 @@ mod tests { 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)]. @@ -827,6 +885,7 @@ mod tests { 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. @@ -849,6 +908,7 @@ mod tests { 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); @@ -874,6 +934,7 @@ mod tests { let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; // One full unit + a 4096-byte (two-sector) SCRAMBLED tail. let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); @@ -896,6 +957,7 @@ mod tests { let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; let mut buf: Vec = Vec::new(); assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok()); @@ -909,6 +971,7 @@ mod tests { 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(); @@ -950,6 +1013,7 @@ mod tests { DecryptKeys::Aacs { unit_keys: vec![(0, [0; 16])], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, } .is_encrypted() ); @@ -1164,6 +1228,7 @@ mod tests { 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); let err = decrypt_sectors(&mut buf, &mut keys, 5) @@ -1184,6 +1249,7 @@ mod tests { let mut keys = DecryptKeys::Aacs { unit_keys: vec![], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error"); @@ -1265,6 +1331,7 @@ mod tests { 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. @@ -1299,6 +1366,7 @@ mod tests { 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"); @@ -1340,6 +1408,7 @@ mod tests { 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 = @@ -1383,6 +1452,7 @@ mod tests { 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"); @@ -1412,6 +1482,7 @@ mod tests { 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"); diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 83aed7c..a761bb3 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -311,15 +311,11 @@ impl Disc { ) -> Result { use crate::aacs; - let uk_ro_data = udf_fs - .read_file(reader, crate::aacs::PATH_UNIT_KEY_RO) - .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE)) - .map_err(|_| Error::AacsNoKeys)?; + let uk_ro_data = + aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?; let dh = aacs::inf::disc_hash(&uk_ro_data); - let cc = udf_fs - .read_file(reader, crate::aacs::PATH_CONTENT_CERT) - .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT)) + let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p)) .ok() .as_deref() .and_then(aacs::inf::parse_content_cert); diff --git a/src/disc/extract.rs b/src/disc/extract.rs index fe99a17..d25c27a 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -288,63 +288,6 @@ impl Disc { } } -/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF -/// file is clear (nav / playlists / filesystem) and needs no decrypt verify. -fn is_aacs_clip(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.ends_with(".m2ts") || lower.ends_with(".ssif") -} - -/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as -/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each -/// clip's declared size plus its absolute disc extents in FILE order. Reads the -/// UDF tree through `reader`. -/// -/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an -/// EMPTY list — the verify gate then covers nothing and the sweep behaves as -/// today. Enumeration must never break a rip, so the error is logged, not -/// propagated. -pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec { - let result = (|| -> Result> { - let fs = udf::read_filesystem(reader)?; - let mut planned: Vec = Vec::new(); - let mut dirs: Vec = Vec::new(); - let mut seen_hosts: std::collections::HashMap = - std::collections::HashMap::new(); - plan_tree( - reader, - &fs, - &fs.root, - Path::new(""), - "", - true, - &mut planned, - &mut dirs, - &mut seen_hosts, - )?; - Ok(planned - .into_iter() - .filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name)) - .map(|pf| crate::disc::verify::ClipLayout { - size: pf.size, - extents: pf.extents, - // Every AACS clip we enumerate today is BD-TS (`.m2ts`/`.ssif`). - // HD-DVD `.evo` (program stream) maps to `ContainerKind::Ps` here - // once `is_aacs_clip` recognises it — the one-line HD-DVD hook. - container: crate::disc::verify::ContainerKind::Ts, - }) - .collect()) - })(); - result.unwrap_or_else(|e| { - tracing::warn!( - target: "freemkv::verify", - error = %e, - "clip enumeration failed; post-read verify disabled for this pass" - ); - Vec::new() - }) -} - /// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an /// inner source for its lifetime while the caller keeps the underlying /// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource` diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 3ef3900..8f23042 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -10,7 +10,7 @@ mod bluray; mod dvd; -pub mod dvd_audio_probe; +pub(crate) mod dvd_audio_probe; mod encrypt; mod extract; mod hddvd; @@ -19,7 +19,6 @@ mod patch; pub mod read_error; mod section_recover; mod sweep; -pub mod verify; use crate::drive::{Drive, extract_scsi_context}; use crate::error::{Error, Result}; @@ -85,7 +84,8 @@ pub struct Disc { pub enum ContentFormat { /// Blu-ray BD Transport Stream (192-byte packets) BdTs, - /// DVD MPEG-2 Program Stream (VOB) + /// MPEG-2 Program Stream — DVD (`.vob`) and HD-DVD (`.evo`). For AACS content + /// this selects the PS-aware encrypted-flag / structural checks. MpegPs, } @@ -1623,12 +1623,11 @@ impl Disc { // detection needs the read, the read needs auth, auth needs detection. // The handshake is itself the detector: on a non-CSS (unencrypted) DVD // the disc-key read fails, `resolve` returns None, and the disc is left - // in the clear. This block is DVD-only (MPEG-PS); BD/UHD (MPEG-TS) goes - // through the AACS handshake above and never reaches here. - if disc.css.is_none() - && disc.content_format == ContentFormat::MpegPs - && !disc.titles.is_empty() - { + // in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT + // `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS, + // not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes + // through the AACS path above). BD/UHD are MPEG-TS and never reach here. + if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() { // CSS title keys are per-VTS, and ONLY the scrambled movie content // carries a non-zero key. Menu / VMG / logo cells (often the // low-LBA first extent) return a ZERO title key over REPORT KEY — @@ -1744,10 +1743,13 @@ impl Disc { // pre-decrypted one. A pre-decrypted image has its scramble flags clear, // so `crack_key` finds no crackable sector and the disc stays in the // clear. AACS images go through KEYDB VUK lookup, not here. - if disc.css.is_none() - && disc.content_format == ContentFormat::MpegPs - && !disc.titles.is_empty() - { + // + // Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD + // `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not + // enter the CSS crack path. A CSS DVD's IFO (which defines the titles + // this branch reads) is unscrambled, so `detect_format` reliably sets + // `Dvd` from the SD-resolution titles even on a still-scrambled image. + if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() { let main_extents = match disc .titles .iter() @@ -1799,10 +1801,9 @@ impl Disc { reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, ) -> Result<(Vec, Vec, u8)> { - let inf = udf_fs - .read_file(reader, crate::aacs::PATH_UNIT_KEY_RO) - .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE)) - .map_err(|_| Error::AacsNoKeys)?; + let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| { + udf_fs.read_file(reader, p) + })?; let mkb = Self::read_mkb_content(reader, udf_fs)?; let version = Self::read_aacs_version(reader, udf_fs); Ok((inf, mkb, version)) @@ -1820,12 +1821,12 @@ impl Disc { /// mis-strided title keys (silent wrong unit keys), so a missing cert must /// not quietly pick the V10 stride for a UHD disc. fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 { - match udf_fs - .read_file(reader, crate::aacs::PATH_CONTENT_CERT) - .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT)) - .ok() - .as_deref() - .and_then(crate::aacs::inf::parse_content_cert) + match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| { + udf_fs.read_file(reader, p) + }) + .ok() + .as_deref() + .and_then(crate::aacs::inf::parse_content_cert) { Some(c) => c.version.major(), None => { @@ -1856,10 +1857,9 @@ impl Disc { const MAX_BYTES: usize = 64 * 1024 * 1024; let mut want = START_BYTES; loop { - let buf = udf_fs - .read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want) - .or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want)) - .map_err(|_| Error::AacsNoKeys)?; + let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| { + udf_fs.read_file_prefix(reader, p, want) + })?; let n = crate::aacs::mkb::mkb_content_len(&buf); // `n` strictly inside `buf` => the record walk reached the padding // boundary (full content captured). `buf` shorter than `want` => @@ -2297,12 +2297,15 @@ fn aligned_unit_keys_validate( unit_keys: &[(u32, [u8; 16])], read_data_key: Option<&[u8; 16]>, samples: &[Vec], + format: ContentFormat, ) -> bool { - use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full}; + use crate::aacs::content::{ + ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean, + }; let scrambled: Vec<&[u8]> = samples .iter() .map(|s| s.as_slice()) - .filter(|s| aacs_unit_needs_decrypt(s)) + .filter(|s| aacs_unit_needs_decrypt(s, format)) .collect(); if scrambled.is_empty() { return true; // nothing to disprove against — accept @@ -2324,7 +2327,13 @@ fn aligned_unit_keys_validate( hb.tick_cpu(tried, total); tried += 1; probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]); - if decrypt_unit_full(&mut probe, k, read_data_key) { + // bus layer (AACS 2.0) first, then the CPS unit key, then the structural + // proof — the composed form of the old `decrypt_unit_full`. + if let Some(rdk) = read_data_key { + decrypt_bus(&mut probe, rdk); + } + decrypt_unit(&mut probe, k); + if is_clean(&probe, format) { covered = true; break; } @@ -2351,6 +2360,7 @@ impl Disc { crate::decrypt::DecryptKeys::Aacs { unit_keys: aacs.unit_keys.clone(), read_data_key: aacs.read_data_key, + format: self.content_format, } } else if let Some(ref css) = self.css { crate::decrypt::DecryptKeys::Css { @@ -2840,7 +2850,12 @@ impl Disc { // de-scramble it. With no samples (or only clear ones) there is nothing // to disprove against, so the key is accepted as-is — keeping the // sample-less paths (resume / mapfile cache) byte-for-byte unchanged. - if !aligned_unit_keys_validate(&candidate_unit_keys, read_data_key.as_ref(), samples) { + if !aligned_unit_keys_validate( + &candidate_unit_keys, + read_data_key.as_ref(), + samples, + self.content_format, + ) { return Err(crate::error::Error::AacsKeyRejected); } @@ -3073,10 +3088,6 @@ impl Disc { progress: opts.progress, halt: opts.halt.clone(), key_fetch: opts.key_fetch.clone(), - // Disc::copy's internal patch grinds each range fully (it's a - // single-call recovery); the breadth-first fast-capture ordering is - // an autorip multi-pass concern. - fast_capture: false, }; let pr = self.patch(reader, path, &patch_opts)?; tracing::info!( @@ -3131,27 +3142,15 @@ impl Disc { // A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without // `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext. // - // A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the - // autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we - // still resolve the keys and VERIFY each unit on a scratch copy: a unit - // that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like - // a SCSI error, and flows into the SAME read-error recovery (skip / - // NonTrimmed / patch). This is the one spot that makes "a read succeeded" - // mean "read AND decrypts" — everything downstream is unchanged. With no - // usable AACS keys (no keydb) it degrades to a plain pass-through. - // - // A plain `--raw` single-pass (no `skip_on_error`) stays a pass-through: - // the user asked for the raw image, untouched and unchecked. - // The sweep COPIES ciphertext (multipass / `--raw`) or decrypts IN PLACE - // (`opts.decrypt`, the rare disc→decrypted-ISO). It deliberately does NOT - // decrypt-VERIFY: a whole-disc sweep reads disc-absolute, but AACS aligned - // units are anchored to each clip's FILE start and clips can be non-6144- - // aligned OR fragmented across UDF extents — so a disc-absolute verify - // mis-aligns the unit grid and false-fails good clips (it skipped the - // ~990 MB orphan-CPS clip on Dunkirk). Verification moved to the - // clip-anchored [`Disc::verify_clips`] pass that runs AFTER the sweep, - // reading each clip file-order-anchored from the ISO. The read here stays - // a fail-safe copy; alignment is never assumed. + // Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and + // plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a + // pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI + // read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure. + // (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute + // unit grid against clip-file-anchored AACS units and false-failed good + // 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 { self.decrypt_keys() } else { @@ -3177,22 +3176,6 @@ impl Disc { }; let reader = &mut reader; - // Post-read verify gate (universal `read -> verify -> sign-off`). Built - // ONLY for the ciphertext sweep (`!opts.decrypt`, the multipass rip - // path) so `observe` always sees on-disc ciphertext and never - // double-decrypts already-plaintext bytes. `UnitVerifier::new` is itself - // fail-safe: it returns `None` (verify disabled, behavior unchanged) for - // a non-AACS disc, no keys, the kill-switch off, or an empty clip - // enumeration. We resolve the REAL AACS keys here even though the sweep - // copies ciphertext, and reuse the application's key-fetch seam. - let mut verifier = if opts.decrypt { - None - } else { - let verify_keys = self.decrypt_keys(); - let layouts = extract::clip_layouts(&mut *reader); - crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone()) - }; - // Mapfile: load if resuming, else wipe + recreate. let mapfile_path = self.mapfile_for(path); // covers_disc reconciliation. A resume against a mapfile whose total @@ -3483,18 +3466,6 @@ impl Disc { // The consumer thread sees decrypted bytes; the // pre-0.18 inline decrypt_sectors call lived here. - // Post-read verify: observe the just-read ciphertext - // BEFORE it is moved into the channel, collecting the - // clip units this batch completes that are confidently - // undecryptable. Sent as `MarkBad` AFTER the `Good` - // below so the FIFO pipe records `Finished` first and the - // downgrade to `NonTrimmed` last. No-op when the gate is - // disabled (`verifier` is `None`). - let verify_bad = verifier - .as_mut() - .map(|v| v.observe(block_lba, &buf[..block_bytes as usize])) - .unwrap_or_default(); - // Move the batch into the channel via fresh // owned Vec. The producer's `buf` is reused // for the next read. @@ -3503,26 +3474,6 @@ impl Disc { producer_err = Some(consumer_gone()); break 'outer; } - // Downgrade any unit that failed verify (decrypt-fail == - // bad read). decrypt-fail is NOT physical damage, so it - // deliberately does not touch the damage-jump window. - let mut send_failed = false; - for (bad_lba, bad_cnt) in verify_bad { - if pipe - .send(WorkItem::MarkBad { - pos: bad_lba as u64 * 2048, - len: bad_cnt as u64 * 2048, - }) - .is_err() - { - producer_err = Some(consumer_gone()); - send_failed = true; - break; - } - } - if send_failed { - break 'outer; - } bytes_done = bytes_done.saturating_add(block_bytes); pos += block_bytes; } @@ -4009,18 +3960,6 @@ pub struct PatchOptions<'a> { /// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N /// recover an orphan CPS unit's key when re-reading its bad range. pub key_fetch: Option, - /// Fast-capture pass: read each bad range ONCE at the full batch and leave - /// every failed block `NonTrimmed` for a later pass — WITHOUT bisecting, - /// re-reading, or grinding it here. This lets a first retry pass grab the - /// readable blocks (the sweep's good skip-ahead overshoot) of EVERY range - /// quickly, before any single range's slow per-sector recovery — so - /// recovered data surfaces across the whole disc first instead of grinding - /// section 1 to exhaustion before even touching section 2. A later pass - /// (`fast_capture = false`) does the granular bisect/retry on what's left. - /// No data is dropped: a failed block stays `NonTrimmed` until a granular - /// pass recovers it or finally gives up. A transport fault (bridge crash) - /// still aborts — it isn't a recoverable bad sector. - pub fast_capture: bool, } /// Result returned by [`Disc::patch`]. @@ -4867,6 +4806,7 @@ mod tests { crate::decrypt::DecryptKeys::Aacs { unit_keys, read_data_key, + .. } => { assert_eq!(unit_keys, uk, "injected UK must be the decrypt key"); assert_eq!(read_data_key, None, "ISO mux needs no bus key"); @@ -5393,7 +5333,8 @@ mod tests { assert!(super::aligned_unit_keys_validate( &[(0, [0x11u8; 16])], None, - &[] + &[], + ContentFormat::BdTs )); // A clear unit (TS syncs intact) is not scrambled -> proves nothing -> @@ -5408,7 +5349,8 @@ mod tests { assert!(super::aligned_unit_keys_validate( &[(0, [0x11u8; 16])], None, - &[clear.clone()] + &[clear.clone()], + ContentFormat::BdTs )); // A genuinely scrambled unit the RIGHT key restores to clear TS. @@ -5423,16 +5365,23 @@ mod tests { assert!(super::aligned_unit_keys_validate( &[(7, uk)], None, - &[enc.clone()] + &[enc.clone()], + ContentFormat::BdTs )); // Wrong key -> cannot de-scramble a scrambled sample -> reject. assert!(!super::aligned_unit_keys_validate( &[(7, [0x00u8; 16])], None, - &[enc.clone()] + &[enc.clone()], + ContentFormat::BdTs )); // Empty key set against a scrambled sample -> reject. - assert!(!super::aligned_unit_keys_validate(&[], None, &[enc])); + assert!(!super::aligned_unit_keys_validate( + &[], + None, + &[enc], + ContentFormat::BdTs + )); } #[test] @@ -5467,21 +5416,24 @@ mod tests { assert!(!super::aligned_unit_keys_validate( &[(0, uk0)], None, - &samples + &samples, + ContentFormat::BdTs )); // Complete key set (both CPS units) -> accept. assert!(super::aligned_unit_keys_validate( &[(0, uk0), (1, uk1)], None, - &samples + &samples, + ContentFormat::BdTs )); // Order-independent: covering key present anywhere in the set is fine. assert!(super::aligned_unit_keys_validate( &[(1, uk1), (0, uk0)], None, - &samples + &samples, + ContentFormat::BdTs )); } diff --git a/src/disc/patch.rs b/src/disc/patch.rs index e53cfa9..0ddb644 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -405,9 +405,15 @@ pub(super) fn compute_initial_state( bad_ranges.reverse(); } let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum(); + // Fail SAFE when metadata is indeterminate: assume a regular file so a + // real `sync_all` failure is surfaced, not swallowed. `/dev/null` and pipes + // report success-with-non-file here (so they still correctly map to + // `false`); only a genuine metadata error (e.g. transient NFS ESTALE) hits + // the default, and for a data-integrity guard "surface the error" is the + // right side to err on. let is_regular = std::fs::metadata(path) .map(|m| m.file_type().is_file()) - .unwrap_or(false); + .unwrap_or(true); Ok(( map, initial_stats, @@ -1247,7 +1253,24 @@ impl Disc { pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 { let map = match mapfile::Mapfile::load(mapfile_path) { Ok(m) => m, - Err(_) => return 0, + // A MISSING mapfile is legitimate (no damage was ever tracked — e.g. a + // clean single-pass rip): 0 bad bytes is correct. Any OTHER load error + // (corrupt / unreadable mapfile) means we CANNOT know the damage — and + // a returned 0 reads to the caller as "clean." Logging alone is not + // fail-safe: the RETURN VALUE drives the loss/abort accounting, not the + // log. So fail safe by reporting the ENTIRE title as bad (its full + // in-extent byte count) — a corrupt damage record must surface as + // maximal loss, never as a clean rip. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return 0, + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + path = %mapfile_path.display(), + error = %e, + "bytes_bad_in_title: mapfile load failed; reporting whole title bad (fail-safe: cannot confirm clean)" + ); + return bytes_bad_in_title(title, &[(0, u64::MAX)]); + } }; let bad_ranges = map.ranges_with(&[ mapfile::SectorStatus::NonTrimmed, @@ -1299,33 +1322,13 @@ impl Disc { let bytes_good_before = initial_stats.bytes_good; let bytes_good_start = bytes_good_before; - // Post-read verify gate for the patch pass (ciphertext multipass only, - // `!opts.decrypt`). Built here from the raw reader's UDF enumeration; - // reused AFTER the recovery loop (`reverify_iso`) to re-check the units - // this pass touched by reading them WHOLE back from the patched ISO — - // patch re-reads only the bad sectors of a unit, so per-unit verify - // can't run live. Fail-safe `None` when disabled / non-AACS / no keys. - let mut verifier = if opts.decrypt { - None - } else { - let verify_keys = self.decrypt_keys(); - let layouts = crate::disc::extract::clip_layouts(&mut *reader); - crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone()) - }; // Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch - // (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting - // patch (the multipass / `--raw --multipass` path) resolves the keys and - // VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt - // fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the - // retry loop keeps re-reading it "until it decrypts or retries exhaust" - // exactly as for a SCSI read error — and a unit that DOES decrypt on a - // fresh read (the drive returned different bytes) is recovered for free. - // With no usable AACS keys this degrades to a plain pass-through. - // Symmetric with `Disc::sweep`: the patch COPIES ciphertext (multipass / - // `--raw`) or decrypts IN PLACE (`opts.decrypt`). It does NOT decrypt- - // VERIFY — the disc-absolute read can't anchor to a clip's file-relative - // unit grid (see `Disc::sweep` + `Disc::verify_clips`). Re-reads recover - // bad sectors; the clip-anchored verify pass re-checks them afterward. + // (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting + // patch (the multipass / `--raw --multipass` path) copies ciphertext + // verbatim (keys = `None` → pass-through). Bad sectors are found by + // 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 { self.decrypt_keys() } else { @@ -1435,64 +1438,7 @@ impl Disc { // sink's summary. `close` failing on a regular-file sync_all is // surfaced here as `Error::IoError`, matching pre-split // behaviour. - let mut summary = pipe.finish()?; - - // Scoped post-read re-verify (decrypt-fail == bad read). The consumer - // has flushed the ISO + mapfile; re-read each clip unit this pass touched - // WHOLE from the patched ISO and downgrade any that still won't decrypt - // to NonTrimmed, so the orchestrator's end-of-recovery promotion - // terminalizes it. Reuses the same verifier as the sweep. Fail-safe: - // disabled gate / unreadable ISO / load failure all leave the pass as-is. - if let Some(mut v) = verifier.take() { - if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) { - // Only units whose every backing sector was actually READ - // (Finished) may be re-verified — we can't verify what wasn't read - // (a non-Finished sector is zero-filled because the read failed), - // and must not waste a key lookup on a known-bad block. - let finished = m.ranges_with(&[mapfile::SectorStatus::Finished]); - let is_finished = |lba: u32| -> bool { - let p = lba as u64 * 2048; - finished.iter().any(|&(s, sz)| p >= s && p < s + sz) - }; - if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) { - let bad = v.reverify_iso(&mut iso, &bad_ranges, &is_finished); - if !bad.is_empty() { - let n: usize = bad.len(); - for (lba, cnt) in bad { - if let Err(e) = m.record( - lba as u64 * 2048, - cnt as u64 * 2048, - mapfile::SectorStatus::NonTrimmed, - ) { - tracing::warn!( - lba, - "reverify downgrade: mapfile record failed ({e}) — unit may stay mismarked as good" - ); - } - } - if let Err(e) = m.flush() { - tracing::warn!( - "reverify downgrade: mapfile flush failed ({e}) — downgrade not persisted; a resume could mismark it good" - ); - } - // The re-verify ran AFTER `pipe.finish()` snapshotted - // `summary.stats`, so those stats still count the just- - // downgraded units as good. Refresh from the mapfile so - // `build_outcome` reports the true post-downgrade picture - // (bytes_good ↓, bytes_pending ↑) — otherwise the caller - // over-reports recovery and can call an imperfect rip - // "complete". - summary.stats = m.stats(); - tracing::info!( - target: "freemkv::verify", - phase = "patch.reverify", - downgraded_ranges = n, - "post-read re-verify downgraded undecryptable units to NonTrimmed" - ); - } - } - } - } + let summary = pipe.finish()?; let outcome = build_outcome( &state, diff --git a/src/disc/sweep.rs b/src/disc/sweep.rs index 1361329..0ac2d1c 100644 --- a/src/disc/sweep.rs +++ b/src/disc/sweep.rs @@ -66,14 +66,6 @@ pub(super) enum WorkItem { /// tell them apart without parsing a flag. GapFill { pos: u64, len: u64 }, - /// Post-read verify downgrade. The producer's `UnitVerifier` found that the - /// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable - /// (a silent bad read). The consumer re-records the range as `NonTrimmed` so - /// the patch pass re-reads it — the ISO bytes (ciphertext) already written by - /// the preceding `Good` are left in place for the patch to overwrite. FIFO - /// pipe ordering guarantees this arrives AFTER the `Good` that wrote them. - MarkBad { pos: u64, len: u64 }, - /// Producer wants the latest mapfile stats for the progress /// callback. Consumer responds on `prog_tx` with a fresh /// [`ProgressSnapshot`]. Best-effort: if the producer hasn't @@ -182,12 +174,6 @@ impl Sink for SweepSink { } self.map.record(pos, len, SectorStatus::NonTrimmed)?; } - WorkItem::MarkBad { pos, len } => { - // Verify downgrade: the ISO bytes are already written by the - // preceding Good; only the mapfile status changes so patch - // re-reads this range. No file write. - self.map.record(pos, len, SectorStatus::NonTrimmed)?; - } WorkItem::StatsRequest => { let stats = self.map.stats(); // DAMAGE only — NOT NonTried. NonTried is the unread remainder diff --git a/src/disc/verify.rs b/src/disc/verify.rs deleted file mode 100644 index 997f7ad..0000000 --- a/src/disc/verify.rs +++ /dev/null @@ -1,1085 +0,0 @@ -//! Universal post-read verify gate. -//! -//! `read() -> verify() -> sign-off`. A unit that fails verify is treated EXACTLY -//! like a bad read (the caller re-marks its disc range pending/lost). Reads are -//! disc-absolute, but the only alignment at which the AACS CPI flag and the -//! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit -//! grid (clips can start off the 6144 grid and fragment across UDF extents). So -//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into -//! clip-file units, then decrypts each with the SAME primitive the mux/rip path -//! uses ([`crate::aacs::content::decrypt_unit`] for TS) so verify and the real -//! decrypt can never disagree. That verdict is "did a key OPEN this unit?" — -//! defect-TOLERANT: an opened unit carrying a few authored-bad packets (pressing -//! defects / forensic-variant frames) is NOT bad; only a unit no key opens is. -//! -//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never -//! break a good read): the gate can ONLY downgrade a unit it is *confident* is -//! bad — a flagged-encrypted, fully-buffered, full-size unit that no held key -//! and no freshly-fetched key can decrypt to clean TS. EVERY other situation — -//! the [`POST_READ_VERIFY`] switch off, a non-AACS disc, no keys, an enumeration -//! failure, a partial tail unit, a unit whose key we simply lack (no key_fetch), -//! an evicted partial — SKIPS, leaving the read byte-for-byte as it is today. -//! -//! Bus encryption (AACS 2.x `read_data_key`) is deliberately NOT handled here: -//! it is a drive<->host transport layer stripped during drive auth. On the -//! unlocked drives this runs against it is off; if it were on and unhandled we -//! would fail at the read/auth stage long before reaching this gate, so by here -//! the bytes are content-layer only. - -use std::collections::{HashMap, VecDeque}; - -use crate::aacs::content::ALIGNED_UNIT_LEN; -use crate::aacs::{self}; -use crate::consts::SECTOR_BYTES_U64; -use crate::decrypt::DecryptKeys; -use crate::sector::KeyFetch; - -/// Master kill-switch for the post-read verify gate. Hardcoded `true`. Flip to -/// `false` and the gate is inert: [`UnitVerifier::new`] returns `None`, nothing -/// is ever buffered, verified, or downgraded, and rip behavior is byte-for-byte -/// what it is today. The single lever to pull the whole feature. -pub const POST_READ_VERIFY: bool = true; - -/// Cap on in-flight partial units. Sequential sweeps complete units almost -/// immediately, so partials only accumulate at damage-jump skips (whose sectors -/// never arrive). When the cap is hit the oldest partial is evicted and simply -/// goes unverified — fail-safe. Bounds memory at `MAX_INFLIGHT_UNITS * 6144`. -const MAX_INFLIGHT_UNITS: usize = 4096; // ~24 MiB ceiling - -/// Cap on key-fetch invocations across the verifier's life. A fetched key is -/// cached and reused for every later unit of the same CPS unit, so in practice -/// one fetch resolves all orphan units; the cap is a runaway backstop only. -const MAX_FETCH_CALLS: u32 = 8; - -/// The stream container of an AACS clip — selects the post-decrypt structural -/// check the verify gate applies. This is the extension seam: the AACS crypto is -/// container-agnostic, only the "is this clean?" check differs. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ContainerKind { - /// BD/UHD `.m2ts` / `.ssif` — MPEG-2 transport stream (all-32 TS syncs). - #[default] - Ts, - /// HD-DVD `.evo` — MPEG-2 program stream (pack-start `00 00 01 BA`). - /// NOT yet enabled by enumeration; present so adding HD-DVD is a one-mapping - /// change. See [`crate::aacs::content::unit_is_clean_ps`] for the (unvalidated) check. - Ps, -} - -/// PS-container per-unit decrypt for the verify gate — the analogue of -/// [`aacs::content::decrypt_unit`] (TS) for HD-DVD program-stream content. Wraps -/// [`aacs::content::decrypt_unit_checked`] with the pack-start structural check. -/// A free `fn` (not a closure) so it has the same `fn(&mut [u8], &[u8;16]) -> bool` -/// type as `decrypt_unit`, letting [`Verifier::decrypt_for`] return either. -fn decrypt_unit_ps(unit: &mut [u8], key: &[u8; 16]) -> bool { - aacs::content::decrypt_unit_checked(unit, key, aacs::content::unit_is_clean_ps) -} - -/// A clip's on-disc layout: declared file size, its absolute disc extents in -/// FILE order, and its stream container. `extents` is `(disc_lba, byte_len)`; -/// the verifier reuses exactly the same `(abs_lba, byte_len)` extents the -/// extractor enumerates. -#[derive(Debug, Clone)] -pub struct ClipLayout { - pub size: u64, - pub extents: Vec<(u32, u32)>, - pub container: ContainerKind, -} - -/// One extent placed in the (disc-LBA -> clip-file-offset) space, for routing an -/// incoming disc sector to the unit it backs. -#[derive(Debug, Clone)] -struct ExtentRec { - disc_lba: u32, - sectors: u32, - /// Byte offset within the clip FILE of this extent's first byte. - file_off: u64, - clip: u32, -} - -/// A unit being assembled from its (up to 3) backing disc sectors. -struct Partial { - buf: Box<[u8; ALIGNED_UNIT_LEN]>, - /// Bit `s` set once sector slot `s` (0..3) has been filled. - have: u8, - /// Disc LBA of each slot, for emitting the bad range if verify fails. - lba: [u32; 3], -} - -/// Whether a fully-assembled unit can be decrypted + verified — the single -/// answer the gate produces per unit (`is this decryptable?`, 3-state). -enum Decryptability { - /// Decrypts to strictly clean MPEG-TS (or is valid clear content). Keep good. - Decryptable, - /// Confidently does NOT decrypt — bad ciphertext or a bad read. The caller - /// downgrades this unit's disc range (decrypt-fail == bad read). - Undecryptable, - /// Can't tell — e.g. we may simply lack the key. Skip, leave the read as-is. - Unknown, -} - -/// The post-read verify gate. Built once per pass; fed the disc-absolute, -/// just-`Finished` byte ranges via [`observe`](Self::observe); emits the disc -/// ranges that are confidently undecryptable so the caller can mark them bad. -pub struct UnitVerifier { - /// Sorted by `disc_lba`; covers only AACS clip (`.m2ts`/`.ssif`) content. - extents: Vec, - /// Number of FULL (6144) units per clip; the partial tail unit is excluded. - full_units: Vec, - /// Stream container per clip — selects the post-decrypt structural check. - containers: Vec, - /// Content unit keys to try (resolved keys plus any fetched + cached). - keys: Vec<[u8; 16]>, - fetch: Option, - fetch_calls: u32, - fetch_spent: bool, - partials: HashMap<(u32, u32), Partial>, - lru: VecDeque<(u32, u32)>, -} - -impl UnitVerifier { - /// Build the gate, or `None` (verify disabled / nothing to verify) when: - /// the [`POST_READ_VERIFY`] switch is off, the disc is not AACS, there are no - /// keys to try and no fetch seam, or no AACS clip extents were enumerated. - /// Returning `None` is the fail-safe default — the caller then verifies - /// nothing and behaves exactly as today. - pub fn new(clips: &[ClipLayout], keys: &DecryptKeys, fetch: Option) -> Option { - if !POST_READ_VERIFY { - return None; - } - // Only AACS has the per-unit CPI flag + decrypt-verify this gate checks. - let DecryptKeys::Aacs { unit_keys, .. } = keys else { - return None; - }; - let held: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect(); - // With neither a held key nor a fetch seam there is nothing we could ever - // confidently reject, so disable rather than buffer for no reason. - if held.is_empty() && fetch.is_none() { - return None; - } - - let mut extents = Vec::new(); - let mut full_units = Vec::new(); - let mut containers = Vec::new(); - for (clip, layout) in clips.iter().enumerate() { - // Full units only; the partial tail (size not a multiple of 6144) is - // never verified (a < 6144 buffer can't satisfy the strict gate). - full_units.push((layout.size / ALIGNED_UNIT_LEN as u64) as u32); - containers.push(layout.container); - let mut file_off: u64 = 0; - for &(disc_lba, byte_len) in &layout.extents { - let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32; - if sectors > 0 { - extents.push(ExtentRec { - disc_lba, - sectors, - file_off, - clip: clip as u32, - }); - } - file_off = file_off.saturating_add(byte_len as u64); - } - } - if extents.is_empty() { - return None; - } - extents.sort_by_key(|e| e.disc_lba); - - Some(Self { - extents, - full_units, - containers, - keys: held, - fetch, - fetch_calls: 0, - fetch_spent: false, - partials: HashMap::new(), - lru: VecDeque::new(), - }) - } - - /// The post-decrypt structural check for a clip's container — used ONLY on the - /// CPI-clear branch (a plaintext unit that is structurally clean is - /// decryptable-as-is; if not, we return Unknown, never bad). - fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool { - match self.containers[clip as usize] { - ContainerKind::Ts => aacs::content::unit_is_clean_ts, - ContainerKind::Ps => aacs::content::unit_is_clean_ps, - } - } - - /// The per-unit DECRYPT for a clip's container — the key-selection primitive - /// verify uses to prove a candidate key opens an ENCRYPTED unit. It MUST be - /// the exact same primitive the mux/rip decrypt path uses, so verify and the - /// real decrypt never disagree (a unit the mux will happily decrypt must not - /// be marked bad here). For TS that is [`aacs::content::decrypt_unit`], whose - /// verdict is defect-TOLERANT (a supermajority of content packets restored) — - /// an authored-bad packet or forensic-variant frame no longer false-flags the - /// whole unit as undecryptable. - fn decrypt_for(&self, clip: u32) -> fn(&mut [u8], &[u8; 16]) -> bool { - match self.containers[clip as usize] { - ContainerKind::Ts => aacs::content::decrypt_unit, - ContainerKind::Ps => decrypt_unit_ps, - } - } - - /// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc - /// sector `disc_lba`). Routes each backing sector into its clip-file unit; - /// every unit that becomes fully assembled is verified immediately. Returns - /// the disc ranges `(lba, sector_count)` of units that are CONFIDENTLY bad - /// (empty when nothing failed). Never errors — a read is never broken here. - pub fn observe(&mut self, disc_lba: u32, bytes: &[u8]) -> Vec<(u32, u32)> { - let mut bad: Vec<(u32, u32)> = Vec::new(); - let sector = crate::consts::SECTOR_BYTES; - let n = bytes.len() / sector; - for s in 0..n { - let lba = disc_lba.saturating_add(s as u32); - let Some((clip, unit, slot)) = self.locate(lba) else { - continue; // not AACS clip content, or an unalignable boundary - }; - // Tail / partial units are never verified. - if unit >= self.full_units[clip as usize] { - continue; - } - let off = s * sector; - self.fill(clip, unit, slot, lba, &bytes[off..off + sector]); - if let Some((raw, lbas)) = self.take_if_complete(clip, unit) { - let accept = self.accept_for(clip); - let decrypt = self.decrypt_for(clip); - match self.decryptability(&raw, accept, decrypt) { - Decryptability::Undecryptable => push_ranges(&mut bad, &lbas), - Decryptability::Decryptable | Decryptability::Unknown => {} - } - } - } - bad - } - - /// Disc LBA -> (clip, unit index, sector slot 0..3), or `None` if the sector - /// is not AACS-clip content or sits at an unalignable (non-sector) file - /// offset (which we conservatively skip). - fn locate(&self, lba: u32) -> Option<(u32, u32, usize)> { - // Largest extent whose disc_lba <= lba. - let idx = self.extents.partition_point(|e| e.disc_lba <= lba); - if idx == 0 { - return None; - } - let e = &self.extents[idx - 1]; - let delta = lba - e.disc_lba; - if delta >= e.sectors { - return None; // past this extent, not covered by any clip - } - let file_off = e.file_off + delta as u64 * SECTOR_BYTES_U64; - // Guard pathological non-sector-aligned extent boundaries. - if file_off % SECTOR_BYTES_U64 != 0 { - return None; - } - let unit = (file_off / ALIGNED_UNIT_LEN as u64) as u32; - let in_unit = (file_off % ALIGNED_UNIT_LEN as u64) as usize; - let slot = in_unit / crate::consts::SECTOR_BYTES; - Some((e.clip, unit, slot)) - } - - fn fill(&mut self, clip: u32, unit: u32, slot: usize, lba: u32, sector_bytes: &[u8]) { - let key = (clip, unit); - let entry = self.partials.entry(key); - let fresh = matches!(entry, std::collections::hash_map::Entry::Vacant(_)); - let p = entry.or_insert_with(|| Partial { - buf: Box::new([0u8; ALIGNED_UNIT_LEN]), - have: 0, - lba: [u32::MAX; 3], - }); - let off = slot * crate::consts::SECTOR_BYTES; - p.buf[off..off + crate::consts::SECTOR_BYTES].copy_from_slice(sector_bytes); - p.have |= 1 << slot; - p.lba[slot] = lba; - if fresh { - self.lru.push_back(key); - self.evict_if_needed(); - } - } - - /// Remove and return the unit if all three sectors have arrived. - fn take_if_complete( - &mut self, - clip: u32, - unit: u32, - ) -> Option<([u8; ALIGNED_UNIT_LEN], [u32; 3])> { - let key = (clip, unit); - let complete = self - .partials - .get(&key) - .map(|p| p.have == 0b111) - .unwrap_or(false); - if !complete { - return None; - } - let p = self.partials.remove(&key)?; - if let Some(pos) = self.lru.iter().position(|k| *k == key) { - self.lru.remove(pos); - } - Some((*p.buf, p.lba)) - } - - fn evict_if_needed(&mut self) { - while self.partials.len() > MAX_INFLIGHT_UNITS { - // Oldest partial goes unverified (fail-safe) to bound memory. - if let Some(key) = self.lru.pop_front() { - self.partials.remove(&key); - } else { - break; - } - } - } - - /// Can this fully-assembled unit be decrypted + verified? `accept` is the - /// container's strict structural check ([`aacs::content::unit_is_clean_ts`] for TS, - /// [`aacs::content::unit_is_clean_ps`] for PS) — the only format-specific part; the - /// AACS crypto is container-agnostic. Returns the 3-state [`Decryptability`]. - fn decryptability( - &mut self, - raw: &[u8; ALIGNED_UNIT_LEN], - accept: fn(&[u8]) -> bool, - decrypt: fn(&mut [u8], &[u8; 16]) -> bool, - ) -> Decryptability { - // CPI clear -> the unit is plaintext by spec (no key needed). If it is - // structurally clean, it is decryptable-as-is. If it ISN'T, we - // DELIBERATELY return Unknown, not Undecryptable: with no key to - // crypto-prove anything, a clear-but-not-clean unit could be a genuine - // bad read OR a mis-aligned read OR legitimately-odd clear content (some - // menu/nav units). We refuse to assert "bad" without proof — only - // ENCRYPTED units that no key opens are ever flagged. (A real bad READ of - // clear content is still caught by the normal SCSI read-error path; this - // gate just won't false-flag it.) - if !aacs::content::aacs_unit_encrypted(raw) { - return if accept(raw) { - Decryptability::Decryptable - } else { - Decryptability::Unknown - }; - } - // Encrypted: any held key that decrypts to a structurally-clean unit. - if self.try_keys(raw, decrypt) { - return Decryptability::Decryptable; - } - // No held key works. Ask the application's key source ONCE for this - // ciphertext; a fetched key is cached for later units. Only if the - // service hands us key(s) that STILL don't open it is the unit - // confidently undecryptable. No seam / no new key -> we may just lack the - // key -> Unknown (skip), never a false-bad. - if !self.fetch_spent && self.fetch_calls < MAX_FETCH_CALLS { - if let Some(cb) = self.fetch.clone() { - self.fetch_calls += 1; - let fresh = cb(&[raw.to_vec()]); - let mut added = false; - for k in fresh { - if !self.keys.contains(&k) { - self.keys.push(k); - added = true; - } - } - if !added { - self.fetch_spent = true; // service has nothing new; stop asking - return Decryptability::Unknown; - } - if self.try_keys(raw, decrypt) { - return Decryptability::Decryptable; - } - return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext - } - } - Decryptability::Unknown - } - - /// True if any currently-held key OPENS `raw` — i.e. the container's per-unit - /// `decrypt` (the same primitive the mux uses) reports the key restored the - /// unit's TS structure. Defect-tolerant for TS: an opened unit carrying a few - /// authored-bad packets still counts as decryptable (never marked bad). - fn try_keys( - &self, - raw: &[u8; ALIGNED_UNIT_LEN], - decrypt: fn(&mut [u8], &[u8; 16]) -> bool, - ) -> bool { - for k in &self.keys { - let mut scratch = *raw; - if decrypt(&mut scratch, k) { - return true; - } - } - false - } - - /// Re-verify, from a 1:1 disc ISO image, every full clip unit that overlaps - /// `ranges` (disc BYTE ranges — e.g. the bad ranges a patch pass re-read). - /// Reads each unit's backing sectors from `iso` (ISO sector N == disc LBA N), - /// runs the same per-unit [`verdict`](Self::verdict), and returns the - /// confidently-bad disc ranges `(lba, count)` to re-mark. - /// - /// This is the PATCH counterpart to [`observe`](Self::observe): patch - /// re-reads only the bad sectors of a unit, so it can never complete a unit - /// from its live read stream (the unit's other sectors are already in the - /// ISO). Reading the whole unit back from the just-patched ISO is the only - /// alignment-correct way to re-check it. FAIL-SAFE: an ISO read error on any - /// of a unit's sectors skips that unit (no false-bad). - /// - /// CRITICAL: `is_finished(lba)` must report whether each disc sector was - /// actually READ (mapfile `Finished`). A unit with ANY non-Finished sector is - /// zero-filled there (the drive read failed) — we CANNOT verify what was - /// never read, so such a unit is skipped entirely. This both avoids asserting - /// "undecryptable" on unread data and avoids wasting a key lookup on a block - /// the read already knows is bad. - pub fn reverify_iso( - &mut self, - iso: &mut S, - ranges: &[(u64, u64)], - is_finished: &dyn Fn(u32) -> bool, - ) -> Vec<(u32, u32)> { - let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new(); - let mut bad: Vec<(u32, u32)> = Vec::new(); - for &(pos, len) in ranges { - if len == 0 { - continue; - } - let start = (pos / SECTOR_BYTES_U64) as u32; - let end = pos.saturating_add(len).div_ceil(SECTOR_BYTES_U64) as u32; - for lba in start..end { - let Some((clip, unit, _)) = self.locate(lba) else { - continue; - }; - if unit >= self.full_units[clip as usize] || !seen.insert((clip, unit)) { - continue; - } - let Some(lbas) = self.unit_disc_sectors(clip, unit) else { - continue; - }; - // We can only verify a unit whose EVERY backing sector was read. - // A non-Finished sector is zero-filled (the read failed there); - // verifying it would judge data we never read and waste a key - // lookup on a known-bad block. Skip the whole unit. - if !lbas.iter().all(|&l| is_finished(l)) { - continue; - } - let mut raw = [0u8; ALIGNED_UNIT_LEN]; - let mut readable = true; - for (slot, &slba) in lbas.iter().enumerate() { - let off = slot * crate::consts::SECTOR_BYTES; - if iso - .read_sectors( - slba, - 1, - &mut raw[off..off + crate::consts::SECTOR_BYTES], - false, - ) - .is_err() - { - readable = false; - break; - } - } - let accept = self.accept_for(clip); - let decrypt = self.decrypt_for(clip); - if readable - && matches!( - self.decryptability(&raw, accept, decrypt), - Decryptability::Undecryptable - ) - { - push_ranges(&mut bad, &lbas); - } - } - } - bad - } - - /// Disc LBAs backing the 3 sectors of full `unit` in `clip`, walking that - /// clip's extents in file order. `None` if any sector is not covered by an - /// extent (never happens for a full unit of an enumerated clip). - fn unit_disc_sectors(&self, clip: u32, unit: u32) -> Option<[u32; 3]> { - let base = unit as u64 * ALIGNED_UNIT_LEN as u64; - let mut out = [u32::MAX; 3]; - for (slot, item) in out.iter_mut().enumerate() { - let foff = base + slot as u64 * SECTOR_BYTES_U64; - let e = self.extents.iter().find(|e| { - e.clip == clip - && e.file_off <= foff - && foff < e.file_off + e.sectors as u64 * SECTOR_BYTES_U64 - })?; - *item = e.disc_lba + ((foff - e.file_off) / SECTOR_BYTES_U64) as u32; - } - Some(out) - } -} - -/// Append `lbas` (a unit's up-to-3 backing sectors) to `out` as `(lba, count)` -/// ranges, coalescing contiguous sectors. Unset slots (`u32::MAX`) are skipped. -fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) { - let mut present: Vec = lbas.iter().copied().filter(|&l| l != u32::MAX).collect(); - present.sort_unstable(); - for lba in present { - if let Some(last) = out.last_mut() { - // Saturating: LBAs come from disc-controlled ICB extents, so a - // corrupt disc must not panic here (matches `udf::merge_ranges`). - if last.0.saturating_add(last.1) == lba { - last.1 += 1; - continue; - } - } - out.push((lba, 1)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - const TS_SYNC: u8 = 0x47; - - /// A clear aligned unit: TS sync at offset 4 + k*192, CPI bits clear. - fn clear_unit() -> Vec { - let mut u = vec![0u8; ALIGNED_UNIT_LEN]; - let mut off = 4; - while off < ALIGNED_UNIT_LEN { - u[off] = TS_SYNC; - off += 192; - } - u - } - - /// A clear MPEG-2 PS aligned unit: pack-start `00 00 01 BA` at each 2048 - /// boundary, CPI bits clear (byte 0 == 0x00). - fn clear_ps_unit() -> Vec { - let mut u = vec![0u8; ALIGNED_UNIT_LEN]; - for o in [0usize, 2048, 4096] { - u[o..o + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); - } - u - } - - /// Encrypt a clear unit in place under `unit_key` (sets CPI, AES-CBC body) — - /// the exact inverse of `decrypt_unit`, so the right key restores clean TS. - fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { - use aes::Aes128; - use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; - unit[0] |= 0xC0; // CPI flag => reads as encrypted - let header: [u8; 16] = unit[..16].try_into().unwrap(); - let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header); - let mut k = [0u8; 16]; - for i in 0..16 { - k[i] = derived[i] ^ header[i]; - } - let cipher = Aes128::new(GenericArray::from_slice(&k)); - let mut prev = crate::aacs::crypto::AACS_IV; - for i in 0..(ALIGNED_UNIT_LEN - 16) / 16 { - let off = 16 + i * 16; - for j in 0..16 { - unit[off + j] ^= prev[j]; - } - let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]); - cipher.encrypt_block(&mut block); - unit[off..off + 16].copy_from_slice(&block); - prev.copy_from_slice(&unit[off..off + 16]); - } - } - - fn aacs_keys(keys: &[[u8; 16]]) -> DecryptKeys { - DecryptKeys::Aacs { - unit_keys: keys - .iter() - .enumerate() - .map(|(i, k)| (i as u32, *k)) - .collect(), - read_data_key: None, - } - } - - /// One contiguous full unit at disc LBA `lba` (size 6144 = 3 sectors). - fn one_clip(lba: u32) -> Vec { - vec![ts_clip( - ALIGNED_UNIT_LEN as u64, - vec![(lba, ALIGNED_UNIT_LEN as u32)], - )] - } - - /// Build a BD-TS `ClipLayout` (the only container current enumeration emits). - fn ts_clip(size: u64, extents: Vec<(u32, u32)>) -> ClipLayout { - ClipLayout { - size, - extents, - container: ContainerKind::Ts, - } - } - - // ── fail-safe: when the gate must NOT exist ──────────────────────────── - - #[test] - fn kill_switch_default_on() { - assert!(POST_READ_VERIFY, "shipping default: gate enabled"); - } - - #[test] - fn new_is_none_for_non_aacs() { - assert!(UnitVerifier::new(&one_clip(100), &DecryptKeys::None, None).is_none()); - assert!( - UnitVerifier::new( - &one_clip(100), - &DecryptKeys::Css { title_key: [0; 5] }, - None - ) - .is_none() - ); - } - - #[test] - fn new_is_none_with_no_keys_and_no_fetch() { - // Nothing could ever be confidently rejected -> don't even buffer. - assert!(UnitVerifier::new(&one_clip(100), &aacs_keys(&[]), None).is_none()); - } - - #[test] - fn new_is_none_with_no_extents() { - let clips = vec![ts_clip(0, vec![])]; - assert!(UnitVerifier::new(&clips, &aacs_keys(&[[1; 16]]), None).is_none()); - } - - // ── clear (CPI=0) content ────────────────────────────────────────────── - - #[test] - fn clear_clean_unit_is_good() { - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap(); - let bad = v.observe(100, &clear_unit()); - assert!(bad.is_empty(), "clean clear unit must not be flagged"); - } - - #[test] - fn clear_corrupted_unit_is_skipped_not_flagged() { - // A CPI-clear unit whose TS syncs don't check out is NOT flagged: with no - // key to crypto-prove anything we won't assert "bad" on clear content - // (could be a mis-aligned read or odd-but-valid clear/menu data). Only - // encrypted-won't-decrypt is ever a downgrade. A genuine bad READ is - // already caught by the SCSI read-error path; this gate must not - // false-flag it. - let mut u = clear_unit(); - u[4] = 0x00; // break the first packet's sync - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap(); - let bad = v.observe(100, &u); - assert!( - bad.is_empty(), - "clear-but-not-clean unit -> skip, never false-bad" - ); - } - - // ── encrypted (CPI set) content ──────────────────────────────────────── - - #[test] - fn encrypted_unit_held_key_decrypts_is_good() { - let key = [0x5a; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &key); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); - assert!(v.observe(100, &u).is_empty(), "right key -> good"); - } - - #[test] - fn encrypted_unit_wrong_key_no_fetch_is_uncertain_not_bad() { - // CRITICAL fail-safe: a unit we can't decrypt because we may simply LACK - // the key (no fetch seam) must be SKIPPED, never flagged bad. - let real = [0x11; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), None).unwrap(); - assert!( - v.observe(100, &u).is_empty(), - "missing key without a fetch seam must NOT be a false-bad" - ); - } - - #[test] - fn encrypted_unit_fetch_supplies_right_key_is_good() { - let real = [0x33; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let fetch: KeyFetch = Arc::new(move |_samples: &[Vec]| vec![real]); - let mut v = - UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x44; 16]]), Some(fetch)).unwrap(); - assert!( - v.observe(100, &u).is_empty(), - "fetched key recovers -> good" - ); - } - - #[test] - fn encrypted_unit_fetch_supplies_wrong_keys_is_bad() { - // The service handed us key(s) that still don't open it -> genuinely bad - // ciphertext, confidently flagged. - let real = [0x55; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let fetch: KeyFetch = Arc::new(move |_s: &[Vec]| vec![[0x99; 16], [0xAA; 16]]); - let mut v = - UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x66; 16]]), Some(fetch)).unwrap(); - assert_eq!( - v.observe(100, &u), - vec![(100, 3)], - "wrong fetched keys -> bad" - ); - } - - #[test] - fn encrypted_unit_fetch_supplies_nothing_is_uncertain() { - let real = [0x77; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let fetch: KeyFetch = Arc::new(|_s: &[Vec]| Vec::new()); - let mut v = - UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x88; 16]]), Some(fetch)).unwrap(); - assert!( - v.observe(100, &u).is_empty(), - "fetch returns nothing new -> uncertain -> skip" - ); - } - - #[test] - fn fetched_key_is_cached_for_later_units() { - // First orphan unit triggers one fetch; the second reuses the cached key - // with no further fetch call. - let real = [0xC3; 16]; - let calls = Arc::new(std::sync::atomic::AtomicU32::new(0)); - let c = calls.clone(); - let fetch: KeyFetch = Arc::new(move |_s: &[Vec]| { - c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - vec![real] - }); - let clips = vec![ts_clip( - 2 * ALIGNED_UNIT_LEN as u64, - vec![(200, 2 * ALIGNED_UNIT_LEN as u32)], - )]; - let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); - let mut u0 = clear_unit(); - encrypt_unit(&mut u0, &real); - let mut u1 = clear_unit(); - encrypt_unit(&mut u1, &real); - assert!(v.observe(200, &u0).is_empty()); - assert!(v.observe(203, &u1).is_empty()); - assert_eq!( - calls.load(std::sync::atomic::Ordering::SeqCst), - 1, - "second orphan unit reuses the cached fetched key (one fetch total)" - ); - } - - // ── alignment: fragmentation, tails, ordering, skips ─────────────────── - - #[test] - fn fragmented_unit_assembles_across_distant_extents() { - // Unit 0 spans extent A (sectors 0,1 at LBA 10) and extent B (sector 2 at - // disc-distant LBA 5000). It must still assemble and verify; a bad unit - // emits BOTH disc ranges. - let real = [0x42; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let clips = vec![ts_clip( - ALIGNED_UNIT_LEN as u64, - vec![(10, 4096), (5000, 2048)], - )]; - // Wrong key + a fetch that yields wrong keys => confident bad, fragmented. - let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); - let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); - // Feed the two extents in separate observe calls, distant order. - assert!( - v.observe(10, &u[..4096]).is_empty(), - "incomplete -> no verdict yet" - ); - let bad = v.observe(5000, &u[4096..]); - assert_eq!( - bad, - vec![(10, 2), (5000, 1)], - "fragmented bad unit -> both ranges" - ); - } - - #[test] - fn partial_tail_unit_is_never_verified() { - // Clip size 6144 + 2048: unit 0 is full, "unit 1" is a 2048 partial tail - // and must be skipped even if corrupt. - let key = [0x5a; 16]; - let mut u0 = clear_unit(); - encrypt_unit(&mut u0, &key); - let clips = vec![ts_clip( - ALIGNED_UNIT_LEN as u64 + 2048, - vec![(100, ALIGNED_UNIT_LEN as u32 + 2048)], - )]; - let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); - // Feed full unit 0 (good) + the tail sector (garbage). Only unit 0 is - // judged; the tail is never a verdict. - let mut feed = u0.clone(); - feed.extend_from_slice(&[0xABu8; 2048]); // tail sector, not a full unit - assert!( - v.observe(100, &feed).is_empty(), - "tail partial never flagged" - ); - } - - #[test] - fn incomplete_unit_from_skip_is_never_flagged() { - // Damage-jump: only 2 of 3 sectors of a unit ever arrive. The unit never - // completes, so it is never verified (its sectors are already pending). - let real = [0x11; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); - let mut v = - UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); - // Only sectors 0 and 1 (skip sector 2). - assert!( - v.observe(100, &u[..4096]).is_empty(), - "incomplete unit -> no verdict" - ); - } - - #[test] - fn sectors_split_across_observe_calls_still_complete() { - let key = [0x5a; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &key); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); - assert!(v.observe(100, &u[..2048]).is_empty()); // sector 0 - assert!(v.observe(101, &u[2048..4096]).is_empty()); // sector 1 - assert!(v.observe(102, &u[4096..]).is_empty()); // sector 2 -> completes, good - } - - #[test] - fn sector_outside_any_clip_is_ignored() { - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap(); - // LBA 50 is before the clip at 100 -> not routed, no panic, no verdict. - assert!(v.observe(50, &[0u8; 2048]).is_empty()); - // LBA 200 is past the clip's 3 sectors -> ignored. - assert!(v.observe(200, &[0u8; 2048]).is_empty()); - } - - // ── reverify_iso (patch path: read whole units back from the ISO) ────── - - /// In-memory 1:1 ISO (sector N == disc LBA N). Unset sectors read as zeros; - /// `err_lba` forces a read error to exercise the fail-safe skip. - struct MockIso { - sectors: std::collections::HashMap, - err_lba: Option, - } - impl crate::sector::SectorSource for MockIso { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::Result { - for i in 0..count as u32 { - if self.err_lba == Some(lba + i) { - return Err(crate::error::Error::DiscRead { - sector: (lba + i) as u64, - status: None, - sense: None, - }); - } - let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]); - let off = i as usize * 2048; - buf[off..off + 2048].copy_from_slice(&s); - } - Ok(count as usize * 2048) - } - } - - /// Place a 6144-byte unit's 3 sectors at disc LBAs `lbas` in the mock ISO. - fn place_unit(iso: &mut MockIso, lbas: [u32; 3], unit: &[u8]) { - for (slot, &lba) in lbas.iter().enumerate() { - let mut s = [0u8; 2048]; - s.copy_from_slice(&unit[slot * 2048..slot * 2048 + 2048]); - iso.sectors.insert(lba, s); - } - } - - #[test] - fn reverify_iso_good_unit_returns_empty() { - let key = [0x5a; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &key); - let mut iso = MockIso { - sectors: Default::default(), - err_lba: None, - }; - place_unit(&mut iso, [100, 101, 102], &u); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); - let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true); - assert!(bad.is_empty(), "decryptable unit re-read clean -> not bad"); - } - - #[test] - fn reverify_iso_bad_unit_returns_its_range() { - let real = [0x11; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let mut iso = MockIso { - sectors: Default::default(), - err_lba: None, - }; - place_unit(&mut iso, [100, 101, 102], &u); - // Wrong held key + a fetch that yields a wrong key => confident bad. - let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); - let mut v = - UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); - // A range covering only ONE sector of the unit still re-reads the WHOLE - // unit from the ISO (patch re-reads partial units). - let bad = v.reverify_iso(&mut iso, &[(101 * 2048, 2048)], &|_| true); - assert_eq!( - bad, - vec![(100, 3)], - "undecryptable unit -> full 3-sector range" - ); - } - - #[test] - fn reverify_iso_defect_packet_unit_is_not_flagged_bad() { - // A unit with ONE authored-bad content packet (a pressing defect). The - // held key OPENS it (31/32 syncs restored), so verify must AGREE with the - // mux decrypt and NOT mark it bad — the bad packet is the muxer's problem, - // not a read/decrypt failure. (Old strict all-32 gate flagged it bad.) - let key = [0x5a; 16]; - let mut u = clear_unit(); - let off = 17 * 192; // corrupt packet 17's sync in the plaintext - u[off + 4] = 0x80; - u[off + 5] = 0xAB; - encrypt_unit(&mut u, &key); - let mut iso = MockIso { - sectors: Default::default(), - err_lba: None, - }; - place_unit(&mut iso, [100, 101, 102], &u); - let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); - let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true); - assert!( - bad.is_empty(), - "defect-packet unit is opened by the held key -> not flagged bad" - ); - } - - #[test] - fn reverify_iso_fragmented_unit_reads_distant_sectors() { - let key = [0x5a; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &key); - // Unit 0: sectors at 10, 11 (extent A) and 5000 (extent B). - let clips = vec![ts_clip( - ALIGNED_UNIT_LEN as u64, - vec![(10, 4096), (5000, 2048)], - )]; - let mut iso = MockIso { - sectors: Default::default(), - err_lba: None, - }; - place_unit(&mut iso, [10, 11, 5000], &u); - let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); - // Range touches only the distant fragment; whole unit still assembled. - let bad = v.reverify_iso(&mut iso, &[(5000 * 2048, 2048)], &|_| true); - assert!(bad.is_empty(), "fragmented decryptable unit re-read clean"); - } - - #[test] - fn reverify_iso_unreadable_sector_skips_unit() { - let real = [0x11; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let mut iso = MockIso { - sectors: Default::default(), - err_lba: Some(102), // 3rd sector unreadable - }; - place_unit(&mut iso, [100, 101, 102], &u); - let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); - let mut v = - UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); - let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true); - assert!( - bad.is_empty(), - "ISO read error on a sector -> skip (fail-safe)" - ); - } - - #[test] - fn reverify_iso_skips_unit_with_unread_sector_and_never_fetches() { - // Sector 102 was NOT read (Unreadable -> zero-filled in the ISO). Even - // though the partly-zero unit wouldn't decrypt, we CANNOT verify what - // wasn't read: the unit is skipped, and crucially NO key lookup is made - // on a block the read already knows is bad. - let real = [0x11; 16]; - let mut u = clear_unit(); - encrypt_unit(&mut u, &real); - let mut iso = MockIso { - sectors: Default::default(), - err_lba: None, - }; - place_unit(&mut iso, [100, 101, 102], &u); - let fetch: KeyFetch = - Arc::new(|_s: &[Vec]| panic!("must NOT key-fetch an unread unit")); - let mut v = - UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); - // 102 not Finished -> the whole unit is skipped. - let is_finished = |lba: u32| lba != 102; - let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &is_finished); - assert!( - bad.is_empty(), - "unit with an unread sector is skipped, not flagged or fetched" - ); - } - - #[test] - fn ps_container_routes_through_pack_check() { - // A clip declared HD-DVD PS. The verifier must dispatch the structural - // check to unit_is_clean_ps (pack starts), not unit_is_clean_ts. - let clips = vec![ClipLayout { - size: ALIGNED_UNIT_LEN as u64, - extents: vec![(100, ALIGNED_UNIT_LEN as u32)], - container: ContainerKind::Ps, - }]; - // A clear, valid PS unit passes the PS pack-start check -> not flagged. - let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[1; 16]]), None).unwrap(); - assert!( - v.observe(100, &clear_ps_unit()).is_empty(), - "valid PS unit passes unit_is_clean_ps" - ); - // An encrypted unit (CPI set) whose decrypt never yields PS pack-starts, - // with a fetch returning a non-working key -> confidently Undecryptable - // through decrypt_unit_checked(.., unit_is_clean_ps). Exercises the Ps - // path end-to-end (and constructs ContainerKind::Ps so it isn't dead). - let mut enc = clear_ps_unit(); - enc[0] |= 0xC0; // CPI set; body is garbage to any key - let fetch: KeyFetch = Arc::new(|_s: &[Vec]| vec![[0xEE; 16]]); - let mut v2 = UnitVerifier::new(&clips, &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); - assert_eq!( - v2.observe(100, &enc), - vec![(100, 3)], - "encrypted PS unit no key opens -> bad via the PS check" - ); - } - - #[test] - fn eviction_bounds_inflight_partials() { - // Open more partials than the cap with single-sector feeds; the map must - // never exceed the cap (oldest evicted, unverified — fail-safe). - let mut v = UnitVerifier::new( - &vec![ts_clip( - (MAX_INFLIGHT_UNITS as u64 + 100) * ALIGNED_UNIT_LEN as u64, - vec![(0, u32::MAX / 2)], - )], - &aacs_keys(&[[1; 16]]), - None, - ) - .unwrap(); - // Feed only slot 0 of many distinct units (every 3rd sector). - for unit in 0..(MAX_INFLIGHT_UNITS as u32 + 50) { - let lba = unit * 3; - let _ = v.observe(lba, &[0u8; 2048]); - } - assert!( - v.partials.len() <= MAX_INFLIGHT_UNITS, - "in-flight partials bounded by the cap" - ); - } -} diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index 2fcea81..dac16aa 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -78,8 +78,8 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64}; /// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page /// cache stays bounded the same way the write side does. /// -/// 32 MiB is the empirically tuned value on the rip1 test bed (single -/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the +/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA: +/// smaller windows (8 / 16 MiB) shorten the /// kernel-readahead overlap and slow the producer; larger windows /// (64 / 128 MiB) let the page cache pin enough of the ISO to /// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`. diff --git a/src/keysource.rs b/src/keysource.rs index faef8ca..dd09636 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -407,7 +407,7 @@ pub fn read_encrypted_units( break; } let u = &buf[o..o + ALIGNED_UNIT_LEN]; - if aacs_unit_encrypted(u) { + if aacs_unit_encrypted(u, title.content_format) { out.push(u.to_vec()); if out.len() >= n { return out; @@ -717,7 +717,7 @@ mod tests { ); for s in &samples { assert!( - aacs_unit_encrypted(s), + aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs), "every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)" ); } @@ -800,7 +800,7 @@ mod tests { ); for s in &samples { assert!( - aacs_unit_encrypted(s), + aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs), "only CPI-flagged units are selected" ); assert_eq!( diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 42cb9e5..7a301f5 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -241,11 +241,23 @@ impl EsWriter for AnnexBWriter { /// Delegates to the canonical hvcC/avcC → Annex-B converters in /// [`crate::mux::hevc`] — the single source of truth across all muxers. fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec { - match codec { - Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(), - Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(), - _ => Vec::new(), - } + let converted = match codec { + Codec::Hevc => hvcc_to_annex_b(record), + Codec::H264 => avcc_to_annex_b(record), + _ => return Vec::new(), + }; + converted.unwrap_or_else(|| { + // A malformed hvcC/avcC record yields no parameter sets. Returning empty + // means keyframes ship WITHOUT in-band SPS/PPS — playable from the first + // keyframe but broken for seek-to-arbitrary-point and hardware decoders. + // Surface it rather than silently degrading the output. + tracing::warn!( + target: "mux", + ?codec, + "codec-private (hvcC/avcC) parse failed; keyframes will lack in-band SPS/PPS" + ); + Vec::new() + }) } /// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped. diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index 29d8aec..557c3c2 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -7,7 +7,7 @@ //! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt //! already runs on a producer thread; the *consumer* (main) thread //! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec -//! parsers. Profiling on the rip1 testbed showed feed at ~37 % and +//! parsers. Profiling showed feed at ~37 % and //! codec parse at ~44 % of consumer wall time — i.e. feed is heavy //! enough that pipelining it with parse pays for itself. //! diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 63ecceb..5f79b8f 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -108,11 +108,6 @@ pub struct DiscStream { /// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None` /// (raw / unencrypted disc) makes the decorator a pass-through. reader: DecryptingSectorSource>, - /// Shared decrypt-loss counter, cloned once at construction from - /// `reader.decrypt_loss()`. `lost_bytes()` loads it directly so the - /// per-frame hot path performs no per-call `Arc::clone` (matching the - /// `PipelinedPesStream` pattern). - decrypt_loss: std::sync::Arc, title: DiscTitle, /// Mirror of the keys handed in at construction. The decorator /// owns the cryptographic state; this field is kept for @@ -242,8 +237,7 @@ impl DiscStream { // concern, never conceal / re-fetch / count as loss (fail loud only on a // genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive // single-pass / direct), never the ciphertext-preserving sweep. - let mut reader = - DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss(); + let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone()); // Wrong-substream fix (Silence-of-the-Lambs): re-route the title's // declared AC-3 audio onto the physically-correct `0x8x` sub-streams by @@ -294,9 +288,6 @@ impl DiscStream { // the decorator is a pass-through). Reset the unit base the probe read // advanced so the first fill_extents read starts cleanly. reader.set_unit_base(0); - // Clone the shared loss counter once here so `lost_bytes()` never - // clones an Arc per frame on the mux hot path. - let decrypt_loss = reader.decrypt_loss(); // B1 resync gates: one per stream, video flagged so the gate only // drop-to-keyframes video (audio/subtitle always admit). Computed before @@ -312,7 +303,6 @@ impl DiscStream { Self { reader, - decrypt_loss, title, decrypt_keys, unit_align, @@ -1004,14 +994,12 @@ impl crate::pes::Stream for DiscStream { } fn lost_bytes(&self) -> u64 { - // Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time - // loss — bytes of scrambled AACS units the decorator could not decrypt - // and passed through still encrypted (the TS assembler silently drops - // them). Both are real missing content the abort gate must see; without - // the decrypt term a partial key failure reports lost_bytes=0 and a rip - // missing segments passes even under abort_on_lost_secs=0. + // Read-error zero-fill loss (counted in fill_extents) — real missing + // content the abort gate must see. There is no decrypt-loss term: the + // decrypt path passes bad-encoded/undecryptable units through (a broken-TS + // unit is the muxer's concern, and a missing key is indistinguishable from + // bad authoring here), so only physical read loss is reported. self.lost_bytes - .saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed)) } } @@ -1490,6 +1478,7 @@ mod tests { let keys = crate::decrypt::DecryptKeys::Aacs { unit_keys: vec![(0, [0u8; 16])], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs); stream.skip_errors = true; diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 5d0b395..6d2d2af 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -785,23 +785,35 @@ fn block_ts(is_video: bool, prev: Option, pts_ticks: i64) -> i64 { /// Encode a Matroska track number as an EBML VINT into a stack buffer, /// returning the buffer and the used length. Track numbers are small (1-based, -/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest; -/// no heap allocation, called once per block on the mux hot path. +/// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`, +/// and 3 bytes covers `< 0x20_0000`; no heap allocation, called once per block +/// on the mux hot path. /// -/// The 2-byte form holds 14 payload bits (max 0x3FFF). The `debug_assert` -/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and -/// OR-ing the 0x40 length marker would clobber it, corrupting the track -/// number. Not reachable today (track numbers are `i+1` over a few streams), -/// so this documents the bound rather than handling 3-byte VINTs. -fn track_vint(track_num: usize) -> ([u8; 2], usize) { +/// Each width uses a marker bit that must NOT collide with the payload's top +/// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40 +/// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all +/// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range +/// track number can never silently clobber the marker bit and corrupt the +/// block. Real discs never approach even the 2-byte range; the 21-bit ceiling +/// is an absurd upper bound kept as a `debug_assert`. +fn track_vint(track_num: usize) -> ([u8; 3], usize) { if track_num < 0x80 { - ([(track_num as u8) | 0x80, 0], 1) + ([(track_num as u8) | 0x80, 0, 0], 1) + } else if track_num < 0x4000 { + ([0x40 | ((track_num >> 8) as u8), track_num as u8, 0], 2) } else { debug_assert!( - track_num < 0x4000, - "track number {track_num} exceeds the 14-bit 2-byte EBML VINT range" + track_num < 0x20_0000, + "track number {track_num} exceeds the 21-bit 3-byte EBML VINT range" ); - ([0x40 | ((track_num >> 8) as u8), track_num as u8], 2) + ( + [ + 0x20 | ((track_num >> 16) as u8), + (track_num >> 8) as u8, + track_num as u8, + ], + 3, + ) } } @@ -3507,7 +3519,7 @@ mod tests { } #[test] - fn track_vint_encodes_one_and_two_byte_forms() { + fn track_vint_encodes_one_two_and_three_byte_forms() { // 1-byte form for track numbers < 0x80, high bit set. let (b, n) = track_vint(1); assert_eq!(&b[..n], &[0x81]); @@ -3518,6 +3530,12 @@ mod tests { assert_eq!(&b[..n], &[0x40, 0x80]); let (b, n) = track_vint(0x3FFF); assert_eq!(&b[..n], &[0x7F, 0xFF]); + // 3-byte form at/above 0x4000, 0x20 length marker in the top byte — + // handled in RELEASE (no silent marker-bit clobber), not just debug. + let (b, n) = track_vint(0x4000); + assert_eq!(&b[..n], &[0x20, 0x40, 0x00]); + let (b, n) = track_vint(0x1F_FFFF); + assert_eq!(&b[..n], &[0x3F, 0xFF, 0xFF]); } // ============================================================ diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index 03cb573..6eec17e 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -1016,7 +1016,11 @@ fn parse_track( arem = arem.saturating_sub(ahlen as u64 + as_); match aid { ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?, - ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8, + // Clamp instead of `as u8`: a foreign/corrupt MKV with a + // CHANNELS value that is a multiple of 256 would truncate to + // 0 (an invalid channel count) on a bare cast. Saturate to + // u8::MAX so an absurd count degrades to "many", never to 0. + ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8, _ => { skip_bytes(r, as_)?; } diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 5c069cf..d1a1809 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -56,14 +56,6 @@ pub struct PipelinedPesStream { /// `std::env::var_os` takes a process-wide lock, so the per-batch / /// per-poll reads it replaces were needless hot-path overhead. skip_parse: bool, - /// Cumulative bytes of scrambled AACS units the producer's decrypt step - /// could not decrypt — silent decrypt loss the demux drops without a sync. - /// Shared with the producer thread's [`DecryptingSectorSource`] - /// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through - /// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial - /// decrypt failure instead of reporting a perfect rip. `None` for pipelines - /// with no AACS decrypt step (e.g. the M2TS byte-stream path). - decrypt_loss: Option>, /// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These /// are expected on every disc; instead of a per-packet WARN they're tallied /// and summarised once at EOF. @@ -134,7 +126,6 @@ impl PipelinedPesStream { pending_frames: std::collections::VecDeque::new(), eof: false, skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(), - decrypt_loss: None, dropped_nav_packets: 0, resync, is_video, @@ -142,20 +133,6 @@ impl PipelinedPesStream { } } - /// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`] - /// reports bytes of scrambled AACS units that could not be decrypted (and - /// were therefore silently dropped downstream). Obtained from the - /// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved - /// into the prefetch thread. The M2TS / no-decrypt pipelines leave this - /// unset. - pub(crate) fn with_decrypt_loss( - mut self, - loss: std::sync::Arc, - ) -> Self { - self.decrypt_loss = Some(loss); - self - } - /// Pull one batch of `PesPacket`s from the demux thread, run /// codec parse on each, enqueue resulting `PesFrame`s on /// `pending_frames`. Returns Ok(true) on success, Ok(false) on @@ -464,17 +441,9 @@ impl Stream for PipelinedPesStream { .and_then(|(_, parser)| parser.codec_private()) } - fn lost_bytes(&self) -> u64 { - // The file-backed highway has no read-error zero-fill term (resolve - // tracks read loss separately), but the producer's decrypt step can - // pass scrambled units through undecrypted — silent loss the demux - // drops. Surface that so the mux abort gate sees a partial AACS/CSS - // decrypt failure rather than reporting a perfect rip. - self.decrypt_loss - .as_ref() - .map(|c| c.load(std::sync::atomic::Ordering::Relaxed)) - .unwrap_or(0) - } + // `lost_bytes` uses the trait default (0): the file-backed highway has no + // read-error zero-fill term (resolve/mapfile tracks physical read loss + // separately) and the decrypt path no longer reports a decrypt-loss term. } #[cfg(test)] diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 26edd6c..8d36ef7 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -391,11 +391,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result DemuxState { /// - `fetch`: optional fresh-key-on-failure callback (see /// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the /// decrypt decorator hands that ciphertext to `fetch` and adds any key it -/// returns. `None` keeps the prior behaviour (the unit is counted as loss). +/// returns, then re-decrypts. `None` means no mid-stream key recovery — the +/// unit's best-effort bytes pass through to the muxer as-is. // Eight reader/title/keys/tuning/callback params is inherent to the mux entry // point; grouping them into a struct would only move the same fields around. #[allow(clippy::too_many_arguments)] @@ -647,19 +648,25 @@ pub fn build_iso_pipeline( }; // MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and // passes the bytes to the muxer; a unit that decrypts to broken TS is the - // muxer's problem, not a decrypt failure, so the mux never conceals, re-fetches - // a key, or counts it as loss — it fails only when it genuinely can't decrypt - // (no key / misaligned unit). The `fetch` key-recovery seam is a rip/verify - // concern (Disc::sweep / Disc::patch), deliberately NOT installed on the mux: - // key recovery happens up front, and the mux never re-asks mid-stream. + // muxer's problem, not a decrypt failure, so the mux never conceals a unit or + // counts it as loss. let mut decrypting = - crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys) - .tolerate_decrypt_loss(); - let _ = &fetch; // rip/verify key-recovery seam; the mux does not consume it - // Loss counter: the mux does not tally broken-TS units (the muxer handles them), - // so for a keyed disc this stays 0; it still surfaces via `lost_bytes()` for the - // abort gate, which now reflects only a genuine can't-decrypt. - let decrypt_loss = decrypting.decrypt_loss(); + crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys); + // Install the fresh-key-on-failure callback (if the app supplied one). This is + // how multi-CPS is muxed: each CPS unit's key is fetched when the mux reaches a + // unit no held key opens — "get the key when we need it." It fires only on a + // genuine miss: now that key selection is accurate (`is_clean_ts`), a unit that + // decrypted correctly but has bad-encoded TS is NOT a miss, so this no longer + // storms the key source the way the old TS supermajority gate did. + if let Some(cb) = fetch { + decrypting = decrypting.with_key_fetch(cb); + } + // Loss-counter handle. The mux does NOT tally decrypt-quality misses: a + // broken-TS unit is the muxer's concern, and a missing key is an up-front + // resolve failure — indistinguishable from bad authoring at this seam, so + // counting it would false-abort a bad-encoded-but-decryptable disc. A genuine + // can't-decrypt surfaces as `Err`; `lost_bytes()` reflects physical read loss + // only (there is no decrypt-loss term to fold in). // Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes // the reader, probe the feature head through the (plaintext) decrypting @@ -685,10 +692,13 @@ pub fn build_iso_pipeline( let (demux_thread, demux_rx) = super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps) .map_err(|e| -> io::Error { e.into() })?; - Ok( - PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track) - .with_decrypt_loss(decrypt_loss), - ) + Ok(PipelinedPesStream::new( + demux_thread, + demux_rx, + title, + parsers, + pid_to_track, + )) } /// Assemble the M2TS file mux pipeline (read → demux → parse) for a diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 91d4379..79993a2 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -12,9 +12,11 @@ use crate::consts::TS_PACKET_BYTES; /// TS sync byte. const SYNC_BYTE: u8 = 0x47; -/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3 -/// concealment fill emits null packets on this PID, tagged with an -/// adaptation-field discontinuity_indicator to signal a concealed gap. +/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer +/// still recognises a `0x1FFF` packet with an adaptation-field +/// discontinuity_indicator as a concealed-gap loss signal, but the in-tree WRITER +/// that emitted these (the removed NULL-TS concealment fill) is gone — the mux no +/// longer conceals; only externally-authored markers reach this path now. const NULL_PID: u16 = 0x1FFF; /// A reassembled PES packet with timestamp info. @@ -34,8 +36,10 @@ pub struct PesPacket { pub source: Option, /// True when one or more packets for this stream were lost before this PES — /// a continuity break (CC gap or adaptation-field discontinuity_indicator) on - /// a tracked PID, or the CC-independent concealment marker the mux emits when - /// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is + /// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE: + /// the mux no longer emits such markers (the concealment writer was removed); + /// this now flags only real discontinuities and externally-authored markers. + /// This PES is /// the FIRST whose data is entirely after the gap: a mid-frame loss drops the /// truncated partial and flags the next complete PES; a loss landing on a PES /// boundary flags the PES STARTING after it (never the one just flushed). So @@ -200,7 +204,7 @@ impl PesAssembler { /// BD Transport Stream demuxer. pub struct TsDemuxer { assemblers: Vec, - pid_index: Vec, // PID → index into assemblers, -1 = not tracked + pid_index: Vec, // PID → index into assemblers, -1 = not tracked remainder: Vec, // leftover bytes from previous feed() call /// Absolute source byte offset of the NEXT byte to be fed — the running /// base that turns an in-buffer packet offset into a source position. @@ -224,20 +228,17 @@ impl TsDemuxer { /// limits. Empty `pids` yields max_pid 0; the floor still produces a /// valid (wholly-unused) table. pub fn new(pids: &[u16]) -> Self { - // The PID→assembler index is stored as i16 (-1 = untracked), so a - // 32768th+ tracked PID would truncate to a negative value and be - // silently treated as untracked. Callers pass a handful of PIDs - // (BD-TS has at most ~8192), so this is a programmer-error guard. - debug_assert!( - pids.len() <= i16::MAX as usize, - "TsDemuxer: too many PIDs for an i16 index table" - ); + // The PID→assembler index is stored as i32 (-1 = untracked). PIDs are + // u16 (≤ 65535) and the assembler index `i` is bounded by the number of + // distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can + // never truncate to a negative value and be mis-read as untracked — + // unlike an i16 table, this is safe in RELEASE, not just under debug. let max_pid = pids.iter().copied().max().unwrap_or(0) as usize; let table_size = (max_pid + 1).max(8192); - let mut pid_index = vec![-1i16; table_size]; + let mut pid_index = vec![-1i32; table_size]; let mut assemblers = Vec::with_capacity(pids.len()); for (i, &pid) in pids.iter().enumerate() { - pid_index[pid as usize] = i as i16; + pid_index[pid as usize] = i as i32; assemblers.push(PesAssembler::new(pid)); } Self { @@ -368,10 +369,13 @@ impl TsDemuxer { let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator let adaptation = (ts[3] >> 4) & 0x03; - // P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable - // aligned unit with NULL-TS packets (PID 0x1FFF) that carry an - // adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`). - // This is the authoritative loss signal — unlike a tracked PID's 4-bit + // P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an + // adaptation-field discontinuity_indicator. NOTE: the in-tree writer that + // laid these down on an undecryptable unit was removed with the pure-decrypt + // passthrough change (the mux no longer conceals), so this recognition now + // only fires on externally-authored markers — a candidate for removal with + // the rest of the retired concealment path. + // As a loss signal it is CC-INDEPENDENT — unlike a tracked PID's 4-bit // continuity_counter it is CC-INDEPENDENT, so it survives a loss that is // an exact multiple of 16 packets and a loss at the very start of a PID // (no prior CC to diff against). The decrypt layer cannot know which @@ -1057,7 +1061,7 @@ mod tests { /// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF /// null packet carrying the adaptation-field discontinuity_indicator (the byte - /// shape `fill_null_ts_unit` writes for every packet of a concealed unit). + /// shape of a concealed-unit packet). fn null_marker_packet() -> Vec { let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; pkt[4] = SYNC_BYTE; // 0x47 diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index b190e1e..d0d73c1 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -16,7 +16,6 @@ use crate::decrypt::{DecryptKeys, decrypt_sectors, decrypt_sectors_in_content}; use crate::error::Result; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use super::SectorSource; @@ -38,34 +37,6 @@ use super::SectorSource; /// so it can ride the mux highway's producer thread. pub type KeyFetch = std::sync::Arc]) -> Vec<[u8; 16]> + Send + Sync>; -/// Cap on how many per-unit decrypt-verify-failure diagnostics one read emits. -/// The diagnostic runs only on the failure (cold) path and bounds log volume so -/// a large undecryptable range can't flood the device log; the first few units -/// of any failed read fully characterise it (all-zero vs ciphertext, latency, -/// best-key-fit). -const MAX_DIAG_UNITS_PER_READ: usize = 4; - -/// Master switch: "a read is not successful unless it also DECRYPTS." -/// -/// When `true`, a read that returns scrambled AACS units which NO held key -/// (after any fetch) could decrypt fails loud with [`Error::DecryptFailed`] -/// instead of silently passing the still-encrypted bytes downstream. This turns -/// an undecryptable unit into a *read failure*, so: -/// * the rip's existing read-error recovery (sweep skip-ahead → patch -/// re-read) re-reads it off the disc while the disc is still present, and -/// * the mux path hard-fails (there is no clean data to mux) rather than -/// dropping content without a TS sync and reporting a clean rip. -/// -/// All-zero (zero-filled) units are NOT `ts_sync_destroyed`, so they never trip -/// this — allowed-loss zero-fill that some authoring deliberately leaves stays -/// allowed (logged loud + continue elsewhere). Only the keyless raw sweep is -/// unaffected: it carries [`DecryptKeys::None`], so the `Aacs` guard below is -/// never met there and ciphertext is written verbatim. -/// -/// Hardcoded `true`. Flip to `false` to ship without the behaviour — the unit -/// is then counted as decrypt loss exactly as before (the prior contract). -pub const DECRYPT_VERIFY_READ: bool = true; - /// Decorator: read from `inner`, then run the configured /// AACS / CSS decrypt over the bytes that landed in `buf`. /// @@ -87,31 +58,12 @@ pub struct DecryptingSectorSource { /// /// [`set_unit_base`]: Self::set_unit_base unit_base: u32, - /// Cumulative bytes of AACS units that did not reassemble to clean TS on the - /// VERIFY paths (sweep / patch / --no-raw verify). `decrypt_sectors` is a pure - /// decrypt (it leaves the applied-key plaintext and never restores/nulls), so - /// this counter is populated only on the fail-loud verify path — the mux path - /// returns pass-through before tallying, treating broken TS as a muxer concern - /// rather than decrypt loss. Where it is populated it feeds [`decrypt_loss`] - /// so a partial verify failure can't be reported as a perfect rip. Shared `Arc` - /// so the highway's producer thread and the consuming `Stream` see one tally. - /// - /// [`decrypt_loss`]: Self::decrypt_loss - decrypt_dropped: Arc, /// The miss policy (see [`crate::sector::recovery::Recover`]) — a generic, /// scheme-neutral recovery the input stream (L3) installs and this decorator /// (L2) executes at the one seam when a content unit will not decrypt. `None` /// = no recovery (a miss is loss). Installed via /// [`with_key_fetch`](Self::with_key_fetch). recovery: Option, - /// Verify-only mode: a read decrypt-CHECKS a scratch copy of the bytes (to - /// detect undecryptable units) but NEVER mutates `buf` — the inner - /// ciphertext is returned unchanged. This is what makes a multipass sweep - /// decrypt-aware: the sweep must write the *encrypted* bytes to the ISO, yet - /// a unit that won't decrypt must still fail the read (`DECRYPT_VERIFY_READ`) - /// so the existing read-error recovery (skip / NonTrimmed / patch) handles - /// it. Default `false` (decrypt in place, the mux / `--no-raw` path). - verify_only: bool, /// Encrypted-content extent map — the disc's m2ts ranges as sorted/merged /// `(start_lba, sector_count)` (see /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). @@ -121,23 +73,12 @@ pub struct DecryptingSectorSource { /// reads encrypted content" (the mux reads title extents only) → every unit /// is treated as content (the legacy behaviour). content_ranges: Option>, - /// Reused scratch buffer for verify-only decrypt checks — avoids a per-read - /// allocation on the sweep's hot path. Grown on demand, never shrunk. - scratch: Vec, - /// MUX pass-through switch (read > decrypt > mux). When `true`, every - /// encrypted unit is decrypted in place and the bytes pass straight to the - /// muxer — a unit that decrypts to broken TS is the muxer's concern, so the mux - /// never conceals, re-fetches a key, or counts it as loss, and the read returns - /// `Ok` (it can never abort over a bad-encoded unit). It hard-fails only on a - /// genuine can't-decrypt (no key / misaligned unit), which surfaces as `Err`. - /// This is "decrypt-verify is a RIP gate, not a MUX gate": the rip/verify path - /// leaves this `false` (default) and fails loud via [`DECRYPT_VERIFY_READ`] so - /// its read-error recovery re-reads the disc. Ciphertext is NEVER passed - /// downstream either way — with a keyed disc every unit has a key applied. - /// - /// Mutually meaningful only with `!verify_only` (the in-place decrypt path - /// the mux uses); a verify-only sweep keeps the rip's fail-loud contract. - tolerate_decrypt_loss: bool, + /// Reused scratch holding the pre-decrypt on-disc ciphertext for the + /// key-fetch retry. Only touched when `recovery` is installed; kept on the + /// struct (rather than a per-read `Vec`) so the mux hot path — 16 MiB + /// batches at highway speed, now that the mux installs a key-fetch for + /// multi-CPS — reuses one allocation instead of alloc/free-ing every read. + cipher_scratch: Vec, } impl DecryptingSectorSource { @@ -152,29 +93,16 @@ impl DecryptingSectorSource { keys, unit_key_idx: 0, unit_base: 0, - decrypt_dropped: Arc::new(AtomicU64::new(0)), // No recovery by default. CSS self-decrypts in `decrypt_sectors` // (needs no external input); AACS installs a key-fetch via // `with_key_fetch`. recovery: None, - verify_only: false, content_ranges: None, - scratch: Vec::new(), - tolerate_decrypt_loss: false, + cipher_scratch: Vec::new(), } } - /// Opt into the MUX pass-through policy (read > decrypt > mux): decrypt every - /// unit in place and pass the bytes to the muxer, never conceal / re-fetch / - /// count broken TS as loss; fail loud only on a genuine can't-decrypt. See the - /// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss) field. The rip/verify - /// path must NOT set this (it relies on fail-loud read-error recovery). - pub fn tolerate_decrypt_loss(mut self) -> Self { - self.tolerate_decrypt_loss = true; - self - } - - /// Restrict decrypt/verify to the disc's encrypted-content extents + /// Restrict decrypt to the disc's encrypted-content extents /// (sorted/merged `(start_lba, sector_count)` — see /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). /// Units outside content (UDF filesystem / BDMV nav) pass through untouched, @@ -186,26 +114,6 @@ impl DecryptingSectorSource { self } - /// Switch to verify-only mode: decrypt-CHECK each read on a scratch copy and - /// fail the read (`DECRYPT_VERIFY_READ`) when a scrambled AACS unit won't - /// decrypt, but leave `buf` as the original ciphertext. The multipass sweep - /// uses this so its ISO stays encrypted while still rejecting silent-bad - /// reads. No-op effect for `DecryptKeys::None` (nothing to check). - pub fn verify_only(mut self) -> Self { - self.verify_only = true; - self - } - - /// A handle to this decorator's decrypt-loss counter — the cumulative bytes - /// of scrambled AACS units that no key could decrypt (see - /// [`decrypt_dropped`](Self::decrypt_dropped)). The mux pipelines read this - /// to fold decrypt-time loss into their `lost_bytes` accounting; the highway - /// shares it across the producer thread and the consuming `Stream`. Returns - /// the live counter, so reads after a decrypt observe the updated total. - pub fn decrypt_loss(&self) -> Arc { - Arc::clone(&self.decrypt_dropped) - } - /// Override the AACS unit-key index. Only meaningful for /// [`DecryptKeys::Aacs`]; other variants ignore it. pub fn with_unit_key_idx(mut self, idx: usize) -> Self { @@ -266,98 +174,6 @@ impl DecryptingSectorSource { None => decrypt_sectors(buf, keys, unit_key_idx), } } - - /// Emit a bounded, structured diagnostic for each undecryptable unit in a - /// failed verify read. Called only on the failure (cold) path. On a fresh - /// rip `buf` holds the post-decrypt bytes straight off the drive, so the - /// per-unit signature is source ground truth (see the call site). - /// - /// Fields, per failing in-content unit: - /// * `lba` — absolute disc LBA of the unit - /// * `read_ms` — how long the inner drive read took (recovery grind vs clean - /// fast read) - /// * `all_zero` — the unit is every-byte-`0x00` (source zero-fill, seen fresh - /// off the disc — no ISO ambiguity) - /// * `ts_sync`/`ts_total` — TS sync bytes present vs possible (0/32 ⇒ - /// scrambled-looking) - /// * `distinct` — distinct byte values (entropy proxy: 1 ⇒ constant fill, - /// ~256 ⇒ ciphertext/garbage) - /// * `best_sync` — the most TS syncs ANY held key restores (≈0 ⇒ no key fits - /// → missing key / garbage; high ⇒ a key nearly works → marginal bytes) - /// * `head` — first 16 bytes (the plaintext TP_extra header) in hex - fn diagnose_decrypt_failure( - base_lba: u32, - buf: &[u8], - read_ms: u64, - content: Option<&[(u32, u32)]>, - keys: &DecryptKeys, - ) { - let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN; - let unit_sectors = (unit_len / 2048) as u32; - // Only AACS produces decrypt-verify failures; None / CSS never reach here - // with a non-zero dropped count. - let (unit_keys, rdk) = match keys { - DecryptKeys::Aacs { - unit_keys, - read_data_key, - } => (unit_keys, *read_data_key), - _ => return, - }; - let mut emitted = 0usize; - for (i, chunk) in buf.chunks_exact(unit_len).enumerate() { - if emitted >= MAX_DIAG_UNITS_PER_READ { - break; - } - let unit_lba = base_lba.saturating_add(i as u32 * unit_sectors); - let in_content = match content { - Some(r) => crate::decrypt::lba_in_ranges(unit_lba, r), - None => true, - }; - // A unit that decrypted is no longer sync-destroyed; a CPI-clear or - // non-content unit is gated out. Only undecryptable in-content units - // that are flagged encrypted carry signal. - if !in_content || !crate::aacs::content::aacs_unit_needs_decrypt(chunk) { - continue; - } - let all_zero = chunk.iter().all(|&b| b == 0); - let ts_sync = crate::aacs::content::ts_sync_count(chunk); - let ts_total = crate::aacs::content::ts_packet_total(chunk); - let mut seen = [false; 256]; - for &b in chunk { - seen[b as usize] = true; - } - let distinct = seen.iter().filter(|&&x| x).count(); - // Does ANY held key get this unit closer to clear TS? - let mut best_sync = ts_sync; - for (_, k) in unit_keys.iter() { - let mut attempt = chunk.to_vec(); - if let Some(ref rdk_key) = rdk { - crate::aacs::content::decrypt_bus(&mut attempt, rdk_key); - } - crate::aacs::content::decrypt_unit(&mut attempt, k); - let s = crate::aacs::content::ts_sync_count(&attempt); - if s > best_sync { - best_sync = s; - } - } - let head: String = chunk[..16].iter().map(|b| format!("{b:02x}")).collect(); - tracing::warn!( - target: "freemkv::decrypt", - lba = unit_lba, - in_content, - read_ms, - all_zero, - ts_sync, - ts_total, - distinct, - best_sync, - keys_held = unit_keys.len(), - head, - "decrypt-verify fail" - ); - emitted += 1; - } - } } impl SectorSource for DecryptingSectorSource { @@ -402,180 +218,64 @@ impl SectorSource for DecryptingSectorSource { { return Err(crate::error::Error::DecryptFailed); } - let read_t0 = std::time::Instant::now(); let n = self .inner .read_sectors_fua(lba, count, buf, recovery, fua)?; - let read_ms = read_t0.elapsed().as_millis() as u64; - // Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*` - // dispatches on the keys (None / CSS / AACS) and returns the count of - // bytes that SHOULD have decrypted but couldn't — the silent-bad-read - // signal. Only AACS ever produces a non-zero count, so nothing below - // needs a per-scheme check. When a content map is installed (whole-disc - // readers), the `*_in_content` entry skips units OUTSIDE the encrypted - // content extents, so clear filesystem / nav bytes are never mistaken for - // ciphertext. The mux installs no map (it reads title extents only). - // - // VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf` - // keeps its ciphertext (the ISO stays encrypted) and the hot path pays no - // per-read allocation. NORMAL: decrypt in place; a fetch callback may - // recover a unit no held key opened. + // Decrypt the bytes just read IN PLACE. Scheme-agnostic (None / CSS / AACS). + // With a content map installed the `*_in_content` entry skips units OUTSIDE + // the encrypted extents (clear filesystem / nav pass through untouched); the + // mux installs no map (it reads title extents only). A genuine can't-decrypt + // (no key / misaligned unit) surfaces as `Err` and propagates; otherwise + // every unit gets its key applied and the bytes pass through — a unit that + // decrypts to broken TS is the consumer's concern (the muxer drops it), + // never a read failure. (The decrypt-verify read gate was removed: bad + // sectors are marked by physical read success, not by TS structure.) let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow let content_ref = content.as_deref(); - // Copy out the small Copy fields the seam needs, so the `&mut self.recovery` - // borrow below does not collide with reads of other `self` fields. The - // recovery closure self-limits (its budget lives in its captures), so the - // decorator simply calls it whenever there is a miss. let unit_key_idx = self.unit_key_idx; - // ── MUX: read > decrypt > mux ────────────────────────────────────────── - // `tolerate_decrypt_loss` (and not verify_only) is the mux path: it is fed - // already-captured data and reads only content extents. `decrypt_buf` - // applies the CPS unit key to every encrypted unit IN PLACE; the resulting - // bytes — clean MPEG-TS or a bad-encoded region — belong to the muxer, which - // drops broken packets and resyncs. TS validity is NOT a decrypt verdict, so - // the mux never conceals, never re-fetches a key, and never counts broken TS - // as loss. Its ONLY failure is a genuine can't-decrypt (no key for a unit, or - // a misaligned unit), which `decrypt_buf` surfaces as `Err` — propagate it - // (fail loud), because a mux over captured data must otherwise always succeed. - if self.tolerate_decrypt_loss && !self.verify_only { - Self::decrypt_buf( - &mut buf[..n], - &mut self.keys, - self.unit_key_idx, - lba, - content_ref, - )?; - return Ok(n); - } + let dropped = Self::decrypt_buf( + &mut buf[..n], + &mut self.keys, + self.unit_key_idx, + lba, + content_ref, + )?; - // ── SWEEP / PATCH / VERIFY: decrypt to verify the disc read ───────────── - // First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on - // fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes: - // * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf` - // keeps its ciphertext (the ISO stays encrypted), but STILL fetch — - // the whole point is to CACHE the key. The fetched key is added to the - // pool, so the unit that triggered it now verifies clean (no false - // read-failure / damage-jump) and every later unit this pass — and any - // later read on this decorator — reuses it instead of re-asking the key - // server. Without this a CPS unit whose key wasn't sampled up front - // (an orphan clip not reachable from any playlist) hard-fails the whole - // range even though one key fetch would recover it. - // * NORMAL (mux / --no-raw): decrypt `buf` in place, same retry. - // The fetch re-decrypt targets the post-decrypt buffer (scratch / buf), - // whose still-scrambled units ARE the failures. - let outcome = if self.verify_only { - let mut scratch = std::mem::take(&mut self.scratch); - scratch.clear(); - scratch.extend_from_slice(&buf[..n]); - let d = match Self::decrypt_buf( - &mut scratch, - &mut self.keys, - self.unit_key_idx, + // FRESH-KEY-ON-FAILURE: hand a unit no held key opened (as its on-disc + // ciphertext) to the application's key source; any returned key is added to + // the pool and the read is re-decrypted, caching the key for later units. + // The pass-through result is the consumer's concern — the decorator never + // counts a decrypt-quality miss as loss. Genuine missing data is the + // zero-filled sectors the physical read layer records, not a TS-structure + // miss; a real can't-decrypt (empty pool / misalignment) already surfaced + // as `Err` from `decrypt_buf`. + if dropped > 0 && self.recovery.is_some() { + // Rare miss only: the in-place decrypt overwrote `buf`, so RE-READ the + // on-disc ciphertext for the key-fetch retry. This keeps the happy path + // zero-copy — the common single-CPS mux batch (dropped == 0) never + // captures or copies; a genuine miss pays one re-read into the reused + // `cipher_scratch`. + self.cipher_scratch.resize(n, 0); + self.inner.read_sectors_fua( lba, - content_ref, - ) { - Ok(d) => d, - Err(e) => { - self.scratch = scratch; - return Err(e); - } - }; - let o = match (d, self.recovery.as_mut()) { - (0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d }, - (d, Some(r)) => { - let rctx = crate::sector::recovery::RecoverCtx { - unit_key_idx, - lba, - content: content.clone(), - prev_dropped: d, - }; - // Target = the decrypted scratch; ciphertext = the untouched - // `buf` (verify-only keeps the ISO encrypted). - r(&mut scratch, &buf[..n], &mut self.keys, &rctx) - } - }; - self.scratch = scratch; - o - } else { - // In-place decrypt (decrypt-sweep / decrypt-patch). Pure decrypt - // overwrites `buf` with plaintext, so keep the on-disc ciphertext for - // the recovery retry BEFORE decrypting — but only when recovery is - // installed (the retry's sole consumer). - let ciphertext: Option> = if self.recovery.is_some() { - Some(buf[..n].to_vec()) - } else { - None - }; - let d = Self::decrypt_buf( - &mut buf[..n], - &mut self.keys, - self.unit_key_idx, - lba, - content_ref, + count, + &mut self.cipher_scratch[..n], + recovery, + fua, )?; - match (d, self.recovery.as_mut()) { - (0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d }, - (d, Some(r)) => { - let rctx = crate::sector::recovery::RecoverCtx { - unit_key_idx, - lba, - content: content.clone(), - prev_dropped: d, - }; - let cipher = ciphertext - .as_deref() - .expect("recovery installed → captured"); - r(&mut buf[..n], cipher, &mut self.keys, &rctx) - } - } - }; - // A loss is a loss: whatever recovery could not decrypt (a missing unit - // key, or an AACS 2.1 forensic-segment unit with no variant key — same - // thing to the read path) is concealed and counted the same way. - let dropped = outcome.dropped; - if dropped > 0 { - self.decrypt_dropped - .fetch_add(dropped as u64, Ordering::Relaxed); - // The mux path already returned above (read > decrypt > mux); only the - // verify callers (sweep / patch / --no-raw verify) reach here. An - // unverified unit means this disc read did NOT prove out. - // DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't - // means this read did NOT truly succeed — it returned ciphertext the - // TS assembler would silently drop. Fail the read loud so the caller's - // read-error recovery re-reads it off the disc (rip) or the mux hard- - // fails (no clean data to mux). Scheme-agnostic (only AACS reaches a - // non-zero count); clear filesystem (gated out) and zero-fill (not - // scrambled) never get here. - // - // An undecryptable unit is an undecryptable unit whatever the scheme — - // a missing unit key or an AACS 2.1 forensic-segment unit with no - // variant key both land here and fail the verify read the same way. - // (`dropped > 0` already holds inside the enclosing block.) - if DECRYPT_VERIFY_READ { - // FACT-FINDING: on a fresh rip these bytes came straight off the - // drive, so each failing unit's signature (all-zero? entropy? - // does any held key get it closer to clear TS?) plus the inner - // read latency are ground truth about the SOURCE — enough to - // classify the failure as source-zeros, marginal-media garbage, - // or a clean read no held key opens. In verify-only mode `buf` is - // untouched ciphertext, so the post-decrypt `scratch` is what - // distinguishes failed units (still TS-destroyed) from succeeded - // ones (now clean TS). - let diag: &[u8] = if self.verify_only { - &self.scratch - } else { - &buf[..n] - }; - Self::diagnose_decrypt_failure( - lba, - diag, - read_ms, - self.content_ranges.as_deref(), - &self.keys, - ); - return Err(crate::error::Error::DecryptFailed); - } + let rctx = crate::sector::recovery::RecoverCtx { + unit_key_idx, + lba, + content: content.clone(), + prev_dropped: dropped, + }; + let r = self + .recovery + .as_mut() + .expect("recovery.is_some() checked above"); + let cipher = &self.cipher_scratch[..n]; + let _ = r(&mut buf[..n], cipher, &mut self.keys, &rctx); } Ok(n) } @@ -893,6 +593,7 @@ mod tests { DecryptKeys::Aacs { unit_keys: Vec::new(), read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, ); let mut buf = vec![0u8; 2048]; @@ -945,6 +646,7 @@ mod tests { 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]; @@ -1053,6 +755,7 @@ mod tests { 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] { @@ -1090,6 +793,7 @@ mod tests { 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- @@ -1187,104 +891,53 @@ mod tests { unit } - /// Regression: when the decrypt step can't decrypt a scrambled AACS unit - /// (wrong/missing key), the decorator must accumulate the dropped bytes in - /// its `decrypt_loss()` counter while STILL returning `Ok` (per-unit - /// tolerance). The mux pipelines read this counter into `lost_bytes()` so a - /// partial decrypt failure can't be reported as a perfect rip. A - /// decryptable unit must leave the counter at zero. - /// - /// Grounding: `read_sectors` folds `decrypt_sectors`' dropped count into - /// `decrypt_dropped`; `decrypt_loss()` exposes it. - #[test] - fn decrypt_loss_counter_accumulates_undecryptable_units() { - let real_key = [0x33u8; 16]; - let wrong_key = [0x44u8; 16]; - - // A source that always yields one unit encrypted under `real_key`. - struct EncUnitSource { - unit: Vec, + /// Like [`encrypt_aacs_unit`] but knocks out the TS sync on `bad_pkts` (kept as + /// NON-zero content, so they read as authored-bad packets, not padding) BEFORE + /// encryption — a unit the correct key still OPENS on its remaining good + /// packets, but that carries bad-encoded content the muxer must drop. + fn encrypt_aacs_unit_bad(unit_key: &[u8; 16], bad_pkts: &[usize]) -> Vec { + use aes::Aes128; + use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; + let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; + let mut off = 4; + while off < unit.len() { + unit[off] = 0x47; + off += 192; } - impl SectorSource for EncUnitSource { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - let bytes = count as usize * 2048; - assert_eq!(bytes, self.unit.len(), "test reads one whole unit"); - buf[..bytes].copy_from_slice(&self.unit); - Ok(bytes) + for &p in bad_pkts { + let o = p * 192; + unit[o + 4] = 0x00; // no TS sync after decrypt + unit[o + 5] = 0xAB; // non-zero payload => real content, not padding + } + unit[0] |= 0xC0; + let header: [u8; 16] = unit[..16].try_into().unwrap(); + let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header); + let mut k = [0u8; 16]; + for i in 0..16 { + k[i] = derived[i] ^ header[i]; + } + let cipher = Aes128::new(GenericArray::from_slice(&k)); + let mut prev = crate::aacs::crypto::AACS_IV; + let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16; + for i in 0..blocks { + let o = 16 + i * 16; + for j in 0..16 { + unit[o + j] ^= prev[j]; } + let mut blk = GenericArray::clone_from_slice(&unit[o..o + 16]); + cipher.encrypt_block(&mut blk); + unit[o..o + 16].copy_from_slice(&blk); + prev.copy_from_slice(&unit[o..o + 16]); } - - let unit = encrypt_aacs_unit(&real_key); - - // Wrong key → undecryptable → loss counted AND the read fails loud - // (DECRYPT_VERIFY_READ: a read that returns an undecryptable AACS unit - // did not truly succeed). The loss counter is still bumped before the - // error so the abort accounting sees the byte count. - let mut wrapped = DecryptingSectorSource::new( - EncUnitSource { unit: unit.clone() }, - DecryptKeys::Aacs { - unit_keys: vec![(0, wrong_key)], - read_data_key: None, - }, - ); - let loss = wrapped.decrypt_loss(); - assert_eq!(loss.load(Ordering::Relaxed), 0, "starts at zero"); - - let mut buf = vec![0u8; 3 * 2048]; - let err = wrapped - .read_sectors(0, 3, &mut buf, false) - .expect_err("DECRYPT_VERIFY_READ: an undecryptable AACS unit fails the read loud"); - assert!( - matches!(err, crate::error::Error::DecryptFailed), - "undecryptable unit errors with DecryptFailed, got {err:?}" - ); - assert_eq!( - loss.load(Ordering::Relaxed), - crate::aacs::content::ALIGNED_UNIT_LEN as u64, - "the undecryptable unit is tallied as loss before the read errors" - ); - - // A second read of the same bad unit accumulates further (and errors). - assert!( - wrapped.read_sectors(0, 3, &mut buf, false).is_err(), - "the same bad unit fails the read again" - ); - assert_eq!( - loss.load(Ordering::Relaxed), - 2 * crate::aacs::content::ALIGNED_UNIT_LEN as u64, - "loss must accumulate across reads" - ); - - // Correct key → no loss. - let mut good = DecryptingSectorSource::new( - EncUnitSource { unit }, - DecryptKeys::Aacs { - unit_keys: vec![(0, real_key)], - read_data_key: None, - }, - ); - let good_loss = good.decrypt_loss(); - good.read_sectors(0, 3, &mut buf, false).unwrap(); - assert_eq!( - good_loss.load(Ordering::Relaxed), - 0, - "a decryptable unit must not register any loss" - ); + unit } - /// MUX (read > decrypt > mux): with `tolerate_decrypt_loss()` 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, so it is NOT counted as decrypt loss. The mux only hard-fails - /// on a genuine can't-decrypt (no key at all / misaligned 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 tolerate_decrypt_loss_passes_undecryptable_unit_through() { + fn mux_passes_undecryptable_unit_through_without_nulling() { let real_key = [0x33u8; 16]; let wrong_key = [0x44u8; 16]; @@ -1325,10 +978,9 @@ mod tests { DecryptKeys::Aacs { unit_keys: vec![(0, wrong_key)], // can't open the encrypted unit read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, - ) - .tolerate_decrypt_loss(); - let loss = wrapped.decrypt_loss(); + ); let mut buf = vec![0u8; 6 * 2048]; // Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt. @@ -1337,13 +989,6 @@ mod tests { .expect("the mux never aborts on a bad-decrypt unit"); assert_eq!(n, 6 * 2048); - // Broken TS is a muxer concern, not decrypt loss — the mux counts none. - assert_eq!( - loss.load(Ordering::Relaxed), - 0, - "the mux does not count broken TS as decrypt loss" - ); - // 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]; @@ -1386,10 +1031,7 @@ mod tests { // The byte-exact expected post-decrypt form of unit B (independent decrypt). let mut expected_tail = good_tail.clone(); - assert!( - crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key), - "padding-tail must decrypt under good_key" - ); + crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key); let mut two_units = bad_unit; two_units.extend_from_slice(&good_tail); @@ -1419,10 +1061,9 @@ mod tests { DecryptKeys::Aacs { unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, - ) - .tolerate_decrypt_loss(); - let loss = wrapped.decrypt_loss(); + ); let mut buf = vec![0u8; 6 * 2048]; let n = wrapped @@ -1430,13 +1071,6 @@ mod tests { .expect("the mux never aborts on a bad-decrypt unit"); assert_eq!(n, 6 * 2048); - // Broken TS is a muxer concern — the mux counts no loss. - assert_eq!( - loss.load(Ordering::Relaxed), - 0, - "the mux does not count broken TS as decrypt loss" - ); - // 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); @@ -1466,39 +1100,6 @@ mod tests { } } - /// `fill_null_ts_unit` round-trip: every BD source packet in the unit becomes - /// a well-formed TS null packet (PID 0x1FFF, invisible to any real PID) that - /// carries the B1 adaptation-field discontinuity_indicator — the marker - /// `mux::ts` reads as a concealed gap. - #[test] - fn null_ts_fill_is_well_formed_and_invisible_to_real_pids() { - let mut unit = vec![0xAAu8; crate::aacs::content::ALIGNED_UNIT_LEN]; - crate::aacs::content::fill_null_ts_unit(&mut unit); - // 32 packets, each: sync 0x47, PID 0x1FFF, adaptation-only (0b10) with a - // discontinuity_indicator in the adaptation field. - let mut off = 0; - let mut pkts = 0; - while off + 192 <= unit.len() { - assert_eq!(unit[off + 4], 0x47, "sync"); - let pid = ((unit[off + 5] as u16 & 0x1F) << 8) | unit[off + 6] as u16; - assert_eq!(pid, 0x1FFF, "null PID"); - assert_eq!( - (unit[off + 7] >> 4) & 0x03, - 0x02, - "adaptation_field_control = AF only (no payload)" - ); - assert!(unit[off + 8] > 0, "adaptation_field_length > 0"); - assert_eq!( - unit[off + 9] & 0x80, - 0x80, - "adaptation-field discontinuity_indicator set (the B1 marker)" - ); - off += 192; - pkts += 1; - } - assert_eq!(pkts, 32, "32 source packets per aligned unit"); - } - /// 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 @@ -1544,18 +1145,19 @@ mod tests { DecryptKeys::Aacs { unit_keys: vec![(0, wrong_key)], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, ) .with_key_fetch(fetch); - let loss = wrapped.decrypt_loss(); let mut buf = vec![0u8; 3 * 2048]; wrapped.read_sectors(0, 3, &mut buf, false).unwrap(); - assert_eq!( - loss.load(Ordering::Relaxed), - 0, - "fetch supplied the key → the unit decrypts → zero loss" + // 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::ts_sync_destroyed(unit0), + "fetch supplied the key → the unit decrypts to clean TS" ); let got = seen.lock().unwrap(); assert_eq!( @@ -1571,26 +1173,6 @@ mod tests { got[0], unit, "the exact on-disc unit is forwarded for fetch" ); - - // Baseline: same setup WITHOUT a callback accumulates loss. - let mut nocb = DecryptingSectorSource::new( - EncUnitSource { unit }, - DecryptKeys::Aacs { - unit_keys: vec![(0, wrong_key)], - read_data_key: None, - }, - ); - let nocb_loss = nocb.decrypt_loss(); - let mut buf2 = vec![0u8; 3 * 2048]; - assert!( - nocb.read_sectors(0, 3, &mut buf2, false).is_err(), - "without a fetch callback the undecryptable unit fails the read (DECRYPT_VERIFY_READ)" - ); - assert_eq!( - nocb_loss.load(Ordering::Relaxed), - crate::aacs::content::ALIGNED_UNIT_LEN as u64, - "without a fetch callback the undecryptable unit is loss" - ); } /// A fetch that comes back EMPTY for one unit must NOT block a later fetch @@ -1607,23 +1189,29 @@ mod tests { struct AltSource { units: Vec>, - idx: usize, } 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, + lba: u32, count: u16, buf: &mut [u8], _r: bool, ) -> Result { let bytes = count as usize * 2048; - let u = &self.units[self.idx.min(self.units.len() - 1)]; + let u = if lba < 3 { + &self.units[0] + } else { + &self.units[1] + }; buf[..bytes].copy_from_slice(u); - self.idx += 1; Ok(bytes) } } @@ -1644,11 +1232,11 @@ mod tests { let mut wrapped = DecryptingSectorSource::new( AltSource { units: vec![unit_a, unit_b], - idx: 0, }, 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); @@ -1686,134 +1274,6 @@ mod tests { assert_eq!(recovered.capacity_sectors(), 42); } - /// Verify-only mode (the multipass sweep/patch path): a read decrypt-CHECKS - /// the bytes but NEVER mutates `buf`, so the ISO keeps its ciphertext. An - /// undecryptable unit still fails the read (DECRYPT_VERIFY_READ) so the - /// existing read-error recovery treats it like a SCSI failure; a decryptable - /// unit returns Ok with the ciphertext intact (the check is non-destructive). - #[test] - fn verify_only_checks_without_mutating_and_fails_on_undecryptable() { - let real_key = [0x33u8; 16]; - let wrong_key = [0x44u8; 16]; - - struct EncUnitSource { - unit: Vec, - } - impl SectorSource for EncUnitSource { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - let bytes = count as usize * 2048; - buf[..bytes].copy_from_slice(&self.unit); - Ok(bytes) - } - } - - let unit = encrypt_aacs_unit(&real_key); - - // Wrong key → undecryptable → read FAILS, but buf is untouched ciphertext. - let mut bad = DecryptingSectorSource::new( - EncUnitSource { unit: unit.clone() }, - DecryptKeys::Aacs { - unit_keys: vec![(0, wrong_key)], - read_data_key: None, - }, - ) - .verify_only(); - let mut buf = vec![0u8; 3 * 2048]; - let err = bad - .read_sectors(0, 3, &mut buf, false) - .expect_err("verify-only: an undecryptable unit must fail the read"); - assert!(matches!(err, crate::error::Error::DecryptFailed)); - assert_eq!( - buf, unit, - "verify-only must NOT mutate buf — ISO stays ciphertext" - ); - - // Right key → read OK, and buf is STILL the original ciphertext (the - // decrypt happened on a scratch copy, not in place). - let mut good = DecryptingSectorSource::new( - EncUnitSource { unit: unit.clone() }, - DecryptKeys::Aacs { - unit_keys: vec![(0, real_key)], - read_data_key: None, - }, - ) - .verify_only(); - let mut buf2 = vec![0u8; 3 * 2048]; - good.read_sectors(0, 3, &mut buf2, false) - .expect("verify-only: a decryptable unit reads OK"); - assert_eq!( - buf2, unit, - "verify-only leaves ciphertext in buf even when the unit decrypts" - ); - } - - /// THE first-2 GB regression at the READ level. With a content map installed, - /// a verify-only read of a scrambled-LOOKING but CLEAR region (UDF filesystem - /// OUTSIDE the content extents) must read OK — not false-fail — while a read - /// INSIDE content that won't decrypt still fails. Before the content gate, the - /// filesystem read was mis-classified as undecryptable ciphertext and the - /// whole opening of every disc was marked NonTrimmed. - #[test] - fn verify_only_content_gate_passes_clear_filesystem_fails_content() { - // Source returns sync-destroyed bytes (looks like ciphertext) for any LBA. - struct ScrambledSource; - impl SectorSource for ScrambledSource { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - let bytes = count as usize * 2048; - for (i, b) in buf[..bytes].iter_mut().enumerate() { - *b = (i as u8).wrapping_mul(31); - } - let mut off = 4; - while off < bytes { - buf[off] = 0xA5; // force a NON-sync byte at every TS probe stride - off += 192; - } - // CPI bits on each aligned unit's byte 0 so it reads as encrypted. - let mut u = 0; - while u < bytes { - buf[u] |= 0xC0; - u += crate::aacs::content::ALIGNED_UNIT_LEN; - } - Ok(bytes) - } - } - - let keys = DecryptKeys::Aacs { - unit_keys: vec![(0, [0xAB; 16])], - read_data_key: None, - }; - // Content lives at LBA 1002..1101 (3-aligned start so reads pass the - // unit-alignment gate). Everything before it is "filesystem". - let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); - let mut dec = DecryptingSectorSource::new(ScrambledSource, keys) - .verify_only() - .with_content_ranges(ranges); - let mut buf = vec![0u8; 3 * 2048]; - - // LBA 0 — OUTSIDE content (filesystem). Scrambled-looking, but clear by - // position → must read OK (the regression that broke the first 2 GB). - dec.read_sectors(0, 3, &mut buf, false) - .expect("a clear filesystem region must read OK — no false decrypt-fail"); - - // LBA 1002 — INSIDE content, undecryptable → the read must fail loud. - let err = dec - .read_sectors(1002, 3, &mut buf, false) - .expect_err("an undecryptable content unit must fail the read"); - assert!(matches!(err, crate::error::Error::DecryptFailed)); - } - /// Source that returns a fixed unit's bytes for any read. struct FixedUnit { unit: Vec, @@ -1832,77 +1292,6 @@ mod tests { } } - /// verify-only + content map: an in-content unit that DOES decrypt reads OK, - /// and `buf` keeps its CIPHERTEXT (the verify is non-mutating). - #[test] - fn verify_only_content_gate_decryptable_unit_keeps_ciphertext() { - let key = [0x5a; 16]; - let unit = encrypt_aacs_unit(&key); - let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 3u32)]); // LBA 0..3 is content - let mut dec = DecryptingSectorSource::new( - FixedUnit { unit: unit.clone() }, - DecryptKeys::Aacs { - unit_keys: vec![(0, key)], - read_data_key: None, - }, - ) - .verify_only() - .with_content_ranges(ranges); - let mut buf = vec![0u8; 3 * 2048]; - dec.read_sectors(0, 3, &mut buf, false) - .expect("a decryptable content unit reads OK"); - assert_eq!( - buf, unit, - "verify-only keeps ciphertext even when the unit decrypts" - ); - } - - /// NO content map (None) ⇒ ungated legacy behaviour: a scrambled-looking read - /// fails. This is what the mux relies on (it only reads content), and the very - /// reason the whole-disc sweep MUST install the map. - #[test] - fn verify_only_without_content_map_is_ungated() { - struct ScrambledSource; - impl SectorSource for ScrambledSource { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _r: bool, - ) -> Result { - let b = count as usize * 2048; - for (i, x) in buf[..b].iter_mut().enumerate() { - *x = (i as u8).wrapping_mul(31); - } - let mut o = 4; - while o < b { - buf[o] = 0xA5; - o += 192; - } - let mut u = 0; - while u < b { - buf[u] |= 0xC0; // CPI bits → reads as encrypted - u += crate::aacs::content::ALIGNED_UNIT_LEN; - } - Ok(b) - } - } - let mut dec = DecryptingSectorSource::new( - ScrambledSource, - DecryptKeys::Aacs { - unit_keys: vec![(0, [0xAB; 16])], - read_data_key: None, - }, - ) - .verify_only(); // no content map installed - let mut buf = vec![0u8; 3 * 2048]; - let err = dec - .read_sectors(0, 3, &mut buf, false) - .expect_err("ungated verify fails on scrambled bytes (legacy / mux behaviour)"); - assert!(matches!(err, crate::error::Error::DecryptFailed)); - } - /// In-place decrypt + content map: a NON-content read passes through unchanged /// (ciphertext, not decrypted); an in-content read is decrypted IN PLACE. #[test] @@ -1917,9 +1306,10 @@ mod tests { DecryptKeys::Aacs { unit_keys: vec![(0, key)], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, ) - .with_content_ranges(ranges); // in-place (NOT verify_only) + .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]; @@ -1958,14 +1348,13 @@ mod tests { } } - /// THE cps2 fix at the read level. Verify-only (sweep) mode now fetches: a - /// content unit no HELD key opens hands its ciphertext to the fetch closure, - /// the returned key is added to the pool (the CACHE), the unit re-verifies - /// clean — and `buf` is left as ciphertext (the ISO stays encrypted). Then the - /// cached key serves the NEXT unit WITHOUT another callback (≈one fetch per CPS - /// unit). This is what stops an orphan CPS unit from hard-failing the sweep. + /// 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 verify_only_fetch_recovers_caches_and_keeps_ciphertext() { + 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); @@ -1976,6 +1365,7 @@ mod tests { *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] }); @@ -1985,18 +1375,22 @@ mod tests { DecryptKeys::Aacs { unit_keys: vec![(0, wrong_key)], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, ) - .verify_only() .with_content_ranges(ranges) .with_key_fetch(fetch); - // First read (LBA 0): wrong key fails → fetch supplies real_key → Ok, - // and buf is still ciphertext (verify-only never mutates the ISO bytes). + // 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_eq!(buf, unit, "verify-only keeps ciphertext even after a fetch"); + assert_ne!(buf, unit, "the fetched key decrypts the unit in place"); + assert!( + !crate::aacs::content::ts_sync_destroyed(&buf), + "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. @@ -2010,37 +1404,43 @@ mod tests { ); } - /// Verify-only fetch that comes back empty (the key source can't help) must - /// still hard-fail the read (DECRYPT_VERIFY_READ) — recovery, not silent loss. + /// 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 verify_only_fetch_exhausted_still_hard_fails() { - let real_key = [0x5au8; 16]; - let wrong = [0x11u8; 16]; - let unit = encrypt_aacs_unit(&real_key); - let fetch: super::KeyFetch = std::sync::Arc::new(|_: &[Vec]| Vec::new()); + 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 = (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, wrong)], + unit_keys: vec![(0, key)], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, ) - .verify_only() - .with_content_ranges(ranges) - .with_key_fetch(fetch); + .with_content_ranges(ranges); let mut buf = vec![0u8; 3 * 2048]; - let err = dec - .read_sectors(0, 3, &mut buf, false) - .expect_err("a fetch that returns no key must still fail the read"); - assert!(matches!(err, crate::error::Error::DecryptFailed)); + 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 verify_only_fetch_not_called_outside_content() { + fn fetch_not_called_outside_content() { let real_key = [0x5au8; 16]; let wrong = [0x11u8; 16]; let unit = encrypt_aacs_unit(&real_key); @@ -2057,9 +1457,9 @@ mod tests { DecryptKeys::Aacs { unit_keys: vec![(0, wrong)], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }, ) - .verify_only() .with_content_ranges(ranges) .with_key_fetch(fetch); let mut buf = vec![0u8; 3 * 2048]; diff --git a/src/sector/mod.rs b/src/sector/mod.rs index 775202d..5acab5a 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -129,6 +129,10 @@ impl SectorSource for Box { fn set_speed(&mut self, kbs: u16) { (**self).set_speed(kbs) } + + fn set_unit_base(&mut self, lba: u32) { + (**self).set_unit_base(lba) + } } impl SectorSource for &mut (dyn SectorSource + '_) { @@ -160,6 +164,10 @@ impl SectorSource for &mut (dyn SectorSource + '_) { fn set_speed(&mut self, kbs: u16) { (**self).set_speed(kbs) } + + fn set_unit_base(&mut self, lba: u32) { + (**self).set_unit_base(lba) + } } /// Write 2048-byte sectors to a disc image or composed sink. @@ -181,7 +189,7 @@ pub trait SectorSink: Send { } pub use crate::io::file_sector_source::FileSectorSource; -pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch}; +pub use decrypting::{DecryptingSectorSource, KeyFetch}; pub use file::FileSectorSink; pub use prefetched::PrefetchedSectorSource; @@ -198,23 +206,33 @@ mod tests { capacity: u32, reads: Arc>>, speeds: Arc>>, + unit_bases: Arc>>, } - /// A `Spy` under test plus the handles recording its reads and speed sets. - type SpyHarness = (Spy, Arc>>, Arc>>); + /// A `Spy` under test plus the handles recording its reads, speed sets, + /// and unit-base sets. + type SpyHarness = ( + Spy, + Arc>>, + Arc>>, + Arc>>, + ); impl Spy { fn new(capacity: u32) -> SpyHarness { let reads = Arc::new(Mutex::new(Vec::new())); let speeds = Arc::new(Mutex::new(Vec::new())); + let unit_bases = Arc::new(Mutex::new(Vec::new())); ( Self { capacity, reads: reads.clone(), speeds: speeds.clone(), + unit_bases: unit_bases.clone(), }, reads, speeds, + unit_bases, ) } } @@ -238,6 +256,16 @@ mod tests { fn set_speed(&mut self, kbs: u16) { self.speeds.lock().unwrap().push(kbs); } + fn set_unit_base(&mut self, lba: u32) { + self.unit_bases.lock().unwrap().push(lba); + } + } + + /// Call `set_unit_base` through a generic `S: SectorSource` bound — this is + /// the path that actually exercises the `Box` / `&mut dyn` FORWARDING + /// impls (a direct call on a `dyn` value dispatches via the vtable instead). + fn set_unit_base_generic(mut s: S, base: u32) { + s.set_unit_base(base); } /// The default `capacity_sectors` is 0 (unknown). Grounding: trait @@ -286,7 +314,7 @@ mod tests { /// Box` forwarding bodies. #[test] fn boxed_dyn_forwards_all_methods() { - let (spy, reads, speeds) = Spy::new(777); + let (spy, reads, speeds, unit_bases) = Spy::new(777); let mut boxed: Box = Box::new(spy); assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward"); @@ -308,24 +336,46 @@ mod tests { vec![5400], "set_speed must forward" ); + + // set_unit_base through the generic bound exercises the forwarding impl + // (a direct `boxed.set_unit_base()` would vtable-dispatch instead). A + // missing forwarding body would silently no-op and record nothing. + set_unit_base_generic(boxed, 64); + assert_eq!( + *unit_bases.lock().unwrap(), + vec![64], + "set_unit_base must forward through Box" + ); } - /// `&mut dyn SectorSource` must likewise forward all three methods. + /// `&mut dyn SectorSource` must likewise forward every method. /// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`. #[test] fn mut_ref_dyn_forwards_all_methods() { - let (mut spy, reads, speeds) = Spy::new(123); - let r: &mut dyn SectorSource = &mut spy; + let (mut spy, reads, speeds, unit_bases) = Spy::new(123); - assert_eq!(r.capacity_sectors(), 123); + { + let r: &mut dyn SectorSource = &mut spy; + assert_eq!(r.capacity_sectors(), 123); - let mut buf = vec![0u8; 2 * 2048]; - let n = r.read_sectors(7, 2, &mut buf, false).unwrap(); - assert_eq!(n, 2 * 2048); + let mut buf = vec![0u8; 2 * 2048]; + let n = r.read_sectors(7, 2, &mut buf, false).unwrap(); + assert_eq!(n, 2 * 2048); - r.set_speed(8800); + r.set_speed(8800); + } + + // Pass `&mut dyn` as a generic S so the forwarding impl's set_unit_base + // is the one under test, not the vtable path. + let r2: &mut dyn SectorSource = &mut spy; + set_unit_base_generic(r2, 128); assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]); assert_eq!(*speeds.lock().unwrap(), vec![8800]); + assert_eq!( + *unit_bases.lock().unwrap(), + vec![128], + "set_unit_base must forward through &mut dyn" + ); } } diff --git a/src/sector/prefetched.rs b/src/sector/prefetched.rs index 42b3424..d6425bf 100644 --- a/src/sector/prefetched.rs +++ b/src/sector/prefetched.rs @@ -252,11 +252,14 @@ impl PrefetchedSectorSource { }; if bytes <= buf.capacity() { // Re-expose `bytes` without zero-filling pages that - // `read_sectors` is about to overwrite. The enclosing - // capacity guard makes the `set_len` provably sound even - // if a recycled buffer ever comes back smaller than the - // `vec![0u8; batch_bytes]` it was born with. - debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity"); + // `read_sectors` is about to overwrite. Sound because the + // enclosing `bytes <= capacity` guard bounds the length, + // and every byte below `capacity` is physically + // initialised: buffers are born `vec![0u8; batch_bytes]` + // and only ever grown via `resize(_, 0)`, so a recycled + // buffer that came back shorter (consumer `truncate`) + // still has initialised backing storage under `set_len`, + // which `read_sectors` then overwrites before any read. unsafe { buf.set_len(bytes) }; } else { buf.resize(bytes, 0); diff --git a/src/sector/recovery.rs b/src/sector/recovery.rs index a2c02b9..03f458a 100644 --- a/src/sector/recovery.rs +++ b/src/sector/recovery.rs @@ -135,6 +135,12 @@ fn aacs_fetch_step( return prev_dropped; } let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN; + // Container of this disc's content — travels with the keys; drives the + // encrypted-flag / structure check below (TS vs PS). + let format = match &*keys { + DecryptKeys::Aacs { format, .. } => *format, + _ => crate::disc::ContentFormat::BdTs, + }; // Gather up to MAX_FETCH_SAMPLES units the current pool did NOT open. Detect // them on the post-decrypt TARGET (a failed unit stays TS-destroyed; an opened // one is now clean TS and is skipped), but SAMPLE the matching on-disc @@ -146,7 +152,7 @@ fn aacs_fetch_step( .chunks_exact(unit_len) .zip(ciphertext.chunks_exact(unit_len)) { - if crate::aacs::content::aacs_unit_needs_decrypt(t) { + if crate::aacs::content::aacs_unit_needs_decrypt(t, format) { samples.push(c.to_vec()); if samples.len() >= MAX_FETCH_SAMPLES { break; @@ -256,6 +262,7 @@ mod tests { let mut keys = DecryptKeys::Aacs { unit_keys: vec![], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; let cipher = buf.clone(); let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144)); @@ -279,6 +286,7 @@ mod tests { let mut keys = DecryptKeys::Aacs { unit_keys: vec![], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; let cipher = buf.clone(); r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); @@ -304,6 +312,7 @@ mod tests { let mut keys = DecryptKeys::Aacs { unit_keys: vec![], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; let mut buf = scrambled_unit(0x44); let cipher = buf.clone(); @@ -330,6 +339,7 @@ mod tests { let mut keys = DecryptKeys::Aacs { unit_keys: vec![], read_data_key: None, + format: crate::disc::ContentFormat::BdTs, }; // Distinct ciphertext each time so the dry-set never short-circuits; only // the internal call budget should stop the fetch. The closure self-limits, diff --git a/src/udf.rs b/src/udf.rs index 974c0ff..1655b31 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1790,6 +1790,57 @@ mod tests { assert!(inf[2048..].iter().all(|&b| b == 0xBB)); } + #[test] + fn read_aacs_inputs_falls_through_to_hddvd_any_dir() { + // HD DVD keeps its AACS material under /ANY!/ (VTKF000.AACS title-key + // file + MKBROM.AACS), NOT /AACS/Unit_Key_RO.inf + /AACS/MKB_RO.inf. The + // role-based candidate lists must fall through to the /ANY!/ files with + // NO disc-type branch, so the online keyserver POST carries the HD DVD + // title-key file (magic "DVD_HD_V_TKF") as inf_b64 + MKBROM as mkb_b64 — + // the server then classifies the disc as HD DVD by that magic. + let any = DirEntry { + name: "ANY!".to_string(), + is_dir: true, + meta_lba: 0, + size: 0, + entries: vec![ + file_entry("VTKF000.AACS", 5, 2048), + file_entry("MKBROM.AACS", 7, 2048), + ], + }; + let root = DirEntry { + name: String::new(), + is_dir: true, + meta_lba: 0, + size: 0, + entries: vec![any], // deliberately NO /AACS/ dir + }; + let mut reader = MapReader::new(); + // VTKF000.AACS: one extent whose content opens with the HD DVD magic. + let mut vtkf = [0u8; 2048]; + vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF"); + reader.put(5, build_efe_long(2048, &[(0, 2048, 10)])); + reader.put(10, vtkf); + // MKBROM.AACS: one extent with a type-0x10 AACS-1.0 (HD DVD) version record. + let mut mkb = [0u8; 2048]; + mkb[..12].copy_from_slice(&[ + 0x10, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x10, 0x03, 0x00, 0x00, 0x00, 0x03, + ]); + reader.put(7, build_efe_long(2048, &[(0, 2048, 50)])); + reader.put(50, mkb); + + let fs = fs_with(0, 0, root); + let (inf, _mkb, _version) = + crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs) + .expect("read_aacs_inputs must source the HD DVD /ANY!/ files"); + assert_eq!( + &inf[..12], + b"DVD_HD_V_TKF", + "inf must be the HD DVD VTKF (its magic), sourced from /ANY!/ via the \ + candidate fall-through — not /AACS/Unit_Key_RO.inf" + ); + } + #[test] fn merge_ranges_saturates_near_u32_max() { // Adjacent ranges near u32::MAX must not panic (debug) or wrap. diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index 6a00e7b..eedcb92 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -134,11 +134,7 @@ fn aacs_decrypt_unit_roundtrip() { assert!(aacs::content::ts_sync_destroyed(&plain)); // Now decrypt - let result = aacs::content::decrypt_unit(&mut plain, &unit_key); - assert!( - result, - "decrypt_unit should return true on valid encrypted unit" - ); + aacs::content::decrypt_unit(&mut plain, &unit_key); assert!( !aacs::content::ts_sync_destroyed(&plain), "decrypted unit should read as clear (TS syncs restored)" @@ -298,13 +294,16 @@ fn aacs_ts_sync_destroyed_detection() { ); } -/// Test: aacs_decrypt_unit_unencrypted_passthrough +/// Test: aacs_clear_unit_reports_not_encrypted /// -/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged. +/// `decrypt_unit` is now PURE (applies the key unconditionally). The "leave a +/// clear unit untouched" policy lives at the caller's gate `aacs_unit_encrypted`: +/// a CPI-clear unit reports not-encrypted, so the caller never hands it to +/// decrypt_unit. #[test] -fn aacs_decrypt_unit_unencrypted_passthrough() { +fn aacs_clear_unit_reports_not_encrypted() { let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN]; - // Intact TS syncs every 192 bytes → not scrambled → passthrough. + // Intact TS syncs every 192 bytes → not scrambled. let mut off = 4; while off < aacs::content::ALIGNED_UNIT_LEN { unit[off] = 0x47; @@ -312,13 +311,12 @@ fn aacs_decrypt_unit_unencrypted_passthrough() { } // CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext. unit[0] &= 0x3F; - let original = unit.clone(); - let key = [0xAA; 16]; assert!(!aacs::content::ts_sync_destroyed(&unit)); - let result = aacs::content::decrypt_unit(&mut unit, &key); - assert!(result, "clear unit should return true"); - assert_eq!(unit, original, "clear unit should be unchanged"); + assert!( + !aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs), + "CPI-clear unit reports not-encrypted; the caller never decrypts it" + ); } // ── AACS cross-validation with independent AES implementation ────────────── @@ -413,11 +411,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() { ); // -- Decrypt with the library -- - let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key); - assert!( - ok, - "decrypt_unit returned false (TS sync verification failed)" - ); + aacs::content::decrypt_unit(&mut plaintext, &unit_key); // Decryption clears no flag, so the unit round-trips byte-for-byte. assert_eq!( @@ -458,44 +452,14 @@ fn aacs_cross_validation_alternate_key() { &mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN], ); - assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key)); + aacs::content::decrypt_unit(&mut plaintext, &unit_key); // Decryption clears no flag, so the unit round-trips byte-for-byte. assert_eq!(&plaintext[..], &expected[..]); } -/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied -/// per-sector to bytes 16..2048 (bus encryption layer). -#[test] -fn aacs_bus_decrypt_cross_validation() { - let read_data_key: [u8; 16] = [ - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, - 0x00, - ]; - - let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN]; - #[allow(clippy::needless_range_loop)] - for i in 0..aacs::content::ALIGNED_UNIT_LEN { - plaintext[i] = ((i * 3 + 17) & 0xFF) as u8; - } - let expected = plaintext.clone(); - - // Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector - for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) { - ref_aes_cbc_encrypt( - &read_data_key, - &CROSS_AACS_IV, - &mut plaintext[sector_start + 16..sector_start + 2048], - ); - } - assert_ne!(&plaintext[16..32], &expected[16..32]); - - aacs::content::decrypt_bus(&mut plaintext, &read_data_key); - assert_eq!( - plaintext, expected, - "bus decrypt did not recover original plaintext" - ); -} +// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate +// in `aacs::content`'s unit tests, not here.) // ── CSS roundtrip test vectors ───────────────────────────────────────────── @@ -663,6 +627,28 @@ fn css_stevenson_attack_validates_cracked_key() { This is expected: synthetic sectors lack the TAB1 output encoding \ present in real CSS-encrypted DVD sectors." ); + // Never let this test pass vacuously: when the attack can't converge on + // synthetic data, still assert always-true properties of the CSS keystream + // so a real regression is caught on every run — descramble_sector is + // DETERMINISTIC (same key/seed/data → same output) and NON-TRIVIAL (it + // actually transforms the payload, not a silent no-op). + for (key, seed) in candidates { + let mut base = vec![0x00u8; 2048]; + base[0x14] = 0x30; + base[0x54..0x59].copy_from_slice(seed); + base[0x80..0x8A] + .copy_from_slice(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]); + let mut a = base.clone(); + let mut b = base.clone(); + css::lfsr::descramble_sector(key, &mut a); + css::lfsr::descramble_sector(key, &mut b); + assert_eq!(a, b, "descramble must be deterministic for key={key:02X?}"); + assert_ne!( + &a[0x80..2048], + &base[0x80..2048], + "descramble must transform the payload for key={key:02X?}" + ); + } } } @@ -707,6 +693,20 @@ fn css_recover_title_key_with_exact_plaintext() { The LFSR0 recovery phase may not converge for this combination.", title_key, seed ); + // Never pass vacuously: when LFSR0 recovery can't converge on this + // synthetic sector, still assert always-true properties of the cipher so + // a real regression is caught on every run — descramble_sector is + // DETERMINISTIC and NON-TRIVIAL (actually transforms the payload). + let mut a = original.clone(); + let mut b = original.clone(); + css::lfsr::descramble_sector(&title_key, &mut a); + css::lfsr::descramble_sector(&title_key, &mut b); + assert_eq!(a, b, "descramble must be deterministic"); + assert_ne!( + &a[0x80..2048], + &original[0x80..2048], + "descramble must transform the payload" + ); } } diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 823c740..766b0ef 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -27,21 +27,39 @@ fn decrypt_sectors_with_aacs_keys_works() { let unit_key: [u8; 16] = [0xAAu8; 16]; - // Encrypt the unit using AACS algorithm - aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data + // 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.) - // Now we have encrypted data - create DecryptKeys with actual keys - let mut keys = DecryptKeys::Aacs { + 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; - // decrypt_sectors should handle this without error - let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0); + // 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!( - result.is_ok(), - "decrypt_sectors with AACS keys should 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)" ); } @@ -111,6 +129,7 @@ fn decrypt_keys_is_encrypted_variants() { let aacs = DecryptKeys::Aacs { unit_keys: vec![], read_data_key: None, + format: libfreemkv::disc::ContentFormat::BdTs, }; assert!(aacs.is_encrypted()); diff --git a/tests/pass_n_size_aware_skip.rs b/tests/pass_n_size_aware_skip.rs index fe4bec6..549dd37 100644 --- a/tests/pass_n_size_aware_skip.rs +++ b/tests/pass_n_size_aware_skip.rs @@ -275,7 +275,6 @@ fn patch_block_sectors_zero_does_not_busy_spin() { progress: None, halt: Some(halt.clone()), key_fetch: None, - fast_capture: false, }; let outcome = disc.patch(&mut reader, &iso_path, &opts); diff --git a/tests/passn_handler_ab.rs b/tests/passn_handler_ab.rs index 49f5b5a..295c01d 100644 --- a/tests/passn_handler_ab.rs +++ b/tests/passn_handler_ab.rs @@ -232,8 +232,6 @@ struct Golden { bytes_unreadable: u64, /// `bytes_pending` (NonTrimmed) at end. bytes_pending: u64, - /// Did the pass exit via wedge-detection? - wedged_exit: bool, /// Sanity bound on trace length — patch makes a finite number of /// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus /// retries. Asserted as an UPPER bound only (so any reduction in @@ -319,7 +317,6 @@ fn profile_01_clean_all_recoverable() { bytes_good: capacity_sectors as u64 * 2048, bytes_unreadable: 0, bytes_pending: 0, - wedged_exit: false, max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8. }; assert_eq!(stats.bytes_good, expected.bytes_good, "01_clean bytes_good"); @@ -796,29 +793,190 @@ fn profile_08_batch_fail_singles_ok() { // ───────────────────────────────────────────────────────────────────────── // -// Suppressed for now: NOT_READY-then-recover, HARDWARE_ERROR (wedge), -// ILLEGAL_REQUEST (wedge), and ABORTED_COMMAND profiles. Each would -// trigger long real-time sleeps inside `handle_read_failure`: +// Sense-family error paths in `Disc::patch`: NOT_READY-then-recover, +// HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND. // -// - NOT_READY (sense_key=0x02, asc=0x02/0x03/0x04): 15 s pause per -// occurrence (`patch_not_ready_pause`), and retries the same LBA -// in-place. Even one NOT_READY costs the test 15 s wall-time. +// These drive `disc.patch(...)` DIRECTLY rather than through `run_profile` +// (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY / +// wedge cooldowns via `sleep_secs_or_halt`). The patch handler chain itself +// uses an injectable deadline clock (`Instant::now` in production) and never +// `thread::sleep`s, so these paths run at full speed with no wall-time cost — +// the earlier "sleeps aren't injectable" suppression only ever applied to the +// copy/sweep driver, not to patch. // -// - HARDWARE_ERROR / ILLEGAL_REQUEST: 30 s per occurrence -// (`WEDGE_FAMILY_COOLDOWN_SECS`), bounded by -// `WEDGE_ABORT_THRESHOLD=16` before wedged-exit. Worst case ~8 -// minutes per profile. -// -// The sleeps are not injectable. Adding them would require either a -// `now()` / `sleep()` trait injection (out of scope for the unification -// task) or a "test mode" compile-time flag (architectural smell). The -// behavioural contracts for those paths are captured in -// `read_error.rs`'s in-module tests instead — they exercise the -// classifier without invoking the patch loop's sleep side-effects. -// -// If the unification ever proceeds, the next step is to add a clock -// injection point in `handle_read_failure` and extend this fixture -// with the wedge/NOT_READY profiles too. +// The load-bearing invariant asserted across every PERSISTENT failure sense is +// the recovery contract: a patch pass NEVER promotes a sector to Unreadable +// (the orchestrator does that only after the final pass) and NEVER silently +// drops bytes — a still-bad sector stays NonTrimmed (pending), so +// good + pending always conserves the total. Exact good/pending splits are +// left loose so wedge-skip tuning can't spuriously fail these. + +/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192) +/// range) patch pass with the given failure step and return the final map +/// stats. 256-sector synthetic disc; everything outside the range is Finished. +fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats { + let capacity_sectors: u32 = 256; + let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors); + reader.always(130, step); + + let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64; + let disc = synthetic_disc(capacity_sectors); + let tmp = tempfile::NamedTempFile::new().unwrap(); + let iso_path = tmp.path().to_path_buf(); + drop(tmp); + let nontrimmed = [(128 * 2048, 64 * 2048)]; + let finished = [ + (0, 128 * 2048), + (192 * 2048, (capacity_sectors as u64 - 192) * 2048), + ]; + prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); + + let opts = libfreemkv::disc::PatchOptions { + decrypt: false, + block_sectors: Some(32), + full_recovery: true, + reverse: true, + wedged_threshold: 50, + progress: None, + halt: None, + key_fetch: None, + }; + disc.patch(&mut reader, &iso_path, &opts) + .expect("patch must not error on a per-sector failure sense"); + + let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); + let stats = Mapfile::load(&map_path).unwrap().stats(); + let _ = std::fs::remove_file(&iso_path); + let _ = std::fs::remove_file(&map_path); + stats +} + +/// A persistent sense that never clears must obey the pass contract: nothing +/// Unreadable, nothing lost (good + pending == total), and at least the dead +/// sector left pending. +fn assert_persistent_sense_contract(step: ScriptStep, label: &str) { + let stats = single_dead_sector_patch_stats(step); + let total = 256u64 * 2048; + assert_eq!( + stats.bytes_unreadable, 0, + "{label}: a patch pass must NEVER mark Unreadable" + ); + assert_eq!( + stats.bytes_good + stats.bytes_pending, + total, + "{label}: conservation — no byte may be silently dropped" + ); + assert!( + stats.bytes_pending >= 2048, + "{label}: the always-dead sector must remain pending (NonTrimmed)" + ); +} + +#[test] +fn patch_persistent_hardware_error_conserves_and_never_unreadable() { + // HARDWARE_ERROR (sense_key=0x04) — wedge family. + assert_persistent_sense_contract( + ScriptStep::Err { + sense_key: 0x04, + asc: 0x11, + ascq: 0x00, + }, + "HARDWARE_ERROR", + ); +} + +#[test] +fn patch_persistent_illegal_request_conserves_and_never_unreadable() { + // ILLEGAL_REQUEST (sense_key=0x05) — wedge family. + assert_persistent_sense_contract( + ScriptStep::Err { + sense_key: 0x05, + asc: 0x21, + ascq: 0x00, + }, + "ILLEGAL_REQUEST", + ); +} + +#[test] +fn patch_persistent_aborted_command_conserves_and_never_unreadable() { + // ABORTED_COMMAND (sense_key=0x0B). + assert_persistent_sense_contract( + ScriptStep::Err { + sense_key: 0x0B, + asc: 0x00, + ascq: 0x00, + }, + "ABORTED_COMMAND", + ); +} + +#[test] +fn patch_not_ready_then_recovers_fully() { + // NOT_READY (sense_key=0x02, asc=0x04) that clears after two attempts must + // recover the sector in-pass — no residual loss, no Unreadable, no hang. + let capacity_sectors: u32 = 256; + let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors); + reader.sequence( + 130, + vec![ + ScriptStep::Err { + sense_key: 0x02, + asc: 0x04, + ascq: 0x00, + }, + ScriptStep::Err { + sense_key: 0x02, + asc: 0x04, + ascq: 0x00, + }, + ScriptStep::Ok, + ], + ); + + let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64; + let disc = synthetic_disc(capacity_sectors); + let tmp = tempfile::NamedTempFile::new().unwrap(); + let iso_path = tmp.path().to_path_buf(); + drop(tmp); + let nontrimmed = [(128 * 2048, 64 * 2048)]; + let finished = [ + (0, 128 * 2048), + (192 * 2048, (capacity_sectors as u64 - 192) * 2048), + ]; + prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); + + let opts = libfreemkv::disc::PatchOptions { + decrypt: false, + block_sectors: Some(32), + full_recovery: true, + reverse: true, + wedged_threshold: 50, + progress: None, + halt: None, + key_fetch: None, + }; + disc.patch(&mut reader, &iso_path, &opts) + .expect("patch must not error on a transient NOT_READY"); + + let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); + let stats = Mapfile::load(&map_path).unwrap().stats(); + assert_eq!( + stats.bytes_unreadable, 0, + "NOT_READY recovery must not mark Unreadable" + ); + assert_eq!( + stats.bytes_pending, 0, + "a NOT_READY that clears must leave nothing pending" + ); + assert_eq!( + stats.bytes_good, + capacity_sectors as u64 * 2048, + "every sector recovers once NOT_READY clears" + ); + let _ = std::fs::remove_file(&iso_path); + let _ = std::fs::remove_file(&map_path); +} // ──────── Handler chain recovers re-readable sectors inside a bad block ──────── // @@ -826,9 +984,10 @@ fn profile_08_batch_fail_singles_ok() { // handler chain's linear pass narrows a failed batch to per-sector reads, so it // recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed — // strictly better than the old fast-capture path, which left the whole failed -// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The -// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is -// a scheduling concern for the handler scheduler, tracked separately.) +// 32-block untouched. (The old `fast_capture` knob was removed: the handler +// chain supersedes it. The breadth-first "fast on all ranges, then escalate" +// ORDERING it once provided is a scheduling concern for the handler scheduler, +// tracked separately.) // // The load-bearing invariant is unchanged: NO data is dropped. A still-bad // sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable. @@ -869,10 +1028,9 @@ fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() { progress: None, halt: None, key_fetch: None, - fast_capture: true, }; disc.patch(&mut reader, &iso_path, &opts) - .expect("fast-capture patch must not error"); + .expect("handler-chain patch must not error"); let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); let stats = Mapfile::load(&map_path).unwrap().stats();