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.
This commit is contained in:
Matthew Jackson
2026-07-15 19:35:12 -07:00
parent 04728d7d94
commit 830d1e360c
32 changed files with 1589 additions and 3126 deletions
+40 -47
View File
@@ -6,64 +6,57 @@
- **Mux no longer nulls decryptable video or storms the key server on a - **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 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 machinery in place. On a unit whose key *decrypted* but whose plaintext didn't
reassemble to clean MPEG-TS, the read path still restored the ciphertext, reassemble to clean MPEG-TS, the read path still restored ciphertext, tallied
tallied it as loss, re-asked the online key server (which returned the same loss, and re-asked the online key server (forever returning the same correct key)
correct key, forever), and the mux concealed the unit as NULL TS. On a UHD while the mux concealed the unit as NULL TS. The root cause: *"did a key produce
title with an authored bad-encoded run this stalled each region for 3090 s per clean TS?"* was used as the verdict *"did we decrypt?"* — they are not the same.
unit — the key server brute-forcing and re-returning the one right key — while A correct key can decrypt content with broken encoding; broken TS is a muxer
nulling video that had, in fact, already decrypted. The root cause was one concern, never a decrypt verdict.
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.
### Changed ### Changed
- **One decrypt authority; policy at the caller.** `decrypt_sectors` is now a - **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, pure decrypt: applies the CPS unit key in place, leaves plaintext, and reports
leaves the plaintext, and reports how many bytes did not reach clean TS unverified bytes. It never restores ciphertext, nulls, or re-fetches a key.
("unverified"). 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*
Clean TS is used only as a multi-key *selection* hint and a read *verify* signal (sweep/patch). Callers own the policy: the mux passes decrypted bytes
signal. The callers decide what an unverified unit means: through unconditionally (the demuxer handles bad TS); sweep/patch treat an
- **mux** (`read → decrypt → mux`): pass the decrypted bytes to the muxer, unverified unit as a failed read and re-read it. Removes the decrypt-time
whatever they are; the muxer handles bad TS. The mux never conceals, ciphertext restore, the mux NULL-TS conceal loop, and the per-unit key-server
re-fetches, or counts broken TS as loss — it fails loud only when it refetch, plus the dead `aacs_unit_still_ciphertext` predicate.
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.
This removes three duplicated decisions — the decrypt-time ciphertext restore, - **Decrypt and TS-structure are now separate primitives.** AACS has no MAC;
the mux NULL-TS conceal loop, and the per-unit key-server refetch — and the the only "did it decrypt?" signal is whether plaintext looks like MPEG-TS —
dead `aacs_unit_still_ciphertext` predicate. The key-fetch recovery now samples a data-quality / key-selection question, not a decrypt verdict. The old
the on-disc ciphertext explicitly (a pure decrypt leaves the buffer plaintext) `decrypt_unit(...) -> bool` is split into `decrypt_unit_raw` (pure crypto) and
and lives only on the rip/verify path, never the mux. `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 ## [1.4.1] — 2026-07-14
### Fixed ### Fixed
- **Mux no longer discards good video over a single defective packet.** AACS - **Mux no longer discards good video over a single defective packet.** AACS
content decryption judged a 6144-byte aligned unit "undecryptable" unless decryption required **every** content packet to be conformant MPEG-TS: one
**every** content packet was conformant MPEG-TS. A single authored-bad packet authored-bad packet (encoding defect, AACS 2.1 forensic-variant frame) made
— a pressing/encoding defect, or an AACS 2.1 forensic-variant frame — made the the mux conceal the **whole** 6144-byte aligned unit as NULL TS (up to 31/32
mux conceal the **whole** unit as NULL TS, destroying up to 31 of 32 good good packets discarded, tallied as loss). On affected discs this produced
packets and tallying them as loss. On discs carrying such packets this false "corruption" over otherwise-perfect video (~466 MB concealed across two
surfaced as false "corruption" over large runs of otherwise-perfect video UHD titles). The gate is now a padding-aware **≥75% supermajority** of content
(observed across two UHD titles: ~466 MB concealed, every unit decryptable). packets restoring their `0x47` sync — no wrong key reaches this threshold
The decrypt path now asks only *"did a key OPEN this unit?"* — a padding-aware (uniform-AES noise floor ≈ 256⁻ⁿ), but a minority of authored-bad packets
**≥75% supermajority** of content packets restoring their `0x47` sync, a gate still passes. Opened units flow through verbatim; the demuxer drops
no wrong key can reach (uniform-AES noise floor ≈ 256⁻ⁿ) yet one that tolerates non-conforming packets on sync-loss. TS-sync conformance is a muxer concern,
a minority of authored-bad packets. Opened units pass through **verbatim**; a never a decrypt verdict. (The supermajority threshold is tightened in 1.4.2.)
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.
- **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC` - **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC`
`CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's `CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's
`BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord` `BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord`
+403 -660
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -463,7 +463,7 @@ pub enum KeyCandidate {
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every /// 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 /// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
/// its declared CPS-unit number); the caller runs /// 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`. /// disc. Rungs above the candidate are `None`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResolvedChain { pub struct ResolvedChain {
@@ -487,7 +487,7 @@ pub struct ResolvedChain {
/// ///
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate /// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
/// `unit_keys` against a real encrypted unit with /// `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 /// 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 /// 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) let version = mkb_type(mkb)
.map(|t| t.generation()) .map(|t| t.generation())
.unwrap_or(AacsVersion::V10); .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() { if ukf.encrypted_keys.is_empty() {
return None; return None;
} }
+172
View File
@@ -161,6 +161,87 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
}) })
} }
/// HD DVD Video Title Key File (`VTKF000.AACS`) magic — "DVD HD Video TKF".
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
/// Fixed header length before the first title-key entry.
const VTKF_HEADER_LEN: usize = 0x80;
/// Each title-key entry: BE32 flag + 16-byte encrypted key + 12-byte 0xFF pad.
const VTKF_ENTRY_LEN: usize = 0x20;
/// Parse an HD DVD `VTKF000.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. Only
/// the on-disc CONTAINER differs between BD and HD DVD; the title-key unwrap is
/// the identical AES-128 VUK step (`Kt = AES-128D(Kvu, Kte)`).
///
/// Layout (grounded in real discs — Shaun of the Dead, Anchorman, Harry Potter):
/// ```text
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
/// [0x0C..0x10] BE32 total file length
/// [0x10..0x1C] associated playlist name ("VPLST000.XPL")
/// [0x1C..0x80] reserved (zero)
/// [0x80..] 32-byte entries: BE32 flag | 16-byte ENCRYPTED title key | 12-byte 0xFF pad
/// flag bit 31 (0x8000_0000) set = present; a cleared flag ends the table
/// [tail] 16-byte signature/MAC (never a key — the cleared-flag stop guards it)
/// ```
/// Entries number 1..=N as CPS units, matching `Unit_Key_RO`'s 1-based CPS
/// numbering, so a title's CPS unit indexes this list identically. The
/// title→CPS mapping itself is playlist-driven (`VPLST000.XPL`) and owned by the
/// HD DVD enumerator, so `title_cps_unit` is left empty here.
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
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<UnitKeyFile> {
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
parse_vtkf(data)
} else {
parse_unit_key_ro(data, version)
}
}
/// MKB disc structure format code. /// MKB disc structure format code.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83; const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
@@ -283,3 +364,94 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
version, 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<u8> {
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));
}
}
+53 -5
View File
@@ -40,10 +40,18 @@ pub mod types;
pub mod variant; pub mod variant;
pub mod variant_select; pub mod variant_select;
/// On-disc UDF paths to the AACS key-input files (with their fallbacks). /// On-disc UDF paths to the AACS key-input files.
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`, ///
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding /// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
/// or changing a fallback in one place can then never silently diverge 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. /// 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: &str = "/AACS/Unit_Key_RO.inf";
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/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_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer"; pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.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<F>(candidates: &[&str], mut read: F) -> crate::error::Result<Vec<u8>>
where
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
{
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 // The module structure IS the public API — consumers import from the owning
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`, // 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 // content-decrypt entry points that downstream key-source crates import through
// the `aacs::` path. These are the stable, load-bearing names; keeping them here // 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. // 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 derive::derive_vuk;
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk}; pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
+4 -3
View File
@@ -156,7 +156,7 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
/// equivalent of path 2 — there's no host-side PK derivation against a /// equivalent of path 2 — there's no host-side PK derivation against a
/// Variant MKB.) /// Variant MKB.)
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> { pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
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 hash_hex = disc_hash_hex(&uk_file.disc_hash);
let bus_encryption = ctx let bus_encryption = ctx
.content_cert .content_cert
@@ -280,8 +280,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
.map(|cc| cc.bus_encryption) .map(|cc| cc.bus_encryption)
.unwrap_or(false); .unwrap_or(false);
// Parse Unit_Key_RO.inf at the version-appropriate stride. // Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?; // 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 hash_hex = disc_hash_hex(&uk_file.disc_hash);
let has_vid = *ctx.volume_id != [0u8; 16]; let has_vid = *ctx.volume_id != [0u8; 16];
+110 -39
View File
@@ -150,10 +150,15 @@ pub fn decrypt_threads() -> usize {
pub enum DecryptKeys { pub enum DecryptKeys {
/// No encryption on this disc. /// No encryption on this disc.
None, 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 { Aacs {
unit_keys: Vec<(u32, [u8; 16])>, unit_keys: Vec<(u32, [u8; 16])>,
read_data_key: Option<[u8; 16]>, read_data_key: Option<[u8; 16]>,
format: crate::disc::ContentFormat,
}, },
/// CSS (DVD). Title key for sector descrambling. /// CSS (DVD). Title key for sector descrambling.
Css { title_key: [u8; 5] }, 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 /// 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. /// 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( pub fn decrypt_sectors_in_content(
buf: &mut [u8], buf: &mut [u8],
keys: &mut DecryptKeys, keys: &mut DecryptKeys,
@@ -242,6 +248,7 @@ fn decrypt_sectors_impl(
DecryptKeys::Aacs { DecryptKeys::Aacs {
unit_keys, unit_keys,
read_data_key, read_data_key,
format,
} => { } => {
// Validate that unit_key_idx is in-range before doing anything else. // Validate that unit_key_idx is in-range before doing anything else.
// This preserves the existing contract: an out-of-range explicit index // 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); return Err(crate::error::Error::DecryptFailed);
} }
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes. // Container of this disc's content the key SELECTOR (`is_clean`)
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect(); // 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 rdk: Option<[u8; 16]> = *read_data_key;
let unit_len = aacs::content::ALIGNED_UNIT_LEN; let unit_len = aacs::content::ALIGNED_UNIT_LEN;
// AACS decrypts whole 6144-byte aligned units. The live mux path // 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), Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
None => true, 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 partial = &buf[buf.len() - partial_len..];
let packets = aacs::content::ts_packet_total(partial); let packets = aacs::content::ts_packet_total(partial);
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 { 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 // must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc. // across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| { let decrypt_one = |chunk: &mut [u8]| {
// Gate on `aacs_unit_needs_decrypt` (CPI set AND TS syncs not yet // Gate on `aacs_unit_needs_decrypt` (encrypted-flag set AND structure
// restored): CPI alone isn't enough because the plaintext seed keeps // not yet restored): the flag alone isn't enough because it lives in
// the CPI bit set after decryption, so an already-decrypted unit would // the plaintext header and survives decryption, so an already-decrypted
// be decrypted a SECOND time (scrambling it) on any re-run of this // unit would be decrypted a SECOND time (scrambling it) on any re-run of
// pass. The intact-TS half makes it idempotent. // this pass. The structure-restored half makes it idempotent. This is
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) { // 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; return;
} }
@@ -355,41 +376,67 @@ fn decrypt_sectors_impl(
// back to the full list skipping the hint. // back to the full list skipping the hint.
let hint = last_key_idx.load(Ordering::Relaxed); let hint = last_key_idx.load(Ordering::Relaxed);
let try_order = 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 // Compose the two SEGREGATED primitives explicitly. `decrypt_unit`
// produce clean TS?" is NOT "did we decrypt?": a correct key can // is the decrypt (apply the key, leave the plaintext). `is_clean`
// decrypt content whose underlying encoding is broken (bad TS sync), // is a SEPARATE structural question used here ONLY as a multi-CPS-unit
// which is a MUXER concern, never a decrypt verdict. Clean TS is used // key SELECTOR — the first key whose output is clean for the disc's
// ONLY as a key-SELECTION hint on multi-CPS-unit discs — the first // container (`format`: TS or PS) is the match. "Did a key produce
// key that yields clean TS is the definite match. When none does we // clean structure?" is NOT "did we decrypt?": a correct key can
// STILL decrypted (the cached-hint key is applied): keep those bytes // decrypt content whose encoding is broken (a muxer concern). When
// and report the unit as UNVERIFIED. This function applies no policy; // NO key yields clean structure we STILL decrypted (the cached-hint
// the caller decides what an unverified unit means (the mux passes it // key is applied): keep those bytes and report the unit UNVERIFIED.
// to the muxer; sweep/patch treat it as a read to recover or fail). // This function applies no policy; the caller decides what unverified
let mut applied: Option<Vec<u8>> = None; // means (mux passes it to the muxer; sweep/patch recover or fail).
// Single-key fast path (the vast majority of titles): with no
// alternate key to fall back on there is nothing to try/rollback,
// so decrypt in place — no per-unit scratch alloc or copy-back.
// Clean → cache the hint; unclean → keep the applied bytes and
// tally unverified, exactly as the loop below would with one key.
if unit_keys.len() == 1 {
aacs::content::decrypt_unit(chunk, &unit_keys[0].1);
if aacs::content::is_clean(chunk, format) {
last_key_idx.store(0, Ordering::Relaxed);
} else {
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
}
return;
}
// Trial each key against a STACK scratch (unit_len is always
// ALIGNED_UNIT_LEN and the guard above proved chunk.len() == unit_len)
// so a failing attempt doesn't clobber the bus-decrypted base in
// `chunk` that the next key retries on — with no per-key heap Vec.
// `chunk` is NOT mutated in this loop, so on total miss we simply
// re-apply the first key in place (decrypt_unit is pure), which
// reproduces the first attempt without stashing its bytes.
let mut scratch = [0u8; aacs::content::ALIGNED_UNIT_LEN];
let scratch = &mut scratch[..chunk.len()];
let mut first_idx: Option<usize> = None;
for idx in try_order { for idx in try_order {
if let Some(key) = raw_keys.get(idx) { if let Some((_, key)) = unit_keys.get(idx) {
// Work on a per-key copy so a failing attempt doesn't scratch.copy_from_slice(chunk);
// clobber the bus-decrypted base we'll retry on. aacs::content::decrypt_unit(scratch, key);
let mut attempt: Vec<u8> = chunk.to_vec(); if aacs::content::is_clean(scratch, format) {
if aacs::content::decrypt_unit(&mut attempt, key) { chunk.copy_from_slice(scratch);
chunk.copy_from_slice(&attempt);
last_key_idx.store(idx, Ordering::Relaxed); last_key_idx.store(idx, Ordering::Relaxed);
return; return;
} }
if applied.is_none() { if first_idx.is_none() {
applied = Some(attempt); first_idx = Some(idx);
} }
} }
} }
// No key yielded clean TS. Keep the applied-key plaintext (the pool is // No key yielded clean structure. Keep the first-tried key's
// non-empty past the guard, so `applied` is always `Some`) and tally // plaintext (the pool is non-empty past the guard, so `first_idx` is
// the unit as unverified. Never restore ciphertext; that is a caller // always `Some`) and tally the unit as unverified. Never restore
// concern, threaded through the recovery ciphertext, not this seam. // ciphertext; that is a caller concern, threaded through the recovery
if let Some(decrypted) = applied { // ciphertext, not this seam.
chunk.copy_from_slice(&decrypted); if let Some(idx) = first_idx {
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
} }
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed); dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
}; };
@@ -427,9 +474,12 @@ fn decrypt_sectors_impl(
// back to the serial path rather than panic. // back to the serial path rather than panic.
match decrypt_pool() { match decrypt_pool() {
Some(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(|| { pool.install(|| {
chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| { buf.par_chunks_mut(unit_len)
.enumerate()
.for_each(|(idx, chunk)| {
process(idx, chunk); process(idx, chunk);
}); });
}); });
@@ -483,6 +533,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110), // 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. // so this nav unit is OUTSIDE content and the gate skips it untouched.
@@ -554,6 +605,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN); let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -587,6 +639,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN); let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)]. // unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
@@ -605,6 +658,7 @@ mod tests {
let mut keys_g = DecryptKeys::Aacs { let mut keys_g = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut keys_u = keys_g.clone(); let mut keys_u = keys_g.clone();
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN); let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -655,6 +709,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN); let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone(); let mut buf = original.clone();
@@ -673,6 +728,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone(); let mut buf = original.clone();
@@ -791,6 +847,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let u = aacs::content::ALIGNED_UNIT_LEN; let u = aacs::content::ALIGNED_UNIT_LEN;
let mut buf = vec![0u8; 3 * u]; let mut buf = vec![0u8; 3 * u];
@@ -808,6 +865,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN); let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)]. // unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
@@ -827,6 +885,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
// One full clear unit + a scrambled single-sector partial, all OUTSIDE // One full clear unit + a scrambled single-sector partial, all OUTSIDE
// content → the partial must be tolerated (Ok), not DecryptFailed. // content → the partial must be tolerated (Ok), not DecryptFailed.
@@ -849,6 +908,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail. // One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN); let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -874,6 +934,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail. // One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -896,6 +957,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf: Vec<u8> = Vec::new(); let mut buf: Vec<u8> = Vec::new();
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok()); assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
@@ -909,6 +971,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2); let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
let snapshot = buf.clone(); let snapshot = buf.clone();
@@ -950,6 +1013,7 @@ mod tests {
DecryptKeys::Aacs { DecryptKeys::Aacs {
unit_keys: vec![(0, [0; 16])], unit_keys: vec![(0, [0; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
} }
.is_encrypted() .is_encrypted()
); );
@@ -1164,6 +1228,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 5) let err = decrypt_sectors(&mut buf, &mut keys, 5)
@@ -1184,6 +1249,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![], unit_keys: vec![],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN); 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"); 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 { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key0), (1, key1)], // two CPS units unit_keys: vec![(0, key0), (1, key1)], // two CPS units
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
// Call with the default hint (idx 0) — the fix must fall back to key1. // 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 { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)], unit_keys: vec![(0, key)],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = unit; let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt"); decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
@@ -1340,6 +1408,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)], unit_keys: vec![(0, wrong_key)],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = unit; let mut buf = unit;
let unverified = let unverified =
@@ -1383,6 +1452,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)], unit_keys: vec![(0, key)],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok"); 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 { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)], unit_keys: vec![(0, key)],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = unit; let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt"); let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
+3 -7
View File
@@ -311,15 +311,11 @@ impl Disc {
) -> Result<AacsState> { ) -> Result<AacsState> {
use crate::aacs; use crate::aacs;
let uk_ro_data = udf_fs let uk_ro_data =
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO) aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let dh = aacs::inf::disc_hash(&uk_ro_data); let dh = aacs::inf::disc_hash(&uk_ro_data);
let cc = udf_fs let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok() .ok()
.as_deref() .as_deref()
.and_then(aacs::inf::parse_content_cert); .and_then(aacs::inf::parse_content_cert);
-57
View File
@@ -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<crate::disc::verify::ClipLayout> {
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
let fs = udf::read_filesystem(reader)?;
let mut planned: Vec<PlannedFile> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
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 /// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
/// inner source for its lifetime while the caller keeps the underlying /// inner source for its lifetime while the caller keeps the underlying
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>` /// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
+73 -121
View File
@@ -10,7 +10,7 @@
mod bluray; mod bluray;
mod dvd; mod dvd;
pub mod dvd_audio_probe; pub(crate) mod dvd_audio_probe;
mod encrypt; mod encrypt;
mod extract; mod extract;
mod hddvd; mod hddvd;
@@ -19,7 +19,6 @@ mod patch;
pub mod read_error; pub mod read_error;
mod section_recover; mod section_recover;
mod sweep; mod sweep;
pub mod verify;
use crate::drive::{Drive, extract_scsi_context}; use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -85,7 +84,8 @@ pub struct Disc {
pub enum ContentFormat { pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets) /// Blu-ray BD Transport Stream (192-byte packets)
BdTs, 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, MpegPs,
} }
@@ -1623,12 +1623,11 @@ impl Disc {
// detection needs the read, the read needs auth, auth needs detection. // detection needs the read, the read needs auth, auth needs detection.
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD // 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 // 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 // in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
// through the AACS handshake above and never reaches here. // `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
if disc.css.is_none() // not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
&& disc.content_format == ContentFormat::MpegPs // through the AACS path above). BD/UHD are MPEG-TS and never reach here.
&& !disc.titles.is_empty() 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 // CSS title keys are per-VTS, and ONLY the scrambled movie content
// carries a non-zero key. Menu / VMG / logo cells (often the // carries a non-zero key. Menu / VMG / logo cells (often the
// low-LBA first extent) return a ZERO title key over REPORT KEY — // 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, // 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 // so `crack_key` finds no crackable sector and the disc stays in the
// clear. AACS images go through KEYDB VUK lookup, not here. // clear. AACS images go through KEYDB VUK lookup, not here.
if disc.css.is_none() //
&& disc.content_format == ContentFormat::MpegPs // Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
&& !disc.titles.is_empty() // `.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 let main_extents = match disc
.titles .titles
.iter() .iter()
@@ -1799,10 +1801,9 @@ impl Disc {
reader: &mut dyn SectorSource, reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs, udf_fs: &udf::UdfFs,
) -> Result<(Vec<u8>, Vec<u8>, u8)> { ) -> Result<(Vec<u8>, Vec<u8>, u8)> {
let inf = udf_fs let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO) udf_fs.read_file(reader, p)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE)) })?;
.map_err(|_| Error::AacsNoKeys)?;
let mkb = Self::read_mkb_content(reader, udf_fs)?; let mkb = Self::read_mkb_content(reader, udf_fs)?;
let version = Self::read_aacs_version(reader, udf_fs); let version = Self::read_aacs_version(reader, udf_fs);
Ok((inf, mkb, version)) Ok((inf, mkb, version))
@@ -1820,9 +1821,9 @@ impl Disc {
/// mis-strided title keys (silent wrong unit keys), so a missing cert must /// mis-strided title keys (silent wrong unit keys), so a missing cert must
/// not quietly pick the V10 stride for a UHD disc. /// not quietly pick the V10 stride for a UHD disc.
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 { fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
match udf_fs match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
.read_file(reader, crate::aacs::PATH_CONTENT_CERT) udf_fs.read_file(reader, p)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT)) })
.ok() .ok()
.as_deref() .as_deref()
.and_then(crate::aacs::inf::parse_content_cert) .and_then(crate::aacs::inf::parse_content_cert)
@@ -1856,10 +1857,9 @@ impl Disc {
const MAX_BYTES: usize = 64 * 1024 * 1024; const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES; let mut want = START_BYTES;
loop { loop {
let buf = udf_fs let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want) udf_fs.read_file_prefix(reader, p, want)
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want)) })?;
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb::mkb_content_len(&buf); let n = crate::aacs::mkb::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding // `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` => // boundary (full content captured). `buf` shorter than `want` =>
@@ -2297,12 +2297,15 @@ fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])], unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>, read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>], samples: &[Vec<u8>],
format: ContentFormat,
) -> bool { ) -> 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 let scrambled: Vec<&[u8]> = samples
.iter() .iter()
.map(|s| s.as_slice()) .map(|s| s.as_slice())
.filter(|s| aacs_unit_needs_decrypt(s)) .filter(|s| aacs_unit_needs_decrypt(s, format))
.collect(); .collect();
if scrambled.is_empty() { if scrambled.is_empty() {
return true; // nothing to disprove against — accept return true; // nothing to disprove against — accept
@@ -2324,7 +2327,13 @@ fn aligned_unit_keys_validate(
hb.tick_cpu(tried, total); hb.tick_cpu(tried, total);
tried += 1; tried += 1;
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]); 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; covered = true;
break; break;
} }
@@ -2351,6 +2360,7 @@ impl Disc {
crate::decrypt::DecryptKeys::Aacs { crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(), unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key, read_data_key: aacs.read_data_key,
format: self.content_format,
} }
} else if let Some(ref css) = self.css { } else if let Some(ref css) = self.css {
crate::decrypt::DecryptKeys::Css { crate::decrypt::DecryptKeys::Css {
@@ -2840,7 +2850,12 @@ impl Disc {
// de-scramble it. With no samples (or only clear ones) there is nothing // 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 // to disprove against, so the key is accepted as-is — keeping the
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged. // 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); return Err(crate::error::Error::AacsKeyRejected);
} }
@@ -3073,10 +3088,6 @@ impl Disc {
progress: opts.progress, progress: opts.progress,
halt: opts.halt.clone(), halt: opts.halt.clone(),
key_fetch: opts.key_fetch.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)?; let pr = self.patch(reader, path, &patch_opts)?;
tracing::info!( tracing::info!(
@@ -3131,27 +3142,15 @@ impl Disc {
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without // A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext. // `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
// //
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the // Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we // plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a
// still resolve the keys and VERIFY each unit on a scratch copy: a unit // pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like // read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure.
// a SCSI error, and flows into the SAME read-error recovery (skip / // (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute
// NonTrimmed / patch). This is the one spot that makes "a read succeeded" // unit grid against clip-file-anchored AACS units and false-failed good
// mean "read AND decrypts" — everything downstream is unchanged. With no // clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
// usable AACS keys (no keydb) it degrades to a plain pass-through. // verify and no post-sweep clip-anchored pass; decryptability is proven at
// // mux time, not at capture time.)
// 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.
let keys = if opts.decrypt { let keys = if opts.decrypt {
self.decrypt_keys() self.decrypt_keys()
} else { } else {
@@ -3177,22 +3176,6 @@ impl Disc {
}; };
let reader = &mut reader; 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. // Mapfile: load if resuming, else wipe + recreate.
let mapfile_path = self.mapfile_for(path); let mapfile_path = self.mapfile_for(path);
// covers_disc reconciliation. A resume against a mapfile whose total // covers_disc reconciliation. A resume against a mapfile whose total
@@ -3483,18 +3466,6 @@ impl Disc {
// The consumer thread sees decrypted bytes; the // The consumer thread sees decrypted bytes; the
// pre-0.18 inline decrypt_sectors call lived here. // 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 // Move the batch into the channel via fresh
// owned Vec. The producer's `buf` is reused // owned Vec. The producer's `buf` is reused
// for the next read. // for the next read.
@@ -3503,26 +3474,6 @@ impl Disc {
producer_err = Some(consumer_gone()); producer_err = Some(consumer_gone());
break 'outer; 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); bytes_done = bytes_done.saturating_add(block_bytes);
pos += 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 /// 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. /// recover an orphan CPS unit's key when re-reading its bad range.
pub key_fetch: Option<crate::sector::KeyFetch>, pub key_fetch: Option<crate::sector::KeyFetch>,
/// 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`]. /// Result returned by [`Disc::patch`].
@@ -4867,6 +4806,7 @@ mod tests {
crate::decrypt::DecryptKeys::Aacs { crate::decrypt::DecryptKeys::Aacs {
unit_keys, unit_keys,
read_data_key, read_data_key,
..
} => { } => {
assert_eq!(unit_keys, uk, "injected UK must be the decrypt 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"); assert_eq!(read_data_key, None, "ISO mux needs no bus key");
@@ -5393,7 +5333,8 @@ mod tests {
assert!(super::aligned_unit_keys_validate( assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])], &[(0, [0x11u8; 16])],
None, None,
&[] &[],
ContentFormat::BdTs
)); ));
// A clear unit (TS syncs intact) is not scrambled -> proves nothing -> // A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
@@ -5408,7 +5349,8 @@ mod tests {
assert!(super::aligned_unit_keys_validate( assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])], &[(0, [0x11u8; 16])],
None, None,
&[clear.clone()] &[clear.clone()],
ContentFormat::BdTs
)); ));
// A genuinely scrambled unit the RIGHT key restores to clear TS. // A genuinely scrambled unit the RIGHT key restores to clear TS.
@@ -5423,16 +5365,23 @@ mod tests {
assert!(super::aligned_unit_keys_validate( assert!(super::aligned_unit_keys_validate(
&[(7, uk)], &[(7, uk)],
None, None,
&[enc.clone()] &[enc.clone()],
ContentFormat::BdTs
)); ));
// Wrong key -> cannot de-scramble a scrambled sample -> reject. // Wrong key -> cannot de-scramble a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate( assert!(!super::aligned_unit_keys_validate(
&[(7, [0x00u8; 16])], &[(7, [0x00u8; 16])],
None, None,
&[enc.clone()] &[enc.clone()],
ContentFormat::BdTs
)); ));
// Empty key set against a scrambled sample -> reject. // 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] #[test]
@@ -5467,21 +5416,24 @@ mod tests {
assert!(!super::aligned_unit_keys_validate( assert!(!super::aligned_unit_keys_validate(
&[(0, uk0)], &[(0, uk0)],
None, None,
&samples &samples,
ContentFormat::BdTs
)); ));
// Complete key set (both CPS units) -> accept. // Complete key set (both CPS units) -> accept.
assert!(super::aligned_unit_keys_validate( assert!(super::aligned_unit_keys_validate(
&[(0, uk0), (1, uk1)], &[(0, uk0), (1, uk1)],
None, None,
&samples &samples,
ContentFormat::BdTs
)); ));
// Order-independent: covering key present anywhere in the set is fine. // Order-independent: covering key present anywhere in the set is fine.
assert!(super::aligned_unit_keys_validate( assert!(super::aligned_unit_keys_validate(
&[(1, uk1), (0, uk0)], &[(1, uk1), (0, uk0)],
None, None,
&samples &samples,
ContentFormat::BdTs
)); ));
} }
+32 -86
View File
@@ -405,9 +405,15 @@ pub(super) fn compute_initial_state(
bad_ranges.reverse(); bad_ranges.reverse();
} }
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum(); 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) let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file()) .map(|m| m.file_type().is_file())
.unwrap_or(false); .unwrap_or(true);
Ok(( Ok((
map, map,
initial_stats, initial_stats,
@@ -1247,7 +1253,24 @@ impl Disc {
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 { pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
let map = match mapfile::Mapfile::load(mapfile_path) { let map = match mapfile::Mapfile::load(mapfile_path) {
Ok(m) => m, 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(&[ let bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTrimmed, mapfile::SectorStatus::NonTrimmed,
@@ -1299,33 +1322,13 @@ impl Disc {
let bytes_good_before = initial_stats.bytes_good; let bytes_good_before = initial_stats.bytes_good;
let bytes_good_start = bytes_good_before; 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 // Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting // (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting
// patch (the multipass / `--raw --multipass` path) resolves the keys and // patch (the multipass / `--raw --multipass` path) copies ciphertext
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt // verbatim (keys = `None` → pass-through). Bad sectors are found by
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the // PHYSICAL read success, not by decrypt structure: a re-read that returns
// retry loop keeps re-reading it "until it decrypts or retries exhaust" // good bytes recovers the range; a read that errors leaves it NonTrimmed
// exactly as for a SCSI read error — and a unit that DOES decrypt on a // for the next pass. (The old decrypt-VERIFY read gate was removed.)
// 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.
let keys = if opts.decrypt { let keys = if opts.decrypt {
self.decrypt_keys() self.decrypt_keys()
} else { } else {
@@ -1435,64 +1438,7 @@ impl Disc {
// sink's summary. `close` failing on a regular-file sync_all is // sink's summary. `close` failing on a regular-file sync_all is
// surfaced here as `Error::IoError`, matching pre-split // surfaced here as `Error::IoError`, matching pre-split
// behaviour. // behaviour.
let mut summary = pipe.finish()?; let 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 outcome = build_outcome( let outcome = build_outcome(
&state, &state,
-14
View File
@@ -66,14 +66,6 @@ pub(super) enum WorkItem {
/// tell them apart without parsing a flag. /// tell them apart without parsing a flag.
GapFill { pos: u64, len: u64 }, 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 /// Producer wants the latest mapfile stats for the progress
/// callback. Consumer responds on `prog_tx` with a fresh /// callback. Consumer responds on `prog_tx` with a fresh
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't /// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
@@ -182,12 +174,6 @@ impl Sink<WorkItem> for SweepSink {
} }
self.map.record(pos, len, SectorStatus::NonTrimmed)?; 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 => { WorkItem::StatsRequest => {
let stats = self.map.stats(); let stats = self.map.stats();
// DAMAGE only — NOT NonTried. NonTried is the unread remainder // DAMAGE only — NOT NonTried. NonTried is the unread remainder
-1085
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -78,8 +78,8 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page /// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
/// cache stays bounded the same way the write side does. /// cache stays bounded the same way the write side does.
/// ///
/// 32 MiB is the empirically tuned value on the rip1 test bed (single /// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the /// smaller windows (8 / 16 MiB) shorten the
/// kernel-readahead overlap and slow the producer; larger windows /// kernel-readahead overlap and slow the producer; larger windows
/// (64 / 128 MiB) let the page cache pin enough of the ISO to /// (64 / 128 MiB) let the page cache pin enough of the ISO to
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`. /// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
+3 -3
View File
@@ -407,7 +407,7 @@ pub fn read_encrypted_units(
break; break;
} }
let u = &buf[o..o + ALIGNED_UNIT_LEN]; 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()); out.push(u.to_vec());
if out.len() >= n { if out.len() >= n {
return out; return out;
@@ -717,7 +717,7 @@ mod tests {
); );
for s in &samples { for s in &samples {
assert!( assert!(
aacs_unit_encrypted(s), aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)" "every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
); );
} }
@@ -800,7 +800,7 @@ mod tests {
); );
for s in &samples { for s in &samples {
assert!( assert!(
aacs_unit_encrypted(s), aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
"only CPI-flagged units are selected" "only CPI-flagged units are selected"
); );
assert_eq!( assert_eq!(
+17 -5
View File
@@ -241,11 +241,23 @@ impl EsWriter for AnnexBWriter {
/// Delegates to the canonical hvcC/avcC → Annex-B converters in /// Delegates to the canonical hvcC/avcC → Annex-B converters in
/// [`crate::mux::hevc`] — the single source of truth across all muxers. /// [`crate::mux::hevc`] — the single source of truth across all muxers.
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> { fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
match codec { let converted = match codec {
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(), Codec::Hevc => hvcc_to_annex_b(record),
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(), Codec::H264 => avcc_to_annex_b(record),
_ => Vec::new(), _ => 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. /// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
+1 -1
View File
@@ -7,7 +7,7 @@
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt //! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
//! already runs on a producer thread; the *consumer* (main) thread //! already runs on a producer thread; the *consumer* (main) thread
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec //! 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 //! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
//! enough that pipelining it with parse pays for itself. //! enough that pipelining it with parse pays for itself.
//! //!
+7 -18
View File
@@ -108,11 +108,6 @@ pub struct DiscStream {
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None` /// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
/// (raw / unencrypted disc) makes the decorator a pass-through. /// (raw / unencrypted disc) makes the decorator a pass-through.
reader: DecryptingSectorSource<Box<dyn SectorSource>>, reader: DecryptingSectorSource<Box<dyn SectorSource>>,
/// 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<std::sync::atomic::AtomicU64>,
title: DiscTitle, title: DiscTitle,
/// Mirror of the keys handed in at construction. The decorator /// Mirror of the keys handed in at construction. The decorator
/// owns the cryptographic state; this field is kept for /// 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 // 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 // genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
// single-pass / direct), never the ciphertext-preserving sweep. // single-pass / direct), never the ciphertext-preserving sweep.
let mut reader = let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's // Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by // 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 // the decorator is a pass-through). Reset the unit base the probe read
// advanced so the first fill_extents read starts cleanly. // advanced so the first fill_extents read starts cleanly.
reader.set_unit_base(0); 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 // B1 resync gates: one per stream, video flagged so the gate only
// drop-to-keyframes video (audio/subtitle always admit). Computed before // drop-to-keyframes video (audio/subtitle always admit). Computed before
@@ -312,7 +303,6 @@ impl DiscStream {
Self { Self {
reader, reader,
decrypt_loss,
title, title,
decrypt_keys, decrypt_keys,
unit_align, unit_align,
@@ -1004,14 +994,12 @@ impl crate::pes::Stream for DiscStream {
} }
fn lost_bytes(&self) -> u64 { fn lost_bytes(&self) -> u64 {
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time // Read-error zero-fill loss (counted in fill_extents) — real missing
// loss — bytes of scrambled AACS units the decorator could not decrypt // content the abort gate must see. There is no decrypt-loss term: the
// and passed through still encrypted (the TS assembler silently drops // decrypt path passes bad-encoded/undecryptable units through (a broken-TS
// them). Both are real missing content the abort gate must see; without // unit is the muxer's concern, and a missing key is indistinguishable from
// the decrypt term a partial key failure reports lost_bytes=0 and a rip // bad authoring here), so only physical read loss is reported.
// missing segments passes even under abort_on_lost_secs=0.
self.lost_bytes 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 { let keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])], unit_keys: vec![(0, [0u8; 16])],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs); let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
stream.skip_errors = true; stream.skip_errors = true;
+31 -13
View File
@@ -785,23 +785,35 @@ fn block_ts(is_video: bool, prev: Option<i64>, pts_ticks: i64) -> i64 {
/// Encode a Matroska track number as an EBML VINT into a stack buffer, /// 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, /// 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; /// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`,
/// no heap allocation, called once per block on the mux hot path. /// 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` /// Each width uses a marker bit that must NOT collide with the payload's top
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and /// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40
/// OR-ing the 0x40 length marker would clobber it, corrupting the track /// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all
/// number. Not reachable today (track numbers are `i+1` over a few streams), /// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range
/// so this documents the bound rather than handling 3-byte VINTs. /// track number can never silently clobber the marker bit and corrupt the
fn track_vint(track_num: usize) -> ([u8; 2], usize) { /// 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 { 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 { } else {
debug_assert!( debug_assert!(
track_num < 0x4000, track_num < 0x20_0000,
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range" "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] #[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. // 1-byte form for track numbers < 0x80, high bit set.
let (b, n) = track_vint(1); let (b, n) = track_vint(1);
assert_eq!(&b[..n], &[0x81]); assert_eq!(&b[..n], &[0x81]);
@@ -3518,6 +3530,12 @@ mod tests {
assert_eq!(&b[..n], &[0x40, 0x80]); assert_eq!(&b[..n], &[0x40, 0x80]);
let (b, n) = track_vint(0x3FFF); let (b, n) = track_vint(0x3FFF);
assert_eq!(&b[..n], &[0x7F, 0xFF]); 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]);
} }
// ============================================================ // ============================================================
+5 -1
View File
@@ -1016,7 +1016,11 @@ fn parse_track(
arem = arem.saturating_sub(ahlen as u64 + as_); arem = arem.saturating_sub(ahlen as u64 + as_);
match aid { match aid {
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?, 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_)?; skip_bytes(r, as_)?;
} }
+3 -34
View File
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
/// `std::env::var_os` takes a process-wide lock, so the per-batch / /// `std::env::var_os` takes a process-wide lock, so the per-batch /
/// per-poll reads it replaces were needless hot-path overhead. /// per-poll reads it replaces were needless hot-path overhead.
skip_parse: bool, 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<std::sync::Arc<std::sync::atomic::AtomicU64>>,
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These /// 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 /// are expected on every disc; instead of a per-packet WARN they're tallied
/// and summarised once at EOF. /// and summarised once at EOF.
@@ -134,7 +126,6 @@ impl PipelinedPesStream {
pending_frames: std::collections::VecDeque::new(), pending_frames: std::collections::VecDeque::new(),
eof: false, eof: false,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(), skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
dropped_nav_packets: 0, dropped_nav_packets: 0,
resync, resync,
is_video, 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<std::sync::atomic::AtomicU64>,
) -> Self {
self.decrypt_loss = Some(loss);
self
}
/// Pull one batch of `PesPacket`s from the demux thread, run /// Pull one batch of `PesPacket`s from the demux thread, run
/// codec parse on each, enqueue resulting `PesFrame`s on /// codec parse on each, enqueue resulting `PesFrame`s on
/// `pending_frames`. Returns Ok(true) on success, Ok(false) 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()) .and_then(|(_, parser)| parser.codec_private())
} }
fn lost_bytes(&self) -> u64 { // `lost_bytes` uses the trait default (0): the file-backed highway has no
// The file-backed highway has no read-error zero-fill term (resolve // read-error zero-fill term (resolve/mapfile tracks physical read loss
// tracks read loss separately), but the producer's decrypt step can // separately) and the decrypt path no longer reports a decrypt-loss term.
// 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)
}
} }
#[cfg(test)] #[cfg(test)]
+32 -22
View File
@@ -391,11 +391,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
let title = disc.titles[idx].clone(); let title = disc.titles[idx].clone();
let format = disc.content_format; let format = disc.content_format;
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) — // ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
// sequential read from fast storage, no bad sectors. Measured // sequential read from fast storage, no bad sectors. Empirically
// optimum on the rip1 testbed; bumping to 16384 sectors (32 MiB) // optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
// regressed (more cache pressure, longer per-batch latency starves // pressure, longer per-batch latency starves the consumer between
// the consumer between iterations). Physical drives keep smaller // iterations). Physical drives keep smaller batches for adaptive
// batches for adaptive error handling. // error handling.
const ISO_MUX_BATCH_SECTORS: u16 = 8192; const ISO_MUX_BATCH_SECTORS: u16 = 8192;
// Pass `DecryptKeys::None` to the decrypt decorator when // Pass `DecryptKeys::None` to the decrypt decorator when
@@ -621,7 +621,8 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
/// - `fetch`: optional fresh-key-on-failure callback (see /// - `fetch`: optional fresh-key-on-failure callback (see
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the /// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it /// 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 // 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. // point; grouping them into a struct would only move the same fields around.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -647,19 +648,25 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
}; };
// MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and // 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 // 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 // muxer's problem, not a decrypt failure, so the mux never conceals a unit or
// a key, or counts it as loss — it fails only when it genuinely can't decrypt // counts it as loss.
// (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.
let mut decrypting = let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys) crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
.tolerate_decrypt_loss(); // Install the fresh-key-on-failure callback (if the app supplied one). This is
let _ = &fetch; // rip/verify key-recovery seam; the mux does not consume it // how multi-CPS is muxed: each CPS unit's key is fetched when the mux reaches a
// Loss counter: the mux does not tally broken-TS units (the muxer handles them), // unit no held key opens — "get the key when we need it." It fires only on a
// so for a keyed disc this stays 0; it still surfaces via `lost_bytes()` for the // genuine miss: now that key selection is accurate (`is_clean_ts`), a unit that
// abort gate, which now reflects only a genuine can't-decrypt. // decrypted correctly but has bad-encoded TS is NOT a miss, so this no longer
let decrypt_loss = decrypting.decrypt_loss(); // 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 // Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
// the reader, probe the feature head through the (plaintext) decrypting // the reader, probe the feature head through the (plaintext) decrypting
@@ -685,10 +692,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
let (demux_thread, demux_rx) = let (demux_thread, demux_rx) =
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps) super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
.map_err(|e| -> io::Error { e.into() })?; .map_err(|e| -> io::Error { e.into() })?;
Ok( Ok(PipelinedPesStream::new(
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track) demux_thread,
.with_decrypt_loss(decrypt_loss), demux_rx,
) title,
parsers,
pid_to_track,
))
} }
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a /// Assemble the M2TS file mux pipeline (read → demux → parse) for a
+25 -21
View File
@@ -12,9 +12,11 @@ use crate::consts::TS_PACKET_BYTES;
/// TS sync byte. /// TS sync byte.
const SYNC_BYTE: u8 = 0x47; const SYNC_BYTE: u8 = 0x47;
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3 /// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer
/// concealment fill emits null packets on this PID, tagged with an /// still recognises a `0x1FFF` packet with an adaptation-field
/// adaptation-field discontinuity_indicator to signal a concealed gap. /// 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; const NULL_PID: u16 = 0x1FFF;
/// A reassembled PES packet with timestamp info. /// A reassembled PES packet with timestamp info.
@@ -34,8 +36,10 @@ pub struct PesPacket {
pub source: Option<crate::pes::SourcePos>, pub source: Option<crate::pes::SourcePos>,
/// True when one or more packets for this stream were lost before this PES — /// 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 continuity break (CC gap or adaptation-field discontinuity_indicator) on
/// a tracked PID, or the CC-independent concealment marker the mux emits when /// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE:
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is /// 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 /// 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 /// 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 /// boundary flags the PES STARTING after it (never the one just flushed). So
@@ -200,7 +204,7 @@ impl PesAssembler {
/// BD Transport Stream demuxer. /// BD Transport Stream demuxer.
pub struct TsDemuxer { pub struct TsDemuxer {
assemblers: Vec<PesAssembler>, assemblers: Vec<PesAssembler>,
pid_index: Vec<i16>, // PID → index into assemblers, -1 = not tracked pid_index: Vec<i32>, // PID → index into assemblers, -1 = not tracked
remainder: Vec<u8>, // leftover bytes from previous feed() call remainder: Vec<u8>, // leftover bytes from previous feed() call
/// Absolute source byte offset of the NEXT byte to be fed — the running /// 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. /// 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 /// limits. Empty `pids` yields max_pid 0; the floor still produces a
/// valid (wholly-unused) table. /// valid (wholly-unused) table.
pub fn new(pids: &[u16]) -> Self { pub fn new(pids: &[u16]) -> Self {
// The PID→assembler index is stored as i16 (-1 = untracked), so a // The PID→assembler index is stored as i32 (-1 = untracked). PIDs are
// 32768th+ tracked PID would truncate to a negative value and be // u16 (≤ 65535) and the assembler index `i` is bounded by the number of
// silently treated as untracked. Callers pass a handful of PIDs // distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can
// (BD-TS has at most ~8192), so this is a programmer-error guard. // never truncate to a negative value and be mis-read as untracked —
debug_assert!( // unlike an i16 table, this is safe in RELEASE, not just under debug.
pids.len() <= i16::MAX as usize,
"TsDemuxer: too many PIDs for an i16 index table"
);
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize; let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
let table_size = (max_pid + 1).max(8192); 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()); let mut assemblers = Vec::with_capacity(pids.len());
for (i, &pid) in pids.iter().enumerate() { 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)); assemblers.push(PesAssembler::new(pid));
} }
Self { Self {
@@ -368,10 +369,13 @@ impl TsDemuxer {
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
let adaptation = (ts[3] >> 4) & 0x03; let adaptation = (ts[3] >> 4) & 0x03;
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable // P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an // adaptation-field discontinuity_indicator. NOTE: the in-tree writer that
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`). // laid these down on an undecryptable unit was removed with the pure-decrypt
// This is the authoritative loss signal — unlike a tracked PID's 4-bit // 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 // 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 // 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 // (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 /// 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 /// 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<u8> { fn null_marker_packet() -> Vec<u8> {
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
pkt[4] = SYNC_BYTE; // 0x47 pkt[4] = SYNC_BYTE; // 0x47
+157 -757
View File
File diff suppressed because it is too large Load Diff
+57 -7
View File
@@ -129,6 +129,10 @@ impl SectorSource for Box<dyn SectorSource> {
fn set_speed(&mut self, kbs: u16) { fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs) (**self).set_speed(kbs)
} }
fn set_unit_base(&mut self, lba: u32) {
(**self).set_unit_base(lba)
}
} }
impl SectorSource for &mut (dyn SectorSource + '_) { impl SectorSource for &mut (dyn SectorSource + '_) {
@@ -160,6 +164,10 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
fn set_speed(&mut self, kbs: u16) { fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs) (**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. /// 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 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 file::FileSectorSink;
pub use prefetched::PrefetchedSectorSource; pub use prefetched::PrefetchedSectorSource;
@@ -198,23 +206,33 @@ mod tests {
capacity: u32, capacity: u32,
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>, reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
speeds: Arc<Mutex<Vec<u16>>>, speeds: Arc<Mutex<Vec<u16>>>,
unit_bases: Arc<Mutex<Vec<u32>>>,
} }
/// A `Spy` under test plus the handles recording its reads and speed sets. /// A `Spy` under test plus the handles recording its reads, speed sets,
type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>); /// and unit-base sets.
type SpyHarness = (
Spy,
Arc<Mutex<Vec<(u32, u16, bool)>>>,
Arc<Mutex<Vec<u16>>>,
Arc<Mutex<Vec<u32>>>,
);
impl Spy { impl Spy {
fn new(capacity: u32) -> SpyHarness { fn new(capacity: u32) -> SpyHarness {
let reads = Arc::new(Mutex::new(Vec::new())); let reads = Arc::new(Mutex::new(Vec::new()));
let speeds = Arc::new(Mutex::new(Vec::new())); let speeds = Arc::new(Mutex::new(Vec::new()));
let unit_bases = Arc::new(Mutex::new(Vec::new()));
( (
Self { Self {
capacity, capacity,
reads: reads.clone(), reads: reads.clone(),
speeds: speeds.clone(), speeds: speeds.clone(),
unit_bases: unit_bases.clone(),
}, },
reads, reads,
speeds, speeds,
unit_bases,
) )
} }
} }
@@ -238,6 +256,16 @@ mod tests {
fn set_speed(&mut self, kbs: u16) { fn set_speed(&mut self, kbs: u16) {
self.speeds.lock().unwrap().push(kbs); 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<dyn>` / `&mut dyn` FORWARDING
/// impls (a direct call on a `dyn` value dispatches via the vtable instead).
fn set_unit_base_generic<S: SectorSource>(mut s: S, base: u32) {
s.set_unit_base(base);
} }
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait /// The default `capacity_sectors` is 0 (unknown). Grounding: trait
@@ -286,7 +314,7 @@ mod tests {
/// Box<dyn SectorSource>` forwarding bodies. /// Box<dyn SectorSource>` forwarding bodies.
#[test] #[test]
fn boxed_dyn_forwards_all_methods() { 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<dyn SectorSource> = Box::new(spy); let mut boxed: Box<dyn SectorSource> = Box::new(spy);
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward"); assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
@@ -308,15 +336,26 @@ mod tests {
vec![5400], vec![5400],
"set_speed must forward" "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<dyn>"
);
} }
/// `&mut dyn SectorSource` must likewise forward all three methods. /// `&mut dyn SectorSource` must likewise forward every method.
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`. /// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
#[test] #[test]
fn mut_ref_dyn_forwards_all_methods() { fn mut_ref_dyn_forwards_all_methods() {
let (mut spy, reads, speeds) = Spy::new(123); let (mut spy, reads, speeds, unit_bases) = Spy::new(123);
let r: &mut dyn SectorSource = &mut spy;
{
let r: &mut dyn SectorSource = &mut spy;
assert_eq!(r.capacity_sectors(), 123); assert_eq!(r.capacity_sectors(), 123);
let mut buf = vec![0u8; 2 * 2048]; let mut buf = vec![0u8; 2 * 2048];
@@ -324,8 +363,19 @@ mod tests {
assert_eq!(n, 2 * 2048); 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!(*reads.lock().unwrap(), vec![(7, 2, false)]);
assert_eq!(*speeds.lock().unwrap(), vec![8800]); assert_eq!(*speeds.lock().unwrap(), vec![8800]);
assert_eq!(
*unit_bases.lock().unwrap(),
vec![128],
"set_unit_base must forward through &mut dyn"
);
} }
} }
+8 -5
View File
@@ -252,11 +252,14 @@ impl PrefetchedSectorSource {
}; };
if bytes <= buf.capacity() { if bytes <= buf.capacity() {
// Re-expose `bytes` without zero-filling pages that // Re-expose `bytes` without zero-filling pages that
// `read_sectors` is about to overwrite. The enclosing // `read_sectors` is about to overwrite. Sound because the
// capacity guard makes the `set_len` provably sound even // enclosing `bytes <= capacity` guard bounds the length,
// if a recycled buffer ever comes back smaller than the // and every byte below `capacity` is physically
// `vec![0u8; batch_bytes]` it was born with. // initialised: buffers are born `vec![0u8; batch_bytes]`
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity"); // 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) }; unsafe { buf.set_len(bytes) };
} else { } else {
buf.resize(bytes, 0); buf.resize(bytes, 0);
+11 -1
View File
@@ -135,6 +135,12 @@ fn aacs_fetch_step(
return prev_dropped; return prev_dropped;
} }
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN; 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 // 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 // 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 // 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) .chunks_exact(unit_len)
.zip(ciphertext.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()); samples.push(c.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES { if samples.len() >= MAX_FETCH_SAMPLES {
break; break;
@@ -256,6 +262,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![], unit_keys: vec![],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let cipher = buf.clone(); let cipher = buf.clone();
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144)); let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
@@ -279,6 +286,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![], unit_keys: vec![],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let cipher = buf.clone(); let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN)); r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
@@ -304,6 +312,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![], unit_keys: vec![],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
let mut buf = scrambled_unit(0x44); let mut buf = scrambled_unit(0x44);
let cipher = buf.clone(); let cipher = buf.clone();
@@ -330,6 +339,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![], unit_keys: vec![],
read_data_key: None, read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}; };
// Distinct ciphertext each time so the dry-set never short-circuits; only // Distinct ciphertext each time so the dry-set never short-circuits; only
// the internal call budget should stop the fetch. The closure self-limits, // the internal call budget should stop the fetch. The closure self-limits,
+51
View File
@@ -1790,6 +1790,57 @@ mod tests {
assert!(inf[2048..].iter().all(|&b| b == 0xBB)); 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] #[test]
fn merge_ranges_saturates_near_u32_max() { fn merge_ranges_saturates_near_u32_max() {
// Adjacent ranges near u32::MAX must not panic (debug) or wrap. // Adjacent ranges near u32::MAX must not panic (debug) or wrap.
+52 -52
View File
@@ -134,11 +134,7 @@ fn aacs_decrypt_unit_roundtrip() {
assert!(aacs::content::ts_sync_destroyed(&plain)); assert!(aacs::content::ts_sync_destroyed(&plain));
// Now decrypt // Now decrypt
let result = aacs::content::decrypt_unit(&mut plain, &unit_key); aacs::content::decrypt_unit(&mut plain, &unit_key);
assert!(
result,
"decrypt_unit should return true on valid encrypted unit"
);
assert!( assert!(
!aacs::content::ts_sync_destroyed(&plain), !aacs::content::ts_sync_destroyed(&plain),
"decrypted unit should read as clear (TS syncs restored)" "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] #[test]
fn aacs_decrypt_unit_unencrypted_passthrough() { fn aacs_clear_unit_reports_not_encrypted() {
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN]; 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; let mut off = 4;
while off < aacs::content::ALIGNED_UNIT_LEN { while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47; 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. // CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
unit[0] &= 0x3F; unit[0] &= 0x3F;
let original = unit.clone();
let key = [0xAA; 16];
assert!(!aacs::content::ts_sync_destroyed(&unit)); assert!(!aacs::content::ts_sync_destroyed(&unit));
let result = aacs::content::decrypt_unit(&mut unit, &key); assert!(
assert!(result, "clear unit should return true"); !aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
assert_eq!(unit, original, "clear unit should be unchanged"); "CPI-clear unit reports not-encrypted; the caller never decrypts it"
);
} }
// ── AACS cross-validation with independent AES implementation ────────────── // ── AACS cross-validation with independent AES implementation ──────────────
@@ -413,11 +411,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
); );
// -- Decrypt with the library -- // -- Decrypt with the library --
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key); aacs::content::decrypt_unit(&mut plaintext, &unit_key);
assert!(
ok,
"decrypt_unit returned false (TS sync verification failed)"
);
// Decryption clears no flag, so the unit round-trips byte-for-byte. // Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!( assert_eq!(
@@ -458,44 +452,14 @@ fn aacs_cross_validation_alternate_key() {
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN], &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. // Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(&plaintext[..], &expected[..]); assert_eq!(&plaintext[..], &expected[..]);
} }
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied // (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
/// per-sector to bytes 16..2048 (bus encryption layer). // in `aacs::content`'s unit tests, not here.)
#[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"
);
}
// ── CSS roundtrip test vectors ───────────────────────────────────────────── // ── 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 \ This is expected: synthetic sectors lack the TAB1 output encoding \
present in real CSS-encrypted DVD sectors." 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.", The LFSR0 recovery phase may not converge for this combination.",
title_key, seed 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"
);
} }
} }
+28 -9
View File
@@ -27,21 +27,39 @@ fn decrypt_sectors_with_aacs_keys_works() {
let unit_key: [u8; 16] = [0xAAu8; 16]; let unit_key: [u8; 16] = [0xAAu8; 16];
// Encrypt the unit using AACS algorithm // Apply the key to the pattern to produce ciphertext-shaped bytes for the
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data // 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 aacs_keys = DecryptKeys::Aacs {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, unit_key)], unit_keys: vec![(0u32, unit_key)],
read_data_key: None, read_data_key: None,
format: libfreemkv::disc::ContentFormat::BdTs,
}; };
let mut none_keys = DecryptKeys::None;
// decrypt_sectors should handle this without error // The regression this guards is passing `DecryptKeys::None` where AACS keys
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0); // 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!( assert_ne!(
result.is_ok(), with_aacs, unit,
"decrypt_sectors with AACS keys should not error" "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 { let aacs = DecryptKeys::Aacs {
unit_keys: vec![], unit_keys: vec![],
read_data_key: None, read_data_key: None,
format: libfreemkv::disc::ContentFormat::BdTs,
}; };
assert!(aacs.is_encrypted()); assert!(aacs.is_encrypted());
-1
View File
@@ -275,7 +275,6 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
progress: None, progress: None,
halt: Some(halt.clone()), halt: Some(halt.clone()),
key_fetch: None, key_fetch: None,
fast_capture: false,
}; };
let outcome = disc.patch(&mut reader, &iso_path, &opts); let outcome = disc.patch(&mut reader, &iso_path, &opts);
+187 -29
View File
@@ -232,8 +232,6 @@ struct Golden {
bytes_unreadable: u64, bytes_unreadable: u64,
/// `bytes_pending` (NonTrimmed) at end. /// `bytes_pending` (NonTrimmed) at end.
bytes_pending: u64, bytes_pending: u64,
/// Did the pass exit via wedge-detection?
wedged_exit: bool,
/// Sanity bound on trace length — patch makes a finite number of /// Sanity bound on trace length — patch makes a finite number of
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus /// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
/// retries. Asserted as an UPPER bound only (so any reduction in /// 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_good: capacity_sectors as u64 * 2048,
bytes_unreadable: 0, bytes_unreadable: 0,
bytes_pending: 0, bytes_pending: 0,
wedged_exit: false,
max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8. 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"); 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), // Sense-family error paths in `Disc::patch`: NOT_READY-then-recover,
// ILLEGAL_REQUEST (wedge), and ABORTED_COMMAND profiles. Each would // HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND.
// trigger long real-time sleeps inside `handle_read_failure`:
// //
// - NOT_READY (sense_key=0x02, asc=0x02/0x03/0x04): 15 s pause per // These drive `disc.patch(...)` DIRECTLY rather than through `run_profile`
// occurrence (`patch_not_ready_pause`), and retries the same LBA // (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY /
// in-place. Even one NOT_READY costs the test 15 s wall-time. // 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 // The load-bearing invariant asserted across every PERSISTENT failure sense is
// (`WEDGE_FAMILY_COOLDOWN_SECS`), bounded by // the recovery contract: a patch pass NEVER promotes a sector to Unreadable
// `WEDGE_ABORT_THRESHOLD=16` before wedged-exit. Worst case ~8 // (the orchestrator does that only after the final pass) and NEVER silently
// minutes per profile. // drops bytes — a still-bad sector stays NonTrimmed (pending), so
// // good + pending always conserves the total. Exact good/pending splits are
// The sleeps are not injectable. Adding them would require either a // left loose so wedge-skip tuning can't spuriously fail these.
// `now()` / `sleep()` trait injection (out of scope for the unification
// task) or a "test mode" compile-time flag (architectural smell). The /// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
// behavioural contracts for those paths are captured in /// range) patch pass with the given failure step and return the final map
// `read_error.rs`'s in-module tests instead — they exercise the /// stats. 256-sector synthetic disc; everything outside the range is Finished.
// classifier without invoking the patch loop's sleep side-effects. fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
// let capacity_sectors: u32 = 256;
// If the unification ever proceeds, the next step is to add a clock let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
// injection point in `handle_read_failure` and extend this fixture reader.always(130, step);
// with the wedge/NOT_READY profiles too.
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 ──────── // ──────── 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 // 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 — // 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 // 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 // 32-block untouched. (The old `fast_capture` knob was removed: the handler
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is // chain supersedes it. The breadth-first "fast on all ranges, then escalate"
// a scheduling concern for the handler scheduler, tracked separately.) // 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 // The load-bearing invariant is unchanged: NO data is dropped. A still-bad
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable. // 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, progress: None,
halt: None, halt: None,
key_fetch: None, key_fetch: None,
fast_capture: true,
}; };
disc.patch(&mut reader, &iso_path, &opts) 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 map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let stats = Mapfile::load(&map_path).unwrap().stats(); let stats = Mapfile::load(&map_path).unwrap().stats();