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:
+40
-47
@@ -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 30–90 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`
|
||||||
|
|||||||
+405
-662
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -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
@@ -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
@@ -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
@@ -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];
|
||||||
|
|||||||
+112
-41
@@ -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,11 +474,14 @@ 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)
|
||||||
process(idx, chunk);
|
.enumerate()
|
||||||
});
|
.for_each(|(idx, chunk)| {
|
||||||
|
process(idx, chunk);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
|||||||
+76
-124
@@ -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,12 +1821,12 @@ 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)
|
||||||
{
|
{
|
||||||
Some(c) => c.version.major(),
|
Some(c) => c.version.major(),
|
||||||
None => {
|
None => {
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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_)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+163
-763
File diff suppressed because it is too large
Load Diff
+62
-12
@@ -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,24 +336,46 @@ 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;
|
|
||||||
|
|
||||||
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 mut buf = vec![0u8; 2 * 2048];
|
||||||
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
||||||
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user