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
+405 -662
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
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
/// its declared CPS-unit number); the caller runs
/// [`super::content::unit_key_validates`] to find which one actually opens the
/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the
/// disc. Rungs above the candidate are `None`.
#[derive(Debug, Clone)]
pub struct ResolvedChain {
@@ -487,7 +487,7 @@ pub struct ResolvedChain {
///
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
/// `unit_keys` against a real encrypted unit with
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
/// `decrypt_unit` + `is_clean_ts` to prove the candidate opens the disc.
///
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
@@ -505,7 +505,8 @@ pub fn resolve_candidate(
let version = mkb_type(mkb)
.map(|t| t.generation())
.unwrap_or(AacsVersion::V10);
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
// BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic.
let ukf = parse_title_keys(unit_key_ro, version)?;
if ukf.encrypted_keys.is_empty() {
return None;
}
+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.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
@@ -283,3 +364,94 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
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_select;
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
/// or changing a fallback in one place can then never silently diverge the
/// On-disc UDF paths to the AACS key-input files.
///
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the
/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The
/// container difference is expressed here purely as DATA: each ROLE
/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered
/// candidate list, and every reader walks it with [`read_first`] taking the
/// first that reads. No reader ever branches on disc type — a BD/UHD disc has
/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through
/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
/// disc_hash / MKB / VID that another reader feeds a key service.
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
@@ -51,6 +59,46 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service
/// recognises it by its `DVD_HD_V_TKF` magic.
pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS";
/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`.
pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS";
/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10).
pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS";
/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD).
pub const UNIT_KEY_RO_PATHS: &[&str] = &[
PATH_UNIT_KEY_RO,
PATH_UNIT_KEY_RO_DUPLICATE,
PATH_VTKF_HDDVD,
];
/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD).
pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD];
/// Content-certificate role, in resolution order (BD/UHD, then HD DVD).
pub const CONTENT_CERT_PATHS: &[&str] = &[
PATH_CONTENT_CERT,
PATH_CONTENT_CERT_ALT,
PATH_CONTENT_CERT_HDDVD,
];
/// Walk an AACS role's candidate paths and return the first that reads.
///
/// `read` performs the actual per-path read (full file or bounded prefix), so
/// callers share the same first-present walk regardless of read style. Returns
/// [`Error::AacsNoKeys`] if no candidate is present. This is the single place
/// the `/AACS/` (BD/UHD) vs `/ANY!/` (HD DVD) layout difference is resolved.
pub(crate) fn read_first<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
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
@@ -61,7 +109,7 @@ pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
// content-decrypt entry points that downstream key-source crates import through
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
// lets those crates track the module refactor without a lockstep re-pin.
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
pub use content::ALIGNED_UNIT_LEN;
pub use derive::derive_vuk;
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
+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
/// Variant MKB.)
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 bus_encryption = ctx
.content_cert
@@ -280,8 +280,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
// Parse Unit_Key_RO.inf at the version-appropriate stride.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
// Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
// version-appropriate stride, or HD DVD VTKF000.AACS) → common UnitKeyFile.
let uk_file = parse_title_keys(ctx.unit_key_ro, version)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let has_vid = *ctx.volume_id != [0u8; 16];
+112 -41
View File
@@ -150,10 +150,15 @@ pub fn decrypt_threads() -> usize {
pub enum DecryptKeys {
/// No encryption on this disc.
None,
/// AACS (Blu-ray / UHD). Unit keys + optional read data key.
/// AACS (Blu-ray / UHD / HD-DVD). Unit keys + optional read data key. The
/// `format` is the disc's content container (BD/UHD/FMTS = Transport Stream,
/// HD-DVD `.evo` = Program Stream); it travels with the keys because both are
/// resolved once per disc, and the key SELECTOR (`is_clean`) needs it to prove
/// a key structurally against the right container.
Aacs {
unit_keys: Vec<(u32, [u8; 16])>,
read_data_key: Option<[u8; 16]>,
format: crate::disc::ContentFormat,
},
/// CSS (DVD). Title key for sector descrambling.
Css { title_key: [u8; 5] },
@@ -206,7 +211,8 @@ pub fn decrypt_sectors(
/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is
/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors.
///
/// `content_ranges` is sorted, merged, disjoint `[start_lba, end_lba)`.
/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)`
/// tuples (each covering `[start_lba, start_lba + sector_count)`).
pub fn decrypt_sectors_in_content(
buf: &mut [u8],
keys: &mut DecryptKeys,
@@ -242,6 +248,7 @@ fn decrypt_sectors_impl(
DecryptKeys::Aacs {
unit_keys,
read_data_key,
format,
} => {
// Validate that unit_key_idx is in-range before doing anything else.
// This preserves the existing contract: an out-of-range explicit index
@@ -250,8 +257,13 @@ fn decrypt_sectors_impl(
return Err(crate::error::Error::DecryptFailed);
}
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
// Container of this disc's content the key SELECTOR (`is_clean`)
// checks the decrypted plaintext against the right structure (TS vs PS).
let format = *format;
// Index `unit_keys` directly for the raw key bytes (the `.1` of each
// `(cps_id, key)`); no per-call `Vec` of stripped keys — the decrypt
// closures only ever need `len()` / `[idx].1`, so collecting one would
// just be a heap alloc/free on every batch of the mux hot path.
let rdk: Option<[u8; 16]> = *read_data_key;
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
// AACS decrypts whole 6144-byte aligned units. The live mux path
@@ -295,7 +307,13 @@ fn decrypt_sectors_impl(
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
None => true,
};
if partial_in_content {
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
// unit) can't be unit-decrypted, so fail loud. The heuristic is
// MPEG-TS sync density, which a PS (`.evo`) partial lacks entirely —
// running it on PS would false-trip `DecryptFailed`. HD-DVD partial-
// scramble detection is not yet wired (consistent with the UNVERIFIED
// PS path in `aacs_unit_encrypted`).
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
let partial = &buf[buf.len() - partial_len..];
let packets = aacs::content::ts_packet_total(partial);
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
@@ -336,12 +354,15 @@ fn decrypt_sectors_impl(
// must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| {
// Gate on `aacs_unit_needs_decrypt` (CPI set AND TS syncs not yet
// restored): CPI alone isn't enough because the plaintext seed keeps
// the CPI bit set after decryption, so an already-decrypted unit would
// be decrypted a SECOND time (scrambling it) on any re-run of this
// pass. The intact-TS half makes it idempotent.
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
// Gate on `aacs_unit_needs_decrypt` (encrypted-flag set AND structure
// not yet restored): the flag alone isn't enough because it lives in
// the plaintext header and survives decryption, so an already-decrypted
// unit would be decrypted a SECOND time (scrambling it) on any re-run of
// this pass. The structure-restored half makes it idempotent. This is
// ALSO the sole gate protecting the now-pure `decrypt_unit` from
// decrypting a clear unit.
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk, format)
{
return;
}
@@ -355,41 +376,67 @@ fn decrypt_sectors_impl(
// back to the full list skipping the hint.
let hint = last_key_idx.load(Ordering::Relaxed);
let try_order =
std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint));
std::iter::once(hint).chain((0..unit_keys.len()).filter(move |&i| i != hint));
// DECRYPT the unit — apply a key, leave the plaintext. "Did a key
// produce clean TS?" is NOT "did we decrypt?": a correct key can
// decrypt content whose underlying encoding is broken (bad TS sync),
// which is a MUXER concern, never a decrypt verdict. Clean TS is used
// ONLY as a key-SELECTION hint on multi-CPS-unit discs — the first
// key that yields clean TS is the definite match. When none does we
// STILL decrypted (the cached-hint key is applied): keep those bytes
// and report the unit as UNVERIFIED. This function applies no policy;
// the caller decides what an unverified unit means (the mux passes it
// to the muxer; sweep/patch treat it as a read to recover or fail).
let mut applied: Option<Vec<u8>> = None;
// Compose the two SEGREGATED primitives explicitly. `decrypt_unit`
// is the decrypt (apply the key, leave the plaintext). `is_clean`
// is a SEPARATE structural question used here ONLY as a multi-CPS-unit
// key SELECTOR — the first key whose output is clean for the disc's
// container (`format`: TS or PS) is the match. "Did a key produce
// clean structure?" is NOT "did we decrypt?": a correct key can
// decrypt content whose encoding is broken (a muxer concern). When
// NO key yields clean structure we STILL decrypted (the cached-hint
// key is applied): keep those bytes and report the unit UNVERIFIED.
// This function applies no policy; the caller decides what unverified
// means (mux passes it to the muxer; sweep/patch recover or fail).
// Single-key fast path (the vast majority of titles): with no
// alternate key to fall back on there is nothing to try/rollback,
// so decrypt in place — no per-unit scratch alloc or copy-back.
// Clean → cache the hint; unclean → keep the applied bytes and
// tally unverified, exactly as the loop below would with one key.
if unit_keys.len() == 1 {
aacs::content::decrypt_unit(chunk, &unit_keys[0].1);
if aacs::content::is_clean(chunk, format) {
last_key_idx.store(0, Ordering::Relaxed);
} else {
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
}
return;
}
// Trial each key against a STACK scratch (unit_len is always
// ALIGNED_UNIT_LEN and the guard above proved chunk.len() == unit_len)
// so a failing attempt doesn't clobber the bus-decrypted base in
// `chunk` that the next key retries on — with no per-key heap Vec.
// `chunk` is NOT mutated in this loop, so on total miss we simply
// re-apply the first key in place (decrypt_unit is pure), which
// reproduces the first attempt without stashing its bytes.
let mut scratch = [0u8; aacs::content::ALIGNED_UNIT_LEN];
let scratch = &mut scratch[..chunk.len()];
let mut first_idx: Option<usize> = None;
for idx in try_order {
if let Some(key) = raw_keys.get(idx) {
// Work on a per-key copy so a failing attempt doesn't
// clobber the bus-decrypted base we'll retry on.
let mut attempt: Vec<u8> = chunk.to_vec();
if aacs::content::decrypt_unit(&mut attempt, key) {
chunk.copy_from_slice(&attempt);
if let Some((_, key)) = unit_keys.get(idx) {
scratch.copy_from_slice(chunk);
aacs::content::decrypt_unit(scratch, key);
if aacs::content::is_clean(scratch, format) {
chunk.copy_from_slice(scratch);
last_key_idx.store(idx, Ordering::Relaxed);
return;
}
if applied.is_none() {
applied = Some(attempt);
if first_idx.is_none() {
first_idx = Some(idx);
}
}
}
// No key yielded clean TS. Keep the applied-key plaintext (the pool is
// non-empty past the guard, so `applied` is always `Some`) and tally
// the unit as unverified. Never restore ciphertext; that is a caller
// concern, threaded through the recovery ciphertext, not this seam.
if let Some(decrypted) = applied {
chunk.copy_from_slice(&decrypted);
// No key yielded clean structure. Keep the first-tried key's
// plaintext (the pool is non-empty past the guard, so `first_idx` is
// always `Some`) and tally the unit as unverified. Never restore
// ciphertext; that is a caller concern, threaded through the recovery
// ciphertext, not this seam.
if let Some(idx) = first_idx {
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
}
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
};
@@ -427,11 +474,14 @@ fn decrypt_sectors_impl(
// back to the serial path rather than panic.
match decrypt_pool() {
Some(pool) => {
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
// `par_chunks_mut` iterates the units in place — no
// intermediate `Vec<&mut [u8]>` allocation per batch.
pool.install(|| {
chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| {
process(idx, chunk);
});
buf.par_chunks_mut(unit_len)
.enumerate()
.for_each(|(idx, chunk)| {
process(idx, chunk);
});
});
}
None => {
@@ -483,6 +533,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
// so this nav unit is OUTSIDE content and the gate skips it untouched.
@@ -554,6 +605,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -587,6 +639,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
@@ -605,6 +658,7 @@ mod tests {
let mut keys_g = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut keys_u = keys_g.clone();
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -655,6 +709,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
@@ -673,6 +728,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
@@ -791,6 +847,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let u = aacs::content::ALIGNED_UNIT_LEN;
let mut buf = vec![0u8; 3 * u];
@@ -808,6 +865,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
@@ -827,6 +885,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
// content → the partial must be tolerated (Ok), not DecryptFailed.
@@ -849,6 +908,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -874,6 +934,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -896,6 +957,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf: Vec<u8> = Vec::new();
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
@@ -909,6 +971,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
let snapshot = buf.clone();
@@ -950,6 +1013,7 @@ mod tests {
DecryptKeys::Aacs {
unit_keys: vec![(0, [0; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}
.is_encrypted()
);
@@ -1164,6 +1228,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 5)
@@ -1184,6 +1249,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
@@ -1265,6 +1331,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Call with the default hint (idx 0) — the fix must fall back to key1.
@@ -1299,6 +1366,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
@@ -1340,6 +1408,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
let unverified =
@@ -1383,6 +1452,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
@@ -1412,6 +1482,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
+3 -7
View File
@@ -311,15 +311,11 @@ impl Disc {
) -> Result<AacsState> {
use crate::aacs;
let uk_ro_data = udf_fs
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let uk_ro_data =
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
let dh = aacs::inf::disc_hash(&uk_ro_data);
let cc = udf_fs
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
.ok()
.as_deref()
.and_then(aacs::inf::parse_content_cert);
-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
/// inner source for its lifetime while the caller keeps the underlying
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
+76 -124
View File
@@ -10,7 +10,7 @@
mod bluray;
mod dvd;
pub mod dvd_audio_probe;
pub(crate) mod dvd_audio_probe;
mod encrypt;
mod extract;
mod hddvd;
@@ -19,7 +19,6 @@ mod patch;
pub mod read_error;
mod section_recover;
mod sweep;
pub mod verify;
use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result};
@@ -85,7 +84,8 @@ pub struct Disc {
pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets)
BdTs,
/// DVD MPEG-2 Program Stream (VOB)
/// MPEG-2 Program Stream — DVD (`.vob`) and HD-DVD (`.evo`). For AACS content
/// this selects the PS-aware encrypted-flag / structural checks.
MpegPs,
}
@@ -1623,12 +1623,11 @@ impl Disc {
// detection needs the read, the read needs auth, auth needs detection.
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
// the disc-key read fails, `resolve` returns None, and the disc is left
// in the clear. This block is DVD-only (MPEG-PS); BD/UHD (MPEG-TS) goes
// through the AACS handshake above and never reaches here.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
// CSS title keys are per-VTS, and ONLY the scrambled movie content
// carries a non-zero key. Menu / VMG / logo cells (often the
// low-LBA first extent) return a ZERO title key over REPORT KEY —
@@ -1744,10 +1743,13 @@ impl Disc {
// pre-decrypted one. A pre-decrypted image has its scramble flags clear,
// so `crack_key` finds no crackable sector and the disc stays in the
// clear. AACS images go through KEYDB VUK lookup, not here.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
//
// Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
// `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not
// enter the CSS crack path. A CSS DVD's IFO (which defines the titles
// this branch reads) is unscrambled, so `detect_format` reliably sets
// `Dvd` from the SD-resolution titles even on a still-scrambled image.
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
let main_extents = match disc
.titles
.iter()
@@ -1799,10 +1801,9 @@ impl Disc {
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
let inf = udf_fs
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
udf_fs.read_file(reader, p)
})?;
let mkb = Self::read_mkb_content(reader, udf_fs)?;
let version = Self::read_aacs_version(reader, udf_fs);
Ok((inf, mkb, version))
@@ -1820,12 +1821,12 @@ impl Disc {
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
/// not quietly pick the V10 stride for a UHD disc.
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
match udf_fs
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(crate::aacs::inf::parse_content_cert)
match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
udf_fs.read_file(reader, p)
})
.ok()
.as_deref()
.and_then(crate::aacs::inf::parse_content_cert)
{
Some(c) => c.version.major(),
None => {
@@ -1856,10 +1857,9 @@ impl Disc {
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
let buf = udf_fs
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
.map_err(|_| Error::AacsNoKeys)?;
let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
udf_fs.read_file_prefix(reader, p, want)
})?;
let n = crate::aacs::mkb::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
@@ -2297,12 +2297,15 @@ fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>],
format: ContentFormat,
) -> bool {
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
use crate::aacs::content::{
ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean,
};
let scrambled: Vec<&[u8]> = samples
.iter()
.map(|s| s.as_slice())
.filter(|s| aacs_unit_needs_decrypt(s))
.filter(|s| aacs_unit_needs_decrypt(s, format))
.collect();
if scrambled.is_empty() {
return true; // nothing to disprove against — accept
@@ -2324,7 +2327,13 @@ fn aligned_unit_keys_validate(
hb.tick_cpu(tried, total);
tried += 1;
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
if decrypt_unit_full(&mut probe, k, read_data_key) {
// bus layer (AACS 2.0) first, then the CPS unit key, then the structural
// proof — the composed form of the old `decrypt_unit_full`.
if let Some(rdk) = read_data_key {
decrypt_bus(&mut probe, rdk);
}
decrypt_unit(&mut probe, k);
if is_clean(&probe, format) {
covered = true;
break;
}
@@ -2351,6 +2360,7 @@ impl Disc {
crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key,
format: self.content_format,
}
} else if let Some(ref css) = self.css {
crate::decrypt::DecryptKeys::Css {
@@ -2840,7 +2850,12 @@ impl Disc {
// de-scramble it. With no samples (or only clear ones) there is nothing
// to disprove against, so the key is accepted as-is — keeping the
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
if !aligned_unit_keys_validate(&candidate_unit_keys, read_data_key.as_ref(), samples) {
if !aligned_unit_keys_validate(
&candidate_unit_keys,
read_data_key.as_ref(),
samples,
self.content_format,
) {
return Err(crate::error::Error::AacsKeyRejected);
}
@@ -3073,10 +3088,6 @@ impl Disc {
progress: opts.progress,
halt: opts.halt.clone(),
key_fetch: opts.key_fetch.clone(),
// Disc::copy's internal patch grinds each range fully (it's a
// single-call recovery); the breadth-first fast-capture ordering is
// an autorip multi-pass concern.
fast_capture: false,
};
let pr = self.patch(reader, path, &patch_opts)?;
tracing::info!(
@@ -3131,27 +3142,15 @@ impl Disc {
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
//
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we
// still resolve the keys and VERIFY each unit on a scratch copy: a unit
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like
// a SCSI error, and flows into the SAME read-error recovery (skip /
// NonTrimmed / patch). This is the one spot that makes "a read succeeded"
// mean "read AND decrypts" — everything downstream is unchanged. With no
// usable AACS keys (no keydb) it degrades to a plain pass-through.
//
// A plain `--raw` single-pass (no `skip_on_error`) stays a pass-through:
// the user asked for the raw image, untouched and unchecked.
// The sweep COPIES ciphertext (multipass / `--raw`) or decrypts IN PLACE
// (`opts.decrypt`, the rare disc→decrypted-ISO). It deliberately does NOT
// decrypt-VERIFY: a whole-disc sweep reads disc-absolute, but AACS aligned
// units are anchored to each clip's FILE start and clips can be non-6144-
// aligned OR fragmented across UDF extents — so a disc-absolute verify
// mis-aligns the unit grid and false-fails good clips (it skipped the
// ~990 MB orphan-CPS clip on Dunkirk). Verification moved to the
// clip-anchored [`Disc::verify_clips`] pass that runs AFTER the sweep,
// reading each clip file-order-anchored from the ISO. The read here stays
// a fail-safe copy; alignment is never assumed.
// Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and
// plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a
// pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI
// read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure.
// (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute
// unit grid against clip-file-anchored AACS units and false-failed good
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
// verify and no post-sweep clip-anchored pass; decryptability is proven at
// mux time, not at capture time.)
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
@@ -3177,22 +3176,6 @@ impl Disc {
};
let reader = &mut reader;
// Post-read verify gate (universal `read -> verify -> sign-off`). Built
// ONLY for the ciphertext sweep (`!opts.decrypt`, the multipass rip
// path) so `observe` always sees on-disc ciphertext and never
// double-decrypts already-plaintext bytes. `UnitVerifier::new` is itself
// fail-safe: it returns `None` (verify disabled, behavior unchanged) for
// a non-AACS disc, no keys, the kill-switch off, or an empty clip
// enumeration. We resolve the REAL AACS keys here even though the sweep
// copies ciphertext, and reuse the application's key-fetch seam.
let mut verifier = if opts.decrypt {
None
} else {
let verify_keys = self.decrypt_keys();
let layouts = extract::clip_layouts(&mut *reader);
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
};
// Mapfile: load if resuming, else wipe + recreate.
let mapfile_path = self.mapfile_for(path);
// covers_disc reconciliation. A resume against a mapfile whose total
@@ -3483,18 +3466,6 @@ impl Disc {
// The consumer thread sees decrypted bytes; the
// pre-0.18 inline decrypt_sectors call lived here.
// Post-read verify: observe the just-read ciphertext
// BEFORE it is moved into the channel, collecting the
// clip units this batch completes that are confidently
// undecryptable. Sent as `MarkBad` AFTER the `Good`
// below so the FIFO pipe records `Finished` first and the
// downgrade to `NonTrimmed` last. No-op when the gate is
// disabled (`verifier` is `None`).
let verify_bad = verifier
.as_mut()
.map(|v| v.observe(block_lba, &buf[..block_bytes as usize]))
.unwrap_or_default();
// Move the batch into the channel via fresh
// owned Vec. The producer's `buf` is reused
// for the next read.
@@ -3503,26 +3474,6 @@ impl Disc {
producer_err = Some(consumer_gone());
break 'outer;
}
// Downgrade any unit that failed verify (decrypt-fail ==
// bad read). decrypt-fail is NOT physical damage, so it
// deliberately does not touch the damage-jump window.
let mut send_failed = false;
for (bad_lba, bad_cnt) in verify_bad {
if pipe
.send(WorkItem::MarkBad {
pos: bad_lba as u64 * 2048,
len: bad_cnt as u64 * 2048,
})
.is_err()
{
producer_err = Some(consumer_gone());
send_failed = true;
break;
}
}
if send_failed {
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
@@ -4009,18 +3960,6 @@ pub struct PatchOptions<'a> {
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
/// recover an orphan CPS unit's key when re-reading its bad range.
pub key_fetch: Option<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`].
@@ -4867,6 +4806,7 @@ mod tests {
crate::decrypt::DecryptKeys::Aacs {
unit_keys,
read_data_key,
..
} => {
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
@@ -5393,7 +5333,8 @@ mod tests {
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[]
&[],
ContentFormat::BdTs
));
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
@@ -5408,7 +5349,8 @@ mod tests {
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[clear.clone()]
&[clear.clone()],
ContentFormat::BdTs
));
// A genuinely scrambled unit the RIGHT key restores to clear TS.
@@ -5423,16 +5365,23 @@ mod tests {
assert!(super::aligned_unit_keys_validate(
&[(7, uk)],
None,
&[enc.clone()]
&[enc.clone()],
ContentFormat::BdTs
));
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(
&[(7, [0x00u8; 16])],
None,
&[enc.clone()]
&[enc.clone()],
ContentFormat::BdTs
));
// Empty key set against a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(&[], None, &[enc]));
assert!(!super::aligned_unit_keys_validate(
&[],
None,
&[enc],
ContentFormat::BdTs
));
}
#[test]
@@ -5467,21 +5416,24 @@ mod tests {
assert!(!super::aligned_unit_keys_validate(
&[(0, uk0)],
None,
&samples
&samples,
ContentFormat::BdTs
));
// Complete key set (both CPS units) -> accept.
assert!(super::aligned_unit_keys_validate(
&[(0, uk0), (1, uk1)],
None,
&samples
&samples,
ContentFormat::BdTs
));
// Order-independent: covering key present anywhere in the set is fine.
assert!(super::aligned_unit_keys_validate(
&[(1, uk1), (0, uk0)],
None,
&samples
&samples,
ContentFormat::BdTs
));
}
+32 -86
View File
@@ -405,9 +405,15 @@ pub(super) fn compute_initial_state(
bad_ranges.reverse();
}
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
// Fail SAFE when metadata is indeterminate: assume a regular file so a
// real `sync_all` failure is surfaced, not swallowed. `/dev/null` and pipes
// report success-with-non-file here (so they still correctly map to
// `false`); only a genuine metadata error (e.g. transient NFS ESTALE) hits
// the default, and for a data-integrity guard "surface the error" is the
// right side to err on.
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
.unwrap_or(true);
Ok((
map,
initial_stats,
@@ -1247,7 +1253,24 @@ impl Disc {
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
let map = match mapfile::Mapfile::load(mapfile_path) {
Ok(m) => m,
Err(_) => return 0,
// A MISSING mapfile is legitimate (no damage was ever tracked — e.g. a
// clean single-pass rip): 0 bad bytes is correct. Any OTHER load error
// (corrupt / unreadable mapfile) means we CANNOT know the damage — and
// a returned 0 reads to the caller as "clean." Logging alone is not
// fail-safe: the RETURN VALUE drives the loss/abort accounting, not the
// log. So fail safe by reporting the ENTIRE title as bad (its full
// in-extent byte count) — a corrupt damage record must surface as
// maximal loss, never as a clean rip.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return 0,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
path = %mapfile_path.display(),
error = %e,
"bytes_bad_in_title: mapfile load failed; reporting whole title bad (fail-safe: cannot confirm clean)"
);
return bytes_bad_in_title(title, &[(0, u64::MAX)]);
}
};
let bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTrimmed,
@@ -1299,33 +1322,13 @@ impl Disc {
let bytes_good_before = initial_stats.bytes_good;
let bytes_good_start = bytes_good_before;
// Post-read verify gate for the patch pass (ciphertext multipass only,
// `!opts.decrypt`). Built here from the raw reader's UDF enumeration;
// reused AFTER the recovery loop (`reverify_iso`) to re-check the units
// this pass touched by reading them WHOLE back from the patched ISO —
// patch re-reads only the bad sectors of a unit, so per-unit verify
// can't run live. Fail-safe `None` when disabled / non-AACS / no keys.
let mut verifier = if opts.decrypt {
None
} else {
let verify_keys = self.decrypt_keys();
let layouts = crate::disc::extract::clip_layouts(&mut *reader);
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
};
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting
// patch (the multipass / `--raw --multipass` path) resolves the keys and
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the
// retry loop keeps re-reading it "until it decrypts or retries exhaust"
// exactly as for a SCSI read error — and a unit that DOES decrypt on a
// fresh read (the drive returned different bytes) is recovered for free.
// With no usable AACS keys this degrades to a plain pass-through.
// Symmetric with `Disc::sweep`: the patch COPIES ciphertext (multipass /
// `--raw`) or decrypts IN PLACE (`opts.decrypt`). It does NOT decrypt-
// VERIFY — the disc-absolute read can't anchor to a clip's file-relative
// unit grid (see `Disc::sweep` + `Disc::verify_clips`). Re-reads recover
// bad sectors; the clip-anchored verify pass re-checks them afterward.
// (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting
// patch (the multipass / `--raw --multipass` path) copies ciphertext
// verbatim (keys = `None` → pass-through). Bad sectors are found by
// PHYSICAL read success, not by decrypt structure: a re-read that returns
// good bytes recovers the range; a read that errors leaves it NonTrimmed
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
@@ -1435,64 +1438,7 @@ impl Disc {
// sink's summary. `close` failing on a regular-file sync_all is
// surfaced here as `Error::IoError`, matching pre-split
// behaviour.
let mut summary = pipe.finish()?;
// Scoped post-read re-verify (decrypt-fail == bad read). The consumer
// has flushed the ISO + mapfile; re-read each clip unit this pass touched
// WHOLE from the patched ISO and downgrade any that still won't decrypt
// to NonTrimmed, so the orchestrator's end-of-recovery promotion
// terminalizes it. Reuses the same verifier as the sweep. Fail-safe:
// disabled gate / unreadable ISO / load failure all leave the pass as-is.
if let Some(mut v) = verifier.take() {
if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) {
// Only units whose every backing sector was actually READ
// (Finished) may be re-verified — we can't verify what wasn't read
// (a non-Finished sector is zero-filled because the read failed),
// and must not waste a key lookup on a known-bad block.
let finished = m.ranges_with(&[mapfile::SectorStatus::Finished]);
let is_finished = |lba: u32| -> bool {
let p = lba as u64 * 2048;
finished.iter().any(|&(s, sz)| p >= s && p < s + sz)
};
if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) {
let bad = v.reverify_iso(&mut iso, &bad_ranges, &is_finished);
if !bad.is_empty() {
let n: usize = bad.len();
for (lba, cnt) in bad {
if let Err(e) = m.record(
lba as u64 * 2048,
cnt as u64 * 2048,
mapfile::SectorStatus::NonTrimmed,
) {
tracing::warn!(
lba,
"reverify downgrade: mapfile record failed ({e}) — unit may stay mismarked as good"
);
}
}
if let Err(e) = m.flush() {
tracing::warn!(
"reverify downgrade: mapfile flush failed ({e}) — downgrade not persisted; a resume could mismark it good"
);
}
// The re-verify ran AFTER `pipe.finish()` snapshotted
// `summary.stats`, so those stats still count the just-
// downgraded units as good. Refresh from the mapfile so
// `build_outcome` reports the true post-downgrade picture
// (bytes_good ↓, bytes_pending ↑) — otherwise the caller
// over-reports recovery and can call an imperfect rip
// "complete".
summary.stats = m.stats();
tracing::info!(
target: "freemkv::verify",
phase = "patch.reverify",
downgraded_ranges = n,
"post-read re-verify downgraded undecryptable units to NonTrimmed"
);
}
}
}
}
let summary = pipe.finish()?;
let outcome = build_outcome(
&state,
-14
View File
@@ -66,14 +66,6 @@ pub(super) enum WorkItem {
/// tell them apart without parsing a flag.
GapFill { pos: u64, len: u64 },
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
MarkBad { pos: u64, len: u64 },
/// Producer wants the latest mapfile stats for the progress
/// callback. Consumer responds on `prog_tx` with a fresh
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
@@ -182,12 +174,6 @@ impl Sink<WorkItem> for SweepSink {
}
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
}
WorkItem::MarkBad { pos, len } => {
// Verify downgrade: the ISO bytes are already written by the
// preceding Good; only the mapfile status changes so patch
// re-reads this range. No file write.
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
}
WorkItem::StatsRequest => {
let stats = self.map.stats();
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
-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
/// cache stays bounded the same way the write side does.
///
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
/// smaller windows (8 / 16 MiB) shorten the
/// kernel-readahead overlap and slow the producer; larger windows
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
+3 -3
View File
@@ -407,7 +407,7 @@ pub fn read_encrypted_units(
break;
}
let u = &buf[o..o + ALIGNED_UNIT_LEN];
if aacs_unit_encrypted(u) {
if aacs_unit_encrypted(u, title.content_format) {
out.push(u.to_vec());
if out.len() >= n {
return out;
@@ -717,7 +717,7 @@ mod tests {
);
for s in &samples {
assert!(
aacs_unit_encrypted(s),
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
);
}
@@ -800,7 +800,7 @@ mod tests {
);
for s in &samples {
assert!(
aacs_unit_encrypted(s),
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
"only CPI-flagged units are selected"
);
assert_eq!(
+17 -5
View File
@@ -241,11 +241,23 @@ impl EsWriter for AnnexBWriter {
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
match codec {
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
_ => Vec::new(),
}
let converted = match codec {
Codec::Hevc => hvcc_to_annex_b(record),
Codec::H264 => avcc_to_annex_b(record),
_ => return Vec::new(),
};
converted.unwrap_or_else(|| {
// A malformed hvcC/avcC record yields no parameter sets. Returning empty
// means keyframes ship WITHOUT in-band SPS/PPS — playable from the first
// keyframe but broken for seek-to-arbitrary-point and hardware decoders.
// Surface it rather than silently degrading the output.
tracing::warn!(
target: "mux",
?codec,
"codec-private (hvcC/avcC) parse failed; keyframes will lack in-band SPS/PPS"
);
Vec::new()
})
}
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
+1 -1
View File
@@ -7,7 +7,7 @@
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
//! already runs on a producer thread; the *consumer* (main) thread
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
//! parsers. Profiling on the rip1 testbed showed feed at ~37 % and
//! parsers. Profiling showed feed at ~37 % and
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
//! enough that pipelining it with parse pays for itself.
//!
+7 -18
View File
@@ -108,11 +108,6 @@ pub struct DiscStream {
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
/// (raw / unencrypted disc) makes the decorator a pass-through.
reader: DecryptingSectorSource<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,
/// Mirror of the keys handed in at construction. The decorator
/// owns the cryptographic state; this field is kept for
@@ -242,8 +237,7 @@ impl DiscStream {
// concern, never conceal / re-fetch / count as loss (fail loud only on a
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
// single-pass / direct), never the ciphertext-preserving sweep.
let mut reader =
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
@@ -294,9 +288,6 @@ impl DiscStream {
// the decorator is a pass-through). Reset the unit base the probe read
// advanced so the first fill_extents read starts cleanly.
reader.set_unit_base(0);
// Clone the shared loss counter once here so `lost_bytes()` never
// clones an Arc per frame on the mux hot path.
let decrypt_loss = reader.decrypt_loss();
// B1 resync gates: one per stream, video flagged so the gate only
// drop-to-keyframes video (audio/subtitle always admit). Computed before
@@ -312,7 +303,6 @@ impl DiscStream {
Self {
reader,
decrypt_loss,
title,
decrypt_keys,
unit_align,
@@ -1004,14 +994,12 @@ impl crate::pes::Stream for DiscStream {
}
fn lost_bytes(&self) -> u64 {
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
// loss — bytes of scrambled AACS units the decorator could not decrypt
// and passed through still encrypted (the TS assembler silently drops
// them). Both are real missing content the abort gate must see; without
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
// missing segments passes even under abort_on_lost_secs=0.
// Read-error zero-fill loss (counted in fill_extents) — real missing
// content the abort gate must see. There is no decrypt-loss term: the
// decrypt path passes bad-encoded/undecryptable units through (a broken-TS
// unit is the muxer's concern, and a missing key is indistinguishable from
// bad authoring here), so only physical read loss is reported.
self.lost_bytes
.saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed))
}
}
@@ -1490,6 +1478,7 @@ mod tests {
let keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
stream.skip_errors = true;
+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,
/// returning the buffer and the used length. Track numbers are small (1-based,
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
/// no heap allocation, called once per block on the mux hot path.
/// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`,
/// and 3 bytes covers `< 0x20_0000`; no heap allocation, called once per block
/// on the mux hot path.
///
/// The 2-byte form holds 14 payload bits (max 0x3FFF). The `debug_assert`
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
/// number. Not reachable today (track numbers are `i+1` over a few streams),
/// so this documents the bound rather than handling 3-byte VINTs.
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
/// Each width uses a marker bit that must NOT collide with the payload's top
/// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40
/// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all
/// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range
/// track number can never silently clobber the marker bit and corrupt the
/// block. Real discs never approach even the 2-byte range; the 21-bit ceiling
/// is an absurd upper bound kept as a `debug_assert`.
fn track_vint(track_num: usize) -> ([u8; 3], usize) {
if track_num < 0x80 {
([(track_num as u8) | 0x80, 0], 1)
([(track_num as u8) | 0x80, 0, 0], 1)
} else if track_num < 0x4000 {
([0x40 | ((track_num >> 8) as u8), track_num as u8, 0], 2)
} else {
debug_assert!(
track_num < 0x4000,
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
track_num < 0x20_0000,
"track number {track_num} exceeds the 21-bit 3-byte EBML VINT range"
);
([0x40 | ((track_num >> 8) as u8), track_num as u8], 2)
(
[
0x20 | ((track_num >> 16) as u8),
(track_num >> 8) as u8,
track_num as u8,
],
3,
)
}
}
@@ -3507,7 +3519,7 @@ mod tests {
}
#[test]
fn track_vint_encodes_one_and_two_byte_forms() {
fn track_vint_encodes_one_two_and_three_byte_forms() {
// 1-byte form for track numbers < 0x80, high bit set.
let (b, n) = track_vint(1);
assert_eq!(&b[..n], &[0x81]);
@@ -3518,6 +3530,12 @@ mod tests {
assert_eq!(&b[..n], &[0x40, 0x80]);
let (b, n) = track_vint(0x3FFF);
assert_eq!(&b[..n], &[0x7F, 0xFF]);
// 3-byte form at/above 0x4000, 0x20 length marker in the top byte —
// handled in RELEASE (no silent marker-bit clobber), not just debug.
let (b, n) = track_vint(0x4000);
assert_eq!(&b[..n], &[0x20, 0x40, 0x00]);
let (b, n) = track_vint(0x1F_FFFF);
assert_eq!(&b[..n], &[0x3F, 0xFF, 0xFF]);
}
// ============================================================
+5 -1
View File
@@ -1016,7 +1016,11 @@ fn parse_track(
arem = arem.saturating_sub(ahlen as u64 + as_);
match aid {
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8,
// Clamp instead of `as u8`: a foreign/corrupt MKV with a
// CHANNELS value that is a multiple of 256 would truncate to
// 0 (an invalid channel count) on a bare cast. Saturate to
// u8::MAX so an absurd count degrades to "many", never to 0.
ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8,
_ => {
skip_bytes(r, as_)?;
}
+3 -34
View File
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
/// per-poll reads it replaces were needless hot-path overhead.
skip_parse: bool,
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
/// could not decrypt — silent decrypt loss the demux drops without a sync.
/// Shared with the producer thread's [`DecryptingSectorSource`]
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
/// are expected on every disc; instead of a per-packet WARN they're tallied
/// and summarised once at EOF.
@@ -134,7 +126,6 @@ impl PipelinedPesStream {
pending_frames: std::collections::VecDeque::new(),
eof: false,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
dropped_nav_packets: 0,
resync,
is_video,
@@ -142,20 +133,6 @@ impl PipelinedPesStream {
}
}
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
/// reports bytes of scrambled AACS units that could not be decrypted (and
/// were therefore silently dropped downstream). Obtained from the
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
/// unset.
pub(crate) fn with_decrypt_loss(
mut self,
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
) -> Self {
self.decrypt_loss = Some(loss);
self
}
/// Pull one batch of `PesPacket`s from the demux thread, run
/// codec parse on each, enqueue resulting `PesFrame`s on
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
@@ -464,17 +441,9 @@ impl Stream for PipelinedPesStream {
.and_then(|(_, parser)| parser.codec_private())
}
fn lost_bytes(&self) -> u64 {
// The file-backed highway has no read-error zero-fill term (resolve
// tracks read loss separately), but the producer's decrypt step can
// pass scrambled units through undecrypted — silent loss the demux
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
// decrypt failure rather than reporting a perfect rip.
self.decrypt_loss
.as_ref()
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
.unwrap_or(0)
}
// `lost_bytes` uses the trait default (0): the file-backed highway has no
// read-error zero-fill term (resolve/mapfile tracks physical read loss
// separately) and the decrypt path no longer reports a decrypt-loss term.
}
#[cfg(test)]
+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 format = disc.content_format;
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
// sequential read from fast storage, no bad sectors. Measured
// optimum on the rip1 testbed; bumping to 16384 sectors (32 MiB)
// regressed (more cache pressure, longer per-batch latency starves
// the consumer between iterations). Physical drives keep smaller
// batches for adaptive error handling.
// sequential read from fast storage, no bad sectors. Empirically
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
// pressure, longer per-batch latency starves the consumer between
// iterations). Physical drives keep smaller batches for adaptive
// error handling.
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
// 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
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
/// returns, then re-decrypts. `None` means no mid-stream key recovery — the
/// unit's best-effort bytes pass through to the muxer as-is.
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
// point; grouping them into a struct would only move the same fields around.
#[allow(clippy::too_many_arguments)]
@@ -647,19 +648,25 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
};
// MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and
// passes the bytes to the muxer; a unit that decrypts to broken TS is the
// muxer's problem, not a decrypt failure, so the mux never conceals, re-fetches
// a key, or counts it as loss — it fails only when it genuinely can't decrypt
// (no key / misaligned unit). The `fetch` key-recovery seam is a rip/verify
// concern (Disc::sweep / Disc::patch), deliberately NOT installed on the mux:
// key recovery happens up front, and the mux never re-asks mid-stream.
// muxer's problem, not a decrypt failure, so the mux never conceals a unit or
// counts it as loss.
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
.tolerate_decrypt_loss();
let _ = &fetch; // rip/verify key-recovery seam; the mux does not consume it
// Loss counter: the mux does not tally broken-TS units (the muxer handles them),
// so for a keyed disc this stays 0; it still surfaces via `lost_bytes()` for the
// abort gate, which now reflects only a genuine can't-decrypt.
let decrypt_loss = decrypting.decrypt_loss();
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
// Install the fresh-key-on-failure callback (if the app supplied one). This is
// how multi-CPS is muxed: each CPS unit's key is fetched when the mux reaches a
// unit no held key opens — "get the key when we need it." It fires only on a
// genuine miss: now that key selection is accurate (`is_clean_ts`), a unit that
// decrypted correctly but has bad-encoded TS is NOT a miss, so this no longer
// storms the key source the way the old TS supermajority gate did.
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
}
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
// broken-TS unit is the muxer's concern, and a missing key is an up-front
// resolve failure — indistinguishable from bad authoring at this seam, so
// counting it would false-abort a bad-encoded-but-decryptable disc. A genuine
// can't-decrypt surfaces as `Err`; `lost_bytes()` reflects physical read loss
// only (there is no decrypt-loss term to fold in).
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
// the reader, probe the feature head through the (plaintext) decrypting
@@ -685,10 +692,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
let (demux_thread, demux_rx) =
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
.map_err(|e| -> io::Error { e.into() })?;
Ok(
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
.with_decrypt_loss(decrypt_loss),
)
Ok(PipelinedPesStream::new(
demux_thread,
demux_rx,
title,
parsers,
pid_to_track,
))
}
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
+25 -21
View File
@@ -12,9 +12,11 @@ use crate::consts::TS_PACKET_BYTES;
/// TS sync byte.
const SYNC_BYTE: u8 = 0x47;
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
/// concealment fill emits null packets on this PID, tagged with an
/// adaptation-field discontinuity_indicator to signal a concealed gap.
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer
/// still recognises a `0x1FFF` packet with an adaptation-field
/// discontinuity_indicator as a concealed-gap loss signal, but the in-tree WRITER
/// that emitted these (the removed NULL-TS concealment fill) is gone — the mux no
/// longer conceals; only externally-authored markers reach this path now.
const NULL_PID: u16 = 0x1FFF;
/// A reassembled PES packet with timestamp info.
@@ -34,8 +36,10 @@ pub struct PesPacket {
pub source: Option<crate::pes::SourcePos>,
/// True when one or more packets for this stream were lost before this PES —
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
/// a tracked PID, or the CC-independent concealment marker the mux emits when
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
/// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE:
/// the mux no longer emits such markers (the concealment writer was removed);
/// this now flags only real discontinuities and externally-authored markers.
/// This PES is
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
/// truncated partial and flags the next complete PES; a loss landing on a PES
/// boundary flags the PES STARTING after it (never the one just flushed). So
@@ -200,7 +204,7 @@ impl PesAssembler {
/// BD Transport Stream demuxer.
pub struct TsDemuxer {
assemblers: Vec<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
/// Absolute source byte offset of the NEXT byte to be fed — the running
/// base that turns an in-buffer packet offset into a source position.
@@ -224,20 +228,17 @@ impl TsDemuxer {
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
/// valid (wholly-unused) table.
pub fn new(pids: &[u16]) -> Self {
// The PID→assembler index is stored as i16 (-1 = untracked), so a
// 32768th+ tracked PID would truncate to a negative value and be
// silently treated as untracked. Callers pass a handful of PIDs
// (BD-TS has at most ~8192), so this is a programmer-error guard.
debug_assert!(
pids.len() <= i16::MAX as usize,
"TsDemuxer: too many PIDs for an i16 index table"
);
// The PID→assembler index is stored as i32 (-1 = untracked). PIDs are
// u16 (≤ 65535) and the assembler index `i` is bounded by the number of
// distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can
// never truncate to a negative value and be mis-read as untracked —
// unlike an i16 table, this is safe in RELEASE, not just under debug.
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
let table_size = (max_pid + 1).max(8192);
let mut pid_index = vec![-1i16; table_size];
let mut pid_index = vec![-1i32; table_size];
let mut assemblers = Vec::with_capacity(pids.len());
for (i, &pid) in pids.iter().enumerate() {
pid_index[pid as usize] = i as i16;
pid_index[pid as usize] = i as i32;
assemblers.push(PesAssembler::new(pid));
}
Self {
@@ -368,10 +369,13 @@ impl TsDemuxer {
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
let adaptation = (ts[3] >> 4) & 0x03;
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
// P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an
// adaptation-field discontinuity_indicator. NOTE: the in-tree writer that
// laid these down on an undecryptable unit was removed with the pure-decrypt
// passthrough change (the mux no longer conceals), so this recognition now
// only fires on externally-authored markers — a candidate for removal with
// the rest of the retired concealment path.
// As a loss signal it is CC-INDEPENDENT — unlike a tracked PID's 4-bit
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
// an exact multiple of 16 packets and a loss at the very start of a PID
// (no prior CC to diff against). The decrypt layer cannot know which
@@ -1057,7 +1061,7 @@ mod tests {
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
/// shape `fill_null_ts_unit` writes for every packet of a concealed unit).
/// shape of a concealed-unit packet).
fn null_marker_packet() -> Vec<u8> {
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
pkt[4] = SYNC_BYTE; // 0x47
+163 -763
View File
File diff suppressed because it is too large Load Diff
+62 -12
View File
@@ -129,6 +129,10 @@ impl SectorSource for Box<dyn SectorSource> {
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
fn set_unit_base(&mut self, lba: u32) {
(**self).set_unit_base(lba)
}
}
impl SectorSource for &mut (dyn SectorSource + '_) {
@@ -160,6 +164,10 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
fn set_unit_base(&mut self, lba: u32) {
(**self).set_unit_base(lba)
}
}
/// Write 2048-byte sectors to a disc image or composed sink.
@@ -181,7 +189,7 @@ pub trait SectorSink: Send {
}
pub use crate::io::file_sector_source::FileSectorSource;
pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch};
pub use decrypting::{DecryptingSectorSource, KeyFetch};
pub use file::FileSectorSink;
pub use prefetched::PrefetchedSectorSource;
@@ -198,23 +206,33 @@ mod tests {
capacity: u32,
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
speeds: Arc<Mutex<Vec<u16>>>,
unit_bases: Arc<Mutex<Vec<u32>>>,
}
/// A `Spy` under test plus the handles recording its reads and speed sets.
type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>);
/// A `Spy` under test plus the handles recording its reads, speed sets,
/// and unit-base sets.
type SpyHarness = (
Spy,
Arc<Mutex<Vec<(u32, u16, bool)>>>,
Arc<Mutex<Vec<u16>>>,
Arc<Mutex<Vec<u32>>>,
);
impl Spy {
fn new(capacity: u32) -> SpyHarness {
let reads = Arc::new(Mutex::new(Vec::new()));
let speeds = Arc::new(Mutex::new(Vec::new()));
let unit_bases = Arc::new(Mutex::new(Vec::new()));
(
Self {
capacity,
reads: reads.clone(),
speeds: speeds.clone(),
unit_bases: unit_bases.clone(),
},
reads,
speeds,
unit_bases,
)
}
}
@@ -238,6 +256,16 @@ mod tests {
fn set_speed(&mut self, kbs: u16) {
self.speeds.lock().unwrap().push(kbs);
}
fn set_unit_base(&mut self, lba: u32) {
self.unit_bases.lock().unwrap().push(lba);
}
}
/// Call `set_unit_base` through a generic `S: SectorSource` bound — this is
/// the path that actually exercises the `Box<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
@@ -286,7 +314,7 @@ mod tests {
/// Box<dyn SectorSource>` forwarding bodies.
#[test]
fn boxed_dyn_forwards_all_methods() {
let (spy, reads, speeds) = Spy::new(777);
let (spy, reads, speeds, unit_bases) = Spy::new(777);
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
@@ -308,24 +336,46 @@ mod tests {
vec![5400],
"set_speed must forward"
);
// set_unit_base through the generic bound exercises the forwarding impl
// (a direct `boxed.set_unit_base()` would vtable-dispatch instead). A
// missing forwarding body would silently no-op and record nothing.
set_unit_base_generic(boxed, 64);
assert_eq!(
*unit_bases.lock().unwrap(),
vec![64],
"set_unit_base must forward through Box<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 + '_)`.
#[test]
fn mut_ref_dyn_forwards_all_methods() {
let (mut spy, reads, speeds) = Spy::new(123);
let r: &mut dyn SectorSource = &mut spy;
let (mut spy, reads, speeds, unit_bases) = Spy::new(123);
assert_eq!(r.capacity_sectors(), 123);
{
let r: &mut dyn SectorSource = &mut spy;
assert_eq!(r.capacity_sectors(), 123);
let mut buf = vec![0u8; 2 * 2048];
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
assert_eq!(n, 2 * 2048);
let mut buf = vec![0u8; 2 * 2048];
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
assert_eq!(n, 2 * 2048);
r.set_speed(8800);
r.set_speed(8800);
}
// Pass `&mut dyn` as a generic S so the forwarding impl's set_unit_base
// is the one under test, not the vtable path.
let r2: &mut dyn SectorSource = &mut spy;
set_unit_base_generic(r2, 128);
assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]);
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
assert_eq!(
*unit_bases.lock().unwrap(),
vec![128],
"set_unit_base must forward through &mut dyn"
);
}
}
+8 -5
View File
@@ -252,11 +252,14 @@ impl PrefetchedSectorSource {
};
if bytes <= buf.capacity() {
// Re-expose `bytes` without zero-filling pages that
// `read_sectors` is about to overwrite. The enclosing
// capacity guard makes the `set_len` provably sound even
// if a recycled buffer ever comes back smaller than the
// `vec![0u8; batch_bytes]` it was born with.
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
// `read_sectors` is about to overwrite. Sound because the
// enclosing `bytes <= capacity` guard bounds the length,
// and every byte below `capacity` is physically
// initialised: buffers are born `vec![0u8; batch_bytes]`
// and only ever grown via `resize(_, 0)`, so a recycled
// buffer that came back shorter (consumer `truncate`)
// still has initialised backing storage under `set_len`,
// which `read_sectors` then overwrites before any read.
unsafe { buf.set_len(bytes) };
} else {
buf.resize(bytes, 0);
+11 -1
View File
@@ -135,6 +135,12 @@ fn aacs_fetch_step(
return prev_dropped;
}
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
// Container of this disc's content — travels with the keys; drives the
// encrypted-flag / structure check below (TS vs PS).
let format = match &*keys {
DecryptKeys::Aacs { format, .. } => *format,
_ => crate::disc::ContentFormat::BdTs,
};
// Gather up to MAX_FETCH_SAMPLES units the current pool did NOT open. Detect
// them on the post-decrypt TARGET (a failed unit stays TS-destroyed; an opened
// one is now clean TS and is skipped), but SAMPLE the matching on-disc
@@ -146,7 +152,7 @@ fn aacs_fetch_step(
.chunks_exact(unit_len)
.zip(ciphertext.chunks_exact(unit_len))
{
if crate::aacs::content::aacs_unit_needs_decrypt(t) {
if crate::aacs::content::aacs_unit_needs_decrypt(t, format) {
samples.push(c.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES {
break;
@@ -256,6 +262,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let cipher = buf.clone();
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
@@ -279,6 +286,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
@@ -304,6 +312,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_unit(0x44);
let cipher = buf.clone();
@@ -330,6 +339,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Distinct ciphertext each time so the dry-set never short-circuits; only
// the internal call budget should stop the fetch. The closure self-limits,
+51
View File
@@ -1790,6 +1790,57 @@ mod tests {
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
}
#[test]
fn read_aacs_inputs_falls_through_to_hddvd_any_dir() {
// HD DVD keeps its AACS material under /ANY!/ (VTKF000.AACS title-key
// file + MKBROM.AACS), NOT /AACS/Unit_Key_RO.inf + /AACS/MKB_RO.inf. The
// role-based candidate lists must fall through to the /ANY!/ files with
// NO disc-type branch, so the online keyserver POST carries the HD DVD
// title-key file (magic "DVD_HD_V_TKF") as inf_b64 + MKBROM as mkb_b64 —
// the server then classifies the disc as HD DVD by that magic.
let any = DirEntry {
name: "ANY!".to_string(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![
file_entry("VTKF000.AACS", 5, 2048),
file_entry("MKBROM.AACS", 7, 2048),
],
};
let root = DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![any], // deliberately NO /AACS/ dir
};
let mut reader = MapReader::new();
// VTKF000.AACS: one extent whose content opens with the HD DVD magic.
let mut vtkf = [0u8; 2048];
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
reader.put(5, build_efe_long(2048, &[(0, 2048, 10)]));
reader.put(10, vtkf);
// MKBROM.AACS: one extent with a type-0x10 AACS-1.0 (HD DVD) version record.
let mut mkb = [0u8; 2048];
mkb[..12].copy_from_slice(&[
0x10, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x10, 0x03, 0x00, 0x00, 0x00, 0x03,
]);
reader.put(7, build_efe_long(2048, &[(0, 2048, 50)]));
reader.put(50, mkb);
let fs = fs_with(0, 0, root);
let (inf, _mkb, _version) =
crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must source the HD DVD /ANY!/ files");
assert_eq!(
&inf[..12],
b"DVD_HD_V_TKF",
"inf must be the HD DVD VTKF (its magic), sourced from /ANY!/ via the \
candidate fall-through not /AACS/Unit_Key_RO.inf"
);
}
#[test]
fn merge_ranges_saturates_near_u32_max() {
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.