mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ebae8f81 | ||
|
|
b2f35a8afc | ||
|
|
a6a4a5d7d2 | ||
|
|
0de6b82a37 | ||
|
|
b9b0a87328 | ||
|
|
d6cef9f665 | ||
|
|
b9b9185633 | ||
|
|
66951a32ed | ||
|
|
505a78efd1 | ||
|
|
9b80084220 | ||
|
|
b246169e5b | ||
|
|
2d96a3e87d | ||
|
|
bae5a9ea07 | ||
|
|
86ed27508d | ||
|
|
f28d79b51b |
+113
-90
@@ -1,98 +1,121 @@
|
||||
# Changelog
|
||||
|
||||
## [1.5.0] — UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- **MP4 input (`mp4://` as a source)** — a progressive-MP4 demuxer, the inverse
|
||||
of the muxer: it rebuilds the title + per-sample index from `moov`/`stbl` and
|
||||
streams samples back as PES frames, so `mp4://` now flows to **any** sink
|
||||
(`mp4:// mkv://`, `mp4:// audio://`, `mp4:// json://`, …). Round-trip is
|
||||
frame-exact (verified `iso:// mp4://` → `mp4:// mkv://` preserves every video
|
||||
and audio packet). Progressive MP4 only; fragmented (`moof`) is future work.
|
||||
- **MP4 output is faststart by default** — `moov` is written *before* `mdat`, so
|
||||
the file plays over HTTP without pre-fetching the end. Done with zero extra I/O:
|
||||
a `moov`-sized hole (`round_up_4MB(16 B/sample × est_samples) + 4 MB`) is
|
||||
reserved up front, so sample offsets are fixed and `moov` is dropped into the
|
||||
hole at finish with a `free` box for the slack (falls back to moov-at-end only
|
||||
if the estimate is blown).
|
||||
- **Native MP4 output (`mp4://`)** — a progressive ISO-BMFF muxer
|
||||
(`ftyp`+`mdat`+`moov`), so a disc goes straight to a play-everywhere `.mp4` in
|
||||
one decrypt pass, no ffmpeg round-trip. Carries HEVC / H.264 video (HDR10 colour
|
||||
via `colr`) and the audio MP4 supports — AC-3 (`dac3`), E-AC-3 (`dec3`), and
|
||||
DTS / DTS-HD (`dtsc`/`dtsh` + `ddts`) — with correct sample tables and B-frame
|
||||
composition offsets. It's a **compatibility export, not archival**: MP4 can't
|
||||
hold TrueHD, LPCM, or bitmap (PGS/VobSub) subtitles, so those are **excluded
|
||||
with a loud, itemized report — never a silent drop** (`mkv://` remains the
|
||||
keep-everything path). Verified byte-for-byte equivalent to `ffmpeg -c copy`:
|
||||
frame-exact video and audio (incl. a real DTS-HD MA 7.1 track, layout-exact),
|
||||
identical colour and duration, clean decode.
|
||||
|
||||
- **Five extraction sinks — dissect a title, don't just rip it.** New write-only
|
||||
destinations that pull one facet of a title out on its own:
|
||||
- **`video://dir/`** — every video track to its own file, as a raw elementary
|
||||
stream in the codec's native form (`.hevc`, `.h264`, `.vc1`, `.m2v`, `.obu`).
|
||||
No audio, no subtitles. Completes the per-track-class trio with `audio://` /
|
||||
`sub://`.
|
||||
- **`audio://dir/`** — every audio track to its own file in a directory, each in
|
||||
its native container (`.thd`, `.dts` / `.dtshd`, `.ac3`, `.eac3`, `.aac`,
|
||||
`.flac`). No video, no subtitles. BD/DVD LPCM has no container of its own and is
|
||||
written as headerless big-endian `.pcm`.
|
||||
- **`sub://dir/`** — every subtitle track to its own file: PGS as `.sup`, VobSub
|
||||
as a paired `.idx` + `.sub`, text subtitles as `.srt`. No video, no audio.
|
||||
- **`chapters://file`** — a title's chapter markers as a single sidecar, in the
|
||||
format the output extension selects: `.xml` (Matroska), `.txt` / `.ogm` (OGM
|
||||
simple), or `.vtt` (WebVTT).
|
||||
- **`json://file`** — one title's complete structure as JSON.
|
||||
|
||||
`audio://` and `sub://` are the demux path with a **kind filter** (only their
|
||||
track class is opened and written). `chapters://` and `json://` are **scan-only**
|
||||
— they read nothing of the elementary streams, so they return in seconds.
|
||||
|
||||
- **Drop-on-undecodable audio — keep everything decodable, drop the frames that
|
||||
aren't.** A damaged audio access unit is now dropped rather than muxed as a
|
||||
decoder-choking glitch, and A/V sync is preserved: a drop becomes a silence
|
||||
gap, never a shift of the following audio, and every drop is logged. Detection
|
||||
is per-codec, each mirroring the format's own authoritative integrity check:
|
||||
**DTS** (ffmpeg's core-header validity parse), **AC-3 / E-AC-3** (native frame
|
||||
CRC-16), **FLAC** (whole-frame CRC-16), **MP2 / MP3** and **AAC-ADTS** (header
|
||||
sanity — raw AAC passes through), and **TrueHD / MLP** (major-sync CRC-16 + AU
|
||||
parity, verified against real ffmpeg output; because MLP state carries across
|
||||
access units, a corrupt AU is dropped *forward* to the next major sync). LPCM
|
||||
and video are excluded by design (no in-frame integrity data; inter-frame
|
||||
prediction). A shared tally counts and logs drops and, for a track that is
|
||||
mostly undecodable, drops the whole track. Detects only frames that are
|
||||
*structurally* undecodable — corruption of the sample payload inside an
|
||||
otherwise-valid frame (bad bit-allocation / XLL data) is source damage a
|
||||
container remux cannot distinguish from good data.
|
||||
|
||||
- **Forced subtitle detection from PGS content.** A PGS subtitle track is flagged
|
||||
`FlagForced` when it displays subtitles and every one carries the HDMV
|
||||
`forced_on_flag` — a dedicated forced/narrative track — read from the stream
|
||||
itself rather than the disc's vendor label metadata, so it works on discs that
|
||||
carry no such blob. `info -v` reports the same verdict (one shared classifier
|
||||
drives both the muxer and the scan-time probe), so `info` and a rip agree.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`json://` emits the complete title model — lossless, not a summary.** Every
|
||||
field the scan resolved is serialized: video carries resolution (+ pixel
|
||||
dimensions, interlaced flag), frame rate (+ exact fraction), HDR, colour space,
|
||||
display aspect, and measured CICP; audio carries channel layout (+ count), sample
|
||||
rate (+ Hz), language, and editorial purpose; subtitles carry the forced flag and
|
||||
qualifier. The clip list and chapter names are included too.
|
||||
## [1.5.2] — UNRELEASED
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TrueHD: a couple of transient errors can no longer poison a whole track.**
|
||||
A corrupt access unit drops forward to the next major sync, but only the
|
||||
individually-verified corruption feeds the whole-track drop verdict now; the
|
||||
resync run is collateral — so two scattered errors no longer discard an
|
||||
otherwise-good multi-hour track. The per-AU PTS rate is refined only from a
|
||||
CRC-validated major sync (a corrupt one can't shift the resumed audio), and the
|
||||
resync clears only on a validated major sync.
|
||||
- **Free-format MPEG-audio** (`bitrate_index == 0`) is a legal, decodable mode and
|
||||
is no longer dropped.
|
||||
- CSS DVDs no longer mux to garbage. Every DVD read path — the file-backed mux
|
||||
highway (`build_iso_pipeline`) and the live-drive single-pass `DiscStream` —
|
||||
now resolves the per-VTS title key at read time through one shared step
|
||||
(`resolve_dvd_title_key`), cracked keylessly in playback order from the title's
|
||||
own extents. An uncrackable title hard-fails (E7023) instead of passing
|
||||
scrambled sectors through as plaintext; `--raw` skips the crack entirely; a
|
||||
user Stop mid-crack surfaces as `Halted`.
|
||||
|
||||
### Changed
|
||||
|
||||
- DVD scan no longer cracks a title key up front (the key is per-VTS, so a single
|
||||
disc key was meaningless). Scan does only the CSS bus-auth read-unlock — hoisted
|
||||
before the UDF prefetch so scrambled small/menu VOBs no longer cost a rejected
|
||||
read each. Cuts a CSS-DVD scan from ~25s to ~6s.
|
||||
- Unlocker report: the DVD entry is renamed `CSS` → `DVD`.
|
||||
|
||||
## [1.5.1] — 2026-07-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TrueHD audio is no longer silently dropped (and no longer sends decoders out
|
||||
of memory).** The previous release added an MLP major-sync checksum gate to drop
|
||||
genuinely-undecodable audio frames, but the checksum was computed with mismatched
|
||||
byte order — the 16-bit CRC result was folded in one endianness and compared in
|
||||
the other — so it never validated a real major sync. The parser then judged every
|
||||
major sync corrupt and dropped every audio frame from the first one onward,
|
||||
flushing the whole TrueHD track at the end of the file: de-interleaved from the
|
||||
video, which sent players and integrity checkers into an unbounded memory spiral
|
||||
("decoder ran out of memory"). The checksum now matches the reference
|
||||
implementation byte-exact (cross-verified against real 7.1/Atmos and 5.1 discs),
|
||||
so major syncs validate and only genuinely-corrupt frames are dropped; as a
|
||||
safety net, a major sync the parser still can't validate is kept rather than
|
||||
allowed to drop an entire track. TrueHD titles produced after the checksum gate
|
||||
landed need a re-rip.
|
||||
- **HD DVD AACS key files are now found on every disc, not just the common
|
||||
layout.** The AACS directory and title-key filename on HD DVD are chosen by
|
||||
the authoring house, and freemkv previously assumed one fixed spelling
|
||||
(`/ANY!/VTKF000.AACS`). Discs that name their AACS directory differently (e.g.
|
||||
`AAC!` instead of `ANY!`) or ship numbered title-key files (`VTKF090.AACS` /
|
||||
`VTKF100.AACS` rather than `VTKF000.AACS`) are now handled: the AACS directory
|
||||
is located by its contents and every title-key file in it is picked up. Blu-ray
|
||||
and UHD are unaffected.
|
||||
- **HD DVD multi-title decryption reads the right keys.** The HD DVD title-key
|
||||
file (`VTKF*.AACS`) stores its keys in 36-byte records — per the AACS HD DVD
|
||||
specification, and confirmed byte-exact on real discs. freemkv had been reading
|
||||
them at a 32-byte stride, which lands the first key correctly but drifts off
|
||||
every key after it, so only single-title discs decrypted. Discs with more than
|
||||
one protected title now recover every title's key instead of only the first.
|
||||
(Choosing the correct title-key file when a disc carries several playlists
|
||||
still needs verification against an encrypted HD DVD.)
|
||||
- **A dirty disc can no longer "rip clean" but decode with errors.** freemkv now
|
||||
asks the drive to *report* marginal reads instead of silently returning
|
||||
best-effort data as success — on smudged/scratched media a drive can hand back
|
||||
subtly-wrong bytes with a clean status, which used to slip through the rip and
|
||||
surface only as playback/decode errors. A read the drive had to fight for is
|
||||
now distrusted and re-read in the patch pass: a clean re-read wins, and a spot
|
||||
that's genuinely unreadable becomes an honest gap rather than silently-wrong
|
||||
data. Best-effort per drive, and it changes nothing on a clean disc.
|
||||
|
||||
## [1.5.0] — 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- **MP4 as a source (`mp4://`)** — read a progressive `.mp4` back in and send it to
|
||||
any sink (`mp4:// mkv://`, `mp4:// audio://`, `mp4:// json://`, …). The round-trip
|
||||
is frame-exact.
|
||||
- **Native MP4 output (`mp4://`)** — a disc goes straight to a play-everywhere
|
||||
`.mp4` in one decrypt pass, no ffmpeg. Carries HEVC / H.264 video (with HDR10) and
|
||||
AC-3, E-AC-3, and DTS / DTS-HD audio, and is faststart by default so it plays over
|
||||
HTTP without downloading the end first. It's a **compatibility export, not
|
||||
archival**: MP4 can't hold TrueHD, LPCM, or bitmap (PGS / VobSub) subtitles, so
|
||||
those are **excluded with a loud, itemized report — never a silent drop**
|
||||
(`mkv://` stays the keep-everything path).
|
||||
- **Five extraction sinks — dissect a title, don't just rip it.** New destinations
|
||||
that pull one part of a title out on its own:
|
||||
- **`video://dir/`** — each video track to its own native elementary-stream file.
|
||||
- **`audio://dir/`** — each audio track to its own file in its native container
|
||||
(`.thd`, `.dts`, `.ac3`, `.eac3`, `.aac`, `.flac`; LPCM as `.pcm`).
|
||||
- **`sub://dir/`** — each subtitle track to its own file (PGS `.sup`, VobSub
|
||||
`.idx` + `.sub`, text `.srt`).
|
||||
- **`chapters://file`** — a title's chapter markers as a sidecar (`.xml` / `.txt`
|
||||
/ `.ogm` / `.vtt`).
|
||||
- **`json://file`** — a title's complete structure as JSON.
|
||||
|
||||
`chapters://` and `json://` read nothing of the elementary streams, so they
|
||||
return in seconds.
|
||||
- **Damaged audio frames are dropped instead of shipped as glitches.** When a
|
||||
source disc has a corrupt audio frame, freemkv drops that frame rather than muxing
|
||||
it as a decoder-choking glitch — keeping A/V in sync (a drop is a silence gap,
|
||||
never a shift) and logging every drop. Works across DTS, AC-3 / E-AC-3, FLAC,
|
||||
MP2 / MP3, AAC, and TrueHD, each using the format's own integrity check; a track
|
||||
that is mostly undecodable is dropped whole. This catches structurally-broken
|
||||
frames — corruption of the audio *data* inside an otherwise-valid frame is source
|
||||
damage that can't be told from good data without decoding.
|
||||
- **Forced subtitles detected from the stream.** A PGS subtitle track is flagged
|
||||
forced when its content is entirely forced/narrative subtitles, read from the
|
||||
stream itself rather than the disc's metadata — so it works on discs that carry
|
||||
none. `info -v` reports the same, so `info` and a rip agree.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`json://` emits the complete title model** — every field the scan resolved:
|
||||
video resolution / frame rate / HDR / colour, audio channel layout / sample rate /
|
||||
language / purpose, the subtitle forced flag, plus the clip list and chapter names.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TrueHD: a couple of transient errors no longer discard a whole track.** A
|
||||
corrupt access unit is dropped forward to the next clean sync point, but a short
|
||||
burst of damage no longer trips the whole-track drop, and a corrupt sync point can
|
||||
no longer shift the audio that follows.
|
||||
- **Free-format MP2 / MP3** is a legal, decodable mode and is no longer dropped.
|
||||
|
||||
## [1.4.5] — 2026-07-18
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.5.0"
|
||||
version = "1.5.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "MIT"
|
||||
@@ -28,7 +28,7 @@ cbc = "0.1"
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.5.0" }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.5.2" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
|
||||
+92
-49
@@ -161,33 +161,52 @@ 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".
|
||||
/// HD DVD Video Title Key File (`VTKF%%%.AACS`) magic — "DVD_HD_V_TKF".
|
||||
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
|
||||
/// Fixed header length before the first title-key entry.
|
||||
/// Fixed header length before the first Title Key Entry (AACS HD DVD Book,
|
||||
/// Table 3-8).
|
||||
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;
|
||||
/// Title Key Entry stride (Table 3-8): 1-byte `BIFO` + 3 reserved + 16-byte
|
||||
/// encrypted title key + 16-byte binding MAC = 36 bytes.
|
||||
const VTKF_ENTRY_LEN: usize = 0x24;
|
||||
/// Byte offset of the encrypted title key within an entry (after `BIFO` + 3
|
||||
/// reserved).
|
||||
const VTKF_KEY_OFF: usize = 4;
|
||||
/// Number of Title Key Entry slots in a VTKF (Table 3-8): a fixed 64.
|
||||
const VTKF_MAX_ENTRIES: usize = 64;
|
||||
/// `BIFO` bit 7 (`AV_FLG`): set = this slot carries an available title key.
|
||||
const VTKF_AV_FLG: u8 = 0x80;
|
||||
|
||||
/// Parse an HD DVD `VTKF000.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
|
||||
/// Parse an HD DVD `VTKF%%%.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):
|
||||
/// Layout — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed
|
||||
/// 2480-byte file, verified byte-exact against real discs (Freedom `VTKF090`,
|
||||
/// Dukes of Hazzard `VTKF000`):
|
||||
/// ```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)
|
||||
/// [0x0C..0x10] BE32 HD_VTKF_SIZE (2480)
|
||||
/// [0x10..0x1C] associated playlist name ("VPLST%%%.XPL")
|
||||
/// [0x1C..0x80] reserved
|
||||
/// [0x80..] 64 entries × 36 bytes:
|
||||
/// BIFO (1) | reserved (3) | ENCRYPTED title key (16) | binding MAC (16)
|
||||
/// BIFO bit 7 (AV_FLG) set = this slot holds a title key
|
||||
/// (pre-recorded discs fill the binding MAC with 0xFF)
|
||||
/// [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key)
|
||||
/// ```
|
||||
/// 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.
|
||||
/// The slot index (1-based) is the CPS unit number, so an absent slot is
|
||||
/// SKIPPED (not a terminator) — collapsing gaps would renumber later keys and
|
||||
/// hand the wrong title key to CPS unit N+1. The title→CPS mapping is
|
||||
/// playlist-driven (`VPLST%%%.XPL`) and owned by the HD DVD enumerator, so
|
||||
/// `title_cps_unit` is left empty here.
|
||||
///
|
||||
/// The prior parser used a 32-byte stride (a 12-byte pad instead of the 16-byte
|
||||
/// binding MAC). That reads entry #1 correctly but drifts +4 bytes per entry
|
||||
/// after it, so it only decrypted single-CPS-unit discs; every multi-key VTKF
|
||||
/// (Freedom, Harry Potter) yielded garbage keys for CPS unit ≥2.
|
||||
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
|
||||
if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC {
|
||||
return None;
|
||||
@@ -198,20 +217,19 @@ pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
|
||||
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 {
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
|
||||
if pos + VTKF_ENTRY_LEN > data.len() {
|
||||
break;
|
||||
}
|
||||
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
|
||||
// number (do NOT break — a gap must not renumber the keys that follow).
|
||||
if data[pos] & VTKF_AV_FLG == 0 {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
key.copy_from_slice(&data[pos + VTKF_KEY_OFF..pos + VTKF_KEY_OFF + 16]);
|
||||
encrypted_keys.push((n as u32 + 1, key));
|
||||
}
|
||||
if encrypted_keys.is_empty() {
|
||||
return None;
|
||||
@@ -369,42 +387,51 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
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.
|
||||
/// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS
|
||||
/// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes
|
||||
/// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64
|
||||
/// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG`
|
||||
/// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
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
|
||||
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
if let Some(k) = keys.get(n) {
|
||||
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||
} else {
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||
}
|
||||
}
|
||||
// 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.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() {
|
||||
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||
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");
|
||||
// Exactly the three present entries — the empty slots and the trailing
|
||||
// 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read
|
||||
// at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride
|
||||
// misread them from inside the previous entry's binding MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
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");
|
||||
@@ -412,6 +439,22 @@ mod vtkf_tests {
|
||||
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_a_full_64_entry_file() {
|
||||
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||
// come back, none dropped and none drifted — the regression the 32-byte
|
||||
// stride failed.
|
||||
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[63],
|
||||
(64, [63u8; 16]),
|
||||
"entry 64 at 0x{:x}",
|
||||
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_non_magic() {
|
||||
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||||
|
||||
+225
-36
@@ -40,17 +40,24 @@ pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files.
|
||||
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||||
/// discovery.
|
||||
///
|
||||
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
|
||||
/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the
|
||||
/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The
|
||||
/// container difference is expressed here purely as DATA: each ROLE
|
||||
/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered
|
||||
/// candidate list, and every reader walks it with [`read_first`] taking the
|
||||
/// first that reads. No reader ever branches on disc type — a BD/UHD disc has
|
||||
/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through
|
||||
/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||||
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||||
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||||
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||||
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||||
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||||
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||||
/// hardcoded.
|
||||
///
|
||||
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||||
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||||
/// which every reader walks with [`read_first`], 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 none of them, so it falls through to the discovered
|
||||
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
@@ -59,41 +66,109 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
||||
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service
|
||||
/// recognises it by its `DVD_HD_V_TKF` magic.
|
||||
pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS";
|
||||
/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`.
|
||||
pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS";
|
||||
/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10).
|
||||
pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS";
|
||||
|
||||
/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD).
|
||||
pub const UNIT_KEY_RO_PATHS: &[&str] = &[
|
||||
PATH_UNIT_KEY_RO,
|
||||
PATH_UNIT_KEY_RO_DUPLICATE,
|
||||
PATH_VTKF_HDDVD,
|
||||
];
|
||||
/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD).
|
||||
pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD];
|
||||
/// Content-certificate role, in resolution order (BD/UHD, then HD DVD).
|
||||
pub const CONTENT_CERT_PATHS: &[&str] = &[
|
||||
PATH_CONTENT_CERT,
|
||||
PATH_CONTENT_CERT_ALT,
|
||||
PATH_CONTENT_CERT_HDDVD,
|
||||
];
|
||||
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
|
||||
/// list (BD/UHD constants, then the discovered HD DVD files).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AacsRole {
|
||||
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
|
||||
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
|
||||
UnitKey,
|
||||
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
|
||||
Mkb,
|
||||
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
|
||||
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
|
||||
ContentCert,
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths and return the first that reads.
|
||||
/// The HD DVD AACS directory in a parsed UDF tree, if present.
|
||||
///
|
||||
/// Identified structurally, NOT by a hardcoded name: the root child directory
|
||||
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||||
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||||
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||||
/// disc has no such directory → `None`.
|
||||
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||||
udf.root.entries.iter().find(|e| {
|
||||
e.is_dir
|
||||
&& e.name.ends_with('!')
|
||||
&& e.entries
|
||||
.iter()
|
||||
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD
|
||||
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
|
||||
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
|
||||
/// the other family's entries simply never read.
|
||||
///
|
||||
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
|
||||
/// sorted name order — a disc may carry more than one variant (Freedom:
|
||||
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
|
||||
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
|
||||
let mut v: Vec<String> = match role {
|
||||
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
|
||||
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
|
||||
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
|
||||
}
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
if let Some(dir) = find_hddvd_aacs_dir(udf) {
|
||||
let d = &dir.name;
|
||||
match role {
|
||||
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||||
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||||
AacsRole::UnitKey => {
|
||||
// Glob VTKF*.AACS — the title-key filename is not fixed at
|
||||
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||||
// deterministic try order.
|
||||
//
|
||||
// TODO(hddvd-playlist): each VTKF%%%.AACS is bound to ONE
|
||||
// playlist (VPLST%%%.XPL) — the AACS HD DVD Book gives the
|
||||
// selector explicitly: match the TKF's 12-byte PLAYLIST_NAME
|
||||
// field (bytes 0x10..0x1C) to the playlist of the title being
|
||||
// decrypted; "unless the names are identical, the Title Keys in
|
||||
// this TKF must not be used." Today read_first just takes the
|
||||
// first that reads, which is correct only for a single-playlist
|
||||
// disc. Thread the active playlist name here (owned by the HD
|
||||
// DVD enumerator) and pick the name-matched VTKF once a
|
||||
// multi-playlist encrypted disc is available to validate against.
|
||||
let mut names: Vec<&str> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.filter(|e| {
|
||||
let u = e.name.to_ascii_uppercase();
|
||||
u.starts_with("VTKF") && u.ends_with(".AACS")
|
||||
})
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths (from [`role_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>>
|
||||
/// [`Error::AacsNoKeys`] if no candidate is present. Generic over the path
|
||||
/// element (`&str` or owned `String`) so it accepts the `Vec<String>` that
|
||||
/// [`role_paths`] builds from the discovered HD DVD directory.
|
||||
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||||
{
|
||||
for path in candidates {
|
||||
if let Ok(buf) = read(path) {
|
||||
if let Ok(buf) = read(path.as_ref()) {
|
||||
return Ok(buf);
|
||||
}
|
||||
}
|
||||
@@ -161,4 +236,118 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
//
|
||||
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||||
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||||
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||||
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||
use crate::udf::fixture::*;
|
||||
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||
// and the candidate list is exactly the static BD/UHD paths.
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "AACS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+258
@@ -136,6 +136,65 @@ pub fn crack_key_outcome(
|
||||
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
||||
}
|
||||
|
||||
/// Resolve a DVD title's CSS descramble key from the reader when the caller
|
||||
/// supplied none — the SINGLE place every DVD read path obtains a title key, so
|
||||
/// the file-backed mux highway ([`crate::build_iso_pipeline`]) and the
|
||||
/// live-drive single-pass [`crate::DiscStream`] descramble a DVD identically
|
||||
/// ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled
|
||||
/// data itself, so a `None`/MPEG-PS title cracks its own key here, in playback
|
||||
/// order over `extents`. Everything else is left untouched:
|
||||
/// - AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS.
|
||||
/// - a title that already carries a key — nothing to resolve.
|
||||
/// - a genuinely clear DVD (no scrambled sector) — stays `None`, a mux no-op.
|
||||
///
|
||||
/// A scrambled-but-uncrackable title is a hard [`crate::error::Error::CssKeyMissing`],
|
||||
/// never a silent scrambled-passthrough mux.
|
||||
pub(crate) fn resolve_dvd_title_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
keys: &mut crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
format: crate::disc::ContentFormat,
|
||||
raw: bool,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> std::io::Result<()> {
|
||||
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
|
||||
// never hard-fail on scrambled-uncrackable — the user asked for the scrambled
|
||||
// bytes. (In raw mode the caller hands us `None` on purpose; without this
|
||||
// guard we'd install a real key and silently DECRYPT, or abort a raw mux.)
|
||||
if raw {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(keys, crate::decrypt::DecryptKeys::None)
|
||||
&& format == crate::disc::ContentFormat::MpegPs
|
||||
{
|
||||
// `halt` threads the caller's cancellation token so /api/stop can
|
||||
// interrupt a long crack scan (the old scan-time crack honored it too).
|
||||
let outcome = crack_key_outcome(reader, extents, batch_sectors, halt);
|
||||
// A cancelled crack breaks out early, so its outcome is a TRUNCATED scan
|
||||
// — not a real verdict. Interpreting it would either hard-fail a good disc
|
||||
// as `ScrambledUncracked` (quarantining staging on a Stop) or, worse,
|
||||
// read a half-scanned title as `Unencrypted` and mux scrambled bytes as
|
||||
// plaintext. Surface the cancellation as `Halted` so the caller takes its
|
||||
// graceful-stop path instead of trusting the partial outcome.
|
||||
if halt.map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return Err(crate::error::Error::Halted.into());
|
||||
}
|
||||
match outcome {
|
||||
CrackOutcome::Cracked(state) => {
|
||||
*keys = crate::decrypt::DecryptKeys::Css {
|
||||
title_key: state.title_key,
|
||||
};
|
||||
}
|
||||
CrackOutcome::ScrambledUncracked => {
|
||||
return Err(crate::error::Error::CssKeyMissing.into());
|
||||
}
|
||||
CrackOutcome::Unencrypted => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
||||
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
||||
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
|
||||
@@ -893,6 +952,205 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
|
||||
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
|
||||
/// call, so these pin its full contract at the shared boundary.
|
||||
///
|
||||
/// Crack path: a `None`-keyed MPEG-PS title with a crackable scrambled sector
|
||||
/// installs a `Css` key that round-trips the sector.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_cracks_none_mpegps() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let crackable = crackable_sector(&title_key, &seed, 8);
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.crackable = Some((1003, crackable));
|
||||
let extents = [Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 50,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("crackable title resolves");
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key: got } => {
|
||||
assert_eq!(got, title_key, "installed key must be the cracked key")
|
||||
}
|
||||
_ => panic!("expected Css key"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard-fail path: a scrambled-but-uncrackable `None`-keyed MPEG-PS title must
|
||||
/// return `CssKeyMissing`, never leave `keys` as `None` (which would mux
|
||||
/// scrambled bytes as plaintext — the 328k-decode-error corruption).
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_scrambled_uncrackable_hard_fails() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true; // every read CSS-locked → ScrambledUncracked
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
let err = resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect_err("scrambled-uncrackable must hard-fail");
|
||||
// The Error::CssKeyMissing flattens into io::Error carrying its E-code
|
||||
// (7023) in the message — assert that specific code survived.
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains(&format!("E{}", crate::error::E_CSS_KEY_MISSING)),
|
||||
"must surface CssKeyMissing (E{}), got: {err}",
|
||||
crate::error::E_CSS_KEY_MISSING
|
||||
);
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"keys must stay None on hard-fail (never a scrambled-passthrough key)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `raw` is deliberate ciphertext passthrough: even a scrambled-uncrackable
|
||||
/// title must return `Ok` and leave `keys` untouched (`None`) — no crack, no
|
||||
/// hard-fail. This is the `--raw` guarantee.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_raw_skips_crack_and_never_fails() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true;
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
true, // raw
|
||||
None,
|
||||
)
|
||||
.expect("raw must never hard-fail");
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"raw must leave keys None (no descramble)"
|
||||
);
|
||||
assert!(
|
||||
src.reads.borrow().is_empty(),
|
||||
"raw must not read any sector for a crack"
|
||||
);
|
||||
}
|
||||
|
||||
/// AACS gate: an MPEG-PS title carrying `Aacs` keys (HD-DVD `.evo`) must be
|
||||
/// left untouched — resolve only fires on `None` keys, never overwriting a
|
||||
/// real key set or cracking AACS ciphertext as CSS.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_leaves_aacs_untouched() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true; // would hard-fail IF it ran the crack
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0u8; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::MpegPs,
|
||||
};
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("AACS title must be left untouched, not cracked");
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }),
|
||||
"Aacs keys must survive unchanged"
|
||||
);
|
||||
assert!(
|
||||
src.reads.borrow().is_empty(),
|
||||
"must not read for a crack when keys are already Aacs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear DVD: a `None`-keyed MPEG-PS title with no scrambled sector stays
|
||||
/// `None` (a mux no-op) and returns `Ok` — genuinely-unencrypted DVDs pass.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_clear_dvd_stays_none() {
|
||||
let mut src = MockSource::new(0x00); // all-clear sectors
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("clear DVD passes");
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"a clear DVD must keep None keys"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cancelled crack (user Stop mid-scan) must surface as `Halted`, NOT be
|
||||
/// misread from the truncated scan as `Unencrypted` (→ scrambled passthrough,
|
||||
/// corruption) or `ScrambledUncracked` (→ CssKeyMissing, which quarantines a
|
||||
/// good disc). This pins the halt-outcome fix.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_halt_surfaces_as_halted_not_a_verdict() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true; // without the halt guard this would be ScrambledUncracked
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let halt = crate::halt::Halt::new();
|
||||
halt.cancel(); // Stop already pressed
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
let err = resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
Some(&halt),
|
||||
)
|
||||
.expect_err("a cancelled crack must return an error");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains(&format!("E{}", crate::error::E_HALTED)),
|
||||
"cancelled crack must surface Halted (E{}), got: {err}",
|
||||
crate::error::E_HALTED
|
||||
);
|
||||
}
|
||||
|
||||
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
||||
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
||||
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
||||
|
||||
+10
-5
@@ -324,13 +324,18 @@ impl Disc {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data =
|
||||
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
|
||||
aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| {
|
||||
udf_fs.read_file(reader, p)
|
||||
})?;
|
||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::inf::parse_content_cert);
|
||||
let cc = aacs::read_first(
|
||||
&aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert),
|
||||
|p| udf_fs.read_file(reader, p),
|
||||
)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::inf::parse_content_cert);
|
||||
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
||||
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
|
||||
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
|
||||
|
||||
+487
-234
@@ -1611,6 +1611,36 @@ impl Disc {
|
||||
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
|
||||
session.set_speed(0xFFFF);
|
||||
|
||||
// CSS bus-auth unlock — run BEFORE any scrambled-sector read.
|
||||
// On a CSS-enforcing drive (e.g. the BU40N) the UDF metadata prefetch
|
||||
// below reaches small/menu VOB extents that are themselves CSS-scrambled;
|
||||
// without the bus-auth handshake first, each of those reads is rejected
|
||||
// with sense 05/6F/03 ("read of scrambled sector without authentication")
|
||||
// after a full drive round-trip — ~13s of pure waste on a real disc, and
|
||||
// the prefetch caches nothing. The handshake needs no title info (it takes
|
||||
// no extents), is self-guarding to DVD media, and is non-fatal on failure,
|
||||
// so run it as soon as the drive has classified the disc as a DVD. The
|
||||
// per-VTS title key is NOT recovered here (or anywhere in scan) — it is
|
||||
// cracked keylessly at read/mux time via `css::resolve_dvd_title_key`;
|
||||
// this only unlocks the drive's read gating so those reads can happen.
|
||||
if session.disc_is_dvd() {
|
||||
tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)");
|
||||
let drive_id = session.drive_id.clone();
|
||||
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Css,
|
||||
&[],
|
||||
);
|
||||
if let Err(e) = css_unlock_res {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
outcome = ?e,
|
||||
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read UDF filesystem with buffered sector reader
|
||||
tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
|
||||
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
||||
@@ -1623,7 +1653,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams");
|
||||
let mut disc = Self::scan_with(
|
||||
let disc = Self::scan_with(
|
||||
&mut buffered,
|
||||
capacity,
|
||||
handshake,
|
||||
@@ -1633,119 +1663,21 @@ impl Disc {
|
||||
)?;
|
||||
tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed");
|
||||
|
||||
// CSS key extraction for DVDs (bus auth → disc key → title key).
|
||||
// Must be a single auth session — can't call authenticate() separately.
|
||||
// Route through the DRM dispatcher: probe a title sector, detect
|
||||
// CSS if scrambled, then load via the SCSI auth path.
|
||||
// We already know this is a DVD (MPEG-PS program stream), so drive the
|
||||
// CSS handshake DIRECTLY off the main title's first content sector. We
|
||||
// must NOT first read a scrambled sector to "detect" CSS: a drive that
|
||||
// enforces CSS (e.g. the BU40N) rejects an UNauthenticated read of a
|
||||
// scrambled sector with sense 05/6F/03 ("read of scrambled sector
|
||||
// without authentication"), so a detect-then-auth ordering dead-locks —
|
||||
// detection needs the read, the read needs auth, auth needs detection.
|
||||
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
|
||||
// the disc-key read fails, `resolve` returns None, and the disc is left
|
||||
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
|
||||
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
|
||||
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
|
||||
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
|
||||
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
|
||||
// CSS title keys are per-VTS, and ONLY the scrambled movie content
|
||||
// carries a non-zero key. Menu / VMG / logo cells (often the
|
||||
// low-LBA first extent) return a ZERO title key over REPORT KEY —
|
||||
// accepting that would leave the whole feature un-descrambled
|
||||
// (raw scrambled bytes passed through as "clear"). So build
|
||||
// candidate LBAs from the MAIN feature (largest title), LARGEST
|
||||
// extent first (the movie body is the biggest scrambled chunk),
|
||||
// and accept the first auth that yields a NON-ZERO title key. A
|
||||
// genuinely unencrypted DVD returns zero for every candidate →
|
||||
// disc stays in the clear. This block is DVD-only (MPEG-PS);
|
||||
// BD/UHD (MPEG-TS) used the AACS handshake above and never reach here.
|
||||
// Main feature = the largest title; its extents, largest (the movie
|
||||
// body) first — that's where the scrambled content with a recoverable
|
||||
// title key lives.
|
||||
let main_extents = match disc
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|t| !t.extents.is_empty())
|
||||
.max_by_key(|t| t.extents.iter().map(|e| e.sector_count as u64).sum::<u64>())
|
||||
{
|
||||
Some(t) => {
|
||||
let mut v = t.extents.clone();
|
||||
v.sort_by(|a, b| b.sector_count.cmp(&a.sector_count));
|
||||
v
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
tracing::info!(target: "freemkv::scan", extents = main_extents.len(), "phase: CSS — main feature located");
|
||||
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
|
||||
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
|
||||
// Unlock the drive's CSS read gating through the uniform
|
||||
// unlocker dispatch: the CSS unlocker matches DiscKind::Css and
|
||||
// runs the bus-auth handshake (self-guarding to DVD media). A
|
||||
// CSS-enforcing drive (the BU40N) refuses to return scrambled
|
||||
// sectors until that handshake has run; we run it purely for that
|
||||
// unlock and IGNORE any key (the descramble key is recovered
|
||||
// keylessly from the scrambled movie data via the known-plaintext
|
||||
// attack — no player keys, no disc-key crack, no REPORT-KEY title
|
||||
// key). Any failure is non-fatal: continue to the crack, which
|
||||
// simply finds nothing if the drive kept the sectors gated.
|
||||
let drive_id = session.drive_id.clone();
|
||||
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Css,
|
||||
&[],
|
||||
);
|
||||
if let Err(e) = css_unlock_res {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
outcome = ?e,
|
||||
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||
);
|
||||
}
|
||||
// Size the crack's batch reads to THIS drive's per-command max
|
||||
// (DVD ≈ 16; the USB bridge may be lower) — an over-large
|
||||
// READ(10) fails outright and would scan nothing.
|
||||
let crack_batch = detect_max_batch_sectors(session.device_path());
|
||||
tracing::info!(target: "freemkv::scan", crack_batch, "phase: CSS — known-plaintext crack");
|
||||
let crack_t0 = std::time::Instant::now();
|
||||
let crack_result = crate::css::crack_key_outcome(
|
||||
session,
|
||||
&main_extents,
|
||||
crack_batch,
|
||||
opts.halt.as_ref(),
|
||||
);
|
||||
tracing::info!(
|
||||
target: "freemkv::scan",
|
||||
elapsed_ms = crack_t0.elapsed().as_millis() as u64,
|
||||
outcome = ?crack_result,
|
||||
"phase: CSS — crack done"
|
||||
);
|
||||
match crack_result {
|
||||
crate::css::CrackOutcome::Cracked(state) => {
|
||||
tracing::debug!(target: "freemkv::disc", "dvd css: title key recovered via known-plaintext crack");
|
||||
disc.css = Some(state);
|
||||
disc.encrypted = true;
|
||||
}
|
||||
crate::css::CrackOutcome::ScrambledUncracked => {
|
||||
// Scrambled sectors WERE seen but no key could be
|
||||
// recovered — the content is encrypted-but-uncrackable.
|
||||
// Record a hard error so callers fail loudly instead of
|
||||
// muxing scrambled MPEG as plaintext garbage at exit 0.
|
||||
tracing::warn!(target: "freemkv::disc", "dvd css: scrambled sectors seen but no title key cracked");
|
||||
disc.encrypted = true;
|
||||
disc.css_error = Some(crate::error::Error::CssKeyMissing);
|
||||
}
|
||||
crate::css::CrackOutcome::Unencrypted => {
|
||||
tracing::debug!(target: "freemkv::disc", "dvd css: no scrambled sector seen (genuinely unencrypted)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No CSS key recovery at scan time. DVD CSS keys are per-VTS/per-title,
|
||||
// and the descramble path re-cracks each title's key keylessly from that
|
||||
// title's own extents at the moment its sectors are read/decrypted — so a
|
||||
// single up-front "disc key" is meaningless (it's not valid for the other
|
||||
// titles and every title re-derives its key at read time anyway). The
|
||||
// scan's only CSS responsibility is the bus-auth unlock above, which opens
|
||||
// the drive's read gating so the sweep can read scrambled sectors at all.
|
||||
// Detection is likewise moot: encrypted or not, a DVD muxes identically —
|
||||
// the read-time descrambler cracks a title key if the sectors are
|
||||
// scrambled and is a no-op if they are clear.
|
||||
|
||||
tracing::info!(target: "freemkv::scan", css = disc.css.is_some(), "phase: scan complete");
|
||||
// `disc.css` is intentionally never set at scan time now (per-title CSS
|
||||
// keys are cracked at read/mux time), so log the format the scan actually
|
||||
// determined rather than a key state that is always `None` here.
|
||||
tracing::info!(target: "freemkv::scan", format = ?disc.format, titles = disc.titles.len(), "phase: scan complete");
|
||||
Ok(disc)
|
||||
}
|
||||
|
||||
@@ -1824,9 +1756,10 @@ impl Disc {
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||||
let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
|
||||
udf_fs.read_file(reader, p)
|
||||
})?;
|
||||
let inf = crate::aacs::read_first(
|
||||
&crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::UnitKey),
|
||||
|p| udf_fs.read_file(reader, p),
|
||||
)?;
|
||||
let mkb = Self::read_mkb_content(reader, udf_fs)?;
|
||||
let version = Self::read_aacs_version(reader, udf_fs);
|
||||
Ok((inf, mkb, version))
|
||||
@@ -1844,9 +1777,10 @@ impl Disc {
|
||||
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
|
||||
/// not quietly pick the V10 stride for a UHD disc.
|
||||
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
|
||||
match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
|
||||
udf_fs.read_file(reader, p)
|
||||
})
|
||||
match crate::aacs::read_first(
|
||||
&crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::ContentCert),
|
||||
|p| udf_fs.read_file(reader, p),
|
||||
)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(crate::aacs::inf::parse_content_cert)
|
||||
@@ -1880,9 +1814,10 @@ impl Disc {
|
||||
const MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
let mut want = START_BYTES;
|
||||
loop {
|
||||
let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
|
||||
udf_fs.read_file_prefix(reader, p, want)
|
||||
})?;
|
||||
let buf = crate::aacs::read_first(
|
||||
&crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::Mkb),
|
||||
|p| udf_fs.read_file_prefix(reader, p, want),
|
||||
)?;
|
||||
let n = crate::aacs::mkb::mkb_content_len(&buf);
|
||||
// `n` strictly inside `buf` => the record walk reached the padding
|
||||
// boundary (full content captured). `buf` shorter than `want` =>
|
||||
@@ -2483,8 +2418,19 @@ impl Disc {
|
||||
// firmware didn't (stock or Renesas drive) AND AACS state was
|
||||
// actually obtained.
|
||||
"AACS" => self.aacs.is_some() && !ld_removed_bus,
|
||||
// The CSS handshake/crack succeeded → title keys recovered.
|
||||
"CSS" => self.css.is_some(),
|
||||
// The DVD read-unlock (CSS bus-auth) is issued during the scan
|
||||
// for EVERY DVD to clear the drive's scrambled-read barrier;
|
||||
// unlike the firmware/AACS arms above (whose success is already
|
||||
// captured in disc state), the bus-auth outcome is not tracked
|
||||
// separately, so this arm reports the MEDIUM — "the DVD
|
||||
// read-unlock path engaged" — rather than a per-rip success
|
||||
// bit. That is intentional: the CSS descramble is keyless and
|
||||
// handled at mux time, and a genuine bus-auth failure surfaces
|
||||
// downstream as a read/crack error, not here. (Formerly this
|
||||
// reported `self.css.is_some()` — "a crack recovered a key" —
|
||||
// which under-reported: an encrypted DVD that reads and muxes
|
||||
// fine showed "no".)
|
||||
"DVD" => self.format == DiscFormat::Dvd,
|
||||
// A newly-registered unlocker with no runtime signal wired
|
||||
// here yet: report `no` rather than guess.
|
||||
_ => false,
|
||||
@@ -2576,94 +2522,97 @@ impl Disc {
|
||||
|
||||
/// Resolve decryption keys for muxing a *specific* title.
|
||||
///
|
||||
/// CSS title keys are per-VTS. The scan cracks one key (from the main
|
||||
/// feature, title 0 for autorip). Applying it to a title that lives in
|
||||
/// a *different* VTS would silently descramble with the wrong key
|
||||
/// (garbage output). When the requested title's extents don't overlap
|
||||
/// the span the cracked key came from, re-crack the key from this
|
||||
/// title's own extents using `reader`. AACS / unencrypted / single-VTS
|
||||
/// paths are identical to [`Self::decrypt_keys`].
|
||||
/// For a **DVD** the CSS title key MUST be recovered before descrambling: a
|
||||
/// scrambled sector without a Stevenson crib cannot self-crack, and CSS leaves
|
||||
/// the pack/PES header clear, so a sector left un-descrambled would mux as a
|
||||
/// structurally-valid but corrupt PES packet with no loss reported. Two ways
|
||||
/// to get it:
|
||||
///
|
||||
/// `batch_sectors` sizes the crack's batched reads (file-safe value for
|
||||
/// an ISO; `detect_max_batch_sectors` for a live drive).
|
||||
/// Also returns the per-title encryption verdict the gate needs to AVOID A
|
||||
/// FALSE ERROR on a genuinely-clear extra title.
|
||||
/// - **Fast path** — the scan already cracked a key whose LBA span
|
||||
/// ([`crate::css::CssState::crack_span`]) covers this title's VTS: reuse it.
|
||||
/// No re-read, and on a live drive no second CSS bus-auth round-trip. CSS
|
||||
/// title keys are per-VTS, so an overlapping span is the same key.
|
||||
/// - **Crack** — when up-front detection missed (`self.css == None`) or the
|
||||
/// title lives in a different VTS: crack the key from this title's OWN
|
||||
/// extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell-
|
||||
/// first, which starved the crack in a big cell's clear prefix — the 1.5.1
|
||||
/// bug). Playback order reaches the scrambled feature body after only the
|
||||
/// small clear front matter (logo / rating card) that precedes it. One
|
||||
/// scan = one CSS-locked early-bail, so a locked title is not re-hammered
|
||||
/// per cell against a live drive (hard rule #2); its 50k-sector budget is
|
||||
/// the same accepted bound the disc-wide scan uses.
|
||||
///
|
||||
/// On a multi-VTS CSS DVD that ALSO carries a clear, unencrypted stub title
|
||||
/// (a 0.5 s menu loop, an FBI-warning nav title) living in its own VTS, the
|
||||
/// re-crack over that stub's extents finds NO scrambled sector and recovers
|
||||
/// no key. Returning ONLY the keys would collapse that to
|
||||
/// `DecryptKeys::None`, indistinguishable from "scrambled but uncrackable",
|
||||
/// so [`Self::ensure_decryptable_keys`] (which fails whenever `css.is_some()`
|
||||
/// and the key is `None`) would wrongly raise `E7023` for a title that needs
|
||||
/// no key at all — the false error the multi-title mux must never emit.
|
||||
/// Crucially the crack path does NOT gate on `self.css`: a detection miss can
|
||||
/// never route the mux into raw passthrough of scrambled sectors.
|
||||
///
|
||||
/// So the re-crack runs via [`crate::css::crack_key_outcome`] and ALSO
|
||||
/// returns `title_is_clear == true` when the title's own extents showed NO
|
||||
/// scrambling (`CrackOutcome::Unencrypted`) — the gate then treats that title
|
||||
/// as needing no key and passes it cleanly. A title that genuinely IS
|
||||
/// scrambled but uncrackable returns `(None, false)` and still hard-fails.
|
||||
/// The returned bool pairs with [`Self::ensure_title_decryptable`].
|
||||
/// Outcomes (`batch_sectors` sizes the crack's batched reads):
|
||||
/// - `(Css{title_key}, false)` — descramble with the recovered/reused key.
|
||||
/// - `(None, true)` — a genuinely-clear title needs no key; the gate passes it.
|
||||
/// - `(None, false)` — scrambled but no key recoverable → hard failure via
|
||||
/// [`Self::ensure_title_decryptable`], never a silent garbage mux.
|
||||
///
|
||||
/// Non-DVD schemes (AACS / FMTS / genuinely unencrypted) return
|
||||
/// [`Self::decrypt_keys`] unchanged.
|
||||
pub fn decrypt_keys_for_title(
|
||||
&self,
|
||||
idx: usize,
|
||||
reader: &mut dyn SectorSource,
|
||||
batch_sectors: u16,
|
||||
) -> (crate::decrypt::DecryptKeys, bool) {
|
||||
let css = match self.css {
|
||||
Some(ref c) => c,
|
||||
None => return (self.decrypt_keys(), false),
|
||||
};
|
||||
let title = match self.titles.get(idx) {
|
||||
Some(t) if !t.extents.is_empty() => t,
|
||||
// No extents to crack from — fall back to the disc-wide key.
|
||||
_ => return (self.decrypt_keys(), false),
|
||||
};
|
||||
// If the title overlaps the span the existing key was cracked from,
|
||||
// it's the same VTS — the cracked key applies. `crack_span: None`
|
||||
// (unknown provenance) is also treated as "applies".
|
||||
let overlaps = match css.crack_span {
|
||||
None => true,
|
||||
Some((cs, ce)) => title.extents.iter().any(|e| {
|
||||
let ts = e.start_lba;
|
||||
let te = e.start_lba.saturating_add(e.sector_count);
|
||||
ts < ce && cs < te
|
||||
}),
|
||||
};
|
||||
if overlaps {
|
||||
// Non-DVD (AACS / FMTS / genuinely unencrypted): disc-wide keys, unchanged.
|
||||
if self.format != DiscFormat::Dvd {
|
||||
return (self.decrypt_keys(), false);
|
||||
}
|
||||
// Different VTS: re-crack from this title's extents, largest first
|
||||
// (the movie body is the biggest scrambled chunk — same heuristic
|
||||
// the scan uses). The disc-wide key provably does NOT apply here
|
||||
// (crack_span is Some and this title doesn't overlap it). Use
|
||||
// `crack_key_outcome` (not the bare `crack_key`) so we can tell a
|
||||
// genuinely-clear title (`Unencrypted` — no scrambled sector in its
|
||||
// own extents) apart from a scrambled-but-uncrackable one:
|
||||
// - Cracked → the title's own key.
|
||||
// - Unencrypted → (None, title_is_clear=true): this extra title
|
||||
// needs no key; the gate must NOT raise E7023.
|
||||
// - ScrambledUncracked → (None, false): genuinely encrypted but no key
|
||||
// → a real hard failure, still surfaced.
|
||||
// The disc-wide fallback is reserved for the unknown-provenance case
|
||||
// (crack_span == None), already handled above via overlaps == true.
|
||||
let mut extents = title.extents.clone();
|
||||
extents.sort_by(|a, b| b.sector_count.cmp(&a.sector_count));
|
||||
match crate::css::crack_key_outcome(reader, &extents, batch_sectors, None) {
|
||||
let title = match self.titles.get(idx) {
|
||||
Some(t) if !t.extents.is_empty() => t,
|
||||
// No extents to crack from: nothing scrambled to worry about, so mark
|
||||
// it clear (`true`). Returning `false` here would let the gate's DVD
|
||||
// "None keys + not clear = scrambled-uncracked" rule wrongly hard-fail
|
||||
// a genuinely-unencrypted DVD title that has no extents.
|
||||
_ => return (self.decrypt_keys(), true),
|
||||
};
|
||||
// Fast path: reuse the scan's cracked key if its span covers this title's
|
||||
// VTS. `crack_span: None` (unknown provenance) is treated as covering.
|
||||
if let Some(css) = self.css.as_ref() {
|
||||
let covers = match css.crack_span {
|
||||
None => true,
|
||||
Some((cs, ce)) => title
|
||||
.extents
|
||||
.iter()
|
||||
.any(|e| e.start_lba < ce && cs < e.start_lba.saturating_add(e.sector_count)),
|
||||
};
|
||||
if covers {
|
||||
return (
|
||||
crate::decrypt::DecryptKeys::Css {
|
||||
title_key: css.title_key,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Detection miss or a different VTS: crack this title's key from its OWN
|
||||
// extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell-
|
||||
// first — that was the 1.5.1 garbage bug, where a big cell's clear prefix
|
||||
// starved the crack). Playback order reaches the scrambled feature body
|
||||
// after only the (small) clear front matter that precedes it on a real
|
||||
// disc. This is ONE crack_key_outcome call, exactly like the disc-wide
|
||||
// scan: its single CSS-locked early-bail runs at most once, so a locked/
|
||||
// uncrackable title is NOT re-hammered per cell against the live drive
|
||||
// (hard rule #2). The crack's 50k-sector budget bounds a fully-clear title,
|
||||
// the same accepted bound the disc-wide scan uses.
|
||||
match crate::css::crack_key_outcome(reader, &title.extents, batch_sectors, None) {
|
||||
crate::css::CrackOutcome::Cracked(state) => (
|
||||
crate::decrypt::DecryptKeys::Css {
|
||||
title_key: state.title_key,
|
||||
},
|
||||
false,
|
||||
),
|
||||
// No scrambled sector in THIS title's extents: it is genuinely clear.
|
||||
// Signal `title_is_clear` so the per-title gate passes it without a
|
||||
// key — NO FALSE E7023 for an unencrypted extra title.
|
||||
crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true),
|
||||
// Scrambled sectors seen but no key recovered: a genuine hard failure.
|
||||
// Scrambled but no key recoverable → hard failure.
|
||||
crate::css::CrackOutcome::ScrambledUncracked => {
|
||||
(crate::decrypt::DecryptKeys::None, false)
|
||||
}
|
||||
// No scrambled sector anywhere in the whole title → genuinely clear.
|
||||
crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2693,6 +2642,25 @@ impl Disc {
|
||||
if title_is_clear && !keys.is_encrypted() {
|
||||
return Ok(());
|
||||
}
|
||||
// A DVD title that cracked to no key and is NOT clear is scrambled-but-
|
||||
// uncrackable (`decrypt_keys_for_title` → `ScrambledUncracked`). Hard-fail
|
||||
// here directly: `ensure_decryptable_keys` gates CSS on `self.css.is_some()`
|
||||
// (the scan's disc-wide detection), which can be `None` when that up-front
|
||||
// detection missed — exactly the case the per-title crack exists to catch.
|
||||
// Without this, an uncrackable DVD title would fall through to `Ok` and mux
|
||||
// scrambled sectors as corrupt PES at exit 0.
|
||||
if self.format == DiscFormat::Dvd && !title_is_clear && !keys.is_encrypted() {
|
||||
return Err(Error::CssKeyMissing);
|
||||
}
|
||||
// A usable per-title key was resolved (a freshly-cracked CSS key, or AACS
|
||||
// unit keys) — the title IS decryptable, so pass it WITHOUT consulting the
|
||||
// disc-wide gate. `ensure_decryptable_keys` hard-fails on `self.css_error`
|
||||
// unconditionally, which reflects the MAIN feature's crack: a bonus title
|
||||
// in a different VTS that just cracked its own key must not be blocked by
|
||||
// the main title having failed.
|
||||
if keys.is_encrypted() {
|
||||
return Ok(());
|
||||
}
|
||||
self.ensure_decryptable_keys(raw, keys)
|
||||
}
|
||||
|
||||
@@ -3893,6 +3861,7 @@ impl Disc {
|
||||
total_errors = pass_sum.total_errors,
|
||||
zones_entered = pass_sum.zones_entered,
|
||||
jumps_taken = pass_sum.jumps_taken,
|
||||
marginal_recovered = pass_sum.marginal_recovered,
|
||||
bytes_good = stats.bytes_good,
|
||||
bytes_pending = stats.bytes_pending,
|
||||
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
|
||||
@@ -5165,6 +5134,8 @@ mod tests {
|
||||
/// `[main_lba, main_end)`, plus a clear stub title living in a DISJOINT VTS.
|
||||
fn css_disc_with_clear_stub() -> (Disc, usize) {
|
||||
let mut disc = make_test_disc(100_000, "DVD");
|
||||
disc.format = DiscFormat::Dvd; // make_test_disc defaults to Uhd
|
||||
disc.content_format = ContentFormat::MpegPs;
|
||||
disc.encrypted = true;
|
||||
disc.css = Some(crate::css::CssState {
|
||||
title_key: [0u8; 5],
|
||||
@@ -5186,29 +5157,31 @@ mod tests {
|
||||
(disc, 1) // stub is title index 1
|
||||
}
|
||||
|
||||
/// THE Fix 2/3 regression: on a multi-VTS CSS DVD, a genuinely-clear extra
|
||||
/// title (an unencrypted menu stub in its own VTS) must resolve to
|
||||
/// `title_is_clear = true` with `None` keys, and `ensure_title_decryptable`
|
||||
/// must PASS it — no false E7023. The old `decrypt_keys_for_title` +
|
||||
/// `ensure_decryptable_keys` pair raised CssKeyMissing here because the
|
||||
/// re-crack of the clear stub returned `None`, indistinguishable from a
|
||||
/// scrambled-uncracked title.
|
||||
/// A genuinely-clear extra title (an unencrypted menu stub in its own VTS)
|
||||
/// on a CSS DVD must mux without a false E7023. The stub lives in a DISJOINT
|
||||
/// VTS (its extents don't overlap the scan's `crack_span`), so
|
||||
/// `decrypt_keys_for_title` takes the crack path over the stub's own extents;
|
||||
/// the reader serves only clear sectors, so the crack returns `Unencrypted`
|
||||
/// → `(None, title_is_clear=true)`. The gate must then PASS the title with no
|
||||
/// key — no false E7023.
|
||||
#[test]
|
||||
fn clear_stub_title_on_css_disc_is_not_a_key_failure() {
|
||||
let (disc, stub_idx) = css_disc_with_clear_stub();
|
||||
assert_eq!(
|
||||
disc.format,
|
||||
DiscFormat::Dvd,
|
||||
"fixture must exercise the DVD path"
|
||||
);
|
||||
let mut reader = ClearStubReader {
|
||||
clear_range: (0, 100_000),
|
||||
};
|
||||
let (keys, title_is_clear) = disc.decrypt_keys_for_title(stub_idx, &mut reader, 8);
|
||||
assert!(
|
||||
!keys.is_encrypted(),
|
||||
"a clear stub needs no key (got encrypted keys)"
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"a clear stub in a disjoint VTS cracks to no key"
|
||||
);
|
||||
assert!(
|
||||
title_is_clear,
|
||||
"the stub's own extents show no scrambling → title_is_clear must be true"
|
||||
);
|
||||
// The gate must PASS the clear stub — NO false E7023.
|
||||
assert!(title_is_clear, "the stub's own extents show no scrambling");
|
||||
// The gate must PASS a clear title — NO false E7023.
|
||||
assert!(
|
||||
disc.ensure_title_decryptable(false, &keys, title_is_clear)
|
||||
.is_ok(),
|
||||
@@ -5613,53 +5586,333 @@ mod tests {
|
||||
disc
|
||||
}
|
||||
|
||||
/// Regression (multi-VTS CSS): a title that OVERLAPS the cracked span is
|
||||
/// the same VTS — the existing key is reused and the reader is NOT touched.
|
||||
/// Build a Stevenson-crackable scrambled CSS sector (a periodic run in the
|
||||
/// clear header continuing past 0x80), mirroring the css-module fixture.
|
||||
fn crackable_css_sector(title_key: &[u8; 5]) -> [u8; 2048] {
|
||||
const RUN_START: usize = 0x59;
|
||||
const PERIOD: usize = 8;
|
||||
let mut sec = [0u8; 2048];
|
||||
sec[0x00..0x04].copy_from_slice(&crate::css::PACK_START);
|
||||
sec[0x14] = 0x10; // scramble flag
|
||||
for (i, b) in sec.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = (0xA0u8.wrapping_add((i % PERIOD) as u8)) ^ 0x5A;
|
||||
}
|
||||
crate::css::lfsr::scramble_sector(title_key, &mut sec);
|
||||
sec
|
||||
}
|
||||
|
||||
/// A reader that serves crackable CSS sectors for LBAs in `scrambled`
|
||||
/// (half-open), all-zero (clear) elsewhere — records every LBA read.
|
||||
struct CssMapReader {
|
||||
key: [u8; 5],
|
||||
scrambled: (u32, u32),
|
||||
reads: std::cell::RefCell<Vec<u32>>,
|
||||
}
|
||||
impl SectorSource for CssMapReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.reads.borrow_mut().push(lba);
|
||||
let n = (count as usize * 2048).min(buf.len());
|
||||
for s in 0..(n / 2048) {
|
||||
let this = lba + s as u32;
|
||||
let dst = &mut buf[s * 2048..(s + 1) * 2048];
|
||||
if this >= self.scrambled.0 && this < self.scrambled.1 {
|
||||
dst.copy_from_slice(&crackable_css_sector(&self.key));
|
||||
} else {
|
||||
dst.fill(0);
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
fn css_dvd_with_extents(extents: Vec<Extent>) -> Disc {
|
||||
let mut disc = make_test_disc(200_000, "DVD");
|
||||
disc.format = DiscFormat::Dvd;
|
||||
disc.content_format = ContentFormat::MpegPs;
|
||||
disc.encrypted = true;
|
||||
let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p);
|
||||
t.extents = extents;
|
||||
disc.titles = vec![t];
|
||||
disc
|
||||
}
|
||||
|
||||
/// `decrypt_keys_for_title` cracks a scrambled DVD title's key from the
|
||||
/// title's OWN extents and hands the mux the validated key — the seed the
|
||||
/// descramble needs, since a crib-less sector cannot self-crack and CSS leaves
|
||||
/// the pack/PES header clear (an un-seeded mux would emit corrupt PES).
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_reuses_key_for_same_vts() {
|
||||
let disc = css_disc_with_two_vts();
|
||||
let mut src = RecordingSource {
|
||||
fn decrypt_keys_for_title_cracks_the_titles_key() {
|
||||
let key = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let disc = css_dvd_with_extents(vec![Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 64,
|
||||
}]);
|
||||
let mut src = CssMapReader {
|
||||
key,
|
||||
scrambled: (100, 164),
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
match disc.decrypt_keys_for_title(0, &mut src, 16).0 {
|
||||
let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut src, 16);
|
||||
assert!(!title_is_clear, "a scrambled title is not clear");
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key } => {
|
||||
assert_eq!(title_key, [0xAB; 5], "same-VTS title reuses cracked key");
|
||||
assert_eq!(title_key, key, "must crack the title's own key")
|
||||
}
|
||||
_ => panic!("expected Css keys for same-VTS title"),
|
||||
_ => panic!("expected Css{{key}} for a scrambled DVD title"),
|
||||
}
|
||||
}
|
||||
|
||||
/// REGRESSION (the 1.5.1 garbage bug): the crack scans extents in PLAYBACK
|
||||
/// ORDER, never largest-cell-first. A title whose LARGEST cell opens with a
|
||||
/// long unscrambled run must still crack its key from the smaller,
|
||||
/// scrambled-early cell that plays first — largest-first would exhaust the
|
||||
/// crack budget in the clear giant and wrongly report the title unencrypted,
|
||||
/// which the mux would pass through as scrambled garbage.
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_scans_playback_order_not_largest_first() {
|
||||
let key = [0xDE, 0xAD, 0xBE, 0xEF, 0x01];
|
||||
let disc = css_dvd_with_extents(vec![
|
||||
// Plays FIRST: small, scrambled from its start.
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 32,
|
||||
},
|
||||
// A CLEAR cell far larger than the crack budget (would starve a
|
||||
// largest-first scan before it reached the scrambled cell above).
|
||||
Extent {
|
||||
start_lba: 10_000,
|
||||
sector_count: 100_000,
|
||||
},
|
||||
]);
|
||||
let mut src = CssMapReader {
|
||||
key,
|
||||
scrambled: (100, 132),
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
|
||||
title_key, key,
|
||||
"must crack from the scrambled cell that plays first, not miss it behind the clear giant"
|
||||
),
|
||||
_ => panic!("largest-first regression: the title was read as unencrypted"),
|
||||
}
|
||||
assert!(
|
||||
src.reads.borrow().is_empty(),
|
||||
"an overlapping title must not trigger a re-crack read"
|
||||
src.reads.borrow().iter().all(|&l| l < 10_000),
|
||||
"the key is found in the first (scrambled) cell — the clear giant must never be scanned: {:?}",
|
||||
src.reads.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (multi-VTS CSS): a title in a DIFFERENT VTS (no overlap with
|
||||
/// the cracked span) must re-crack from its OWN extents — verified by the
|
||||
/// reader being driven over that title's LBA range (5000..). The fixture
|
||||
/// yields unscrambled sectors so the re-crack finds NO key; the fix
|
||||
/// requires this to be a HARD failure (`DecryptKeys::None`), NOT a silent
|
||||
/// fall-back to the known-wrong-VTS disc-wide key (which would descramble
|
||||
/// to garbage). Both the read-attempt and the None result are asserted.
|
||||
/// A reader whose every read is CSS-locked (`05/6F/03`) — a genuinely
|
||||
/// encrypted DVD whose sectors can't be authenticated/cracked.
|
||||
struct LockedReader;
|
||||
impl SectorSource for LockedReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: Some(2),
|
||||
sense: Some(crate::scsi::ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x6F,
|
||||
ascq: 0x03,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end: a scrambled-but-uncrackable DVD title with NO up-front
|
||||
/// detection (`self.css == None`) drives `decrypt_keys_for_title` to
|
||||
/// `(None, false)`, and the gate MUST hard-fail (CssKeyMissing) rather than
|
||||
/// pass it to the muxer — the silent-garbage case the per-title crack catches.
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_recracks_for_other_vts() {
|
||||
let disc = css_disc_with_two_vts();
|
||||
fn decrypt_keys_for_title_scrambled_uncracked_dvd_hard_fails_even_without_detection() {
|
||||
let disc = css_dvd_with_extents(vec![Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 8,
|
||||
}]);
|
||||
assert!(disc.css.is_none(), "fixture: no up-front detection");
|
||||
let mut reader = LockedReader;
|
||||
let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut reader, 8);
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None) && !title_is_clear,
|
||||
"a locked/uncrackable scrambled title resolves to (None, false)"
|
||||
);
|
||||
let err = disc
|
||||
.ensure_title_decryptable(false, &keys, title_is_clear)
|
||||
.expect_err("scrambled-uncracked DVD title must hard-fail without detection");
|
||||
assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
|
||||
}
|
||||
|
||||
/// Fast path: when the scan already cracked a key whose `crack_span` COVERS
|
||||
/// this title's VTS, `decrypt_keys_for_title` reuses it and never touches the
|
||||
/// reader (no redundant crack, no second bus-auth on a live drive).
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_reuses_covered_scan_key_without_reading() {
|
||||
let disc = css_disc_with_two_vts(); // css=[0xAB;5], crack_span=(100,200)
|
||||
let mut src = RecordingSource {
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
let keys = disc.decrypt_keys_for_title(1, &mut src, 16).0;
|
||||
// Title 0's extents (100..200) overlap the cracked span → reuse.
|
||||
let (keys, clear) = disc.decrypt_keys_for_title(0, &mut src, 16);
|
||||
assert!(!clear);
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key } => {
|
||||
assert_eq!(title_key, [0xAB; 5], "reuse the scan's cracked key")
|
||||
}
|
||||
_ => panic!("expected the reused Css key"),
|
||||
}
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"a re-crack miss in a provably-different VTS must be a hard failure (None), \
|
||||
not the wrong-VTS disc-wide key"
|
||||
src.reads.borrow().is_empty(),
|
||||
"a covered title must NOT re-read/re-crack: {:?}",
|
||||
src.reads.borrow()
|
||||
);
|
||||
let reads = src.reads.borrow();
|
||||
}
|
||||
|
||||
/// A title in a DIFFERENT VTS (extents disjoint from `crack_span`) does NOT
|
||||
/// reuse the scan key — it cracks its own key from its own extents.
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_cracks_other_vts_on_no_overlap() {
|
||||
let key = [0x77, 0x66, 0x55, 0x44, 0x33];
|
||||
let disc = css_disc_with_two_vts(); // title 1 lives at 5000.., span=(100,200)
|
||||
let mut src = CssMapReader {
|
||||
key,
|
||||
scrambled: (5000, 5100),
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
let (keys, _) = disc.decrypt_keys_for_title(1, &mut src, 16);
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
|
||||
title_key, key,
|
||||
"a disjoint-VTS title cracks its OWN key, not the reused scan key"
|
||||
),
|
||||
_ => panic!("expected a freshly-cracked Css key for the other VTS"),
|
||||
}
|
||||
assert!(
|
||||
!reads.is_empty(),
|
||||
"a non-overlapping title must trigger a re-crack read"
|
||||
src.reads.borrow().iter().all(|&l| l >= 5000),
|
||||
"must crack from title 1's own extents (>=5000): {:?}",
|
||||
src.reads.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
/// A title whose (realistic) clear front matter — studio logo / rating card —
|
||||
/// plays FIRST, then the scrambled feature, still cracks: the single
|
||||
/// playback-order scan reads through the small clear prefix and reaches the
|
||||
/// scrambled body within its budget. (A clear prefix LARGER than the ~100 MB
|
||||
/// crack budget would starve — the accepted bounded-budget limit, identical to
|
||||
/// the disc-wide scan; not producible by real DVD front matter.)
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_cracks_feature_after_clear_front_matter() {
|
||||
let key = [0xCA, 0xFE, 0xBA, 0xBE, 0x02];
|
||||
// css=None so the crack path runs. ~10 MB of clear front matter plays
|
||||
// first (well under the crack budget), then the scrambled feature.
|
||||
let disc = css_dvd_with_extents(vec![
|
||||
Extent {
|
||||
start_lba: 10_000,
|
||||
sector_count: 5_000,
|
||||
}, // clear front matter (~10 MB), plays first
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 2_000,
|
||||
}, // scrambled feature body
|
||||
]);
|
||||
let mut src = CssMapReader {
|
||||
key,
|
||||
scrambled: (100, 2_100),
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
|
||||
title_key, key,
|
||||
"must crack the scrambled feature after reading through clear front matter"
|
||||
),
|
||||
_ => panic!("clear front matter wrongly starved the crack"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrambling that begins well INTO a cell (after a clear prefix), not at its
|
||||
/// start, must still be cracked: the single playback-order scan reads through
|
||||
/// the clear prefix and reaches the scrambled body within its budget — never a
|
||||
/// silent "clear" verdict that would mux the scrambled tail as corrupt PES.
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_cracks_scrambling_after_a_clear_prefix_in_one_cell() {
|
||||
let key = [0x0D, 0xEE, 0x40, 0x00, 0x05];
|
||||
// One cell: clear for the first 9000 sectors, then scrambled (well within
|
||||
// the crack budget). css=None so the crack path runs.
|
||||
let disc = css_dvd_with_extents(vec![Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 20_000,
|
||||
}]);
|
||||
let mut src = CssMapReader {
|
||||
key,
|
||||
scrambled: (100 + 9_000, 100 + 20_000),
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
|
||||
title_key, key,
|
||||
"the scan must crack scrambling that starts past a clear prefix"
|
||||
),
|
||||
_ => panic!("in-cell-deep scrambling was misread as clear (silent-garbage direction)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A DVD title with EMPTY extents (an angle/PGC placeholder with no cells)
|
||||
/// resolves to `(decrypt_keys(), true)` — clear, no key needed — and the gate
|
||||
/// must PASS it. Returning `false` here would trip the DVD scrambled-uncracked
|
||||
/// rule and wrongly hard-fail a genuinely-clear empty title.
|
||||
#[test]
|
||||
fn decrypt_keys_for_title_empty_extents_is_clear_not_hard_fail() {
|
||||
let mut disc = css_dvd_with_extents(vec![Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 8,
|
||||
}]);
|
||||
disc.titles
|
||||
.push(title_with_video(Codec::Mpeg2, Resolution::R480p)); // idx 1: no extents
|
||||
let mut reader = LockedReader;
|
||||
let (keys, title_is_clear) = disc.decrypt_keys_for_title(1, &mut reader, 8);
|
||||
assert!(
|
||||
title_is_clear,
|
||||
"an empty-extents title is clear (nothing to descramble)"
|
||||
);
|
||||
assert!(
|
||||
reads.iter().all(|&lba| lba >= 5000),
|
||||
"re-crack must read title 1's own extents (>=5000), got {reads:?}"
|
||||
disc.ensure_title_decryptable(false, &keys, title_is_clear)
|
||||
.is_ok(),
|
||||
"an empty-extents DVD title must not hard-fail"
|
||||
);
|
||||
}
|
||||
|
||||
/// A bonus title that cracked its OWN valid key must NOT be blocked by the
|
||||
/// disc-wide `css_error` set when the MAIN feature's scan failed. A usable
|
||||
/// per-title key means the title is decryptable regardless of another title's
|
||||
/// failure. (Regression for the audit r5 css_error-over-valid-key finding.)
|
||||
#[test]
|
||||
fn ensure_title_decryptable_valid_key_ignores_disc_wide_css_error() {
|
||||
let mut disc = css_dvd_with_extents(vec![Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 8,
|
||||
}]);
|
||||
disc.css_error = Some(crate::error::Error::CssKeyMissing); // main feature failed
|
||||
let keys = crate::decrypt::DecryptKeys::Css {
|
||||
title_key: [0x42; 5], // this bonus title cracked its own key
|
||||
};
|
||||
assert!(
|
||||
disc.ensure_title_decryptable(false, &keys, false).is_ok(),
|
||||
"a title with its own valid CSS key must pass despite disc-wide css_error"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,12 @@ pub struct ReadCtx {
|
||||
/// good reads after the last error in the cluster." Used to count
|
||||
/// zone entries and to bound zone_reads accurately.
|
||||
pub in_damage_zone: bool,
|
||||
/// Count of RECOVERED ERROR (marginal) reads the drive reported this pass
|
||||
/// (surfaced by the PER=1 mode-select at drive-prep). Each is distrusted and
|
||||
/// marked NonTrimmed for a Pass N re-read; the count is reported in the
|
||||
/// pass summary so an operator can see how much of a "clean" rip was actually
|
||||
/// marginal.
|
||||
pub marginal_recovered: u64,
|
||||
}
|
||||
|
||||
/// Coarse classification of a SCSI sense key for diagnostic logging.
|
||||
@@ -187,6 +193,7 @@ impl ReadCtx {
|
||||
zones_entered: 0,
|
||||
jumps_taken: 0,
|
||||
in_damage_zone: false,
|
||||
marginal_recovered: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +233,7 @@ impl ReadCtx {
|
||||
zones_entered: 0,
|
||||
jumps_taken: 0,
|
||||
in_damage_zone: false,
|
||||
marginal_recovered: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +291,7 @@ impl ReadCtx {
|
||||
total_errors: self.total_errors,
|
||||
zones_entered: self.zones_entered,
|
||||
jumps_taken: self.jumps_taken,
|
||||
marginal_recovered: self.marginal_recovered,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +305,8 @@ pub struct PassSummary {
|
||||
pub total_errors: u64,
|
||||
pub zones_entered: u64,
|
||||
pub jumps_taken: u64,
|
||||
/// RECOVERED ERROR (marginal) reads distrusted and re-queued for Pass N.
|
||||
pub marginal_recovered: u64,
|
||||
}
|
||||
|
||||
/// What the caller should do after a read failure. The caller owns the
|
||||
@@ -639,6 +650,36 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
};
|
||||
}
|
||||
|
||||
// 4b. RECOVERED ERROR — the drive returned this sector but had to fight for
|
||||
// it (ECC worked hard / retried). We enable reporting of these via MODE
|
||||
// SELECT PER=1 at drive-prep precisely so they surface: on marginal/dirty
|
||||
// media the drive's best-effort correction can be silently WRONG (a rip
|
||||
// that "passed clean" but decoded with errors — the Bourne case). We
|
||||
// distrust it: mark THIS block NonTrimmed and let Pass N re-read it with
|
||||
// proper recovery (FUA cache-bypass) — a clean re-read wins, a persistent
|
||||
// marginal becomes an honest concealed gap.
|
||||
//
|
||||
// Critically this is a per-block SkipBlock, NOT the damage-jump path: a
|
||||
// recovered error is a single marginal sector, not a clustered hard-damage
|
||||
// zone, so it must NOT trigger the 64 MB JumpAhead (which would nuke huge
|
||||
// swaths of good data on a lightly-smudged disc). It also does NOT touch
|
||||
// the damage window / multiplier — a recovered read is not damage-zone
|
||||
// signal. Returns before the fast-jump logic below.
|
||||
if sense_key == scsi::SENSE_KEY_RECOVERED_ERROR {
|
||||
ctx.marginal_recovered += 1;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "recovered_error",
|
||||
marginal_recovered = ctx.marginal_recovered,
|
||||
asc = err.scsi_sense().map(|s| s.asc),
|
||||
ascq = err.scsi_sense().map(|s| s.ascq),
|
||||
"drive reported a recovered (marginal) read — distrusting; marking NonTrimmed for Pass N re-read"
|
||||
);
|
||||
return ReadAction::SkipBlock {
|
||||
pause_secs: FAIL_PAUSE_SECS,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Marginal media (MEDIUM_ERROR / ABORTED_COMMAND) on a multi-
|
||||
// sector batch: the drive can often read the same sectors
|
||||
// individually. Bisect into single-sector reads (gentler on the
|
||||
@@ -803,6 +844,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn recovered_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(2),
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: scsi::SENSE_KEY_RECOVERED_ERROR,
|
||||
asc: 0x17,
|
||||
ascq: 0x01,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_error_skips_block_not_jump_pass_1() {
|
||||
// A recovered (marginal) read on Pass 1 must NOT trigger the 64 MB
|
||||
// damage-jump — that would nuke huge good regions on a lightly-smudged
|
||||
// disc. It marks just this block NonTrimmed (SkipBlock) for a Pass N
|
||||
// re-read, and is counted as marginal_recovered.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&recovered_err(), &mut ctx);
|
||||
assert!(
|
||||
matches!(action, ReadAction::SkipBlock { .. }),
|
||||
"recovered error must SkipBlock, not JumpAhead; got {action:?}"
|
||||
);
|
||||
assert_eq!(ctx.marginal_recovered, 1);
|
||||
// It must not have inflated the damage-jump multiplier (not damage signal).
|
||||
assert_eq!(ctx.jump_multiplier, 1);
|
||||
assert_eq!(ctx.jumps_taken, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_error_skips_block_pass_n_too() {
|
||||
// Pass N sees the same: a recovered read is distrusted → SkipBlock (the
|
||||
// outer patch loop re-reads the range with FUA).
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
let action = handle_read_error(&recovered_err(), &mut ctx);
|
||||
assert!(
|
||||
matches!(action, ReadAction::SkipBlock { .. }),
|
||||
"got {action:?}"
|
||||
);
|
||||
assert_eq!(ctx.marginal_recovered, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_recovered_errors_never_jump() {
|
||||
// Even a run of recovered errors must never escalate to a damage-jump —
|
||||
// they're marginal reads, not a hard-damage cluster.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
for _ in 0..40 {
|
||||
let a = handle_read_error(&recovered_err(), &mut ctx);
|
||||
assert!(matches!(a, ReadAction::SkipBlock { .. }), "got {a:?}");
|
||||
}
|
||||
assert_eq!(ctx.jumps_taken, 0, "recovered errors never jump");
|
||||
assert_eq!(ctx.marginal_recovered, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_marginal_with_batch_gt_1_bisects() {
|
||||
let mut ctx = ReadCtx::for_patch(32);
|
||||
|
||||
@@ -61,8 +61,28 @@ const SPIN_UP_SETTLE_SECS: u64 = 10;
|
||||
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
||||
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
|
||||
/// SBC/MMC Read-Write Error Recovery mode page (page code 0x01). We flip the
|
||||
/// `PER` bit to make the drive REPORT a recovered read (via CHECK CONDITION +
|
||||
/// sense key RECOVERED ERROR) instead of silently returning best-effort data as
|
||||
/// GOOD status. On marginal/dirty media that silent-GOOD data can be
|
||||
/// mis-corrected — a rip that "passed clean" but decoded with errors. With PER
|
||||
/// on, freemkv sees the marginal read and re-reads it in Pass N (a loud miss,
|
||||
/// never a silent commit). See `build_error_recovery_select_payload`.
|
||||
const MODE_PAGE_ERROR_RECOVERY: u8 = 0x01;
|
||||
/// Bit masks in the Read-Write Error Recovery flags byte (page byte 2).
|
||||
const ERP_FLAG_TB: u8 = 0x20; // Transfer Block: still deliver the recovered data
|
||||
const ERP_FLAG_PER: u8 = 0x04; // Post Error: report recovered errors
|
||||
const ERP_FLAG_DTE: u8 = 0x02; // Data Terminate on Error: MUST be off (we want the data)
|
||||
/// `Parameters Saveable` bit in a mode page's byte 0 — valid only on MODE SENSE;
|
||||
/// must be cleared before echoing the page back in a MODE SELECT.
|
||||
const MODE_PAGE_PS_BIT: u8 = 0x80;
|
||||
/// MODE SENSE(10) parameter header length (bytes), preceding any block
|
||||
/// descriptors and the mode pages.
|
||||
const MODE10_HEADER_LEN: usize = 8;
|
||||
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
@@ -464,6 +484,12 @@ impl Drive {
|
||||
// wants max speed. Best-effort: a failure here must NOT fail the rip.
|
||||
if r.is_ok() {
|
||||
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
||||
// Ask the drive to REPORT recovered/marginal reads rather than
|
||||
// silently commit best-effort data as GOOD (the dirty-disc
|
||||
// "passed-clean-but-decodes-with-errors" trap). Best-effort: a drive
|
||||
// that doesn't honor it just keeps its defaults — no regression, and
|
||||
// on a clean disc it changes nothing.
|
||||
self.enable_recovered_error_reporting();
|
||||
}
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
@@ -599,6 +625,53 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the drive to REPORT recovered/marginal reads instead of silently
|
||||
/// returning best-effort data as GOOD status. MODE SENSE the Read-Write
|
||||
/// Error Recovery page, flip `PER` (and `TB` on / `DTE` off so we still get
|
||||
/// the data), and MODE SELECT it back — preserving the drive's own retry
|
||||
/// count and other bits.
|
||||
///
|
||||
/// Best-effort: a drive that doesn't support the page, or rejects the SELECT,
|
||||
/// simply keeps its default behaviour — no regression, the rip proceeds. On a
|
||||
/// clean disc this changes nothing (no recovered errors fire); it only
|
||||
/// surfaces the marginal reads that a dirty disc would otherwise commit
|
||||
/// silently. Returns whether the page was successfully written.
|
||||
pub fn enable_recovered_error_reporting(&mut self) -> bool {
|
||||
let Some(sense) = self.mode_sense_page(MODE_PAGE_ERROR_RECOVERY) else {
|
||||
tracing::debug!(target: "freemkv::drive", "MODE SENSE error-recovery page unavailable; leaving drive defaults");
|
||||
return false;
|
||||
};
|
||||
let Some(payload) = build_error_recovery_select_payload(&sense) else {
|
||||
tracing::debug!(target: "freemkv::drive", "error-recovery page malformed/short; leaving drive defaults");
|
||||
return false;
|
||||
};
|
||||
// MODE SELECT(10): PF=1 (page format), parameter list length = payload.
|
||||
let len = payload.len() as u16;
|
||||
let cdb = [
|
||||
SCSI_MODE_SELECT,
|
||||
0x10, // PF=1, SP=0 (don't persist across power cycles)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
(len >> 8) as u8,
|
||||
len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = payload;
|
||||
match self.checked_exec(&cdb, crate::scsi::DataDirection::ToDevice, &mut buf, 5_000) {
|
||||
Ok(_) => {
|
||||
tracing::info!(target: "freemkv::drive", phase = "error_recovery", "recovered-error reporting enabled (PER=1) — marginal reads will surface instead of committing silently");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(target: "freemkv::drive", error = %e, "MODE SELECT error-recovery page rejected; leaving drive defaults");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read vendor-specific READ BUFFER data.
|
||||
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
||||
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
||||
@@ -1101,6 +1174,46 @@ fn select_drive_with_media(drives: impl Iterator<Item = Drive>) -> Option<Drive>
|
||||
fallback
|
||||
}
|
||||
|
||||
/// Turn a MODE SENSE(10) Read-Write Error Recovery page response into the
|
||||
/// payload for a MODE SELECT(10) that enables recovered-error REPORTING —
|
||||
/// preserving every other bit (notably the drive's own read-retry count).
|
||||
///
|
||||
/// Pure so the bit-twiddling is unit-tested without a drive. Steps:
|
||||
/// - locate the page after the 8-byte header + block descriptors (bytes 6-7);
|
||||
/// - verify it is page 0x01 with a flags byte present;
|
||||
/// - in the flags byte: set `PER` (report) and `TB` (still deliver the data),
|
||||
/// clear `DTE` (don't terminate the transfer on the recovered error);
|
||||
/// - clear the page's `PS` bit (valid only on SENSE) and zero the header's
|
||||
/// mode-data-length field (reserved on SELECT).
|
||||
///
|
||||
/// Returns `None` (caller leaves the drive at its defaults) when the response is
|
||||
/// too short or isn't the error-recovery page — never panics on adversarial
|
||||
/// bytes.
|
||||
fn build_error_recovery_select_payload(sense: &[u8]) -> Option<Vec<u8>> {
|
||||
if sense.len() < MODE10_HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let block_desc_len = u16::from_be_bytes([sense[6], sense[7]]) as usize;
|
||||
let page_off = MODE10_HEADER_LEN.checked_add(block_desc_len)?;
|
||||
// Need page byte 0 (code), byte 1 (length), byte 2 (flags).
|
||||
if page_off.checked_add(3)? > sense.len() {
|
||||
return None;
|
||||
}
|
||||
if sense[page_off] & 0x3F != MODE_PAGE_ERROR_RECOVERY {
|
||||
return None;
|
||||
}
|
||||
let mut payload = sense.to_vec();
|
||||
// Header: mode-data-length is reserved on SELECT — zero it.
|
||||
payload[0] = 0;
|
||||
payload[1] = 0;
|
||||
// Page byte 0: clear PS (SENSE-only).
|
||||
payload[page_off] &= !MODE_PAGE_PS_BIT;
|
||||
// Flags byte: PER on, TB on, DTE off. Retry count (next byte) untouched.
|
||||
payload[page_off + 2] |= ERP_FLAG_PER | ERP_FLAG_TB;
|
||||
payload[page_off + 2] &= !ERP_FLAG_DTE;
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
/// Decode a READ CAPACITY (10) response into a sector count.
|
||||
///
|
||||
/// A short transfer (`bytes_transferred < 4`, which would leave the high
|
||||
@@ -1276,6 +1389,84 @@ mod command_tests {
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// A minimal MODE SENSE(10) response carrying the Read-Write Error Recovery
|
||||
/// page (0x01) with the given flags byte and retry count, no block
|
||||
/// descriptors. `ps` sets the page's PS bit (SENSE-only), which the SELECT
|
||||
/// payload must clear.
|
||||
fn mode_sense_error_recovery(flags: u8, retry: u8, ps: bool) -> Vec<u8> {
|
||||
let mut v = vec![0u8; MODE10_HEADER_LEN + 12];
|
||||
// Header: nonzero mode-data-length (must be zeroed on SELECT); no block
|
||||
// descriptors.
|
||||
v[0] = 0x00;
|
||||
v[1] = 0x22;
|
||||
v[6] = 0x00;
|
||||
v[7] = 0x00; // block descriptor length = 0
|
||||
let po = MODE10_HEADER_LEN;
|
||||
v[po] = MODE_PAGE_ERROR_RECOVERY | if ps { MODE_PAGE_PS_BIT } else { 0 };
|
||||
v[po + 1] = 0x0A; // page length
|
||||
v[po + 2] = flags; // error-recovery flags
|
||||
v[po + 3] = retry; // read retry count
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_recovery_payload_sets_per_tb_clears_dte_ps_preserves_retry() {
|
||||
// Start with PER off, DTE on, PS on, a specific retry count. The SELECT
|
||||
// payload must flip PER on, TB on, DTE off, clear PS, zero the header
|
||||
// mode-data-length, and leave the retry count untouched.
|
||||
let sense = mode_sense_error_recovery(ERP_FLAG_DTE, 0x2C, true);
|
||||
let out = build_error_recovery_select_payload(&sense).expect("valid page");
|
||||
let po = MODE10_HEADER_LEN;
|
||||
assert_eq!(out[0], 0, "header mode-data-length zeroed for SELECT");
|
||||
assert_eq!(out[1], 0);
|
||||
assert_eq!(out[po] & MODE_PAGE_PS_BIT, 0, "PS cleared for SELECT");
|
||||
assert_eq!(out[po] & 0x3F, MODE_PAGE_ERROR_RECOVERY, "still page 0x01");
|
||||
assert_eq!(out[po + 2] & ERP_FLAG_PER, ERP_FLAG_PER, "PER set");
|
||||
assert_eq!(
|
||||
out[po + 2] & ERP_FLAG_TB,
|
||||
ERP_FLAG_TB,
|
||||
"TB set (still get data)"
|
||||
);
|
||||
assert_eq!(
|
||||
out[po + 2] & ERP_FLAG_DTE,
|
||||
0,
|
||||
"DTE cleared (don't terminate)"
|
||||
);
|
||||
assert_eq!(out[po + 3], 0x2C, "read retry count preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_recovery_payload_honors_block_descriptor_offset() {
|
||||
// With an 8-byte block descriptor between header and page, the function
|
||||
// must locate the page at header+desc, not a fixed offset.
|
||||
let mut sense = vec![0u8; MODE10_HEADER_LEN + 8 + 12];
|
||||
sense[7] = 8; // block descriptor length
|
||||
let po = MODE10_HEADER_LEN + 8;
|
||||
sense[po] = MODE_PAGE_ERROR_RECOVERY;
|
||||
sense[po + 1] = 0x0A;
|
||||
sense[po + 2] = 0x00;
|
||||
let out = build_error_recovery_select_payload(&sense).expect("valid");
|
||||
assert_eq!(
|
||||
out[po + 2] & ERP_FLAG_PER,
|
||||
ERP_FLAG_PER,
|
||||
"PER set at the descriptor-offset page"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_recovery_payload_rejects_wrong_or_short_page() {
|
||||
// Wrong page code → None (leave drive at defaults).
|
||||
let mut wrong = mode_sense_error_recovery(0, 0, false);
|
||||
wrong[MODE10_HEADER_LEN] = 0x08; // page 0x08 (caching), not 0x01
|
||||
assert!(build_error_recovery_select_payload(&wrong).is_none());
|
||||
// Too short to hold the header → None, no panic.
|
||||
assert!(build_error_recovery_select_payload(&[0u8; 4]).is_none());
|
||||
// Header claims a block descriptor that runs off the buffer → None.
|
||||
let mut bad = mode_sense_error_recovery(0, 0, false);
|
||||
bad[7] = 0xF0; // descriptor length way past the buffer
|
||||
assert!(build_error_recovery_select_payload(&bad).is_none());
|
||||
}
|
||||
|
||||
/// Mock transport: returns a fixed data payload (copied into the
|
||||
/// caller's buffer, truncated to fit) on every `execute()`.
|
||||
struct FixedTransport {
|
||||
|
||||
@@ -178,10 +178,9 @@ impl CodecParser for PassthroughParser {
|
||||
/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
|
||||
/// ResyncGate) and lets the decoder conceal — a fundamentally different model
|
||||
/// than per-frame audio dropping.
|
||||
/// - TrueHD/MLP and the rare passthrough audio codecs (FLAC/MP2/AAC) do not yet
|
||||
/// gate: MLP carries inter-AU restart state so a safe drop must land on a
|
||||
/// major-sync boundary, and the passthrough codecs are essentially never seen
|
||||
/// on optical media.
|
||||
/// - TrueHD/MLP, FLAC, MP2/MP3 and AAC-ADTS also gate undecodable frames via a
|
||||
/// `DropTally` (poison/drop-forward for MLP's inter-AU restart state on a
|
||||
/// major-sync boundary; CRC/sync-verdict drops for the passthrough codecs).
|
||||
///
|
||||
/// Create the appropriate parser for a codec, with optional codec private data.
|
||||
///
|
||||
|
||||
+76
-7
@@ -105,7 +105,25 @@ impl TrueHdParser {
|
||||
return AuCheck::Unverifiable;
|
||||
};
|
||||
if !mlp_major_sync_crc_ok(ms, mshdr) {
|
||||
return AuCheck::Corrupt; // corrupt major-sync header
|
||||
// A failing major-sync checksum is only a TRUSTWORTHY corruption
|
||||
// signal once we already hold a validated baseline (num_substreams
|
||||
// captured from a PRIOR checksum-clean major sync). Before that — at
|
||||
// stream head, when no major sync has validated yet — a checksum
|
||||
// mismatch is far more likely a limitation of our own major-sync
|
||||
// header-size parse than real corruption. Arming the drop-forward
|
||||
// there is catastrophic: every following AU is collateral-dropped
|
||||
// waiting for a "validated" major sync that (if our parse is the
|
||||
// problem) NEVER comes, so the WHOLE TrueHD track is silently dropped
|
||||
// — the AUs are physically emitted late (empty until the mux end),
|
||||
// de-interleaving the track and sending decoders into an unbounded
|
||||
// memory spiral. So until we have a baseline to protect, treat a
|
||||
// checksum-failed major sync as UNVERIFIABLE (keep it) rather than
|
||||
// Corrupt. Once a clean major sync HAS established the baseline, a
|
||||
// later failure is a real re-sync trigger and still drops forward.
|
||||
if self.num_substreams.is_some() {
|
||||
return AuCheck::Corrupt; // real corruption vs a proven baseline
|
||||
}
|
||||
return AuCheck::Unverifiable; // no baseline yet — keep, don't nuke the track
|
||||
}
|
||||
self.num_substreams = mlp_num_substreams(ms);
|
||||
header_size += mshdr;
|
||||
@@ -242,8 +260,20 @@ fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool {
|
||||
if mshdr < 4 || ms.len() < mshdr {
|
||||
return false;
|
||||
}
|
||||
let crc = crc16_mlp(&ms[..mshdr - 4]) ^ u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
|
||||
crc == u16::from_be_bytes([ms[mshdr - 2], ms[mshdr - 1]])
|
||||
// ffmpeg `ff_mlp_checksum16(buf, buf_size)` (libavcodec/mlp.c):
|
||||
// av_crc(crc_2D, 0, buf, buf_size - 2) ^ AV_RL16(buf + buf_size - 2)
|
||||
// is called with `buf_size = mshdr - 2` and its result compared to
|
||||
// `AV_RL16(buf + mshdr - 2)` — i.e. the 16-bit checksum over `ms[..mshdr-4]`,
|
||||
// XORed with the LITTLE-ENDIAN word just before the trailer, must equal the
|
||||
// LITTLE-ENDIAN trailer word. `crc16_mlp` is the same crc_2D table (poly 0x2D,
|
||||
// MSB-first) but yields its two bytes in the OPPOSITE order to libavutil's
|
||||
// `av_crc`, so swap them back to match. (The previous code mixed endianness —
|
||||
// little-endian XOR word but big-endian compare — so the checksum could never
|
||||
// validate any real extended major sync, silently dropping the whole track;
|
||||
// cross-verified byte-exact against real 7.1/Atmos and 5.1 discs.)
|
||||
let checksum = crc16_mlp(&ms[..mshdr - 4]).swap_bytes()
|
||||
^ u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
|
||||
checksum == u16::from_le_bytes([ms[mshdr - 2], ms[mshdr - 1]])
|
||||
}
|
||||
|
||||
/// `num_substreams` from a major-sync header: it sits at bit 128 (byte 16, top
|
||||
@@ -698,12 +728,14 @@ mod tests {
|
||||
au[20] = (au[20] & 0x0F) | 0x10;
|
||||
// Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear.
|
||||
au[32] &= 0x7F;
|
||||
// Major-sync CRC over the header body, stored big-endian in the trailer.
|
||||
// Major-sync checksum, built EXACTLY as `mlp_major_sync_crc_ok` verifies it
|
||||
// (ffmpeg `ff_mlp_checksum16`): swap_bytes(crc16_mlp(body)) ^ LE word before
|
||||
// the trailer, stored little-endian in the trailer.
|
||||
let body_end = 4 + MSHDR - 4; // AU[4..28]
|
||||
let crc = super::crc16_mlp(&au[4..body_end])
|
||||
let crc = super::crc16_mlp(&au[4..body_end]).swap_bytes()
|
||||
^ u16::from_le_bytes([au[body_end], au[body_end + 1]]);
|
||||
au[4 + MSHDR - 2] = (crc >> 8) as u8;
|
||||
au[4 + MSHDR - 1] = (crc & 0xFF) as u8;
|
||||
au[4 + MSHDR - 2] = (crc & 0xFF) as u8;
|
||||
au[4 + MSHDR - 1] = (crc >> 8) as u8;
|
||||
// Parity: choose the AU check nibble (AU[0] high bits) so the header +
|
||||
// directory fold to 0xF. The length low nibble (AU[0] low bits) is kept.
|
||||
let hi = au[0] & 0x0F;
|
||||
@@ -766,6 +798,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_failed_head_major_sync_is_kept_not_track_killed() {
|
||||
// REGRESSION (every TrueHD title muxed after the checksum gate landed): a
|
||||
// real-world major sync whose checksum our own header-size parse can't
|
||||
// validate must NOT, at stream head — before ANY major sync has validated —
|
||||
// arm the drop-forward. Doing so latched `resync_pending` on the very first
|
||||
// AU and collateral-dropped EVERY following AU forever (no "validated" major
|
||||
// sync ever came), silently dropping the entire TrueHD track: its AUs were
|
||||
// emitted only at mux end, de-interleaving the track and spiralling decoders
|
||||
// into unbounded memory ("decoder ran out of memory"). With no baseline to
|
||||
// protect, a checksum-failed major sync is KEPT and the audio flows.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut ms_bad = valid_major_sync();
|
||||
ms_bad[10] ^= 0xFF; // break a checksum-covered header byte → checksum fails
|
||||
let mut data = ms_bad;
|
||||
for _ in 0..6 {
|
||||
data.extend_from_slice(&valid_normal_au());
|
||||
}
|
||||
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
7,
|
||||
"no baseline yet: the CRC-failed head major sync + all following AUs are \
|
||||
kept, not dropped (got {})",
|
||||
frames.len()
|
||||
);
|
||||
assert_eq!(
|
||||
parser.dropped_frames(),
|
||||
0,
|
||||
"nothing dropped without a validated baseline to protect"
|
||||
);
|
||||
// And the invariant still holds ONCE a baseline exists: after a genuinely
|
||||
// valid major sync, a later corrupt one IS dropped (see
|
||||
// `corrupt_major_sync_drops_forward_to_next_valid`).
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_failure_is_dropped() {
|
||||
// A normal AU whose header parity is broken (after a major sync sets
|
||||
|
||||
+138
-12
@@ -209,14 +209,34 @@ impl DiscStream {
|
||||
/// The caller opens the source, scans for titles/keys, and passes them in.
|
||||
/// The stream handles demuxing, decryption, and codec parsing internally.
|
||||
pub fn new(
|
||||
reader: Box<dyn SectorSource>,
|
||||
mut reader: Box<dyn SectorSource>,
|
||||
title: DiscTitle,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
mut decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
content_format: crate::disc::ContentFormat,
|
||||
) -> Self {
|
||||
raw: bool,
|
||||
halt: Option<Halt>,
|
||||
) -> std::io::Result<Self> {
|
||||
let mut title = title;
|
||||
let extents = title.extents.clone();
|
||||
|
||||
// Resolve this title's CSS key from the reader if the caller supplied
|
||||
// none — the SAME shared step the file-backed mux highway
|
||||
// (`build_iso_pipeline`) uses, so single-pass and multi-pass descramble a
|
||||
// DVD identically. No-op for AACS / already-keyed / genuinely-clear input
|
||||
// or `raw`; a scrambled-but-uncrackable DVD is a hard `CssKeyMissing`.
|
||||
// `halt` is passed here (not deferred to `with_halt`) so a Stop during the
|
||||
// crack scan is honored — the scan runs at construction, before the caller
|
||||
// can attach a token.
|
||||
crate::css::resolve_dvd_title_key(
|
||||
&mut *reader,
|
||||
&extents,
|
||||
&mut decrypt_keys,
|
||||
batch_sectors,
|
||||
content_format,
|
||||
raw,
|
||||
halt.as_ref(),
|
||||
)?;
|
||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
|
||||
// Debug log reader type at construction — critical for diagnosing mux
|
||||
@@ -301,7 +321,7 @@ impl DiscStream {
|
||||
.map(|_| super::resync::ResyncGate::new())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
reader,
|
||||
title,
|
||||
decrypt_keys,
|
||||
@@ -315,7 +335,7 @@ impl DiscStream {
|
||||
errors: 0,
|
||||
lost_bytes: 0,
|
||||
skip_errors: false,
|
||||
halt: None,
|
||||
halt,
|
||||
event_fn: None,
|
||||
eof: false,
|
||||
dropped_nav_packets: 0,
|
||||
@@ -330,7 +350,7 @@ impl DiscStream {
|
||||
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
||||
resync,
|
||||
is_video,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Set event handler for sector-level events (binary search, skip, recover).
|
||||
@@ -1094,7 +1114,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut src: Box<dyn Stream> = Box::new(stream);
|
||||
|
||||
@@ -1129,7 +1152,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_halt(halt.clone());
|
||||
assert!(!stream.is_halted());
|
||||
halt.cancel();
|
||||
@@ -1161,7 +1187,10 @@ mod tests {
|
||||
aacs,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_key_map(std::sync::Arc::new(map));
|
||||
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
|
||||
assert_eq!(
|
||||
@@ -1320,7 +1349,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
|
||||
// would return Err — so reaching EOF cleanly proves recovery worked.
|
||||
stream.skip_errors = false;
|
||||
@@ -1433,7 +1465,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
|
||||
// Drive fill_extents across batches: the good leading sectors mux fine,
|
||||
@@ -1493,7 +1528,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
|
||||
let res = stream.fill_extents();
|
||||
@@ -1539,7 +1577,16 @@ mod tests {
|
||||
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,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
assert_eq!(
|
||||
stream.unit_align, ALIGN as u16,
|
||||
@@ -1635,7 +1682,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
|
||||
|
||||
@@ -1676,7 +1726,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_halt(Halt::from_arc(arc.clone()));
|
||||
assert!(!stream.is_halted());
|
||||
arc.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -1685,4 +1738,77 @@ mod tests {
|
||||
"with_halt(Halt::from_arc) must observe Arc-side flips"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every read fails CSS-locked (`05/6F/03`) — a scrambled DVD whose title key
|
||||
/// can't be cracked. Drives `resolve_dvd_title_key` to `ScrambledUncracked`.
|
||||
struct LockedReader;
|
||||
impl crate::sector::SectorSource for LockedReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
Err(crate::error::Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: Some(2),
|
||||
sense: Some(crate::scsi::ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x6F,
|
||||
ascq: 0x03,
|
||||
}),
|
||||
})
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
64
|
||||
}
|
||||
}
|
||||
|
||||
fn mpegps_title(sector_count: u32) -> DiscTitle {
|
||||
let mut t = synthetic_title(sector_count);
|
||||
t.content_format = ContentFormat::MpegPs;
|
||||
t
|
||||
}
|
||||
|
||||
/// PARITY with `build_iso_pipeline_dvd_none_keys_scrambled_hard_fails`: the
|
||||
/// live-drive single-pass constructor must ALSO hard-fail (not build a
|
||||
/// scrambled-passthrough stream) for a `None`-keyed scrambled MPEG-PS DVD —
|
||||
/// the exact 328k-decode-error corruption path, on the single-pass side.
|
||||
#[test]
|
||||
fn disc_stream_new_dvd_none_scrambled_hard_fails() {
|
||||
let res = DiscStream::new(
|
||||
Box::new(LockedReader),
|
||||
mpegps_title(8),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"single-pass DiscStream must hard-fail on a scrambled, keyless CSS DVD"
|
||||
);
|
||||
}
|
||||
|
||||
/// `raw` must bypass the CSS crack at the DiscStream boundary too: the same
|
||||
/// scrambled-uncrackable input that hard-fails above must CONSTRUCT in raw
|
||||
/// mode (ciphertext passthrough), never hard-fail.
|
||||
#[test]
|
||||
fn disc_stream_new_raw_bypasses_css_crack() {
|
||||
let res = DiscStream::new(
|
||||
Box::new(LockedReader),
|
||||
mpegps_title(8),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::MpegPs,
|
||||
true, // raw
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"raw single-pass must construct without cracking, even on scrambled-uncrackable input"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+94
-22
@@ -403,26 +403,29 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
.into());
|
||||
}
|
||||
// Per-title key resolution. For a multi-VTS CSS DVD the scan's
|
||||
// single cracked key only descrambles its own VTS; re-crack from
|
||||
// the chosen title's extents if it lives elsewhere. A fresh reader
|
||||
// avoids disturbing the mux reader below. 64 sectors is a
|
||||
// file-safe batch for an ISO. AACS / single-VTS paths are
|
||||
// unchanged (decrypt_keys_for_title short-circuits to decrypt_keys).
|
||||
let (keys, title_is_clear) =
|
||||
match crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
Ok(mut crack_reader) => disc.decrypt_keys_for_title(idx, &mut crack_reader, 64),
|
||||
Err(_) => (disc.decrypt_keys(), false),
|
||||
};
|
||||
// Per-title decrypt gate (parallel to the disc-wide gate above): on
|
||||
// a multi-VTS CSS disc, the per-title re-crack may return `None` when
|
||||
// the chosen title's VTS could not be re-cracked. Muxing that would
|
||||
// emit scrambled ciphertext verbatim, so fail loudly here — EXCEPT
|
||||
// when the title proved genuinely clear (`title_is_clear`), an
|
||||
// unencrypted stub on an otherwise-CSS disc that needs no key. That
|
||||
// case must NOT raise a false E7023.
|
||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Per-title key resolution. DVD CSS is resolved at exactly ONE site —
|
||||
// `build_iso_pipeline`'s per-title crack (below), which decrypts a
|
||||
// crackable title, passes a genuinely-clear one through, and
|
||||
// hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do
|
||||
// NOT pre-crack here: pass `None` and let the pipeline own it.
|
||||
// Pre-cracking would re-open the ISO and re-scan every clear title
|
||||
// (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway).
|
||||
// AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw`
|
||||
// (any format) is deliberate ciphertext passthrough — also `None`.
|
||||
let is_dvd = disc.format == crate::disc::DiscFormat::Dvd;
|
||||
let (keys, title_is_clear) = if opts.raw || is_dvd {
|
||||
(crate::decrypt::DecryptKeys::None, false)
|
||||
} else {
|
||||
(disc.decrypt_keys(), false)
|
||||
};
|
||||
// Decrypt gate for the AACS / non-DVD path: a None key means no usable
|
||||
// disc key, which would mux scrambled ciphertext verbatim — fail loudly
|
||||
// (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its
|
||||
// CSS hard-fail), and `--raw` passes.
|
||||
if !is_dvd {
|
||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
}
|
||||
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
||||
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
||||
// the key-fetch closure and can actually attempt resolution. (An older
|
||||
@@ -485,6 +488,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
effective_keys,
|
||||
ISO_MUX_BATCH_SECTORS,
|
||||
format,
|
||||
opts.raw,
|
||||
None,
|
||||
None,
|
||||
fetch,
|
||||
@@ -1120,13 +1124,17 @@ pub fn resolve_mux_key_map(
|
||||
/// - `batch_sectors`: read batch size in logical (2048-byte) sectors — a
|
||||
/// throughput/latency tuning knob, not a correctness parameter.
|
||||
/// - `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer).
|
||||
/// - `raw`: ciphertext passthrough. When `true`, the per-title CSS crack
|
||||
/// (`resolve_dvd_title_key`) is skipped entirely — no key is resolved and a
|
||||
/// scrambled title is neither descrambled nor hard-failed.
|
||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||
/// pipeline stops at the next boundary (and the CSS crack surfaces `Halted`).
|
||||
/// `None` disables cancellation.
|
||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
|
||||
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
|
||||
/// callback: the map decides the key for every LBA before the read loop starts.
|
||||
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
||||
// Nine reader/title/keys/tuning/callback params is inherent to the mux entry
|
||||
// point; grouping them into a struct would only move the same fields around.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
@@ -1135,11 +1143,27 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
mut keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
format: ContentFormat,
|
||||
raw: bool,
|
||||
halt: Option<crate::halt::Halt>,
|
||||
event_fn: Option<crate::sector::prefetched::EventFn>,
|
||||
fetch: Option<crate::sector::KeyFetch>,
|
||||
) -> io::Result<PipelinedPesStream> {
|
||||
let extents = title.extents.clone();
|
||||
// CSS (DVD) key resolution — the shared per-title step (also used by the
|
||||
// live-drive single-pass `DiscStream`). A `None`/MPEG-PS title cracks its own
|
||||
// key from the reader in playback order; AACS `.evo` (also MPEG-PS) arrives as
|
||||
// `Aacs` and is untouched; a clear DVD stays `None`; `raw` skips it entirely.
|
||||
// Without this a detection-miss CSS DVD would mux scrambled sectors as corrupt
|
||||
// video. `halt` lets /api/stop interrupt the crack scan.
|
||||
crate::css::resolve_dvd_title_key(
|
||||
&mut reader,
|
||||
&extents,
|
||||
&mut keys,
|
||||
batch_sectors,
|
||||
format,
|
||||
raw,
|
||||
halt.as_ref(),
|
||||
)?;
|
||||
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
|
||||
// units, so the producer must hand the decrypt step 3-sector-aligned batches.
|
||||
// CSS (DVD) and unencrypted content decrypt per 2048-byte sector — forcing
|
||||
@@ -1702,6 +1726,7 @@ mod tests {
|
||||
DecryptKeys::None,
|
||||
8192,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -1743,6 +1768,7 @@ mod tests {
|
||||
DecryptKeys::None,
|
||||
8192,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -1795,10 +1821,56 @@ mod tests {
|
||||
DecryptKeys::None,
|
||||
0,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(res.is_err(), "zero batch_sectors must be rejected");
|
||||
}
|
||||
|
||||
/// REGRESSION (autorip production corruption): `build_iso_pipeline` for a DVD
|
||||
/// (MPEG-PS) with `None` keys — what autorip's mux passes on a detection-miss
|
||||
/// DVD (`disc.decrypt_keys()` == None) — must resolve the CSS key from the
|
||||
/// reader itself. A scrambled-but-uncrackable title must HARD-FAIL, never
|
||||
/// build a passthrough pipeline that muxes the scrambled sectors as corrupt
|
||||
/// video. Before this fix, autorip handed None straight through and the mux
|
||||
/// wrote garbage at exit 0.
|
||||
#[test]
|
||||
fn build_iso_pipeline_dvd_none_keys_scrambled_hard_fails() {
|
||||
// One CSS-scrambled, crib-less (uncrackable) MPEG-PS sector.
|
||||
let key = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let mut sec = vec![0u8; 2048];
|
||||
sec[0..4].copy_from_slice(&crate::css::PACK_START);
|
||||
for (i, b) in sec.iter_mut().enumerate().take(0x80).skip(4) {
|
||||
*b = (i as u8).wrapping_mul(7).wrapping_add(1); // non-repeating → no crib
|
||||
}
|
||||
sec[0x14] = 0x10; // scramble flag
|
||||
for (i, b) in sec.iter_mut().enumerate().skip(0x80) {
|
||||
*b = (i as u8) ^ 0x3C;
|
||||
}
|
||||
crate::css::lfsr::scramble_sector(&key, &mut sec);
|
||||
|
||||
let mut title = aac_audio_title(0x1100);
|
||||
title.extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 1,
|
||||
}];
|
||||
|
||||
let res = build_iso_pipeline(
|
||||
MemSource { data: sec },
|
||||
title,
|
||||
DecryptKeys::None,
|
||||
8192,
|
||||
ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"a scrambled DVD title with no key must hard-fail, not build a scrambled-passthrough pipeline"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
|
||||
DecryptKeys::None,
|
||||
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
|
||||
ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -136,7 +136,16 @@ fn test_bytes_read_emitted_during_disc_copy() {
|
||||
let title = synthetic_title(64);
|
||||
let keys = libfreemkv::DecryptKeys::None;
|
||||
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
||||
let mut stream = DiscStream::new(
|
||||
Box::new(reader),
|
||||
title,
|
||||
keys,
|
||||
60,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = Arc::new(AtomicU64::new(0));
|
||||
let count_cb = count.clone();
|
||||
@@ -286,7 +295,16 @@ fn test_drop_impls_do_not_panic_or_block() {
|
||||
let reader = ZeroSectorReader::new(64);
|
||||
let title = synthetic_title(64);
|
||||
let keys = libfreemkv::DecryptKeys::None;
|
||||
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
||||
let stream = DiscStream::new(
|
||||
Box::new(reader),
|
||||
title,
|
||||
keys,
|
||||
60,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Drop on a worker thread; main thread enforces the timeout.
|
||||
let handle = std::thread::spawn(move || {
|
||||
|
||||
Reference in New Issue
Block a user