mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66951a32ed | ||
|
|
505a78efd1 | ||
|
|
9b80084220 | ||
|
|
b246169e5b | ||
|
|
2d96a3e87d | ||
|
|
bae5a9ea07 | ||
|
|
86ed27508d | ||
|
|
f28d79b51b |
+93
-90
@@ -1,98 +1,101 @@
|
||||
# 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.1] — 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.
|
||||
- **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.1"
|
||||
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.1" }
|
||||
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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+13
-9
@@ -1824,9 +1824,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 +1845,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 +1882,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` =>
|
||||
@@ -3893,6 +3896,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user