mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dcf9b5a0d | ||
|
|
f01c097784 | ||
|
|
c265fa349e | ||
|
|
6a59dcb7ba | ||
|
|
0eb1817ec7 | ||
|
|
21f3b6adbd | ||
|
|
b32d636c64 | ||
|
|
d877630c45 | ||
|
|
1895095f49 | ||
|
|
5f8c049444 | ||
|
|
7d427d0b17 | ||
|
|
0a47b22db5 | ||
|
|
2ebcb60059 | ||
|
|
f46d99a348 | ||
|
|
4d93d90561 | ||
|
|
4eeafd3378 | ||
|
|
a73ba98f82 | ||
|
|
7676f3f2f6 | ||
|
|
e1cfe59dc9 | ||
|
|
c43f23a759 | ||
|
|
770e0ab936 | ||
|
|
5557765a86 | ||
|
|
458b04ae63 | ||
|
|
c31ac6b66b | ||
|
|
3e84ecfe42 | ||
|
|
998f82dc07 | ||
|
|
b80af33775 | ||
|
|
054902cefe | ||
|
|
aa277224a9 | ||
|
|
26154b6977 | ||
|
|
bb90991e05 | ||
|
|
175f131008 | ||
|
|
af2f750629 | ||
|
|
f1517f7313 | ||
|
|
984774b068 | ||
|
|
ddc44e1105 | ||
|
|
b8a51ef1c7 | ||
|
|
08cb80aac1 | ||
|
|
60d0ebc8a1 | ||
|
|
5dee947007 | ||
|
|
a1796e8345 | ||
|
|
5cf4252e44 | ||
|
|
b5f9c4e426 | ||
|
|
82dca307ad | ||
|
|
a9a21d8139 | ||
|
|
8dc7075356 | ||
|
|
02218a6511 | ||
|
|
e32a3af898 | ||
|
|
1253db037f |
@@ -39,29 +39,11 @@ jobs:
|
||||
# would always fail (no lockfile to lock against on a fresh runner).
|
||||
- run: cargo test
|
||||
|
||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
||||
# failing test suite still blocks publication to crates.io — external
|
||||
# consumers who `cargo add libfreemkv` must never receive a release whose
|
||||
# tests were failing. (The two upstream jobs run in parallel, so this gate
|
||||
# does not serialize publish behind test beyond their own completion.)
|
||||
publish:
|
||||
needs: [verify, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# --no-verify: CI already compiled this exact commit (in the `test` job
|
||||
# and on every push via ci.yml). cargo publish's default re-verify does a
|
||||
# full cold release build of the packaged tarball, which here is pure
|
||||
# redundant work (~a cold lib build). Skip it.
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish --no-verify
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||
|
||||
+109
-5
@@ -1,5 +1,109 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0). All code is an independent Rust
|
||||
implementation of the public/standard disc formats and cryptographic
|
||||
algorithms; comments that cross-referenced GPL/LGPL C projects (libdvdcss,
|
||||
libaacs, libbluray, libdvdread, libdvdnav) were dropped, and the CSS content
|
||||
cipher and the Stevenson title-key attack are attributed to their published
|
||||
cryptanalysis rather than any particular software.
|
||||
|
||||
### Added
|
||||
|
||||
- **Authoritative HD-DVD title composition** from the Advanced-Content playlist
|
||||
(`ADV_OBJ/VPLST000.XPL`): each title's clips, real duration, display name, and
|
||||
chapters come from the disc's own playlist instead of a clip-name heuristic. A
|
||||
layer-break split (`FEATURE_1`+`FEATURE_2`, `feature`/`feature_Divide`) composes
|
||||
into ONE title with the two parts as clips and their title-time offsets. Falls
|
||||
back to the clip-name heuristic when no playlist is present.
|
||||
|
||||
## [1.3.0] — 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 (FMTS) is a first-class disc format.** FMTS discs are detected,
|
||||
labeled, and scanned as their own format rather than misread as plain UHD. The
|
||||
forensic variant segments are located from `IndividualSegment.tbl` and the
|
||||
`SegmentKey.tbl` container is parsed; the bulk of the title decrypts with the
|
||||
unit key as usual, and the forensic segments (for which no segment-key source
|
||||
exists yet) are skipped as expected loss, so a 2.1 disc rips mostly-complete
|
||||
instead of failing outright.
|
||||
- **AACS 2.1 variant Media Key chain runs end to end.** The variant media key is
|
||||
derived as a clean Processing-Key to media-key primitive, with the record
|
||||
layout pinned against reference variant MKBs — the per-slot `C` block from the
|
||||
`0x0c` cvalue table, the `VARIANTS` table plus trailing nonce at `0x2d`, and
|
||||
`VKD` at `0x2f` — so a genuine variant MKB resolves through the ladder.
|
||||
- **Partial HD-DVD support.** HD-DVD is detected as its own format and its
|
||||
`HVDVD_TS` `.evo` clips mux through the pipeline: EVO video is demuxed from the
|
||||
MPEG program stream, including VC-1 titles carried on extended stream id `0xFD`
|
||||
(real selector in the PES `stream_id_extension`), with the VC-1 access units
|
||||
reframed so each I-frame keeps its preceding sequence and entry-point headers.
|
||||
Title composition is heuristic for now (authoritative program-chain parsing is
|
||||
planned), so a disc that authors two distinct features under the layer-break
|
||||
naming may present them as one title.
|
||||
- **Display-order timestamps for program-stream H.264 / VC-1 / HEVC.** A program
|
||||
stream stamps a PES PTS only once per GOP; the parsers now reconstruct a
|
||||
display-order PTS per frame from the coded picture type and the sparse anchor
|
||||
(duration self-calibrated from anchor spacing), so a decoder no longer sees
|
||||
colliding DTS. Gated to the program-stream path — the BD/UHD transport path
|
||||
(per-frame PTS) is unchanged.
|
||||
- **Stream-label parsers: reader-backed detection and a menu-language fallback.**
|
||||
Label detection can inspect a jar's contents, so vendor parsers claim only
|
||||
their own discs; a new last-resort parser reads menu-artwork languages.
|
||||
- **keydb round-trips AACS 2.0 host certs** (the `HC2` line) so a load/save cycle
|
||||
no longer drops v2 host credentials.
|
||||
|
||||
### Changed
|
||||
|
||||
- **MPEG-2 reassembles through the shared `AuAssembler`.** The MPEG-2 parser's
|
||||
hand-rolled PES buffer and offset-keyed mark queues are replaced by the same
|
||||
access-unit assembler the H.264/HEVC/VC-1 parsers use (in a new MPEG-2 mode);
|
||||
the GOP-buffered `temporal_reference` reorder and PTS origin-locking are
|
||||
unchanged, so DVD output is identical.
|
||||
- **Generic per-scheme recovery seam.** Decrypt-miss handling is now a
|
||||
scheme-neutral seam the input stream installs (no recovery, or an AACS
|
||||
fresh-key fetch), with CSS self-recovering separately from the data itself. An
|
||||
undecryptable unit is counted the same whatever the scheme, so the separate
|
||||
"undecryptable" loss bucket folds into one loss count.
|
||||
- **`aacs` module reorganized.** The former god-module is split into
|
||||
`media_key` / `volume_key` / `inf` / `resolve` / `mkb` / `crypto` / `content`,
|
||||
the `boil` veneer is removed, and module paths (not a `mod.rs` facade) are the
|
||||
public API.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Main title is chosen by largest physical size, not clip count**, so a
|
||||
chapter-per-clip disc (e.g. Fast & Furious) is no longer mis-ranked behind a
|
||||
virtual composite.
|
||||
- **A fresh-rip ISO `sync_all` failure is no longer swallowed**: `is_regular` is
|
||||
read from the open file handle instead of a pre-create `metadata(path)` that
|
||||
always failed on a path that does not exist yet.
|
||||
- **A transient CLIP-info parse failure no longer suppresses a clip's extents**
|
||||
for a later playlist item that references the same clip.
|
||||
- **Reverify downgrades that fail to persist are logged, not swallowed**, so a
|
||||
bad unit cannot be silently mismarked good on resume.
|
||||
- **The CLI sanitizes on-disc metadata** (title, volume label, playlist, stream
|
||||
labels) before printing, so a crafted disc cannot inject terminal escape
|
||||
sequences.
|
||||
- **keydb entry validation matches the parser exactly** — a `0x` line counts only
|
||||
with a ` = ` — so content that parses to zero usable entries can no longer be
|
||||
saved as valid.
|
||||
- **autorip** recovers a poisoned config lock in the rip thread instead of
|
||||
panicking it, and corrects the resume pass count.
|
||||
- Criterion stream numbering (a map value of 0 no longer shadows stream 1); AACS
|
||||
resolve classifies a media-keys-only source missing the VID as "VID
|
||||
unavailable"; a dropped partial PES flags a discontinuity; the no-demuxer path
|
||||
detects an early consumer disconnect.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Decrypt thread count is resolved once** and cached off the per-buffer hot
|
||||
path (the env var and `available_parallelism` are no longer probed per call).
|
||||
|
||||
## [1.2.2] — 2026-07-04
|
||||
|
||||
### Added
|
||||
@@ -39,7 +143,7 @@
|
||||
|
||||
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
|
||||
key at its subset-difference node (one AES-G from the Media Key), so it is now
|
||||
tried directly against the MKB cvalue tables (matching libaacs `_calc_mk_pks`)
|
||||
tried directly against the MKB cvalue tables (direct PK × cvalue iteration)
|
||||
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
|
||||
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
|
||||
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
|
||||
@@ -272,7 +376,7 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
|
||||
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
|
||||
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
|
||||
(CPI flag → decrypt → strict all-32 TS-sync)
|
||||
before it is signed off as good. A unit that no held or freshly-fetched key
|
||||
decrypts is treated exactly like a bad read — re-read by
|
||||
the patch pass, terminal loss only if truly unrecoverable — closing the
|
||||
@@ -293,7 +397,7 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
|
||||
when the decrypted unit has the TS sync byte on *all* 32 source packets
|
||||
(libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key
|
||||
(all-32 TS-sync verify), replacing a majority-vote heuristic where a wrong key
|
||||
could coincidentally restore enough syncs to pass and silently corrupt a unit.
|
||||
- keydb download/save moved out of the library into freemkv-keysources;
|
||||
libfreemkv no longer has any keydb I/O (it already held no keys).
|
||||
@@ -301,7 +405,7 @@ consumers are the in-tree toolchain crates.
|
||||
### Fixed
|
||||
|
||||
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
|
||||
flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a
|
||||
flag is bit 7 of byte 1 (`p[1] >> 7`) but was read as bit 0, so a
|
||||
bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud
|
||||
guard that refuses to decrypt bus-wrapped data to garbage when no bus key was
|
||||
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
|
||||
@@ -601,7 +705,7 @@ hardening.
|
||||
|
||||
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
|
||||
key database — the title key is recovered directly from the scrambled disc
|
||||
data via the Stevenson known-plaintext attack (ported from libdvdcss) and
|
||||
data via the Stevenson known-plaintext attack and
|
||||
validated by descrambling a sector and confirming the known plaintext
|
||||
reappears, so a wrong key fails cleanly instead of producing silent garbage
|
||||
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
|
||||
|
||||
+1
-1
@@ -24,4 +24,4 @@ cargo test
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree your code will be licensed under AGPL-3.0.
|
||||
By contributing, you agree your code will be licensed under MIT.
|
||||
|
||||
+9
-3
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.2.2"
|
||||
version = "1.3.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
@@ -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.2.2" }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.3.1" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
@@ -36,6 +36,12 @@ rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = "0.20"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2026 FreeMKV Contributors
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, version 3 of the License.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
|
||||
# libfreemkv
|
||||
|
||||
@@ -190,4 +190,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only
|
||||
MIT
|
||||
|
||||
@@ -7,7 +7,7 @@ AACS decryption requires an external `keydb.cfg` (default
|
||||
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** AGPL-3.0-only
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
//! AACS derivation "boil-down" — one public home for the key chain.
|
||||
//!
|
||||
//! Thin newtypes at the API boundary and three wrapper functions over the
|
||||
//! existing crypto. Nothing here re-implements a primitive: every function
|
||||
//! delegates to the already-audited code in [`super::keys`] and
|
||||
//! [`super::variants`], so the boil-down cannot drift from production math.
|
||||
//!
|
||||
//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto
|
||||
//! internals continue to operate on raw arrays. They exist so a caller threads
|
||||
//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for
|
||||
//! another, not to refactor the resolver.
|
||||
//!
|
||||
//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and
|
||||
//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte):
|
||||
//!
|
||||
//! ```text
|
||||
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
||||
//! mk_from_pk(processing_keys, mkb) → MediaKey (Km)
|
||||
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||
//! ```
|
||||
//!
|
||||
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key,
|
||||
//! both via the MKB's Subset-Difference cvalue tables: the device-key path
|
||||
//! recovers its Processing Key at the matching SD node and walks on to the MK;
|
||||
//! the processing-key path starts from a precomputed PK. Neither needs a VID
|
||||
//! (the VID enters at `vuk_from_mk`).
|
||||
|
||||
use super::keys::{
|
||||
decrypt_unit_key, derive_media_key_and_pk_from_dk, derive_media_key_from_pk, derive_vuk,
|
||||
};
|
||||
use super::types::DeviceKey;
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to [`uk_from_vuk`] (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association (the `u32` in
|
||||
/// `ResolvedKeys::unit_keys`) is a higher-level concern owned by
|
||||
/// [`super::keys::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
}
|
||||
|
||||
/// Derive the Volume Unique Key from a Media Key and Volume ID.
|
||||
///
|
||||
/// Wraps [`derive_vuk`] verbatim: `VUK = AES-128-ECB-DECRYPT(MK, VID) XOR VID`.
|
||||
/// This is byte-identical to the inline `derive_vuk(&mk, ctx.volume_id)` call in
|
||||
/// every classical resolver path AND to the `Kvu = AES-G(Km, VID)` step inside
|
||||
/// [`derive_media_key_variant`] (AES-G and `derive_vuk` are the same math), so
|
||||
/// `vuk_from_mk(mk_from_dk(..)?, vid)` reproduces the V21 variant VUK exactly.
|
||||
pub fn vuk_from_mk(mk: MediaKey, vid: Vid) -> Vuk {
|
||||
Vuk(derive_vuk(&mk.0, &vid.0))
|
||||
}
|
||||
|
||||
/// Decrypt the disc's encrypted title keys with a VUK.
|
||||
///
|
||||
/// Wraps [`decrypt_unit_key`] (AES-128-ECB-DECRYPT) per entry, mirroring the
|
||||
/// `derive_uks` closure in `resolve_keys_classical` / `resolve_keys_v21`. The
|
||||
/// returned `UnitKey::idx` is the slice position; pair with CPS-unit numbers via
|
||||
/// [`super::keys::parse_unit_key_ro`] when the numbering matters.
|
||||
pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
enc_title_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, enc)| UnitKey {
|
||||
idx: i as u32,
|
||||
key: decrypt_unit_key(&vuk.0, enc),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from positioned device keys via the MKB's
|
||||
/// Subset-Difference tables.
|
||||
///
|
||||
/// Wraps [`derive_media_key_and_pk_from_dk`] — the real SD walk the resolver
|
||||
/// runs: each positioned device key is placed against the MKB's subset-diff /
|
||||
/// cvalue records, recovering its Processing Key at the matching node and
|
||||
/// continuing to the Media Key. Reachable for real discs whenever a device key
|
||||
/// applies to the MKB. No VID is involved here — it enters at [`vuk_from_mk`].
|
||||
///
|
||||
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no supplied device key
|
||||
/// resolves the MKB — the same terminal error as [`mk_from_pk`]; no numeric
|
||||
/// distinction is load-bearing at this boundary.
|
||||
///
|
||||
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||
pub fn mk_from_dk(device_keys: &[DeviceKey], mkb: &[u8]) -> Result<MediaKey, crate::error::Error> {
|
||||
// Positioned device keys drive the real Subset-Difference MKB walk
|
||||
// ([`derive_media_key_and_pk_from_dk`], the same walk the resolver runs). The
|
||||
// old Media-Key-Variant path needed integrator Key Correction Data absent
|
||||
// in-tree, so it Err'd for EVERY real disc (dead for both consumers —
|
||||
// freemkv-keysources' DK fallback and the kdb harvester). No VID is needed
|
||||
// for the Media Key; it enters only at [`vuk_from_mk`].
|
||||
match derive_media_key_and_pk_from_dk(mkb, device_keys) {
|
||||
Some((km, _pk)) => Ok(MediaKey(km)),
|
||||
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from one or more Processing Keys and the disc MKB.
|
||||
///
|
||||
/// Wraps [`derive_media_key_from_pk`] — the Subset-Difference PK→MK walk: each
|
||||
/// processing key is validated (and tree-walked) against the MKB's cvalue tables
|
||||
/// (records `0x04`/`0x05`) until one yields the Media Key whose verify record
|
||||
/// (`0x81`/`0x86`) matches. Unlike [`mk_from_dk`] this path is reachable for
|
||||
/// real discs — a leaked/precomputed AACS Processing Key in the keydb resolves
|
||||
/// the Media Key directly. No VID is involved at this step; the VID enters at
|
||||
/// [`vuk_from_mk`].
|
||||
///
|
||||
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no processing key resolves
|
||||
/// the MKB — the same terminal error as [`mk_from_dk`]; no numeric distinction
|
||||
/// is load-bearing at this boundary.
|
||||
///
|
||||
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||
pub fn mk_from_pk(
|
||||
processing_keys: &[[u8; 16]],
|
||||
mkb: &[u8],
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
match derive_media_key_from_pk(mkb, processing_keys) {
|
||||
Some(km) => Ok(MediaKey(km)),
|
||||
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||
///
|
||||
/// Each variant carries the module's existing newtype for that rung (a `Dk` is a
|
||||
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||
/// [`super::keys::recover_dk_position`] first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KeyCandidate {
|
||||
Uk(UnitKey),
|
||||
Vuk(Vuk),
|
||||
Mk(MediaKey),
|
||||
Pk(ProcessingKey),
|
||||
Dk(DeviceKey),
|
||||
}
|
||||
|
||||
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
|
||||
///
|
||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (positional
|
||||
/// order); the caller runs [`super::decrypt::unit_key_validates`] to find which
|
||||
/// one actually opens the disc. Rungs above the candidate are `None` (a `Vuk`
|
||||
/// candidate has no `mk`/`pk`/`dk`; a `Uk` candidate has only `unit_keys`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedChain {
|
||||
/// Every unit key derived from the VUK, as `(cps_unit_number, key)` — the
|
||||
/// CPS-unit numbers come from `Unit_Key_RO.inf` (via `parse_unit_key_ro`), so
|
||||
/// a consumer maps `UK → CPS unit` directly. Same shape as
|
||||
/// [`super::keys::ResolvedKeys::unit_keys`]. A `Uk` candidate yields exactly
|
||||
/// itself, keyed by its own `idx`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub vuk: Option<Vuk>,
|
||||
pub mk: Option<MediaKey>,
|
||||
pub pk: Option<ProcessingKey>,
|
||||
/// The positioned device key (for a `Dk` candidate).
|
||||
pub dk: Option<DeviceKey>,
|
||||
}
|
||||
|
||||
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||
///
|
||||
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
|
||||
/// `VUK → UKs`, or `UK → itself`. Composes the module's own boil steps
|
||||
/// ([`mk_from_pk`], [`vuk_from_mk`], [`uk_from_vuk`]) and parses
|
||||
/// `Unit_Key_RO.inf` at the version the disc's MKB declares (48-byte stride for
|
||||
/// AACS-1.0, 64 for AACS-2.x), so a multi-CPS disc yields all its unit keys from
|
||||
/// the one candidate.
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Every step
|
||||
/// is deterministic AES, so the returned keys are only as sound as the input
|
||||
/// candidate — validate `unit_keys` against a real encrypted unit with
|
||||
/// [`super::decrypt::unit_key_validates`] to prove the candidate opens the disc.
|
||||
///
|
||||
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||
/// one, or an unparseable/empty `Unit_Key_RO.inf`.
|
||||
pub fn resolve_candidate(
|
||||
candidate: &KeyCandidate,
|
||||
mkb: &[u8],
|
||||
unit_key_ro: &[u8],
|
||||
vid: Option<Vid>,
|
||||
) -> Option<ResolvedChain> {
|
||||
use super::keys::{AacsVersion, derive_media_key_and_pk_from_dk, mkb_type, parse_unit_key_ro};
|
||||
|
||||
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
|
||||
// `.inf` parsing lives here: derive the stride version from the disc's own
|
||||
// MKB, then defer the VUK→unit-keys step to the shared `derive_unit_keys`
|
||||
// (the one place both resolvers and this path decrypt the title keys).
|
||||
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||
let version = mkb_type(mkb)
|
||||
.map(|t| t.generation())
|
||||
.unwrap_or(AacsVersion::V10);
|
||||
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
||||
if ukf.encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(super::keys::derive_unit_keys(&ukf, &vuk.0))
|
||||
};
|
||||
|
||||
match candidate {
|
||||
KeyCandidate::Uk(uk) => Some(ResolvedChain {
|
||||
unit_keys: vec![(uk.idx, uk.key)],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Vuk(v) => Some(ResolvedChain {
|
||||
unit_keys: boil(*v)?,
|
||||
vuk: Some(*v),
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Mk(mk) => {
|
||||
let vuk = vuk_from_mk(*mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(*mk),
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Pk(pk) => {
|
||||
let mk = mk_from_pk(std::slice::from_ref(&pk.0), mkb).ok()?;
|
||||
let vuk = vuk_from_mk(mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(mk),
|
||||
pk: Some(*pk),
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Dk(dk) => {
|
||||
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
|
||||
let mk = MediaKey(km);
|
||||
let vuk = vuk_from_mk(mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(mk),
|
||||
pk: Some(ProcessingKey(pk)),
|
||||
dk: Some(dk.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::decrypt::aes_ecb_encrypt;
|
||||
use crate::aacs::keys::{decrypt_unit_key, derive_vuk};
|
||||
|
||||
/// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for
|
||||
/// several known (MK, VID) vectors.
|
||||
#[test]
|
||||
fn vuk_from_mk_matches_inline_derive_vuk() {
|
||||
let cases: [([u8; 16], [u8; 16]); 3] = [
|
||||
([0x5A; 16], [0xA5; 16]),
|
||||
([0x11; 16], [0x22; 16]),
|
||||
(
|
||||
[
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F,
|
||||
],
|
||||
[
|
||||
0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, 0x78, 0x69, 0x5A, 0x4B, 0x3C,
|
||||
0x2D, 0x1E, 0x0F,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (mk, vid) in cases {
|
||||
let inline = derive_vuk(&mk, &vid);
|
||||
let boiled = vuk_from_mk(MediaKey(mk), Vid(vid));
|
||||
assert_eq!(boiled.0, inline, "vuk_from_mk must equal derive_vuk");
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` must equal the inline `decrypt_unit_key` path bit-for-bit
|
||||
/// and carry positional indices 0..n. Built by encrypting known plaintext
|
||||
/// title keys under the VUK (the same primitive the resolver inverts).
|
||||
#[test]
|
||||
fn uk_from_vuk_matches_inline_decrypt_unit_key() {
|
||||
let vuk = [0x5Au8; 16];
|
||||
let plain_keys = [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]];
|
||||
let enc: Vec<[u8; 16]> = plain_keys
|
||||
.iter()
|
||||
.map(|k| aes_ecb_encrypt(&vuk, k))
|
||||
.collect();
|
||||
|
||||
let boiled = uk_from_vuk(Vuk(vuk), &enc);
|
||||
assert_eq!(boiled.len(), enc.len());
|
||||
for (i, uk) in boiled.iter().enumerate() {
|
||||
assert_eq!(uk.idx, i as u32, "idx must be the positional index");
|
||||
// Matches the inline derive_uks closure: decrypt_unit_key(vuk, enc).
|
||||
assert_eq!(uk.key, decrypt_unit_key(&vuk, &enc[i]));
|
||||
// And recovers the original plaintext title key.
|
||||
assert_eq!(
|
||||
uk.key, plain_keys[i],
|
||||
"VUK roundtrip recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` on an empty slice yields no keys (no panic, no phantom idx).
|
||||
#[test]
|
||||
fn uk_from_vuk_empty_is_empty() {
|
||||
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` when the MKB has no
|
||||
/// processable Subset-Difference tables (empty MKB, or one with no
|
||||
/// mk_dv/cvalues/subdiff records) — never a wrong key, never a panic.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_on_unprocessable_mkb() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → no SD records to walk → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[]);
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// An MKB with no complete Subset-Difference tables (mk_dv / cvalues /
|
||||
// subdiff) cannot yield a Media Key, so the real walk also errors —
|
||||
// never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // stray data record only
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb);
|
||||
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
}
|
||||
|
||||
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
|
||||
/// header included) and append `body`. Mirrors the MKB record framing the
|
||||
/// parser expects; no crypto.
|
||||
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut rec = Vec::with_capacity(total);
|
||||
rec.push(rec_type);
|
||||
rec.push(((total >> 16) & 0xFF) as u8);
|
||||
rec.push(((total >> 8) & 0xFF) as u8);
|
||||
rec.push((total & 0xFF) as u8);
|
||||
rec.extend_from_slice(body);
|
||||
rec
|
||||
}
|
||||
|
||||
/// `mk_from_pk` resolves a planted Processing Key against a synthetic MKB and
|
||||
/// drives the FULL boil chain PK → MK → VUK → UK. The MKB is built with the
|
||||
/// same (pk, cv, mk_dv, uv) construction the production SD walk validates, so
|
||||
/// this proves a PK entry yields real Unit Keys — not just an `Ok`.
|
||||
#[test]
|
||||
fn mk_from_pk_drives_full_chain_to_uks() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
// cv = AES-E(pk, mk_raw), where mk_raw is mk with the last-4-bytes-uv XOR
|
||||
// pre-undone, so the validate step XORs uv back in and recovers mk.
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
// mk_dv = AES-E(mk, magic||pad): AES-D(mk, mk_dv) starts with the AACS
|
||||
// verify sentinel.
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// Synthetic MKB: type/version (0x10), verify record (0x86 = mk_dv),
|
||||
// one-entry SD index (0x04 = [u_mask_shift=0][uv]), one-entry cvalue
|
||||
// table (0x05 = cv).
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
||||
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
||||
|
||||
// PK → MK.
|
||||
let got_mk = mk_from_pk(std::slice::from_ref(&pk), &mkb).expect("planted PK resolves MK");
|
||||
assert_eq!(got_mk, MediaKey(mk), "mk_from_pk recovers the planted MK");
|
||||
|
||||
// MK → VUK → UK over an encrypted title key.
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = vuk_from_mk(got_mk, vid);
|
||||
let enc = aes_ecb_encrypt(&vuk.0, &plain_uk);
|
||||
let uks = uk_from_vuk(vuk, std::slice::from_ref(&enc));
|
||||
assert_eq!(uks.len(), 1);
|
||||
assert_eq!(uks[0].key, plain_uk, "PK chain recovers the title key");
|
||||
|
||||
// A corrupt PK resolves nothing.
|
||||
let mut bad = pk;
|
||||
bad[0] ^= 0xFF;
|
||||
assert!(matches!(
|
||||
mk_from_pk(std::slice::from_ref(&bad), &mkb),
|
||||
Err(crate::error::Error::AacsMkUnavailable)
|
||||
));
|
||||
}
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let stride = 48usize;
|
||||
let n = encs.len();
|
||||
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||
let mut inf = vec![0u8; total.max(20)];
|
||||
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||
for (i, k) in encs.iter().enumerate() {
|
||||
let o = uk_pos + 48 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(k);
|
||||
}
|
||||
inf
|
||||
}
|
||||
|
||||
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||
#[test]
|
||||
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||
let vuk = Vuk([0x33u8; 16]);
|
||||
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||
let inf = synth_inf(&encs);
|
||||
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives");
|
||||
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(
|
||||
cps,
|
||||
vec![1, 2, 3],
|
||||
"every CPS unit surfaced, numbered from the inf"
|
||||
);
|
||||
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk.0, enc),
|
||||
"key = VUK-decrypt of its slot"
|
||||
);
|
||||
}
|
||||
assert_eq!(r.vuk, Some(vuk));
|
||||
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||
}
|
||||
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey {
|
||||
idx: 2,
|
||||
key: [0x9u8; 16],
|
||||
};
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
}
|
||||
|
||||
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_requires_vid() {
|
||||
let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None);
|
||||
assert!(r.is_none(), "MK path returns None without a VID");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
|
||||
//! AACS content decryption — aligned-unit / bus decryption and TS verification.
|
||||
//! The low-level AES primitives it uses live in [`super::crypto`].
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
use super::crypto::{aes_cbc_decrypt, aes_ecb_encrypt};
|
||||
// Available at module scope for this module's test fixtures (they reference
|
||||
// `super::AACS_IV` when building CBC ciphertext directly); test-only.
|
||||
#[cfg(test)]
|
||||
use super::crypto::AACS_IV;
|
||||
|
||||
// ── AACS constants ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations.
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
/// Size of an AACS aligned unit (3 × 2048-byte sectors).
|
||||
/// Size of an AACS aligned unit (3 × 2048-byte sectors). [BD] §3.10.1.
|
||||
pub const ALIGNED_UNIT_LEN: usize = 6144;
|
||||
|
||||
/// An AACS aligned unit spans this many 2048-byte sectors (3).
|
||||
@@ -47,58 +49,6 @@ use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||
/// TS sync byte.
|
||||
const TS_SYNC: u8 = 0x47;
|
||||
|
||||
// ── AES primitives ──────────────────────────────────────────────────────────
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block.
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block.
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len() % 16 == 0,
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let num_blocks = data.len() / 16;
|
||||
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
|
||||
for i in (0..num_blocks).rev() {
|
||||
let offset = i * 16;
|
||||
let prev = if i == 0 {
|
||||
AACS_IV
|
||||
} else {
|
||||
let mut p = [0u8; 16];
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
cipher.decrypt_block(&mut block);
|
||||
for j in 0..16 {
|
||||
data[offset + j] = block[j] ^ prev[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content decryption ──────────────────────────────────────────────────────
|
||||
|
||||
/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
|
||||
@@ -127,14 +77,14 @@ pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy
|
||||
/// Permission Indicator (CPI) in the top 2 bits of byte 0. Byte 0 is the first
|
||||
/// Permission Indicator (CPI) in the top 2 bits of byte 0. [BD] §3.10.2. Byte 0 is the first
|
||||
/// byte of the first source packet's `TP_extra_header`, which AACS always leaves
|
||||
/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So
|
||||
/// this is readable WITHOUT a key:
|
||||
/// * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through.
|
||||
/// * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt.
|
||||
///
|
||||
/// This is exactly libaacs' test (`if (!(buf[0] & 0xc0)) return; /* clear */`)
|
||||
/// This is exactly the spec CPI test (`buf[0] & 0xc0 == 0` means clear)
|
||||
/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte
|
||||
/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct
|
||||
/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A
|
||||
@@ -288,7 +238,7 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check —
|
||||
/// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source
|
||||
/// the standards-correct all-32-sync verify: EVERY one of the 32 BD source
|
||||
/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first
|
||||
/// miss fails. This is the authoritative gate for the POST-READ verify stage,
|
||||
/// independent of (and not coupled to) `decrypt_unit`.
|
||||
@@ -385,7 +335,10 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
}
|
||||
|
||||
// Save original first 16 bytes (the plaintext seed / header) and derive the
|
||||
// per-unit decrypt key (identical to `decrypt_unit_checked`).
|
||||
// per-unit Block Key (identical to `decrypt_unit_checked`).
|
||||
// Block Key = AES-128E(Kcu, seed) ⊕ seed ([BD] §3.10.1 Fig 3-8, two-node
|
||||
// construction: encrypt the clear seed under the CPS Unit Key, then XOR the
|
||||
// seed back in — the trailing ⊕seed is load-bearing).
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
@@ -393,6 +346,7 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
for i in 0..16 {
|
||||
decrypt_key[i] = derived[i] ^ header[i];
|
||||
}
|
||||
// Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1.
|
||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
|
||||
// Verify content packets; zero out padding packets (their decrypted bytes are
|
||||
@@ -583,7 +537,9 @@ pub fn decrypt_unit_full(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
use super::*;
|
||||
use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly
|
||||
|
||||
#[test]
|
||||
fn test_aes_ecb_roundtrip() {
|
||||
@@ -1255,7 +1211,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unit_is_clean_ts_is_strict_all_32_syncs() {
|
||||
// Standards-correct gate (libaacs `_verify_ts`): EVERY one of the 32
|
||||
// Standards-correct gate (all-32 TS syncs): EVERY one of the 32
|
||||
// packet syncs is required. A fully-synced clear unit passes.
|
||||
let clear = clear_unit();
|
||||
assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean");
|
||||
@@ -0,0 +1,102 @@
|
||||
//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2.
|
||||
//!
|
||||
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
|
||||
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
|
||||
//! the AES-G3 Triple Generator, AES-CBC decrypt — and their fixed constants
|
||||
//! (`iv0`, `s0`). Used by every AACS generation; relocated here so the
|
||||
//! primitives live in one place instead of being scattered across the
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV. [C] §2.1.2 (`AES-128CBCD`).
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len() % 16 == 0,
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let num_blocks = data.len() / 16;
|
||||
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
|
||||
for i in (0..num_blocks).rev() {
|
||||
let offset = i * 16;
|
||||
let prev = if i == 0 {
|
||||
AACS_IV
|
||||
} else {
|
||||
let mut p = [0u8; 16];
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
cipher.decrypt_block(&mut block);
|
||||
for j in 0..16 {
|
||||
data[offset + j] = block[j] ^ prev[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::derive::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
let mut out = aes_ecb_decrypt(x1, x2);
|
||||
for i in 0..16 {
|
||||
out[i] ^= x2[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
|
||||
pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
|
||||
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
||||
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||
///
|
||||
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||
/// tree); a single definition keeps the two walks byte-identical.
|
||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
let mut out = aes_ecb_decrypt(key, &seed);
|
||||
for i in 0..16 {
|
||||
out[i] ^= seed[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
//! Media-key derivation: DK/PK → Media Key via the subset-difference tree.
|
||||
//! [C] §3.2.2–§3.2.5.
|
||||
|
||||
use super::crypto::*;
|
||||
use super::inf::*;
|
||||
use super::mkb::*;
|
||||
use super::types::*;
|
||||
|
||||
/// Derive Media Key from MKB data using processing keys.
|
||||
///
|
||||
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
|
||||
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent) — the
|
||||
/// direct PK × cvalue iteration. On a large AACS 2.x UHD MKB
|
||||
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
|
||||
/// label and walking the tree.
|
||||
///
|
||||
/// If you hold a **device-node label** at unknown tree depth (not a terminal
|
||||
/// PK), derive its Media Key through the device-key path
|
||||
/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree
|
||||
/// walk; the PK path never descends.
|
||||
///
|
||||
/// MKB format:
|
||||
/// Record type 0x10 = Type and Version Record (has MKB version)
|
||||
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
|
||||
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
|
||||
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
|
||||
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
|
||||
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
|
||||
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv)
|
||||
}
|
||||
|
||||
/// Core terminal-PK table scan over explicit record bodies. Each processing
|
||||
/// key is tried **directly** against every `(uv, cvalue)` pair — no tree
|
||||
/// descent. Reached in production via [`derive_media_key_from_pk`]; factored
|
||||
/// out so reproduction harnesses can drive it with explicit tables.
|
||||
pub(crate) fn try_pk_against_tables(
|
||||
processing_keys: &[[u8; 16]],
|
||||
uvs: &[u8],
|
||||
cvalues: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for pk in processing_keys {
|
||||
for i in 0..num_uvs {
|
||||
if (i + 1) * 16 > cvalues.len() {
|
||||
continue;
|
||||
}
|
||||
let record_start = i * 5;
|
||||
if record_start + 5 > uvs.len() {
|
||||
continue;
|
||||
}
|
||||
let uv = &uvs[record_start + 1..record_start + 5];
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) {
|
||||
return Some(mk);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate a processing key against a cvalue/UV pair.
|
||||
/// Returns the Media Key if valid.
|
||||
///
|
||||
/// Steps (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4):
|
||||
/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4
|
||||
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4
|
||||
/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4
|
||||
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4
|
||||
pub(crate) fn validate_processing_key(
|
||||
pk: &[u8; 16],
|
||||
cvalue: &[u8],
|
||||
uv: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
if cvalue.len() < 16 || uv.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Step 1: mk = AES-128D(pk, cvalue)
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalue[..16]);
|
||||
let mut mk = aes_ecb_decrypt(pk, &cv);
|
||||
|
||||
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
|
||||
for a in 0..4 {
|
||||
mk[12 + a] ^= uv[a];
|
||||
}
|
||||
|
||||
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
|
||||
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if dec_vd[..8] == VERIFY_MAGIC {
|
||||
return Some(mk);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variant`].
|
||||
pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||
v_mask <<= 1;
|
||||
}
|
||||
v_mask
|
||||
}
|
||||
|
||||
/// Derive processing key from device key using subset-difference tree traversal.
|
||||
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`].
|
||||
pub(super) fn calc_pk_from_dk(
|
||||
dk: &[u8; 16],
|
||||
uv: u32,
|
||||
v_mask: u32,
|
||||
dev_key_v_mask: u32,
|
||||
) -> [u8; 16] {
|
||||
// Descend from the device node to the record node, following the record's
|
||||
// `uv` bits. At each level only the child we descend INTO is needed (the
|
||||
// sibling is computed but never used), and the Processing Key is the
|
||||
// `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the
|
||||
// PK once at the end, instead of left/pk/right at every level. Identical
|
||||
// result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.)
|
||||
let mut node = *dk;
|
||||
let mut current_v_mask = dev_key_v_mask;
|
||||
|
||||
// The subset-difference tree is at most 32 levels deep (u32 mask), so the
|
||||
// walk must converge in <= 32 steps. The arithmetic `>> 1` sign-extends
|
||||
// current_v_mask, so a v_mask coarser than dev_key_v_mask (reachable from
|
||||
// a crafted/corrupt MKB) would otherwise saturate at 0xFFFF_FFFF and spin
|
||||
// forever — bound the loop to keep a bad disc from hanging the rip thread.
|
||||
let mut steps = 0u32;
|
||||
while current_v_mask != v_mask {
|
||||
if steps >= 32 {
|
||||
break;
|
||||
}
|
||||
steps += 1;
|
||||
// Find the highest unset bit in current_v_mask
|
||||
let mut bit_pos: i32 = -1;
|
||||
for i in (0..32).rev() {
|
||||
if (current_v_mask & (1u32 << i)) == 0 {
|
||||
bit_pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||
0 // left child
|
||||
} else {
|
||||
2 // right child
|
||||
};
|
||||
node = aesg3(&node, inc);
|
||||
|
||||
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||
}
|
||||
|
||||
aesg3(&node, 1)
|
||||
}
|
||||
|
||||
/// Derive Media Key from MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
|
||||
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
|
||||
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
|
||||
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
|
||||
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
|
||||
}
|
||||
|
||||
/// Derive both the Media Key and the intermediate Processing Key from an
|
||||
/// MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
|
||||
/// returns the Processing Key `Kp` derived at the matching subset-difference
|
||||
/// node — the value `calc_pk_from_dk` produces immediately before it
|
||||
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
|
||||
/// that walks a uv slot whose Processing Key validates against the MKB.
|
||||
pub fn derive_media_key_and_pk_from_dk(
|
||||
mkb: &[u8],
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<([u8; 16], [u8; 16])> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
|
||||
// Count UV entries
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for dk in device_keys {
|
||||
let device_number = dk.node as u32;
|
||||
|
||||
// Find applying subset-difference for this device
|
||||
for uvs_idx in 0..num_uvs {
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
||||
|
||||
// `num_uvs` was computed via `take_while(.. c[0] & 0xC0 == 0)`, so
|
||||
// every iterated slot already has its revoked-marker bits clear — no
|
||||
// inner `& 0xC0` re-check is needed (it would be unreachable).
|
||||
//
|
||||
// Shifts of 32..=63 (0x20..=0x3F) have those bits clear but would
|
||||
// panic in debug / wrap to a wrong mask in release. The MKB byte is
|
||||
// disc-controlled, so a crafted/corrupt MKB must not crash the ripper:
|
||||
// skip an out-of-range slot rather than `<<` it.
|
||||
if u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3.
|
||||
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
|
||||
let v_mask = calc_v_mask(uv);
|
||||
|
||||
// Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4.
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
&& ((device_number & v_mask) != (uv & v_mask))
|
||||
{
|
||||
// Found matching subset-difference — find the right device key.
|
||||
// dk.u_mask_shift is a u8 from keydb with no range check;
|
||||
// guard the shift the same way as the MKB byte above.
|
||||
if dk.u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
|
||||
|
||||
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||
// Derive processing key via tree traversal
|
||||
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||
|
||||
// Validate and derive media key
|
||||
if uvs_idx < cvalues.len() / 16 {
|
||||
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
|
||||
if let Some(mk) =
|
||||
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
|
||||
{
|
||||
return Some((mk, pk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an
|
||||
/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the
|
||||
/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that
|
||||
/// node empirically: for each MKB subset-difference record, it tries the device
|
||||
/// at the record's node AND at every ancestor v-position (the device may sit one
|
||||
/// or more levels ABOVE the record, descending via AES-G to reach it), deriving
|
||||
/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate,
|
||||
/// no full re-walk) and checking it validates against that record's cvalue.
|
||||
///
|
||||
/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for
|
||||
/// the key across all discs — and resolves a gate-passing `node` (a one-time
|
||||
/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and
|
||||
/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the
|
||||
/// key does not apply to this MKB.
|
||||
///
|
||||
/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference
|
||||
/// index, not the quartic cost of re-deriving per candidate.
|
||||
pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
let n_cv = cvalues.len() / 16;
|
||||
|
||||
// Hoisted ONCE for the whole scan: the Processing Key the device produces if
|
||||
// it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does
|
||||
// not depend on the record, so the zero-descent probe of every slot reuses
|
||||
// this single value instead of re-deriving it per slot.
|
||||
let pk_zero_descent = aesg3(key, 1);
|
||||
|
||||
// The slots are independent, so the scan parallelises — a UHD MKB has ~181k
|
||||
// slots (~26s single-threaded). `find_map_any` returns the first matching
|
||||
// node found by any thread and cancels the rest; a valid MKB has exactly one
|
||||
// matching subset-difference, so which thread finds it is immaterial.
|
||||
use rayon::prelude::*;
|
||||
let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| {
|
||||
let u_mask_shift = uvs[5 * i];
|
||||
if u_mask_shift >= 32 {
|
||||
return None;
|
||||
}
|
||||
let p_uv = &uvs[1 + 5 * i..];
|
||||
let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv_r == 0 {
|
||||
return None;
|
||||
}
|
||||
let v_mask = calc_v_mask(uv_r);
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
let uv_bytes = &uvs[1 + i * 5..];
|
||||
|
||||
// Zero descent (device sits at this slot's node): cheapest, most common.
|
||||
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_r, u_mask_shift));
|
||||
}
|
||||
// Descent: device is an ANCESTOR of the slot. Walk the depth bit up from
|
||||
// the slot's lowest set bit; each level descends to the slot's node.
|
||||
let p = uv_r.trailing_zeros();
|
||||
for k in (p + 1)..32 {
|
||||
let uv_d = if k + 1 >= 32 {
|
||||
1u32 << k
|
||||
} else {
|
||||
(uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k)
|
||||
};
|
||||
let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d));
|
||||
if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_d, u_mask_shift));
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask))
|
||||
}
|
||||
|
||||
/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at
|
||||
/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's
|
||||
/// subset-difference gate on `mkb`. The derived key is independent of the exact
|
||||
/// node (it only gates), so any gating node yields the same Media Key — a
|
||||
/// one-time ≤32-try search, run only once at the recovered position.
|
||||
pub(crate) fn resolve_dk_node(
|
||||
mkb: &[u8],
|
||||
key: &[u8; 16],
|
||||
uv: u32,
|
||||
u_mask_shift: u8,
|
||||
) -> Option<DeviceKey> {
|
||||
for b in 0..u_mask_shift {
|
||||
let dk = DeviceKey {
|
||||
key: *key,
|
||||
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
};
|
||||
if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() {
|
||||
return Some(dk);
|
||||
}
|
||||
}
|
||||
// Degenerate MKB (no gating bit): fall back to the node itself.
|
||||
Some(DeviceKey {
|
||||
key: *key,
|
||||
node: (uv & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public, side-effect-free accessors over the MKB record helpers, exposed so
|
||||
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
|
||||
/// exercise the exact same parser + verify primitives the production walk uses.
|
||||
/// These are thin wrappers — no new logic.
|
||||
#[doc(hidden)]
|
||||
pub mod probe {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
|
||||
/// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86).
|
||||
pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
super::mkb_find_mk_dv(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Subset-Difference Index record (type 0x04).
|
||||
pub fn mkb_subdiff(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_subdiff_records(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Media-Key-Data (cvalues) record. Selects record
|
||||
/// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference
|
||||
/// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05`
|
||||
/// is absent.
|
||||
pub fn mkb_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_cvalues(mkb)
|
||||
}
|
||||
|
||||
/// Body (header stripped) of the first MKB record of `rec_type`. Lets a
|
||||
/// harness pin an exact record type for cross-checking the production
|
||||
/// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes).
|
||||
pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option<Vec<u8>> {
|
||||
super::find_record_body(mkb, rec_type)
|
||||
}
|
||||
|
||||
/// AES-128-ECB single-block decrypt (the AACS verify primitive).
|
||||
pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(key, block)
|
||||
}
|
||||
|
||||
/// Does `km` satisfy the MKB's Verify-Media-Key relation?
|
||||
/// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`.
|
||||
pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool {
|
||||
match super::mkb_find_mk_dv(mkb) {
|
||||
Some(mk_dv) => {
|
||||
aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Volume key: Media Key + Volume ID → VUK → unit keys ──────────────────────
|
||||
|
||||
/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3
|
||||
/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D):
|
||||
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
|
||||
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
|
||||
for i in 0..16 {
|
||||
vuk[i] ^= volume_id[i];
|
||||
}
|
||||
vuk
|
||||
}
|
||||
|
||||
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). [PR] §3.5
|
||||
/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3.
|
||||
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(vuk, encrypted_uk)
|
||||
}
|
||||
|
||||
/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK,
|
||||
/// paired with its declared CPS-unit number. THE single VUK→unit-keys step:
|
||||
/// both classical/v21 resolvers and [`resolve_candidate`] call this, so the
|
||||
/// map cannot drift between the player and harvest paths.
|
||||
pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||
///
|
||||
/// Each variant carries the [`super::types`] newtype for that rung (a `Dk` is a
|
||||
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||
/// [`recover_dk_position`] first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KeyCandidate {
|
||||
Uk(UnitKey),
|
||||
Vuk(Vuk),
|
||||
Mk(MediaKey),
|
||||
Pk(ProcessingKey),
|
||||
Dk(DeviceKey),
|
||||
}
|
||||
|
||||
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
|
||||
///
|
||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
||||
/// its declared CPS-unit number); the caller runs
|
||||
/// [`super::content::unit_key_validates`] to find which one actually opens the
|
||||
/// disc. Rungs above the candidate are `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedChain {
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub vuk: Option<Vuk>,
|
||||
pub mk: Option<MediaKey>,
|
||||
pub pk: Option<ProcessingKey>,
|
||||
/// The positioned device key (for a `Dk` candidate).
|
||||
pub dk: Option<DeviceKey>,
|
||||
}
|
||||
|
||||
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||
///
|
||||
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
|
||||
/// `VUK → UKs`, or `UK → itself`. Composes the raw derivation primitives
|
||||
/// ([`derive_media_key_from_pk`], [`derive_media_key_and_pk_from_dk`],
|
||||
/// [`derive_vuk`], [`derive_unit_keys`]) and parses `Unit_Key_RO.inf` at the
|
||||
/// version the disc's MKB declares, so a multi-CPS disc yields all its unit
|
||||
/// keys from the one candidate.
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
||||
/// `unit_keys` against a real encrypted unit with
|
||||
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
|
||||
///
|
||||
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||
/// one, or an unparseable/empty `Unit_Key_RO.inf`.
|
||||
pub fn resolve_candidate(
|
||||
candidate: &KeyCandidate,
|
||||
mkb: &[u8],
|
||||
unit_key_ro: &[u8],
|
||||
vid: Option<Vid>,
|
||||
) -> Option<ResolvedChain> {
|
||||
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
|
||||
// Derive the stride version from the disc's own MKB, then defer to the shared
|
||||
// `derive_unit_keys` (the one place both resolvers and this path decrypt).
|
||||
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||
let version = mkb_type(mkb)
|
||||
.map(|t| t.generation())
|
||||
.unwrap_or(AacsVersion::V10);
|
||||
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
||||
if ukf.encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(derive_unit_keys(&ukf, &vuk.0))
|
||||
};
|
||||
|
||||
match candidate {
|
||||
KeyCandidate::Uk(uk) => Some(ResolvedChain {
|
||||
unit_keys: vec![(uk.idx, uk.key)],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Vuk(v) => Some(ResolvedChain {
|
||||
unit_keys: boil(*v)?,
|
||||
vuk: Some(*v),
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Mk(mk) => {
|
||||
let vuk = Vuk(derive_vuk(&mk.0, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(*mk),
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Pk(pk) => {
|
||||
let km = derive_media_key_from_pk(mkb, std::slice::from_ref(&pk.0))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(*pk),
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Dk(dk) => {
|
||||
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(ProcessingKey(pk)),
|
||||
dk: Some(dk.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_candidate_tests {
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let stride = 48usize;
|
||||
let n = encs.len();
|
||||
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||
let mut inf = vec![0u8; total.max(20)];
|
||||
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||
for (i, k) in encs.iter().enumerate() {
|
||||
let o = uk_pos + 48 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(k);
|
||||
}
|
||||
inf
|
||||
}
|
||||
|
||||
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||
#[test]
|
||||
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||
let vuk = Vuk([0x33u8; 16]);
|
||||
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||
let inf = synth_inf(&encs);
|
||||
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives");
|
||||
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(
|
||||
cps,
|
||||
vec![1, 2, 3],
|
||||
"every CPS unit surfaced, numbered from the inf"
|
||||
);
|
||||
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk.0, enc),
|
||||
"key = VUK-decrypt of its slot"
|
||||
);
|
||||
}
|
||||
assert_eq!(r.vuk, Some(vuk));
|
||||
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||
}
|
||||
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey {
|
||||
idx: 2,
|
||||
key: [0x9u8; 16],
|
||||
};
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
}
|
||||
|
||||
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_requires_vid() {
|
||||
let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None);
|
||||
assert!(r.is_none(), "MK path returns None without a VID");
|
||||
}
|
||||
|
||||
/// A planted Processing Key resolves against a synthetic MKB and drives the
|
||||
/// FULL chain PK → MK → VUK → UK — proving a PK candidate yields real keys.
|
||||
#[test]
|
||||
fn resolve_candidate_pk_drives_full_chain() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// 4-byte record header (type + BE24 total length) + body.
|
||||
let rec = |t: u8, body: &[u8]| -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut r = vec![
|
||||
t,
|
||||
((total >> 16) & 0xFF) as u8,
|
||||
((total >> 8) & 0xFF) as u8,
|
||||
(total & 0xFF) as u8,
|
||||
];
|
||||
r.extend_from_slice(body);
|
||||
r
|
||||
};
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&rec(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&rec(0x04, &sd));
|
||||
mkb.extend_from_slice(&rec(0x05, &cv));
|
||||
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = derive_vuk(&mk, &vid.0);
|
||||
let enc = aes_ecb_encrypt(&vuk, &plain_uk);
|
||||
let inf = synth_inf(std::slice::from_ref(&enc));
|
||||
|
||||
let r = resolve_candidate(&KeyCandidate::Pk(ProcessingKey(pk)), &mkb, &inf, Some(vid))
|
||||
.expect("planted PK resolves the full chain");
|
||||
assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK");
|
||||
assert_eq!(r.unit_keys.len(), 1);
|
||||
assert_eq!(
|
||||
r.unit_keys[0].1, plain_uk,
|
||||
"PK chain recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
pub fn collect_host_certs(
|
||||
opts: &crate::disc::ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::types::HostCert> = Vec::new();
|
||||
if let Some(c) = &opts.credentials {
|
||||
host_certs.extend(c.host_certs.iter().cloned());
|
||||
}
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
|
||||
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
|
||||
//! These turn raw disc files into the structures the key paths consume.
|
||||
|
||||
use super::mkb::*;
|
||||
|
||||
/// Parsed Unit_Key_RO.inf file.
|
||||
#[derive(Debug)]
|
||||
pub struct UnitKeyFile {
|
||||
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
|
||||
pub disc_hash: [u8; 20],
|
||||
/// Application type (1 = BD-ROM)
|
||||
pub app_type: u8,
|
||||
/// Number of BDMV directories
|
||||
pub num_bdmv_dir: u8,
|
||||
/// Whether SKB MKB is used
|
||||
pub use_skb_mkb: bool,
|
||||
/// AACS generation this file's stride matches
|
||||
pub version: AacsVersion,
|
||||
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||
pub title_cps_unit: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
|
||||
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
|
||||
use sha1::{Digest, Sha1};
|
||||
let hash = Sha1::digest(data);
|
||||
let mut out = [0u8; 20];
|
||||
out.copy_from_slice(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
|
||||
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
let mut s = String::with_capacity(42);
|
||||
s.push_str("0x");
|
||||
for b in hash {
|
||||
s.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||
///
|
||||
/// Format (from AACS spec):
|
||||
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||
/// [16] app_type (1 = BD-ROM)
|
||||
/// [17] num_bdmv_dir
|
||||
/// [18] bit 7: use_skb_mkb
|
||||
/// [20..22] BE16: first_play CPS unit
|
||||
/// [22..24] BE16: top_menu CPS unit
|
||||
/// [24..26] BE16: num_titles
|
||||
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
|
||||
///
|
||||
/// Key storage at uk_pos:
|
||||
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||
/// AACS 1.0: 48-byte stride
|
||||
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = disc_hash(data);
|
||||
|
||||
// Header
|
||||
let app_type = data[16];
|
||||
let num_bdmv_dir = data[17];
|
||||
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
|
||||
|
||||
// Key storage offset
|
||||
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if uk_pos + 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Number of unit keys
|
||||
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
|
||||
if num_uk == 0 {
|
||||
return Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys: Vec::new(),
|
||||
title_cps_unit: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stride between keys
|
||||
let stride = version.unit_key_stride();
|
||||
|
||||
// Validate size
|
||||
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||
if keys_start + 16 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract encrypted keys
|
||||
let mut encrypted_keys = Vec::with_capacity(num_uk);
|
||||
let mut pos = keys_start;
|
||||
for i in 0..num_uk {
|
||||
if pos + 16 > data.len() {
|
||||
break;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos..pos + 16]);
|
||||
encrypted_keys.push(((i + 1) as u32, key));
|
||||
pos += stride;
|
||||
}
|
||||
|
||||
// The loop above `break`s if the buffer runs out mid-key. A short list
|
||||
// means the .inf is malformed/truncated — reject it rather than silently
|
||||
// accepting fewer keys than the header declared, which would later map
|
||||
// title CPS units to nonexistent keys.
|
||||
if encrypted_keys.len() != num_uk {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||||
// ready-to-use key index rather than a raw 1-based number.
|
||||
let to_key_idx = |cps: u16| -> u16 {
|
||||
if cps >= 1 && cps as usize <= num_uk {
|
||||
cps - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut title_cps_unit = Vec::new();
|
||||
if data.len() >= 26 {
|
||||
let first_play = u16::from_be_bytes([data[20], data[21]]);
|
||||
let top_menu = u16::from_be_bytes([data[22], data[23]]);
|
||||
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
|
||||
|
||||
title_cps_unit.push(to_key_idx(first_play));
|
||||
title_cps_unit.push(to_key_idx(top_menu));
|
||||
|
||||
for i in 0..num_titles {
|
||||
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
|
||||
if off + 2 <= data.len() {
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys,
|
||||
title_cps_unit,
|
||||
})
|
||||
}
|
||||
|
||||
/// MKB disc structure format code.
|
||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||
|
||||
/// MKB pack buffer size.
|
||||
const MKB_PACK_SIZE: usize = 32772;
|
||||
|
||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||
/// Returns the concatenated MKB data from all packs.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut dyn crate::scsi::ScsiTransport,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
let cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 32772];
|
||||
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||
|
||||
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if data_len < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let len = data_len - 2;
|
||||
let num_packs = buf[3] as usize;
|
||||
|
||||
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
|
||||
if len > 0 && len <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len]);
|
||||
}
|
||||
|
||||
// Read remaining packs
|
||||
for pack in 1..num_packs {
|
||||
let mut cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
// Pack number goes in address field
|
||||
cdb[2] = ((pack >> 24) & 0xFF) as u8;
|
||||
cdb[3] = ((pack >> 16) & 0xFF) as u8;
|
||||
cdb[4] = ((pack >> 8) & 0xFF) as u8;
|
||||
cdb[5] = (pack & 0xFF) as u8;
|
||||
|
||||
let mut buf = vec![0u8; 32772];
|
||||
if session
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||
.is_ok()
|
||||
{
|
||||
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if len > 2 && len - 2 <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len - 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mkb)
|
||||
}
|
||||
|
||||
/// AACS Content Certificate — identifies disc AACS version and features.
|
||||
#[derive(Debug)]
|
||||
pub struct ContentCert {
|
||||
/// Bus encryption enabled flag
|
||||
pub bus_encryption: bool,
|
||||
/// Content Certificate ID (6 bytes)
|
||||
pub cc_id: [u8; 6],
|
||||
/// AACS generation indicated by the certificate type byte.
|
||||
///
|
||||
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
|
||||
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
|
||||
/// V20 from V21 — Variant detection happens after the MKB walk.
|
||||
pub version: AacsVersion,
|
||||
}
|
||||
|
||||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
|
||||
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
|
||||
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
|
||||
let bus_encryption = (data[1] >> 7) & 1 == 1;
|
||||
let mut cc_id = [0u8; 6];
|
||||
cc_id.copy_from_slice(&data[14..20]);
|
||||
|
||||
Some(ContentCert {
|
||||
bus_encryption,
|
||||
cc_id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
//! AACS Media Key Block — [C] Chapter 3.
|
||||
//!
|
||||
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
||||
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
||||
//! utilities (content length, trimming, version). Consolidated here so the one
|
||||
//! place that understands MKB bytes is `mkb`. Some duplicate record finders
|
||||
//! still live side by side pending a follow-up that collapses them.
|
||||
|
||||
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
|
||||
// The ONE canonical set. Every record-type comparison in the `aacs` module
|
||||
// references these, so a type byte is never a bare literal scattered across
|
||||
// files (the `0x0c` variant-data record in particular used to appear in several
|
||||
// hand-rolled forms).
|
||||
|
||||
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
|
||||
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
|
||||
/// Subset-Difference index — the per-slot `(u_mask_shift, uv)` table.
|
||||
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
|
||||
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
|
||||
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
|
||||
/// Explicit Subset-Difference — the smaller cvalue table some MKBs use.
|
||||
pub(crate) const REC_EXPLICIT_SUBSET_DIFF: u8 = 0x07;
|
||||
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
|
||||
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
|
||||
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
|
||||
/// Variant Data + Nonce (AACS 2.1) — the `VARIANTS[uv]` table (leading bytes)
|
||||
/// with the 16-byte `Kvn` Nonce at the tail.
|
||||
pub(crate) const REC_VARIANT_DATA_AND_NONCE: u8 = 0x2d;
|
||||
/// Variant Key Data table (AACS 2.1) — 65,535×16, indexed by the resolved VKD index.
|
||||
pub(crate) const REC_VKD_TABLE: u8 = 0x2f;
|
||||
/// Verify-Media-Key — AACS 1.0.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V1: u8 = 0x81;
|
||||
/// Verify-Media-Key — AACS 2.x.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V2: u8 = 0x86;
|
||||
|
||||
/// A single MKB record produced by [`walk_mkb`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MkbRecord {
|
||||
/// Byte offset of the record within the MKB.
|
||||
pub offset: usize,
|
||||
/// Record type byte.
|
||||
pub rec_type: u8,
|
||||
/// Record length in bytes (includes the 4-byte header).
|
||||
pub rec_len: usize,
|
||||
/// Record body (the bytes after the 4-byte header).
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Walk an MKB into a flat list of records.
|
||||
///
|
||||
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
|
||||
/// INCLUDING the 4-byte header, followed by payload. The walker stops
|
||||
/// at the first `(type=0, len=0)` end marker or at end of buffer.
|
||||
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
mkb_records(mkb)
|
||||
.map(|(offset, rec_type, rec_len)| MkbRecord {
|
||||
offset,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body: mkb[offset + 4..offset + rec_len].to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
|
||||
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
|
||||
/// then the body — stopping at the `00 000000` end marker or a
|
||||
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||
/// MKB record walk in `aacs::resolve`/`aacs::derive` are built on this, so the framing rules — and
|
||||
/// any future fix to them — live in exactly one place (they had drifted across
|
||||
/// six hand-rolled copies).
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 4 > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = ((mkb[pos + 1] as usize) << 16)
|
||||
| ((mkb[pos + 2] as usize) << 8)
|
||||
| (mkb[pos + 3] as usize);
|
||||
if rec_type == 0 && rec_len == 0 {
|
||||
return None;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let here = pos;
|
||||
pos += rec_len;
|
||||
Some((here, rec_type, rec_len))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// AACS protection generation a disc carries.
|
||||
///
|
||||
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
|
||||
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
|
||||
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
|
||||
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
|
||||
/// Key Data table).
|
||||
///
|
||||
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
|
||||
/// bytes for V20 / V21.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AacsVersion {
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
V10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
|
||||
V20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
|
||||
V21,
|
||||
}
|
||||
|
||||
/// AACS major version as the small integer threaded through the scan / key
|
||||
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
|
||||
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
|
||||
/// the V10-vs-else stride choice it drives — lives in exactly one place.
|
||||
pub const AACS_MAJOR_BD: u8 = 1;
|
||||
|
||||
pub const AACS_MAJOR_UHD: u8 = 2;
|
||||
|
||||
impl AacsVersion {
|
||||
/// Stride (in bytes) between successive encrypted unit keys in
|
||||
/// `Unit_Key_RO.inf`.
|
||||
pub(crate) fn unit_key_stride(self) -> usize {
|
||||
match self {
|
||||
AacsVersion::V10 => 48,
|
||||
AacsVersion::V20 | AacsVersion::V21 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
|
||||
pub fn major(self) -> u8 {
|
||||
match self {
|
||||
AacsVersion::V10 => AACS_MAJOR_BD,
|
||||
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version a bare major integer selects for stride purposes: only the
|
||||
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
|
||||
pub fn from_major(major: u8) -> Self {
|
||||
if major == AACS_MAJOR_BD {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
let found = mkb_records(mkb).find(|&(_, rt, len)| {
|
||||
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
|
||||
});
|
||||
match found {
|
||||
Some((o, rec_type, rec_len)) => {
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[o + 4..o + 20]);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
rec_type,
|
||||
pos = o,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
Some(dv)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Subset-Difference records (type 0x04) in MKB. [C] §3.2.5.1.5.
|
||||
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
find_record_body(mkb, 0x04)
|
||||
}
|
||||
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB. [C] §3.2.4 / §3.2.5.1.7.
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
/// the giant `0x04` index), while record `0x07` (Explicit
|
||||
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
|
||||
/// is NOT the cvalue table. An earlier version of this function preferred
|
||||
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
|
||||
/// prevented the DK→walk path from ever finding the matching uv. The
|
||||
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
|
||||
/// malformed/legacy MKBs that somehow lack a `0x05` record.
|
||||
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(body) = find_record_body(mkb, 0x05) {
|
||||
return Some(body);
|
||||
}
|
||||
find_record_body(mkb, 0x07)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the payload (header stripped) of the first
|
||||
/// record matching `rec_type`. Returns `None` if no such record exists or
|
||||
/// the record is empty.
|
||||
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
|
||||
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
// End of the last framed record = where the fixed-region zero padding begins.
|
||||
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
|
||||
// zeros, so this matches the prior "stop at the first padding byte".)
|
||||
mkb_records(mkb)
|
||||
.last()
|
||||
.map(|(o, _, len)| o + len)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Trim an MKB's trailing fixed-region padding to its real content length —
|
||||
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
|
||||
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
|
||||
/// hand downstream consumers (and the online key service) an EMPTY MKB that can
|
||||
/// never resolve. So a 0 (or a length that isn't strictly inside the buffer)
|
||||
/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and
|
||||
/// `truncate`-d unconditionally, zeroing unrecognised MKBs.
|
||||
pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
|
||||
let n = mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
mkb
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Layout: 4-byte record header at `pos` (type + BE24 length), then the
|
||||
/// record body starts at `pos + 4`. The body holds the BE u32 Type field at
|
||||
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
|
||||
/// (`pos + 8`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
|
||||
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
||||
}
|
||||
|
||||
/// `0x00031003` — recordable media MKB (Class I & II compute Km directly).
|
||||
pub const MKB_TYPE_3_RECORDABLE: u32 = 0x0003_1003;
|
||||
|
||||
/// `0x00041003` — AACS 1.0 pre-recorded content MKB (KCD-based). Standard BD.
|
||||
pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
||||
|
||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
||||
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
|
||||
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||
|
||||
/// The AACS MKB Type field, decoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MkbType {
|
||||
/// Type 3 — recordable media.
|
||||
Recordable,
|
||||
/// Type 4 — AACS 1.0 pre-recorded content (KCD). Standard Blu-ray.
|
||||
Prerecorded,
|
||||
/// Type 10 — Class II / Unified (SKB).
|
||||
ClassII,
|
||||
/// AACS 2.0 Category C — UHD content.
|
||||
CategoryC20,
|
||||
/// AACS 2.1 Category C — UHD content.
|
||||
CategoryC21,
|
||||
/// Unrecognized MKBType value (raw field preserved).
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
impl MkbType {
|
||||
pub(crate) fn from_raw(raw: u32) -> Self {
|
||||
match raw {
|
||||
MKB_TYPE_3_RECORDABLE => MkbType::Recordable,
|
||||
MKB_TYPE_4_PRERECORDED => MkbType::Prerecorded,
|
||||
MKB_TYPE_10_CLASS_II => MkbType::ClassII,
|
||||
MKB_20_CATEGORY_C => MkbType::CategoryC20,
|
||||
MKB_21_CATEGORY_C => MkbType::CategoryC21,
|
||||
other => MkbType::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// AACS generation this MKB belongs to (Category C → 2.0/2.1, else 1.0).
|
||||
pub fn generation(self) -> AacsVersion {
|
||||
match self {
|
||||
MkbType::CategoryC21 => AacsVersion::V21,
|
||||
MkbType::CategoryC20 => AacsVersion::V20,
|
||||
_ => AacsVersion::V10,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for UHD (AACS 2.x Category C); `false` for Blu-ray (AACS 1.x).
|
||||
pub fn is_uhd(self) -> bool {
|
||||
matches!(self, MkbType::CategoryC20 | MkbType::CategoryC21)
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
|
||||
/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2.
|
||||
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
|
||||
}
|
||||
|
||||
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
|
||||
pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
|
||||
mkb_type_raw(mkb).map(MkbType::from_raw)
|
||||
}
|
||||
|
||||
/// `Some(true)` if this MKB is a UHD (AACS 2.x Category C) block, `Some(false)`
|
||||
/// for Blu-ray (AACS 1.x), `None` if the Type record is absent.
|
||||
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||
mkb_type(mkb).map(MkbType::is_uhd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
|
||||
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let len = 4 + body.len();
|
||||
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
|
||||
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
|
||||
let mut body = mkb_type.to_be_bytes().to_vec();
|
||||
body.extend_from_slice(&version.to_be_bytes());
|
||||
rec(REC_TYPE_AND_VERSION, &body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_frames_records_and_stops_at_end_marker() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
|
||||
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0xAA; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_stops_on_malformed_or_out_of_bounds_length() {
|
||||
// A record whose declared length runs past the buffer end must terminate
|
||||
// the walk rather than panic or read OOB.
|
||||
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
|
||||
assert!(
|
||||
walk_mkb(&mkb).is_empty(),
|
||||
"over-long record yields no records"
|
||||
);
|
||||
// A sub-4 length (shorter than the header itself) is also rejected.
|
||||
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
|
||||
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
|
||||
// A truncated header (< 4 bytes) yields nothing.
|
||||
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_type_and_version_decode_from_the_type_record() {
|
||||
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
|
||||
assert_eq!(mkb_version(&mkb), Some(100));
|
||||
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
|
||||
|
||||
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
|
||||
assert_eq!(
|
||||
mkb_is_uhd(&bd),
|
||||
Some(false),
|
||||
"AACS 1.0 prerecorded is not UHD"
|
||||
);
|
||||
// No Type record → None (not a panic, not a fabricated value).
|
||||
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
|
||||
assert_eq!(mkb_type_raw(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_mkb_keeps_only_the_framed_records() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
|
||||
let content_len = mkb.len(); // the single framed record, no end marker
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
|
||||
let trimmed = trim_mkb(mkb);
|
||||
assert_eq!(
|
||||
trimmed.len(),
|
||||
content_len,
|
||||
"trim keeps the framed records, dropping the end marker and padding"
|
||||
);
|
||||
}
|
||||
}
|
||||
+51
-59
@@ -13,15 +13,31 @@
|
||||
//!
|
||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||
//!
|
||||
//! ## Spec provenance
|
||||
//!
|
||||
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
|
||||
//! specification (Final Rev 0.953), so each primitive links to the section it
|
||||
//! implements:
|
||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod keys;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod provider;
|
||||
pub mod resolve;
|
||||
pub mod segment;
|
||||
pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variants;
|
||||
pub mod variant;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
@@ -35,53 +51,29 @@ 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";
|
||||
|
||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||
pub use boil::{
|
||||
KeyCandidate, MediaKey, ProcessingKey, ResolvedChain, UnitKey, Vid, Vuk, mk_from_dk,
|
||||
mk_from_pk, resolve_candidate, uk_from_vuk, vuk_from_mk,
|
||||
};
|
||||
// Structured, English-free resolution trace.
|
||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
|
||||
aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
|
||||
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
|
||||
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps,
|
||||
unit_is_clean_ts, unit_key_validates,
|
||||
};
|
||||
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
|
||||
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||
// don't commit semver stability to test primitives.
|
||||
#[doc(hidden)]
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C,
|
||||
MKB_TYPE_3_RECORDABLE, MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext,
|
||||
ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
|
||||
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
|
||||
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use types::{DeviceKey, DiscEntry, HostCert};
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
||||
};
|
||||
// The module structure IS the public API — consumers import from the owning
|
||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
||||
// The `derive::probe` reproduction harness stays reachable via its module path.
|
||||
//
|
||||
// A small set of flat re-exports is kept for the typed key primitives and the
|
||||
// content-decrypt entry points that downstream key-source crates import through
|
||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||||
// lets those crates track the module refactor without a lockstep re-pin.
|
||||
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
|
||||
pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Re-export surface guards. The module's public API is the set of
|
||||
//! `pub use` items above. A regression that drops or renames an export
|
||||
//! (the class of bug that shipped in 0.31.0 by silently changing a
|
||||
//! surface) breaks compilation of these references, so they act as a
|
||||
//! compile-time contract for the crate's AACS surface.
|
||||
//! Surface guards. The public API is the module tree itself (no facade).
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::*;
|
||||
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
@@ -103,21 +95,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_correction_data_placeholder_is_all_zero() {
|
||||
// The variant chain refuses to run against this all-zero placeholder
|
||||
// KCD; the public constant must therefore be exactly 16 zero bytes.
|
||||
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_through_the_facade() {
|
||||
// Touch a representative function from each re-export group so a
|
||||
// dropped/renamed export fails to compile. These are smoke calls, not
|
||||
// behavioural assertions (behaviour is covered in each module).
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = mk_from_pk(&[[0u8; 16]], &[]);
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey {
|
||||
idx: 0,
|
||||
key: [0u8; 16],
|
||||
}),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ use super::types::{DeviceKey, DiscEntry, HostCert};
|
||||
/// Source of AACS key material.
|
||||
///
|
||||
/// Implementors return raw material only — the resolver in
|
||||
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
|
||||
/// `aacs::resolve` and `aacs::derive` own the crypto (DK→PK walking, PK validation,
|
||||
/// MK→VUK→TK derivation). See module docs for method semantics.
|
||||
pub trait KeyProvider: Send + Sync {
|
||||
/// Device keys (top-of-tree, walked by the resolver).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
||||
//! forensic-watermark mechanism. The same frames are authored as several
|
||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
||||
//! right device keys can decrypt exactly one variant per segment, and which one
|
||||
//! silently identifies the player (traitor tracing). Decrypting a variant
|
||||
//! segment with the Unit Key yields garbage — broken HEVC reference frames
|
||||
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//!
|
||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
||||
//! them with segment keys and select one coherent variant instead of muxing
|
||||
//! unit-key garbage.
|
||||
//!
|
||||
//! Format (validated against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||
//! record[count] (16 bytes each):
|
||||
//! u32 marker (= 0x01000000) | u16 segment_number | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! Source-packet numbers are the 192-byte BDAV packet index: byte offset =
|
||||
//! `spn * 192`. Observed on one disc: 792 segments, each ~2560 packets
|
||||
//! (~480 KB), spread across the entire 54 GB feature (one roughly every 67 MB).
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
|
||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
|
||||
///
|
||||
/// `true` (today): the forensic variant segments are skipped as expected loss
|
||||
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
|
||||
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
|
||||
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
||||
/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a
|
||||
/// forensic-holed rip is refused rather than produced. No segment-key source
|
||||
/// exists yet, so `true` is the only value under which a 2.1 disc rips at all;
|
||||
/// flip to `false` once segment keys can be sourced and a partial rip should be
|
||||
/// refused. Hardcoded on purpose — not a user setting.
|
||||
///
|
||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||
pub const BYPASS_FMTS_KEY: bool = true;
|
||||
|
||||
/// One forensic variant segment: the inclusive source-packet range it occupies
|
||||
/// in the FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// 1-based segment number (table order).
|
||||
pub number: u16,
|
||||
/// First source packet of the segment (inclusive).
|
||||
pub start_spn: u32,
|
||||
/// Last source packet of the segment (inclusive).
|
||||
pub end_spn: u32,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
/// Source-packet count in this (inclusive) segment.
|
||||
pub fn packet_count(&self) -> u32 {
|
||||
self.end_spn
|
||||
.saturating_sub(self.start_spn)
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
/// Byte offset of the segment start within the clip (`start_spn * 192`).
|
||||
pub fn start_byte(&self) -> u64 {
|
||||
self.start_spn as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// Byte length of the segment (`packet_count * 192`).
|
||||
pub fn byte_len(&self) -> u64 {
|
||||
self.packet_count() as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// True when source packet `spn` falls inside this segment.
|
||||
pub fn contains_spn(&self, spn: u32) -> bool {
|
||||
spn >= self.start_spn && spn <= self.end_spn
|
||||
}
|
||||
|
||||
/// True when the inclusive source-packet span `[first, last]` overlaps this
|
||||
/// segment. Used to decide whether an aligned unit (which spans several
|
||||
/// packets) touches the segment at all, not just whether one packet does.
|
||||
pub fn overlaps_spn(&self, first: u32, last: u32) -> bool {
|
||||
first <= self.end_spn && last >= self.start_spn
|
||||
}
|
||||
}
|
||||
|
||||
/// Source packets spanned by one AACS aligned unit: `6144 / 192 = 32`.
|
||||
pub const PACKETS_PER_UNIT: u32 =
|
||||
(crate::aacs::content::ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32;
|
||||
|
||||
/// Byte offset within the clip of a clip-relative 2048-byte sector `lba`. The
|
||||
/// FMTS decode reads the clip file directly, so `lba` 0 is the clip's first
|
||||
/// byte and this offset lines up with the source-packet grid the segment map
|
||||
/// uses.
|
||||
pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
lba as u64 * 2048
|
||||
}
|
||||
|
||||
/// The forensic segment an AACS aligned unit belongs to, if any, given the
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||
/// overlaps a forensic segment must be opened with that segment's **variant
|
||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||
/// caller falls back to the normal unit-key fetch.
|
||||
///
|
||||
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||
/// unit that only partly overlaps a segment edge is still classified as
|
||||
/// variant; on the observed disc segments are unit-aligned, but the span test
|
||||
/// does not rely on that.
|
||||
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
||||
segments.iter().find(|s| s.overlaps_spn(first, last))
|
||||
}
|
||||
|
||||
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
|
||||
/// order. Returns `None` when the header is malformed, the record size is not
|
||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||
/// yielding bogus ranges.
|
||||
pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
if tbl.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let count = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
|
||||
let record_size = u16::from_be_bytes([tbl[6], tbl[7]]) as usize;
|
||||
if record_size != SEGMENT_RECORD_LEN {
|
||||
return None;
|
||||
}
|
||||
if 8usize.checked_add(count.checked_mul(record_size)?)? > tbl.len() {
|
||||
return None;
|
||||
}
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// o+4..o+8 = segment_number (u16) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let number = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||
segments.push(Segment {
|
||||
number,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
}
|
||||
Some(segments)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(segment_number, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
|
||||
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes()); // flag
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on a retail 2.1 disc: 2560-packet segments.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
(3, 1051840, 1054399),
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].number, 1);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
assert_eq!(segs[0].byte_len(), 2560 * 192);
|
||||
assert_eq!(segs[0].start_byte(), 343680 * 192);
|
||||
assert!(segs[0].contains_spn(345000));
|
||||
assert!(!segs[0].contains_spn(343679));
|
||||
assert!(!segs[0].contains_spn(346240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_record_size() {
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_overrun() {
|
||||
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_is_empty_not_none() {
|
||||
let tbl = build_tbl(&[]);
|
||||
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_per_unit_is_thirty_two() {
|
||||
// 6144-byte aligned unit / 192-byte source packet.
|
||||
assert_eq!(PACKETS_PER_UNIT, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_variant() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.number, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_every_segment_is_unit_key_miss() {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_variant() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the variant key,
|
||||
// because part of its ciphertext is variant-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.number, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(variant_segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_variant() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_maps_to_the_packet_grid() {
|
||||
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
|
||||
// the second aligned unit, which starts at packet 32.
|
||||
let off = lba_byte_offset(3);
|
||||
assert_eq!(off / SOURCE_PACKET_LEN, 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`.
|
||||
//!
|
||||
//! One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key
|
||||
//! store for the forensic variant segments mapped by [`super::segment`]. A
|
||||
//! device does not read a segment key directly. It derives a **16-bit variant
|
||||
//! selector** from the Media Key Variant chain (see [`super::variant`]) and uses
|
||||
//! that selector to index this table, which is how the device's position in the
|
||||
//! key tree decides which variant it can decrypt (the traitor-tracing link).
|
||||
//!
|
||||
//! Container format (confirmed against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 tag | u16 index_space | u16 record_size
|
||||
//! record[index_space] (record_size bytes each)
|
||||
//! ```
|
||||
//! On the reference disc: `index_space` = `0xffff` (the full 16-bit selector
|
||||
//! space, 65536 records), `record_size` = `0x0218` = 536. Total
|
||||
//! `8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each
|
||||
//! record begins with an 8-byte sub-header, then 528 bytes of encrypted key
|
||||
//! material.
|
||||
//!
|
||||
//! **Not yet reversed:** the internal layout of a record's 528-byte payload, and
|
||||
//! how it maps onto the segments of [`super::segment`]. One numeric coincidence
|
||||
//! worth noting for whoever cracks it: the reference disc has 792 segments and
|
||||
//! `528 = 33 * 16`, with `792 = 24 * 33`, so `33` appears on both sides. Until
|
||||
//! the mapping and the key derivation are pinned, this module exposes only the
|
||||
//! confirmed container: locate the record for a given 16-bit selector.
|
||||
|
||||
/// Bytes of the fixed file header.
|
||||
pub const HEADER_LEN: usize = 8;
|
||||
|
||||
/// The on-disc segment-key table container. Borrows the file bytes; a record is
|
||||
/// looked up by the 16-bit variant selector.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SegmentKeyTable<'a> {
|
||||
data: &'a [u8],
|
||||
/// Number of records (the selector index space, e.g. 65536).
|
||||
count: usize,
|
||||
/// Bytes per record (e.g. 536).
|
||||
record_size: usize,
|
||||
}
|
||||
|
||||
impl<'a> SegmentKeyTable<'a> {
|
||||
/// Parse and validate the container header against the buffer length.
|
||||
///
|
||||
/// Returns `None` when the buffer is too small, or the declared
|
||||
/// `count * record_size` (plus header) does not match the buffer, so a
|
||||
/// truncated or foreign table degrades to "no segment keys" rather than
|
||||
/// handing back bogus records. `index_space` of `0xffff` is read as the full
|
||||
/// 65536-entry space (a device selector is a full 16-bit value).
|
||||
pub fn parse(data: &'a [u8]) -> Option<Self> {
|
||||
if data.len() < HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let index_space = u16::from_be_bytes([data[4], data[5]]);
|
||||
let record_size = u16::from_be_bytes([data[6], data[7]]) as usize;
|
||||
// 0xffff means the full 16-bit selector space (65536 records).
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
if record_size == 0 {
|
||||
return None;
|
||||
}
|
||||
let body = count.checked_mul(record_size)?;
|
||||
if HEADER_LEN.checked_add(body)? != data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
data,
|
||||
count,
|
||||
record_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of records (the selector index space).
|
||||
pub fn record_count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Bytes per record.
|
||||
pub fn record_size(&self) -> usize {
|
||||
self.record_size
|
||||
}
|
||||
|
||||
/// The raw record for a 16-bit variant `selector`, including its 8-byte
|
||||
/// sub-header. `None` if the selector is past the table (only possible when
|
||||
/// `index_space` was not the full 16-bit space).
|
||||
pub fn record(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
let idx = selector as usize;
|
||||
if idx >= self.count {
|
||||
return None;
|
||||
}
|
||||
let start = HEADER_LEN + idx * self.record_size;
|
||||
self.data.get(start..start + self.record_size)
|
||||
}
|
||||
|
||||
/// The encrypted key payload for a selector: the record with its 8-byte
|
||||
/// sub-header stripped. The internal layout of these bytes is not yet
|
||||
/// reversed (see module docs).
|
||||
pub fn record_payload(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
self.record(selector).and_then(|r| r.get(HEADER_LEN..))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a container with `record_size` and the given `index_space`, filling
|
||||
/// each record with a distinguishable byte so lookups can be checked.
|
||||
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
|
||||
v.extend_from_slice(&index_space.to_be_bytes());
|
||||
v.extend_from_slice(&record_size.to_be_bytes());
|
||||
for i in 0..count {
|
||||
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
|
||||
// sub-header, as seen on disc
|
||||
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
v.extend_from_slice(&rec);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_retail_container_geometry() {
|
||||
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
|
||||
let data = build(0xffff, 536);
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
35_127_304,
|
||||
"matches the retail file size exactly"
|
||||
);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 65_536);
|
||||
assert_eq!(t.record_size(), 536);
|
||||
let rec = t.record(0x1234).expect("record");
|
||||
assert_eq!(rec.len(), 536);
|
||||
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_index_space_bounds_lookups() {
|
||||
let data = build(4, 32);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 4);
|
||||
assert!(t.record(3).is_some());
|
||||
assert!(t.record(4).is_none(), "selector past the table is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_size_mismatch_and_truncation() {
|
||||
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
|
||||
let mut data = build(4, 32);
|
||||
data.truncate(data.len() - 1); // body no longer matches header
|
||||
assert!(SegmentKeyTable::parse(&data).is_none());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,38 @@ pub struct HostCert {
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to the VUK→UK step (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association is a higher-level concern owned by
|
||||
/// [`super::inf::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscEntry {
|
||||
|
||||
@@ -3,34 +3,20 @@
|
||||
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
|
||||
//! the classical subset-difference walk. The classical walk yields a
|
||||
//! Media Key Precursor (Kmp) rather than the final Media Key; the
|
||||
//! Precursor combines with disc-supplied Variant Key Data (VKD) and an
|
||||
//! integrator-supplied Key Correction Data (KCD) constant to produce
|
||||
//! the Media Key.
|
||||
//! Precursor combines with disc-supplied Variant Key Data (VKD) and a
|
||||
//! per-licensee Key Correction Data (KCD) constant to produce the Media
|
||||
//! Key.
|
||||
//!
|
||||
//! This module is wiring only — `resolve_keys` is not aware of it. The
|
||||
//! entry point is [`derive_media_key_variant`]. The Variant scheme is
|
||||
//! detected via the real AACS 2.1 MKB records `0x2d` (Encrypted Media
|
||||
//! Key Variant Data / C), `0x2f` (Variant Key Data table, 65,535×16),
|
||||
//! and `0x0c` (variant cvalues, one per `0x04` subset-difference slot).
|
||||
//! When a disc carries none, callers fall back to the classical
|
||||
//! single-stage derivation in [`super::keys`]. (The earlier `0x82`/`0x83`
|
||||
//! record types were a speculative guess that never appeared in any real
|
||||
//! MKB; they were replaced with the real records once a live variant MKB
|
||||
//! was obtained.)
|
||||
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
|
||||
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
|
||||
//! separate [`walk_processing_key`] step. The Variant scheme is detected
|
||||
//! via the AACS 2.1 MKB records `0x2d` (Encrypted Media Key Variant Data
|
||||
//! / C), `0x2f` (Variant Key Data table, up to 65,535×16), and `0x0c`
|
||||
//! (variant cvalues, one per `0x04` subset-difference slot). When a disc
|
||||
//! carries none, callers fall back to the classical single-stage
|
||||
//! derivation in [`super::derive`].
|
||||
//!
|
||||
//! **Status: the chain cannot yet produce a key on a real disc — for two
|
||||
//! reasons, one external, one internal:**
|
||||
//! - EXTERNAL: no device key in our pool walks any real variant MKB,
|
||||
//! so `Kp` (and thus the whole chain) can't be produced live. This is
|
||||
//! a key-acquisition gap, not a code gap.
|
||||
//! - INTERNAL: the exact `VARIANTS[uv]` lookup ([`variants_for_uv`])
|
||||
//! and the Nonce / C sub-field offsets can't be pinned without a real
|
||||
//! disc + covering key to run the chain end-to-end against the `0x86`
|
||||
//! verify. Until then [`variants_for_uv`] returns `None` and the
|
||||
//! chain halts at [`MediaKeyVariantError::VariantsTableUnavailable`],
|
||||
//! so a wrong best-effort offset is never silently trusted.
|
||||
//!
|
||||
//! The chain follows the published spec:
|
||||
//! The chain:
|
||||
//!
|
||||
//! ```text
|
||||
//! Kmp = AES-128D(Kp, C) XOR uv
|
||||
@@ -41,96 +27,55 @@
|
||||
//! Km = AES-128D(Kpnew, VKD) XOR uv
|
||||
//! ```
|
||||
//!
|
||||
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
|
||||
//! (Soft Correction and Online Challenge). The chain refuses to run in
|
||||
//! either case — callers must handle those modes out of band.
|
||||
//! **Status.** The record layout is pinned against real variant MKBs:
|
||||
//! `C` is the per-slot block of the `0x0c` cvalue table (indexed by the
|
||||
//! matched subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the
|
||||
//! `0x2d` VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d`
|
||||
//! tail), and `VKD` is `0x2f`. Two inputs still block an end-to-end run
|
||||
//! against the `0x86` Verify-Media-Key record: the real per-licensee KCD
|
||||
//! (see [`KEY_CORRECTION_DATA`] — not coded, per-manufacturer), and a
|
||||
//! covering 2.1 Processing Key. Either one missing yields a wrong `Km`
|
||||
//! that the final verify gate rejects, so a bad key is never emitted —
|
||||
//! only an error. (A covering key would also confirm the last layout
|
||||
//! picks: the 16-bit `Kvn` width and Nonce head-vs-tail.)
|
||||
//!
|
||||
//! # Status: Kp verification
|
||||
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
|
||||
//! Correction and Online Challenge); the chain does not model those modes
|
||||
//! and treats such a slot as non-covering.
|
||||
//!
|
||||
//! On the classical path [`walk_processing_key`] gates each match on
|
||||
//! the VERIFY_MAGIC relation, which authenticates the Processing Key.
|
||||
//! On a variant MKB that magic check does NOT hold (the walk yields a
|
||||
//! Media Key *Precursor*, not the Media Key), so the walk accepts a
|
||||
//! variant match without it. The replacement gate lives at the END of
|
||||
//! [`derive_media_key_variant`]: the derived final `Km` is verified
|
||||
//! against the MKB's Verify-Media-Key record before any `(Km, Kvu)` is
|
||||
//! returned. A future implementer wiring [`variants_for_uv`] must keep
|
||||
//! that final gate — the per-match magic check no longer protects the
|
||||
//! variant path.
|
||||
//! **Verify gate.** On the classical path [`walk_processing_key`] gates
|
||||
//! each match on the VERIFY_MAGIC relation, which authenticates the
|
||||
//! Processing Key. On a variant MKB that magic does NOT hold (the walk
|
||||
//! yields a Precursor, not the Media Key), so the authoritative gate is
|
||||
//! at the END of the chain: the derived `Km` is verified against the
|
||||
//! MKB's Verify-Media-Key record before it is ever returned.
|
||||
|
||||
use super::decrypt::aes_ecb_decrypt;
|
||||
use super::crypto::{aes_ecb_decrypt, aes_g};
|
||||
use super::mkb::*;
|
||||
use super::types::DeviceKey;
|
||||
|
||||
// The MKB record types this chain selects — `REC_MEDIA_KEY_VARIANT_DATA`
|
||||
// (`0x0c`, the per-slot C table), `REC_VARIANT_DATA_AND_NONCE` (`0x2d`, VARIANTS
|
||||
// + tail Nonce), `REC_VKD_TABLE` (`0x2f`), the subset-difference / cvalue records
|
||||
// (`0x04` / `0x05` / `0x07`), and the verify records (`0x81` / `0x86`) — are the
|
||||
// canonical set in [`super::mkb`], in scope here via the `use super::mkb::*` glob.
|
||||
|
||||
// ── Public constants ──────────────────────────────────────────────────────
|
||||
|
||||
/// Placeholder Key Correction Data. Sixteen zero bytes.
|
||||
/// AACS 2.1 Key Correction Data — a zero placeholder, NOT real key material.
|
||||
///
|
||||
/// Integrators MUST supply a non-placeholder KCD via the `kcd` argument
|
||||
/// to [`derive_media_key_variant`]; the chain refuses to operate when
|
||||
/// the supplied KCD compares equal to this placeholder.
|
||||
pub const KEY_CORRECTION_DATA_PLACEHOLDER: [u8; 16] = [0u8; 16];
|
||||
/// **KCD is PER-LICENSEE** (per player manufacturer) — there is no single
|
||||
/// universal value. libfreemkv compiles in no AACS key material (keydb.cfg is
|
||||
/// the single source of truth), so this stays all-zero: the chain's SHAPE still
|
||||
/// runs, but on a real variant disc the derivation yields a wrong Media Key that
|
||||
/// the final Verify-Media-Key gate rejects. The variant chain therefore cannot
|
||||
/// complete on a real disc today — a key-acquisition gap, not a code gap. If a
|
||||
/// real per-licensee KCD is ever available it must come from keydb.cfg, never a
|
||||
/// compiled constant.
|
||||
const KEY_CORRECTION_DATA: [u8; 16] = [0u8; 16];
|
||||
|
||||
// ── MKB record walking ────────────────────────────────────────────────────
|
||||
|
||||
/// A single MKB record produced by [`walk_mkb`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MkbRecord {
|
||||
/// Byte offset of the record within the MKB.
|
||||
pub offset: usize,
|
||||
/// Record type byte.
|
||||
pub rec_type: u8,
|
||||
/// Record length in bytes (includes the 4-byte header).
|
||||
pub rec_len: usize,
|
||||
/// Record body (the bytes after the 4-byte header).
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Walk an MKB into a flat list of records.
|
||||
///
|
||||
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
|
||||
/// INCLUDING the 4-byte header, followed by payload. The walker stops
|
||||
/// at the first `(type=0, len=0)` end marker or at end of buffer.
|
||||
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
mkb_records(mkb)
|
||||
.map(|(offset, rec_type, rec_len)| MkbRecord {
|
||||
offset,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body: mkb[offset + 4..offset + rec_len].to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
|
||||
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
|
||||
/// then the body — stopping at the `00 000000` end marker or a
|
||||
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||
/// MKB record walk in `aacs::keys` are built on this, so the framing rules — and
|
||||
/// any future fix to them — live in exactly one place (they had drifted across
|
||||
/// six hand-rolled copies).
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 4 > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = ((mkb[pos + 1] as usize) << 16)
|
||||
| ((mkb[pos + 2] as usize) << 8)
|
||||
| (mkb[pos + 3] as usize);
|
||||
if rec_type == 0 && rec_len == 0 {
|
||||
return None;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let here = pos;
|
||||
pos += rec_len;
|
||||
Some((here, rec_type, rec_len))
|
||||
})
|
||||
}
|
||||
|
||||
/// True iff `records` contains at least one Media Key Variant record.
|
||||
///
|
||||
/// The real AACS 2.1 Variant markers — confirmed against a live variant MKB —
|
||||
@@ -140,40 +85,41 @@ pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)
|
||||
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
|
||||
/// real MKB.
|
||||
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||
records.iter().any(|r| matches!(r.rec_type, 0x2d | 0x2f))
|
||||
records
|
||||
.iter()
|
||||
.any(|r| matches!(r.rec_type, REC_VARIANT_DATA_AND_NONCE | REC_VKD_TABLE))
|
||||
}
|
||||
|
||||
/// Body of the Encrypted Media Key Variant Data record (type `0x2d`).
|
||||
///
|
||||
/// Confirmed against a live variant MKB as the `0x2d` record (92,220 bytes on
|
||||
/// the reference disc — not a clean multiple of 16, so it is a structured /
|
||||
/// count-prefixed record, not a flat C-block table). The exact per-uv C
|
||||
/// selection is the one sub-field still unconfirmed without a real disc+key to
|
||||
/// test against; the chain currently consumes the leading 16-byte block.
|
||||
/// `pub(crate)` until that offset is pinned.
|
||||
/// Body of the `0x2d` record: the `VARIANTS` table followed by the trailing
|
||||
/// 16-byte `Kvn` Nonce. Measured `46_100*2 + 16 = 92_216` on Zombieland v70 and
|
||||
/// `92_220` on Stand By Me v70 — in both, the leading `body.len() - 16` bytes are
|
||||
/// the big-endian `u16` `VARIANTS` table (one per subset-difference) and the last
|
||||
/// 16 bytes are the Nonce, with NO leading header. This does NOT hold the C used
|
||||
/// for `Kmp` — that is the per-slot block in `0x0c`
|
||||
/// ([`REC_MEDIA_KEY_VARIANT_DATA`]). Both [`variant_nonce`] and
|
||||
/// [`variants_for_uv`] read this body.
|
||||
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x2d)
|
||||
.find(|r| r.rec_type == REC_VARIANT_DATA_AND_NONCE)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
|
||||
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)` — the trailing 16 bytes of the
|
||||
/// `0x2d` record ([`variant_data_record`]).
|
||||
///
|
||||
/// **UNCONFIRMED source.** The speculative `0x83` "Variant Number" record does
|
||||
/// not exist on a real variant MKB. The `0x2d` Encrypted-Media-Key-Variant-Data
|
||||
/// record is the most likely home for a per-disc nonce, so this best-effort
|
||||
/// reads the trailing 16 bytes of `0x2d`. Confirming this (vs. a field inside
|
||||
/// `0x21`, or a fixed slice of `0x2d`) needs a real disc+key to test the whole
|
||||
/// chain against the `0x86` verify — until then the chain halts earlier at
|
||||
/// [`variants_for_uv`], so a wrong nonce here is never silently trusted.
|
||||
/// The Nonce-at-tail placement is consistent across both reference MKBs (the
|
||||
/// leading `body-16` bytes form the `VARIANTS` table exactly), but head-vs-tail
|
||||
/// is only truly pinned by running the full chain against the `0x86` verify with
|
||||
/// a covering key. Until then a wrong nonce can only fail that final gate, never
|
||||
/// emit a bad key.
|
||||
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records.iter().find(|r| r.rec_type == 0x2d)?;
|
||||
if r.body.len() < 16 {
|
||||
let body = variant_data_record(records)?;
|
||||
if body.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[r.body.len() - 16..]);
|
||||
out.copy_from_slice(&body[body.len() - 16..]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -185,34 +131,17 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x2f && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.find(|r| r.rec_type == REC_VKD_TABLE && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
// ── AES-G ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2.
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
let mut out = aes_ecb_decrypt(x1, x2);
|
||||
for i in 0..16 {
|
||||
out[i] ^= x2[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
|
||||
|
||||
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
|
||||
// on) are shared with the classical walk in [`super::keys`] — a single
|
||||
// on) are shared with the classical walk in [`super::derive`] — a single
|
||||
// definition keeps the variant SD tree byte-identical to the classical one.
|
||||
// (`aesg3` itself is imported separately in the test module.)
|
||||
use super::keys::{calc_pk_from_dk, calc_v_mask};
|
||||
use super::derive::{calc_pk_from_dk, calc_v_mask};
|
||||
|
||||
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||
/// processing key and the matching `uv` slot — both needed as inputs
|
||||
@@ -229,17 +158,11 @@ pub struct ProcessingKeyMatch {
|
||||
pub cvalue_index: usize,
|
||||
}
|
||||
|
||||
fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records
|
||||
.iter()
|
||||
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?;
|
||||
let r = records.iter().find(|r| {
|
||||
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
|
||||
&& r.body.len() >= 16
|
||||
})?;
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[..16]);
|
||||
Some(out)
|
||||
@@ -249,7 +172,7 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
||||
///
|
||||
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
|
||||
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
|
||||
/// [`super::derive::derive_media_key_and_pk_from_dk`]. The two are kept
|
||||
/// separate on purpose and select MKB records in DELIBERATELY different
|
||||
/// order:
|
||||
///
|
||||
@@ -258,10 +181,10 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
/// small `0x07` Explicit-Subset-Difference record carries the
|
||||
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
|
||||
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
|
||||
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
|
||||
/// note on [`super::derive::probe::mkb_cvalues`]). They must NOT be
|
||||
/// unified to one order — each is correct for its own MKB shape.
|
||||
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
|
||||
/// because the variant chain also reads `0x82`/`0x83`); the
|
||||
/// because the variant chain also reads `0x2d`/`0x2f`); the
|
||||
/// classical walk operates on raw MKB bytes. Same framing, different
|
||||
/// input type.
|
||||
///
|
||||
@@ -273,14 +196,14 @@ pub fn walk_processing_key(
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<ProcessingKeyMatch> {
|
||||
let mk_dv = mkb_find_mk_dv(records)?;
|
||||
let uvs = mkb_find_body(records, 0x04)?;
|
||||
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||
// Variant cvalue source: a real variant MKB carries its per-uv cvalue table
|
||||
// in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference
|
||||
// slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB
|
||||
// shape that keeps its cvalues there.
|
||||
let cvalues = mkb_find_body(records, 0x0c)
|
||||
.or_else(|| mkb_find_body(records, 0x07))
|
||||
.or_else(|| mkb_find_body(records, 0x05))?;
|
||||
let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||
.or_else(|| mkb_find_body(records, REC_EXPLICIT_SUBSET_DIFF))
|
||||
.or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?;
|
||||
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
@@ -298,14 +221,11 @@ pub fn walk_processing_key(
|
||||
// parse stops, no inner re-check needed.
|
||||
let u_mask_shift = uvs[5 * uvs_idx];
|
||||
|
||||
if u_mask_shift & 0xC0 != 0 {
|
||||
break;
|
||||
}
|
||||
// 0x20..=0x3F (32..=63) pass the 0xC0 revoked-marker check but are
|
||||
// out of range for a u32 shift. `wrapping_shl` would silently
|
||||
// compute shift % 32 (e.g. 32 → no shift → 0xFFFF_FFFF), matching a
|
||||
// wrong uv slot and deriving a wrong key. Disc-controlled byte:
|
||||
// skip the slot instead.
|
||||
// 0x20..=0x3F (32..=63) have their revoked-marker bits clear (so they
|
||||
// pass the take_while above) but are out of range for a u32 shift.
|
||||
// `wrapping_shl` would silently compute shift % 32 (e.g. 32 → no shift
|
||||
// → 0xFFFF_FFFF), matching a wrong uv slot and deriving a wrong key.
|
||||
// Disc-controlled byte: skip the slot instead.
|
||||
if u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
@@ -393,10 +313,8 @@ pub enum MediaKeyVariantError {
|
||||
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
|
||||
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
|
||||
OnlineChallengeRequired,
|
||||
/// Supplied KCD equals [`KEY_CORRECTION_DATA_PLACEHOLDER`]. The
|
||||
/// derivation refuses to run with the all-zero placeholder.
|
||||
KcdNotProvided,
|
||||
/// `VARIANTS[uv]` lookup for the matched uv is not implemented.
|
||||
/// `VARIANTS[uv]` could not be read from the `0x2d` record for the
|
||||
/// matched slot.
|
||||
VariantsTableUnavailable,
|
||||
/// VKD index resolved out of the supplied `vkd_table`.
|
||||
VkdIndexOutOfRange,
|
||||
@@ -414,7 +332,6 @@ impl std::fmt::Display for MediaKeyVariantError {
|
||||
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
|
||||
MediaKeyVariantError::SoftCorrectionRequired => 7103,
|
||||
MediaKeyVariantError::OnlineChallengeRequired => 7104,
|
||||
MediaKeyVariantError::KcdNotProvided => 7105,
|
||||
MediaKeyVariantError::VariantsTableUnavailable => 7106,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
|
||||
MediaKeyVariantError::MediaKeyVerifyFailed => 7108,
|
||||
@@ -427,99 +344,136 @@ impl std::error::Error for MediaKeyVariantError {}
|
||||
|
||||
// ── Chain ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Look up the per-slot `VARIANTS` value for the matched subset-difference
|
||||
/// slot. AACS 2.1 keys the VARIANTS table by the matched SD slot (the same
|
||||
/// index that selected the cvalue), so the caller passes
|
||||
/// [`ProcessingKeyMatch::cvalue_index`]. The byte layout of the per-slot entry
|
||||
/// in the Variant Number record is undocumented and disc-specific; this helper
|
||||
/// returns `None` until a Variant disc is available to fix the layout against.
|
||||
/// Look up the per-slot `VARIANTS` value for the matched subset-difference slot,
|
||||
/// keyed by the same index that selected the cvalue ([`ProcessingKeyMatch::cvalue_index`]).
|
||||
///
|
||||
/// `sd_slot_index` is the matched subset-difference slot (== cvalue index).
|
||||
fn variants_for_uv(_records: &[MkbRecord], _sd_slot_index: usize) -> Option<u16> {
|
||||
None
|
||||
/// LAYOUT (fixed against a real 2.1 variant MKB — Zombieland v70, `MKB_RO.inf`):
|
||||
/// the `0x2d` Encrypted-Media-Key-Variant-Data body is exactly
|
||||
/// `46_100*2 + 16 = 92_216` bytes, i.e. one **big-endian u16 `VARIANTS` entry per
|
||||
/// subset-difference slot** (1:1 with the `0x0c` variant cvalues and the `0x04`
|
||||
/// subset-differences), with the 16-byte per-disc Nonce packed at the **tail**
|
||||
/// (see [`variant_nonce`]). So the VARIANTS table is the leading `sd_count*2`
|
||||
/// bytes and this reads its `sd_slot_index`-th entry.
|
||||
///
|
||||
/// The record/field *sizing* is confirmed; the one bit still to pin against a
|
||||
/// covering key is Nonce-head-vs-tail (both fit the size) — a wrong pick can only
|
||||
/// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a
|
||||
/// silent bad key).
|
||||
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
|
||||
let body = variant_data_record(records)?;
|
||||
// The VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at
|
||||
// the TAIL (see [`variant_nonce`]). Bound the read to the table region so a
|
||||
// near-end slot can never read Nonce bytes as a VARIANTS entry. NO leading
|
||||
// header (measured: Zombieland v70 `0x2d` body = 46_100*2 + 16 = 92_216).
|
||||
const NONCE: usize = 16;
|
||||
let table_len = body.len().checked_sub(NONCE)?;
|
||||
let off = sd_slot_index.checked_mul(2)?;
|
||||
if off + 2 > table_len {
|
||||
return None;
|
||||
}
|
||||
Some(u16::from_be_bytes([body[off], body[off + 1]]))
|
||||
}
|
||||
|
||||
/// Run the Media Key Variant chain on an MKB.
|
||||
///
|
||||
/// Inputs:
|
||||
///
|
||||
/// - `mkb_records` : MKB pre-walked via [`walk_mkb`].
|
||||
/// - `device_keys` : pool of device keys; the chain runs against the
|
||||
/// first uv slot any DK covers.
|
||||
/// - `kcd` : integrator-supplied Key Correction Data. Must not
|
||||
/// equal [`KEY_CORRECTION_DATA_PLACEHOLDER`].
|
||||
/// - `vid` : 16-byte Volume ID for the disc. Used to derive
|
||||
/// the final VUK alongside the Media Key.
|
||||
///
|
||||
/// Returns `(Km, Kvu)` on success.
|
||||
///
|
||||
/// NOTE: the `VARIANTS[uv]` lookup ([`variants_for_uv`]) is not yet
|
||||
/// implemented, so on a real Variant disc this always returns
|
||||
/// `Err(`[`MediaKeyVariantError::VariantsTableUnavailable`]`)` before a
|
||||
/// key is produced. The chain can only succeed against synthetic test
|
||||
/// fixtures today.
|
||||
pub fn derive_media_key_variant(
|
||||
mkb_records: &[MkbRecord],
|
||||
device_keys: &[DeviceKey],
|
||||
kcd: &[u8; 16],
|
||||
vid: &[u8; 16],
|
||||
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
|
||||
if !is_variant_mkb(mkb_records) {
|
||||
return Err(MediaKeyVariantError::NotVariantMkb);
|
||||
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
|
||||
/// record (`0x04`), in table order — the same parse [`walk_processing_key`] uses
|
||||
/// to index cvalues. Factored out so a bare Processing Key (which arrives without
|
||||
/// its slot) can be tried against each slot.
|
||||
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
|
||||
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||
let mut out = Vec::new();
|
||||
let mut idx = 0usize;
|
||||
while (idx + 1) * 5 <= uvs.len() {
|
||||
let u_mask_shift = uvs[5 * idx];
|
||||
// The `0xC0` revoked-marker terminates the table (matches the walk's
|
||||
// `take_while`). Shifts ≥ 32 are out of range and skipped, never wrapped.
|
||||
if u_mask_shift & 0xC0 != 0 {
|
||||
break;
|
||||
}
|
||||
let p_uv = &uvs[1 + 5 * idx..];
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv != 0 && u_mask_shift < 32 {
|
||||
out.push((uv, idx));
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
let pkm = walk_processing_key(mkb_records, device_keys)
|
||||
.ok_or(MediaKeyVariantError::ProcessingKeyUnavailable)?;
|
||||
/// The MKB-derived inputs the variant chain needs for every slot it tries against
|
||||
/// a given Processing Key. Fetched once by [`derive_media_key_variant`] so the
|
||||
/// per-slot body stays a lean `(Kp, uv, slot)` call.
|
||||
struct VariantMkb<'a> {
|
||||
records: &'a [MkbRecord],
|
||||
nonce: [u8; 16],
|
||||
vkd_table: &'a [u8],
|
||||
/// The per-subset-difference Encrypted-Media-Key-Variant-Data (C) table from
|
||||
/// record `0x0c` — one 16-byte C per slot. C for slot `i` is
|
||||
/// `cvalues[i*16..][..16]`, the SAME source/index [`walk_processing_key`]
|
||||
/// uses. (NOT `0x2d`, which is VARIANTS + Nonce.)
|
||||
cvalues: &'a [u8],
|
||||
mk_dv: [u8; 16],
|
||||
}
|
||||
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
if c_value.len() < 16 {
|
||||
return Err(MediaKeyVariantError::MkbIncomplete);
|
||||
}
|
||||
/// The variant chain body for ONE known `(Kp, uv, slot)`: derive and verify the
|
||||
/// Media Key against the MKB's Verify-Media-Key record. VID-free — the Km is
|
||||
/// MKB-scoped; the VUK is a separate [`super::derive::derive_vuk`] step. Returns
|
||||
/// the verified Km, or a classification of why this slot did not yield one.
|
||||
fn variant_km_for_slot(
|
||||
m: &VariantMkb<'_>,
|
||||
kp: &[u8; 16],
|
||||
uv: u32,
|
||||
slot_index: usize,
|
||||
) -> Result<[u8; 16], MediaKeyVariantError> {
|
||||
// C for THIS subset-difference: the slot's 16-byte block in the `0x0c`
|
||||
// Encrypted-Media-Key-Variant-Data table (same index that selected the
|
||||
// cvalue in `walk_processing_key`). `0x2d` is VARIANTS + Nonce, not C.
|
||||
let cv_off = slot_index
|
||||
.checked_mul(16)
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let c_slice = m
|
||||
.cvalues
|
||||
.get(cv_off..cv_off + 16)
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mut c_block = [0u8; 16];
|
||||
c_block.copy_from_slice(&c_value[..16]);
|
||||
c_block.copy_from_slice(c_slice);
|
||||
|
||||
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
|
||||
let mut kmp = aes_ecb_decrypt(&pkm.kp, &c_block);
|
||||
let uv_bytes = pkm.uv.to_be_bytes();
|
||||
let mut kmp = aes_ecb_decrypt(kp, &c_block);
|
||||
let uv_bytes = uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
kmp[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
|
||||
// Condition bits on Kmp[15] route off the hardcoded-KCD path.
|
||||
// Condition bits on Kmp[15] select the correction mode. Bit 0x02 (SoftKCD)
|
||||
// and 0x04 (online challenge) need out-of-band data we don't model; the
|
||||
// default path (neither bit set) uses the fixed KCD constant.
|
||||
if kmp[15] & 0b0000_0010 != 0 {
|
||||
return Err(MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
if kmp[15] & 0b0000_0100 != 0 {
|
||||
return Err(MediaKeyVariantError::OnlineChallengeRequired);
|
||||
}
|
||||
if kcd == &KEY_CORRECTION_DATA_PLACEHOLDER {
|
||||
return Err(MediaKeyVariantError::KcdNotProvided);
|
||||
}
|
||||
|
||||
// Step: Kpnew = Kmp XOR KCD.
|
||||
let mut kpnew = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
kpnew[i] = kmp[i] ^ kcd[i];
|
||||
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
|
||||
}
|
||||
|
||||
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
|
||||
let kvn_block = aes_g(&pkm.kp, &nonce);
|
||||
let kvn_block = aes_g(kp, &m.nonce);
|
||||
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
|
||||
|
||||
// Step: VKD_idx = Kvn XOR VARIANTS[uv].
|
||||
let v_for_uv = variants_for_uv(mkb_records, pkm.cvalue_index)
|
||||
// Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx].
|
||||
let v_for_uv = variants_for_uv(m.records, slot_index)
|
||||
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
|
||||
let vkd_idx = kvn ^ v_for_uv;
|
||||
|
||||
// Step: VKD = vkd_table[VKD_idx * 16 .. +16].
|
||||
let off = (vkd_idx as usize) * 16;
|
||||
if off + 16 > vkd_table.len() {
|
||||
if off + 16 > m.vkd_table.len() {
|
||||
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
|
||||
}
|
||||
let mut vkd = [0u8; 16];
|
||||
vkd.copy_from_slice(&vkd_table[off..off + 16]);
|
||||
vkd.copy_from_slice(&m.vkd_table[off..off + 16]);
|
||||
|
||||
// Step: Km = AES-128D(Kpnew, VKD) XOR uv.
|
||||
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
|
||||
@@ -527,30 +481,159 @@ pub fn derive_media_key_variant(
|
||||
km[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
|
||||
// Gate: verify the derived Media Key against the MKB's Verify-Media-Key
|
||||
// record. On the variant path the per-match magic check in
|
||||
// `walk_processing_key` does NOT hold (it only saw the Precursor), so this
|
||||
// is the authoritative Kp/Km verification — it MUST run before returning a
|
||||
// real key.
|
||||
// Gate: the derived Media Key MUST reproduce the MKB's Verify-Media-Key magic
|
||||
// (the per-match magic in `walk_processing_key` only saw the Precursor). This
|
||||
// is the authoritative check — no unverified key is ever returned.
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC {
|
||||
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
|
||||
}
|
||||
Ok(km)
|
||||
}
|
||||
|
||||
/// Derive the AACS 2.1 variant **Media Key** from a Processing Key.
|
||||
///
|
||||
/// The one deterministic `Kp → Km` derivation for a variant MKB. A leaked 2.1
|
||||
/// Processing Key arrives without its subset-difference slot, so this tries `pk`
|
||||
/// against every slot and returns the Km for the slot whose full chain passes the
|
||||
/// MKB's Verify-Media-Key record — exactly the shape of the classical bare-PK
|
||||
/// [`super::derive::derive_media_key_from_pk`], gated by the chain's own verify so
|
||||
/// an unverified key is never returned.
|
||||
///
|
||||
/// VID-free by design: the Media Key is MKB-scoped. Derive the per-disc VUK from
|
||||
/// the returned Km with [`super::derive::derive_vuk`]. Deriving a Processing Key
|
||||
/// from device keys (DK → PK) is a separate concern — walk it first via
|
||||
/// [`walk_processing_key`], then call this.
|
||||
///
|
||||
/// Errors: `NotVariantMkb` (caller should use the classical path), `MkbIncomplete`
|
||||
/// (a required record is missing), or `ProcessingKeyUnavailable` (no slot verified
|
||||
/// — `pk` does not cover this MKB, or its slot needs the soft-correction / online
|
||||
/// path, surfaced as `SoftCorrectionRequired` / `OnlineChallengeRequired`).
|
||||
pub fn derive_media_key_variant(
|
||||
mkb_records: &[MkbRecord],
|
||||
pk: &[u8; 16],
|
||||
) -> Result<[u8; 16], MediaKeyVariantError> {
|
||||
if !is_variant_mkb(mkb_records) {
|
||||
return Err(MediaKeyVariantError::NotVariantMkb);
|
||||
}
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
// C for the Kmp step is the per-subset-difference `0x0c` table (one 16-byte
|
||||
// C per slot) — the SAME source and index `walk_processing_key` uses. `0x2d`
|
||||
// holds VARIANTS + Nonce, NOT C. Fall back to `0x07`/`0x05` for the synthetic
|
||||
// fixtures that keep a single cvalue there.
|
||||
let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||
.or_else(|| mkb_find_body(mkb_records, REC_EXPLICIT_SUBSET_DIFF))
|
||||
.or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA))
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let m = VariantMkb {
|
||||
records: mkb_records,
|
||||
nonce,
|
||||
vkd_table,
|
||||
cvalues,
|
||||
mk_dv,
|
||||
};
|
||||
|
||||
// Try `pk` against each slot; return the first verified Km. If none verify,
|
||||
// surface a correction-mode error over the generic miss so a disc that needs
|
||||
// the soft/online path is distinguishable from a non-covering key.
|
||||
let mut correction: Option<MediaKeyVariantError> = None;
|
||||
for (uv, slot_index) in slots {
|
||||
match variant_km_for_slot(&m, pk, uv, slot_index) {
|
||||
Ok(km) => return Ok(km),
|
||||
Err(e @ MediaKeyVariantError::SoftCorrectionRequired)
|
||||
| Err(e @ MediaKeyVariantError::OnlineChallengeRequired) => {
|
||||
correction.get_or_insert(e);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
|
||||
}
|
||||
|
||||
/// Run the variant chain from a caller-supplied Processing Key and EXPLICIT
|
||||
/// per-slot inputs — the harness entry that tries a captured `Kp` against known
|
||||
/// slot material, bypassing both the device-key walk and the on-MKB
|
||||
/// `VARIANTS[uv]` lookup. The caller supplies the `0x0c` C block, the slot's
|
||||
/// subset-difference number `uv`, and its `VARIANTS[uv]`; the MKB supplies the
|
||||
/// Nonce, the VKD table, and the Verify-Media-Key value.
|
||||
///
|
||||
/// Returns `(Km, Kvu)`. The terminal Verify-Media-Key gate is identical to
|
||||
/// [`derive_media_key_variant`], so a wrong `c_block` / `uv` / `variants_uv`
|
||||
/// returns [`MediaKeyVariantError::MediaKeyVerifyFailed`] rather than a bogus
|
||||
/// key. The soft-correction / online-challenge bits on `Kmp[15]` are classified
|
||||
/// the same way, so a slot needing an out-of-band correction path is
|
||||
/// distinguishable from a non-matching input.
|
||||
///
|
||||
/// (Note the KCD caveat on [`KEY_CORRECTION_DATA`]: without the real per-licensee
|
||||
/// KCD this fails the verify gate on a real disc — a key-acquisition gap.)
|
||||
pub fn media_key_variant_from_kp(
|
||||
kp: &[u8; 16],
|
||||
c_block: &[u8; 16],
|
||||
uv: u32,
|
||||
variants_uv: u16,
|
||||
mkb_records: &[MkbRecord],
|
||||
vid: &[u8; 16],
|
||||
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
|
||||
// Kmp = AES-128D(Kp, C) XOR uv.
|
||||
let mut kmp = aes_ecb_decrypt(kp, c_block);
|
||||
let uv_bytes = uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
kmp[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
if kmp[15] & 0b0000_0010 != 0 {
|
||||
return Err(MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
if kmp[15] & 0b0000_0100 != 0 {
|
||||
return Err(MediaKeyVariantError::OnlineChallengeRequired);
|
||||
}
|
||||
|
||||
// Kpnew = Kmp XOR KCD.
|
||||
let mut kpnew = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
|
||||
}
|
||||
|
||||
// Kvn = AES-G(Kp, Nonce) & 0xFFFF; VKD_idx = Kvn XOR VARIANTS[uv].
|
||||
let kvn_block = aes_g(kp, &nonce);
|
||||
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
|
||||
let vkd_idx = kvn ^ variants_uv;
|
||||
let off = (vkd_idx as usize) * 16;
|
||||
if off + 16 > vkd_table.len() {
|
||||
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
|
||||
}
|
||||
let mut vkd = [0u8; 16];
|
||||
vkd.copy_from_slice(&vkd_table[off..off + 16]);
|
||||
|
||||
// Km = AES-128D(Kpnew, VKD) XOR uv, then the authoritative Verify-Media-Key gate.
|
||||
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
|
||||
for i in 0..4 {
|
||||
km[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
|
||||
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
|
||||
}
|
||||
|
||||
// Step: Kvu = AES-G(Km, VID).
|
||||
// Kvu = AES-G(Km, VID).
|
||||
let kvu = aes_g(&km, vid);
|
||||
|
||||
Ok((km, kvu))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// These three live in `super::keys` now (consolidated SD-walk helpers);
|
||||
// These three live in `super::derive` now (consolidated SD-walk helpers);
|
||||
// `use super::*` does not re-export the parent module's private `use`
|
||||
// imports, so pull them in directly for the tests below.
|
||||
use super::super::keys::{aesg3, calc_pk_from_dk};
|
||||
use super::super::crypto::aesg3;
|
||||
use super::super::derive::calc_pk_from_dk;
|
||||
|
||||
#[test]
|
||||
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
|
||||
@@ -631,49 +714,36 @@ mod tests {
|
||||
#[test]
|
||||
fn chain_rejects_non_variant_mkb() {
|
||||
let recs = walk_mkb(&synthetic_mkb_classical());
|
||||
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
|
||||
let err = derive_media_key_variant(&recs, &[0xAA; 16])
|
||||
.expect_err("classical MKB must be rejected");
|
||||
assert_eq!(err, MediaKeyVariantError::NotVariantMkb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_rejects_placeholder_kcd() {
|
||||
// To reach the KCD check we need a complete variant MKB AND a
|
||||
// DK that walks it. We construct both via the synthetic
|
||||
// fixture below.
|
||||
let (recs, dk, _kp, _expected_kmp) = synthetic_variant_setup(/*kmp15*/ 0x00);
|
||||
let err =
|
||||
derive_media_key_variant(&recs, &[dk], &KEY_CORRECTION_DATA_PLACEHOLDER, &[0u8; 16])
|
||||
.expect_err("placeholder KCD must be rejected");
|
||||
assert_eq!(err, MediaKeyVariantError::KcdNotProvided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_detects_soft_correction_bit() {
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
|
||||
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
// Kmp[15] bit 0x02 on the covering PK's slot surfaces the soft-correction
|
||||
// classification over the generic no-slot miss.
|
||||
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
|
||||
let err = derive_media_key_variant(&recs, &kp)
|
||||
.expect_err("bit 0x02 must surface SoftCorrectionRequired");
|
||||
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_detects_online_challenge_bit() {
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
|
||||
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
|
||||
let err = derive_media_key_variant(&recs, &kp)
|
||||
.expect_err("bit 0x04 must surface OnlineChallengeRequired");
|
||||
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_surfaces_variants_table_gap_on_clean_kmp() {
|
||||
// With both condition bits clear and a non-placeholder KCD, the
|
||||
// chain advances to the per-uv VARIANTS[uv] lookup, which is
|
||||
// not yet wired. That returns VariantsTableUnavailable —
|
||||
// proving the bit checks and KCD check all passed.
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
|
||||
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("expected VariantsTableUnavailable at the per-uv lookup");
|
||||
assert_eq!(err, MediaKeyVariantError::VariantsTableUnavailable);
|
||||
fn variants_for_uv_reads_the_table_from_0x2d() {
|
||||
// variants_for_uv reads the VARIANTS u16 from the 0x2d record, so on a
|
||||
// variant MKB that carries 0x2d it yields Some (never dead-stops the chain
|
||||
// at VariantsTableUnavailable).
|
||||
let (recs, _dk, _kp, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
|
||||
assert!(variants_for_uv(&recs, 0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -686,7 +756,6 @@ mod tests {
|
||||
MediaKeyVariantError::ProcessingKeyUnavailable,
|
||||
MediaKeyVariantError::SoftCorrectionRequired,
|
||||
MediaKeyVariantError::OnlineChallengeRequired,
|
||||
MediaKeyVariantError::KcdNotProvided,
|
||||
MediaKeyVariantError::VariantsTableUnavailable,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange,
|
||||
MediaKeyVariantError::MediaKeyVerifyFailed,
|
||||
@@ -723,13 +792,13 @@ mod tests {
|
||||
/// times — Kp = aesg3(dk, 1).
|
||||
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
|
||||
/// Kmp whose byte-15 is exactly `kmp15`.
|
||||
/// - record 0x82 with a 16-byte body (acts as both Variant Data
|
||||
/// and Variant Key Data; satisfies the parser heuristics).
|
||||
/// - record 0x83 with a 16-byte Nonce.
|
||||
/// - record 0x2d (Encrypted Media Key Variant Data): a 32-byte body
|
||||
/// carrying C in the head 16 bytes and a 16-byte Nonce in the tail.
|
||||
/// - record 0x2f (Variant Key Data): one 16-byte entry.
|
||||
///
|
||||
/// Returns (records, dk, planted_kp, planted_kmp).
|
||||
fn synthetic_variant_setup(kmp15: u8) -> (Vec<MkbRecord>, DeviceKey, [u8; 16], [u8; 16]) {
|
||||
use crate::aacs::decrypt::aes_ecb_encrypt;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
// Build header.
|
||||
let mut mkb = vec![
|
||||
@@ -758,21 +827,24 @@ mod tests {
|
||||
aes_d_result[15] ^= 0x02;
|
||||
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
|
||||
|
||||
// cvalues record (0x07): one 16-byte cvalue. The walker
|
||||
// indexes it for the magic-check step; on a variant MKB the
|
||||
// magic check fails but `variant_present` is true so the
|
||||
// walker still returns the match. Content is don't-care.
|
||||
// cvalues record (0x07): the per-SD C the chain reads for `Kmp`. This
|
||||
// fixture has no `0x0c`, so both the walk and the chain fall back to
|
||||
// `0x07` — plant the computed `c_block` HERE so `AES-D(Kp, C) XOR uv ==
|
||||
// Kmp` and the chosen `kmp15` bit lands. On a variant MKB the per-match
|
||||
// magic check fails, but `variant_present` is true, so the walk still
|
||||
// returns the match.
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&c_block);
|
||||
|
||||
// Verify Media Key (0x86): body content is don't-care.
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
|
||||
// 0x2d record: Encrypted Media Key Variant Data — C (head 16) then a
|
||||
// trailing 16-byte Nonce (variant_nonce reads the tail), 32-byte body.
|
||||
// 0x2d record: VARIANTS table (head, don't-care for these tests) then the
|
||||
// trailing 16-byte Nonce (`variant_nonce` reads the tail), 32-byte body.
|
||||
// (C is NOT here — it is the `0x07`/`0x0c` cvalue above.)
|
||||
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
|
||||
mkb.extend_from_slice(&c_block);
|
||||
mkb.extend_from_slice(&[0x11; 16]);
|
||||
mkb.extend_from_slice(&[0x77; 16]);
|
||||
|
||||
// 0x2f record: Variant Key Data table — one 16-byte entry.
|
||||
@@ -817,7 +889,7 @@ mod tests {
|
||||
fn mkb_records_matches_walk_mkb_framing() {
|
||||
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
|
||||
// (offset, type, len) for every record — they share the one framing
|
||||
// walker, and every keys.rs MKB walk now relies on this equivalence.
|
||||
// walker, and every aacs::resolve/derive MKB walk now relies on this equivalence.
|
||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
|
||||
@@ -936,13 +1008,16 @@ mod tests {
|
||||
// ── derive_media_key_variant: missing-record classification ────────────
|
||||
|
||||
#[test]
|
||||
fn chain_reports_processing_key_unavailable_with_no_dks() {
|
||||
// A complete variant MKB but an empty device-key pool → no uv covered
|
||||
// → ProcessingKeyUnavailable (the walk_processing_key None branch).
|
||||
fn chain_yields_no_key_for_non_covering_pk() {
|
||||
// A complete variant MKB but a Processing Key that covers no slot → no
|
||||
// Km verifies → an error (never a key). A non-covering key resolves to
|
||||
// ProcessingKeyUnavailable, or to a correction-mode classification if its
|
||||
// Kmp happens to set the soft/online bit — either way, no key is emitted.
|
||||
let (recs, _dk, _, _) = synthetic_variant_setup(0x00);
|
||||
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("no DK → ProcessingKeyUnavailable");
|
||||
assert_eq!(err, MediaKeyVariantError::ProcessingKeyUnavailable);
|
||||
let out = derive_media_key_variant(&recs, &[0x11; 16]);
|
||||
assert!(out.is_err(), "non-covering PK must not yield a Media Key");
|
||||
assert_ne!(out, Err(MediaKeyVariantError::NotVariantMkb));
|
||||
assert_ne!(out, Err(MediaKeyVariantError::MkbIncomplete));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -950,7 +1025,7 @@ mod tests {
|
||||
// Build a variant MKB (still variant via 0x2f, and a DK can walk it)
|
||||
// but WITHOUT the 0x2d record that carries C + the trailing Nonce →
|
||||
// MkbIncomplete at the variant_nonce `?`.
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(0x00);
|
||||
let (recs, _dk, kp, _) = synthetic_variant_setup(0x00);
|
||||
// Reconstruct bytes without the 0x2d record.
|
||||
let mut mkb = Vec::new();
|
||||
for r in &recs {
|
||||
@@ -965,8 +1040,7 @@ mod tests {
|
||||
}
|
||||
let recs2 = walk_mkb(&mkb);
|
||||
assert!(is_variant_mkb(&recs2), "still variant via 0x2f");
|
||||
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("missing nonce → MkbIncomplete");
|
||||
let err = derive_media_key_variant(&recs2, &kp).expect_err("missing nonce → MkbIncomplete");
|
||||
assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
|
||||
}
|
||||
|
||||
@@ -1054,7 +1128,7 @@ mod tests {
|
||||
fn error_codes_are_unique_and_in_7100_range() {
|
||||
// Each MediaKeyVariantError maps to a distinct E71xx code. A
|
||||
// copy-paste collision (two variants sharing a code) would break
|
||||
// operator triage; assert all nine are distinct.
|
||||
// operator triage; assert all are distinct.
|
||||
use std::collections::HashSet;
|
||||
let cases = [
|
||||
MediaKeyVariantError::NotVariantMkb,
|
||||
@@ -1062,7 +1136,6 @@ mod tests {
|
||||
MediaKeyVariantError::ProcessingKeyUnavailable,
|
||||
MediaKeyVariantError::SoftCorrectionRequired,
|
||||
MediaKeyVariantError::OnlineChallengeRequired,
|
||||
MediaKeyVariantError::KcdNotProvided,
|
||||
MediaKeyVariantError::VariantsTableUnavailable,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange,
|
||||
MediaKeyVariantError::MediaKeyVerifyFailed,
|
||||
@@ -1070,4 +1143,31 @@ mod tests {
|
||||
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
|
||||
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
|
||||
}
|
||||
|
||||
/// `media_key_variant_from_kp` runs the full chain from explicit inputs and
|
||||
/// classifies the `Kmp[15]` soft-correction bit. A `c_block` chosen so
|
||||
/// `AES-D(Kp, C) == Kmp` with bit `0x02` set (uv=0) must surface
|
||||
/// `SoftCorrectionRequired` before it touches the VKD / verify steps —
|
||||
/// proving the explicit-input entry runs the same chain and gates.
|
||||
#[test]
|
||||
fn media_key_variant_from_kp_classifies_soft_correction() {
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
let kp = [0x11u8; 16];
|
||||
// Plant Kmp[15]=0x02 (soft-correction) with uv=0 so Kmp == AES-D(kp, C).
|
||||
let mut target_kmp = [0x00u8; 16];
|
||||
target_kmp[15] = 0x02;
|
||||
let c_block = aes_ecb_encrypt(&kp, &target_kmp);
|
||||
// Minimal variant MKB: 0x2d (16-byte body = tail Nonce), 0x2f (one VKD
|
||||
// entry), 0x86 (Verify-Media-Key).
|
||||
let mut mkb = vec![0x2d, 0x00, 0x00, 0x14];
|
||||
mkb.extend_from_slice(&[0x99; 16]);
|
||||
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xBB; 16]);
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
let recs = walk_mkb(&mkb);
|
||||
let err = media_key_variant_from_kp(&kp, &c_block, 0, 0, &recs, &[0u8; 16])
|
||||
.expect_err("soft-correction bit → classified, not a key");
|
||||
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -236,7 +236,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// language, codec sub-fields). Layout per BD spec / libbluray
|
||||
/// language, codec sub-fields). Layout per the BD CLPI spec
|
||||
/// clpi_parse.c:
|
||||
///
|
||||
/// ```text
|
||||
@@ -394,7 +394,7 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
|
||||
// Stream PID entry — bit-packed per the BD CLPI spec:
|
||||
// stream_PID: 16 bits → ep_map[2..4]
|
||||
// reserved: 10 bits ┐
|
||||
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
|
||||
@@ -821,7 +821,7 @@ mod tests {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Added hardening tests. Grounded in the BD-ROM CLPI spec
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI) and libbluray clpi_parse.c.
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
|
||||
|
||||
+120
-134
@@ -1,58 +1,46 @@
|
||||
//! CSS cipher implementation based on the Stevenson 1999 analysis.
|
||||
//! CSS content cipher — an independent implementation of the publicly
|
||||
//! documented Content Scramble System stream cipher.
|
||||
//!
|
||||
//! The CSS cipher uses two table-driven feedback circuits:
|
||||
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
||||
//! key[0..2]), driven by TAB2/TAB3
|
||||
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
||||
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
||||
//! The algorithm is the one recovered and published in Frank A. Stevenson's
|
||||
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
|
||||
//! described in the open CSS literature. It is implemented here from that public
|
||||
//! description; its constants (see [`super::tables`]) are the cipher's own
|
||||
//! defined values. Nothing in this file is copied or translated from any
|
||||
//! particular CSS software.
|
||||
//!
|
||||
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
|
||||
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
||||
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
||||
//! (NOT a plain XOR; the cipher is not its own inverse).
|
||||
//! The cipher uses two table-driven linear-feedback circuits:
|
||||
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
|
||||
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||
//! - **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR
|
||||
//! seed[2..5]`), stepped through a feedback polynomial and `TAB4`.
|
||||
//!
|
||||
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
|
||||
//! Tables: CSS specification constants.
|
||||
//! Each output byte is the sum-with-carry of the two register outputs. A body
|
||||
//! byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1`
|
||||
//! substitution of the ciphertext byte followed by an XOR with the keystream
|
||||
//! (so the cipher is deliberately not its own inverse).
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
||||
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
||||
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
||||
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
||||
/// The two feedback registers are seeded **directly** from
|
||||
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
|
||||
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
|
||||
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
|
||||
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||
///
|
||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. Like
|
||||
/// libdvdcss, the flag byte is NOT modified here — the caller treats a
|
||||
/// nonzero `sector[0x14] & 0x30` as "needs unscrambling" and the descramble
|
||||
/// is its own inverse, so re-running it on plaintext would re-scramble.
|
||||
/// (freemkv historically cleared the flag; we keep clearing it so callers
|
||||
/// and the existing tests can distinguish a descrambled sector. This does
|
||||
/// not affect the recovered body.)
|
||||
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
|
||||
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
|
||||
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
|
||||
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
|
||||
/// (the flag guard below skips it). Clearing does not affect the recovered body.
|
||||
///
|
||||
/// No-op (returns without modifying `sector`) in two cases:
|
||||
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
|
||||
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
|
||||
/// left untouched. The `debug_assert!` flags this misuse in debug/test
|
||||
/// builds; a DVD sector is always exactly 2048 bytes.
|
||||
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
|
||||
/// present. Callers chunk by 2048, so a trailing partial chunk is left
|
||||
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
|
||||
/// DVD sector is always exactly 2048 bytes.
|
||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
||||
///
|
||||
/// Design reference: libdvdcss `dvdcss_unscramble`. The combiner mirrors
|
||||
/// `css.c` line-for-line:
|
||||
/// ```text
|
||||
/// i_t1 = (key[0] ^ sec[0x54]) | 0x100;
|
||||
/// i_t2 = key[1] ^ sec[0x55];
|
||||
/// i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ (sec[0x56]|sec[0x57]<<8|sec[0x58]<<16);
|
||||
/// i_t4 = i_t3 & 7; i_t3 = i_t3*2 + 8 - i_t4;
|
||||
/// // per byte over 0x80..0x800:
|
||||
/// i_t4 = TAB2[i_t2] ^ TAB3[i_t1];
|
||||
/// i_t2 = i_t1 >> 1; i_t1 = ((i_t1 & 1) << 8) ^ i_t4; i_t4 = TAB5[i_t4];
|
||||
/// i_t6 = (((((((i_t3>>3)^i_t3)>>1)^i_t3)>>8)^i_t3)>>5) & 0xff;
|
||||
/// i_t3 = (i_t3 << 8) | i_t6; i_t6 = TAB4[i_t6];
|
||||
/// i_t5 += i_t6 + i_t4; *p = TAB1[*p] ^ (i_t5 & 0xff); i_t5 >>= 8;
|
||||
/// ```
|
||||
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
debug_assert!(
|
||||
sector.len() >= 2048,
|
||||
@@ -62,102 +50,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
||||
// Not scrambled (flag bits 4-5 clear) → nothing to do.
|
||||
if sector[0x14] & 0x30 == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
|
||||
// set bit 8 (`| 0x100`) as its running marker.
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
|
||||
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
|
||||
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
|
||||
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
// Keystream accumulator; the low byte is the current keystream byte and the
|
||||
// high bits carry into the next iteration.
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
// Advance LFSR1.
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
// Step LFSR1: its output byte `o1`.
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
// Step LFSR0: its output byte `o0`.
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
|
||||
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
||||
i_t5 >>= 8;
|
||||
// Combine (sum with carry) and recover the plaintext byte.
|
||||
acc += o0 + o1;
|
||||
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
||||
// so downstream code and tests can tell a sector was descrambled.
|
||||
// Clear the scramble bits so downstream code and tests can tell a sector was
|
||||
// descrambled; bits 6-7 of byte 0x14 are preserved.
|
||||
sector[0x14] &= 0xCF;
|
||||
}
|
||||
|
||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||
/// CSS ciphertext under `title_key`.
|
||||
///
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
||||
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
||||
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
||||
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
||||
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
|
||||
/// keystream. The keystream derivation is the same as [`descramble_sector`];
|
||||
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
|
||||
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
|
||||
///
|
||||
/// Not on any production read path — it exists so the key-recovery tests
|
||||
/// (and any caller that needs to produce a known CSS-encrypted sector) can
|
||||
/// build genuine ciphertext rather than approximating it.
|
||||
/// Not on any production read path — it exists so the key-recovery tests (and
|
||||
/// any caller that needs a known CSS-encrypted sector) can build genuine
|
||||
/// ciphertext rather than approximating it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
acc += o0 + o1;
|
||||
|
||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
|
||||
i_t5 >>= 8;
|
||||
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// Mark the sector scrambled so the descrambler will process it.
|
||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||
}
|
||||
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
|
||||
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
|
||||
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
|
||||
#[cfg(test)]
|
||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||
let mut inv = [0u8; 256];
|
||||
@@ -181,14 +170,15 @@ mod tests {
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
|
||||
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
|
||||
/// reference C semantics with the reference tables. Pins the content
|
||||
/// cipher to libdvdcss byte-for-byte.
|
||||
/// Regression vector: the deterministic output of the CSS content cipher for
|
||||
/// a fixed key/seed/body. The value is generated by this implementation and
|
||||
/// is self-consistent with the scramble/descramble round-trip below — any
|
||||
/// correct CSS descrambler yields the same bytes, since the cipher is
|
||||
/// deterministic. Pins the implementation against accidental change.
|
||||
///
|
||||
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_matches_libdvdcss_unscramble_vector() {
|
||||
fn descramble_produces_the_reference_css_vector() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
@@ -200,12 +190,12 @@ mod tests {
|
||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||
0xA0, 0x31
|
||||
],
|
||||
"descramble body head must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body head must match the reference CSS vector"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body tail must match the reference CSS vector"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,8 +231,8 @@ mod tests {
|
||||
|
||||
/// Test 2: descramble inverts scramble over the body.
|
||||
///
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
||||
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
|
||||
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||
/// with the same key must reproduce the original body exactly.
|
||||
#[test]
|
||||
@@ -279,9 +269,9 @@ mod tests {
|
||||
|
||||
/// css_tab1_relationship
|
||||
///
|
||||
/// Verify the structure of TAB1: it is a substitution table used in
|
||||
/// key mangling. Check that no two inputs map to the same output
|
||||
/// (TAB1 is a permutation of 0..255).
|
||||
/// Verify the structure of TAB1: it is a substitution table used in key
|
||||
/// mangling. Check that no two inputs map to the same output (TAB1 is a
|
||||
/// permutation of 0..255).
|
||||
#[test]
|
||||
fn css_tab1_is_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
@@ -332,8 +322,8 @@ mod tests {
|
||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||
///
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
|
||||
/// in bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
|
||||
/// bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||
/// seen as scrambled and the body would change.
|
||||
#[test]
|
||||
@@ -352,11 +342,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector
|
||||
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector as
|
||||
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
///
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
|
||||
/// nonzero.
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
|
||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||
#[test]
|
||||
@@ -381,8 +370,8 @@ mod tests {
|
||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||
///
|
||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
|
||||
/// high bits assert fails.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
|
||||
/// bits assert fails.
|
||||
#[test]
|
||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
@@ -398,11 +387,11 @@ mod tests {
|
||||
|
||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
|
||||
/// (the header) must NOT be modified by the keystream — except byte 0x14
|
||||
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
|
||||
/// (which live inside the header) must survive untouched, since the
|
||||
/// descrambler reads them but never writes them.
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
|
||||
/// header) must NOT be modified by the keystream — except byte 0x14 whose
|
||||
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
|
||||
/// live inside the header) must survive untouched, since the descrambler
|
||||
/// reads them but never writes them.
|
||||
///
|
||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||
/// 128..2048 only.
|
||||
@@ -429,16 +418,15 @@ mod tests {
|
||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
|
||||
/// prefix. With a constant body and constant key, the keystream is
|
||||
/// non-degenerate enough that the very last sector byte (index 2047) is
|
||||
/// altered. This guards the loop bound `.take(2048)` against an
|
||||
/// off-by-one that would leave the final byte(s) scrambled.
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
|
||||
/// With a constant body and constant key, the keystream is non-degenerate
|
||||
/// enough that the very last sector byte (index 2047) is altered. This guards
|
||||
/// the loop bound `.take(2048)` against an off-by-one that would leave the
|
||||
/// final byte(s) scrambled.
|
||||
///
|
||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||
/// assert fires (keystream byte for the last position is verified nonzero
|
||||
/// below by the round-trip, and this body is all-zero so any XOR shows).
|
||||
/// assert fires (this body is all-zero so any keystream XOR shows).
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
@@ -447,9 +435,7 @@ mod tests {
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||
// the keystream reaches the final byte. (If the last keystream byte
|
||||
// happened to be 0 this could be a flaky test, so assert the run-end
|
||||
// region as a whole differs from zero.)
|
||||
// the keystream reaches the final byte.
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
@@ -458,13 +444,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Descramble is keyed by `title_key XOR seed`: two different title keys
|
||||
/// produce two different bodies for the same scrambled input. A cipher
|
||||
/// that ignored the title key (or mixed it in wrongly) would yield
|
||||
/// identical output — silent wrong-key decryption.
|
||||
/// produce two different bodies for the same scrambled input. A cipher that
|
||||
/// ignored the title key (or mixed it in wrongly) would yield identical
|
||||
/// output — silent wrong-key decryption.
|
||||
///
|
||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
|
||||
/// keys give the same body, assert fires.
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
|
||||
/// give the same body, assert fires.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_title_key() {
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
+47
-1
@@ -46,7 +46,7 @@ pub struct CssState {
|
||||
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
||||
///
|
||||
/// The crib comes from `AttackPattern`: a scrambled sector's cleartext region
|
||||
/// The crib comes from the periodic-run detector: a scrambled sector's cleartext region
|
||||
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
|
||||
/// constant fill); the attack assumes that run continues across the 0x80
|
||||
/// boundary into the encrypted region, giving the known plaintext the 2^16
|
||||
@@ -271,6 +271,52 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
||||
lfsr::descramble_sector(&state.title_key, sector);
|
||||
}
|
||||
|
||||
/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB
|
||||
/// region boundary. `title_key` is a CACHE of the last crack, not a fixed disc
|
||||
/// key: it changes per VTS/VOB region, so it is validated on every scrambled
|
||||
/// sector and re-cracked on a miss (the standard on-demand per-region rekey).
|
||||
///
|
||||
/// This CSS key acquisition is intrinsic to the cipher — CSS has no external key
|
||||
/// source, the ONLY way to a title key is cracking the data — so it lives with
|
||||
/// the CSS primitives and runs inside `decrypt::decrypt_sectors` (a public,
|
||||
/// self-contained CSS decrypt), NOT at the post-decrypt recovery seam that AACS
|
||||
/// key-fetch and FMTS segment-skip use (those consume external inputs).
|
||||
///
|
||||
/// The clear header (`<0x80`) is never scrambled, so its periodic crib predicts
|
||||
/// the plaintext at `0x80`. Descramble with the cached key; if the crib fails to
|
||||
/// reappear the key region changed (or the primed key was wrong) — restore the
|
||||
/// ciphertext, re-crack from this very sector, and descramble again. A crib-less
|
||||
/// sector (no periodic run) can be neither validated nor cracked, so it rides the
|
||||
/// cached key — correct, because it lives in the same region as the nearby crib
|
||||
/// sector that set the cache.
|
||||
pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) {
|
||||
for chunk in buf.chunks_mut(2048) {
|
||||
if chunk.len() < 2048 || !is_scrambled(chunk) {
|
||||
continue;
|
||||
}
|
||||
let crib = stevenson::attack_crib(chunk);
|
||||
// Snapshot the ciphertext (chunk is exactly 2048 here) only when there is
|
||||
// a crib to validate against, so the common cache-hit path costs no
|
||||
// per-sector heap allocation.
|
||||
let mut original = [0u8; 2048];
|
||||
if crib.is_some() {
|
||||
original.copy_from_slice(chunk);
|
||||
}
|
||||
lfsr::descramble_sector(title_key, chunk);
|
||||
if let Some(crib) = crib {
|
||||
if chunk[0x80..0x80 + 10] != crib[..] {
|
||||
// Cached key is stale for this region — restore the ciphertext and
|
||||
// crack this sector's own key.
|
||||
chunk.copy_from_slice(&original);
|
||||
if let Some(fresh) = stevenson::crack_title_key(chunk) {
|
||||
*title_key = fresh;
|
||||
}
|
||||
lfsr::descramble_sector(title_key, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a sector has the CSS scramble flag set.
|
||||
///
|
||||
/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by
|
||||
|
||||
+42
-61
@@ -1,48 +1,37 @@
|
||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
||||
//! (css.c).
|
||||
//!
|
||||
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
||||
//! no player keys and no disc-key crack, using only known plaintext.
|
||||
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
|
||||
//! Contents Scrambling System"). It recovers the 5-byte CSS title key from a
|
||||
//! single scrambled DVD sector with no player keys and no disc-key crack, using
|
||||
//! only known plaintext. Implemented from that public description; nothing here
|
||||
//! is copied or translated from any particular CSS software.
|
||||
//!
|
||||
//! # The cipher this attacks
|
||||
//!
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
||||
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
||||
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
||||
//!
|
||||
//! ```text
|
||||
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
||||
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
||||
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
|
||||
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
|
||||
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
|
||||
//! ```
|
||||
//!
|
||||
//! There is NO `decrypt_key` mangling on the content path. So the recovery
|
||||
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
|
||||
//! working-key inversion the previous (non-CSS) implementation used.
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
|
||||
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
|
||||
//! `sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from
|
||||
//! bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body
|
||||
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
|
||||
//! title-key mangling on the content path, so the recovery is a single inversion
|
||||
//! of the sector cipher.
|
||||
//!
|
||||
//! # The attack
|
||||
//!
|
||||
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
||||
//! to the ciphertext, the per-byte keystream is
|
||||
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
|
||||
//! `RecoverTitleKey`'s `p_buffer`).
|
||||
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
|
||||
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
|
||||
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from
|
||||
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a
|
||||
//! wrong LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
|
||||
//! search for the byte shifted in) to reach the initial state, then undo
|
||||
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
||||
//! the descramble seeds directly, so there is no inversion).
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from the
|
||||
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
|
||||
//! LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
|
||||
//! the byte shifted in) to reach the initial state, then undo the
|
||||
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
|
||||
//!
|
||||
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
|
||||
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
|
||||
//! encrypted region at 0x80.
|
||||
//! Known plaintext for step 1 comes from the longest periodic run in the
|
||||
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
|
||||
//! 0x80.
|
||||
|
||||
use super::lfsr::descramble_sector;
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
@@ -52,13 +41,11 @@ const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||
const FLAG_BYTE: usize = 0x14;
|
||||
|
||||
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
||||
///
|
||||
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
|
||||
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
|
||||
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
|
||||
/// recovered 5-byte title key; `None` if no LFSR seed reproduces the
|
||||
/// keystream.
|
||||
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
|
||||
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
|
||||
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
|
||||
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
|
||||
/// reproduces the keystream.
|
||||
///
|
||||
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
||||
@@ -222,16 +209,13 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
||||
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||
}
|
||||
|
||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
||||
/// and assume the plaintext at 0x80 continues it.
|
||||
///
|
||||
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
|
||||
/// same periodic cribs on real DVD data, though its byte-comparison anchor
|
||||
/// differs from the C on phase-misaligned runs. Scans cleartext
|
||||
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
||||
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
|
||||
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
||||
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
||||
/// Find a repeating pattern just before the encrypted region and assume the
|
||||
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
|
||||
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
|
||||
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
|
||||
/// least two full cycles), the known plaintext at 0x80 is taken to be the
|
||||
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
|
||||
/// applied.
|
||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES {
|
||||
return None;
|
||||
@@ -260,10 +244,7 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
|
||||
/// out so the public entry point can wall-clock the whole attempt for the
|
||||
/// runaway guard without threading a timer through every return path.
|
||||
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
|
||||
/// Crib: the predicted 10-byte plaintext at byte 0x80.
|
||||
///
|
||||
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
||||
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||
@@ -369,7 +350,7 @@ mod tests {
|
||||
|
||||
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||
/// in a periodic run that continues into the encrypted region — the case
|
||||
/// `AttackPattern` (crack_title_key) is designed to crack.
|
||||
/// `crack_title_key` is designed to crack.
|
||||
fn synth_periodic_sector(
|
||||
title_key: &[u8; 5],
|
||||
seed: &[u8; 5],
|
||||
@@ -382,7 +363,7 @@ mod tests {
|
||||
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||
// mirrors a real VOB: a periodic data run just before the scrambled
|
||||
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
|
||||
// AttackPattern detector would break mid-run. The phase is anchored to
|
||||
// the crib detector would break mid-run. The phase is anchored to
|
||||
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||
// — enough for >=2 cycles of every tested period (<=19).
|
||||
@@ -469,7 +450,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// MANDATORY (Task C.1): the AttackPattern entry point crack_title_key —
|
||||
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
|
||||
/// no plaintext supplied — recovers a round-tripping key when the
|
||||
/// cleartext ends in a periodic run that continues into 0x80.
|
||||
#[test]
|
||||
@@ -491,7 +472,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// recover_title_key_from_plain inverts dvdcss_unscramble exactly: scramble
|
||||
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
|
||||
/// a known body, hand back the keystream-derived key, and the recovered
|
||||
/// key (XOR-back included) reproduces the plaintext.
|
||||
#[test]
|
||||
|
||||
+11
-12
@@ -24,9 +24,8 @@ pub const TAB1: [u8; 256] = [
|
||||
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
|
||||
];
|
||||
|
||||
/// Table 2: LFSR1 high-byte feedback permutation.
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
||||
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
|
||||
/// cipher (per the published algorithm).
|
||||
pub const TAB2: [u8; 256] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
||||
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
|
||||
@@ -46,12 +45,12 @@ pub const TAB2: [u8; 256] = [
|
||||
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
|
||||
];
|
||||
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries).
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries) — a fixed constant
|
||||
/// of the CSS cipher (per the published algorithm).
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab3` (csstables.h): the 8-value
|
||||
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
|
||||
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
|
||||
/// bits select the output — the high bits are ignored, hence the constant
|
||||
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
|
||||
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
|
||||
/// low 3 bits select the output — the high bits are ignored, hence the constant
|
||||
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||
/// masking.
|
||||
pub const TAB3: [u8; 512] = [
|
||||
@@ -197,12 +196,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// TAB3 is the libdvdcss `p_css_tab3`: the 8-value feedback block
|
||||
/// TAB3 is the CSS LFSR1 low-word table: the 8-value feedback block
|
||||
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
||||
/// 9-bit index do not affect the output (libdvdcss's LFSR1 step indexes
|
||||
/// with the full 9-bit low register but only `& 7` matters). This pins
|
||||
/// all 512 entries to the published table.
|
||||
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
|
||||
/// full 9-bit low register but only `& 7` matters). This pins all 512
|
||||
/// entries to the published cipher's table.
|
||||
///
|
||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||
/// check fails at that index.
|
||||
|
||||
+165
-115
@@ -123,17 +123,24 @@ pub fn decrypt_threads() -> usize {
|
||||
if explicit > 0 {
|
||||
return explicit;
|
||||
}
|
||||
let env = std::env::var("FREEMKV_THREADS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
if env > 0 {
|
||||
return env.min(MAX_THREADS);
|
||||
}
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2);
|
||||
cores.clamp(1, MAX_THREADS)
|
||||
// Resolve the `FREEMKV_THREADS` env var + `available_parallelism()` ONCE and
|
||||
// cache it — this runs on the per-buffer decrypt hot path, and a getenv +
|
||||
// String alloc + parallelism syscall per call is pure overhead. The explicit
|
||||
// `set_decrypt_threads` override above still takes effect dynamically.
|
||||
static DEFAULT_THREADS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
*DEFAULT_THREADS.get_or_init(|| {
|
||||
let env = std::env::var("FREEMKV_THREADS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
if env > 0 {
|
||||
return env.min(MAX_THREADS);
|
||||
}
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2);
|
||||
cores.clamp(1, MAX_THREADS)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolved decryption state from disc scanning.
|
||||
@@ -244,7 +251,7 @@ fn decrypt_sectors_impl(
|
||||
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
|
||||
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
|
||||
let rdk: Option<[u8; 16]> = *read_data_key;
|
||||
let unit_len = aacs::ALIGNED_UNIT_LEN;
|
||||
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
||||
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
||||
// (mux/disc.rs::fill_extents) issues 1- or 2-sector reads at every
|
||||
// extent tail, so a buffer is commonly NOT a multiple of the unit
|
||||
@@ -288,8 +295,8 @@ fn decrypt_sectors_impl(
|
||||
};
|
||||
if partial_in_content {
|
||||
let partial = &buf[buf.len() - partial_len..];
|
||||
let packets = aacs::ts_packet_total(partial);
|
||||
if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 {
|
||||
let packets = aacs::content::ts_packet_total(partial);
|
||||
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
}
|
||||
@@ -326,7 +333,7 @@ fn decrypt_sectors_impl(
|
||||
// must happen first — it's a shared layer on top that is key-independent
|
||||
// across all CPS units on the disc.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
if chunk.len() != unit_len || !aacs::aacs_unit_needs_decrypt(chunk) {
|
||||
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
return;
|
||||
}
|
||||
// Save original bytes so we can restore if no key validates.
|
||||
@@ -335,7 +342,7 @@ fn decrypt_sectors_impl(
|
||||
// Build a bus-decrypted copy to try unit keys against, or work
|
||||
// in-place when there is no bus layer.
|
||||
if let Some(ref rdk_key) = rdk {
|
||||
aacs::decrypt_bus(chunk, rdk_key);
|
||||
aacs::content::decrypt_bus(chunk, rdk_key);
|
||||
}
|
||||
|
||||
// Reorder the key iterator: try the cached hint first, then fall
|
||||
@@ -349,7 +356,7 @@ fn decrypt_sectors_impl(
|
||||
// Work on a per-key copy so a failing attempt doesn't
|
||||
// clobber the bus-decrypted base we'll retry on.
|
||||
let mut attempt: Vec<u8> = chunk.to_vec();
|
||||
if aacs::decrypt_unit(&mut attempt, key) {
|
||||
if aacs::content::decrypt_unit(&mut attempt, key) {
|
||||
chunk.copy_from_slice(&attempt);
|
||||
last_key_idx.store(idx, Ordering::Relaxed);
|
||||
return;
|
||||
@@ -418,49 +425,13 @@ fn decrypt_sectors_impl(
|
||||
dropped_bytes.into_inner()
|
||||
}
|
||||
DecryptKeys::Css { title_key } => {
|
||||
// CSS has no supplied key list: the ONLY source of a title key is
|
||||
// cracking the data, and the key changes per VTS/VOB region. So
|
||||
// `title_key` is a CACHE of the last crack, not a fixed disc key —
|
||||
// applying it blindly across a region boundary descrambles with the
|
||||
// wrong key (valid headers, garbage payload). Validate it on every
|
||||
// scrambled sector and re-crack on a miss (libdvdcss's on-demand
|
||||
// per-region rekey; the same validate-then-rekey shape the AACS arm
|
||||
// above uses, but re-cracking instead of picking from a list).
|
||||
//
|
||||
// The clear header (<0x80) is never scrambled, so its periodic crib
|
||||
// predicts the plaintext at 0x80. Descramble with the cached key; if
|
||||
// the crib fails to reappear the key region changed (or the primed
|
||||
// key was wrong) — restore the ciphertext, re-crack from this very
|
||||
// sector, and descramble again. A crib-less sector (no periodic run)
|
||||
// can be neither validated nor cracked, so it rides the cached key —
|
||||
// correct, because it lives in the same region as the nearby crib
|
||||
// sector that set the cache.
|
||||
for chunk in buf.chunks_mut(2048) {
|
||||
if chunk.len() < 2048 || !css::is_scrambled(chunk) {
|
||||
continue;
|
||||
}
|
||||
let crib = css::stevenson::attack_crib(chunk);
|
||||
// Snapshot the ciphertext into a stack buffer (chunk is exactly
|
||||
// 2048 here — guaranteed by the `< 2048` continue above) only
|
||||
// when there's a crib to validate against, so the common
|
||||
// cache-hit path costs no per-sector heap allocation.
|
||||
let mut original = [0u8; 2048];
|
||||
if crib.is_some() {
|
||||
original.copy_from_slice(chunk);
|
||||
}
|
||||
css::lfsr::descramble_sector(title_key, chunk);
|
||||
if let Some(crib) = crib {
|
||||
if chunk[0x80..0x80 + 10] != crib[..] {
|
||||
// Cached key is stale for this region — restore the
|
||||
// ciphertext and crack this sector's own key.
|
||||
chunk.copy_from_slice(&original);
|
||||
if let Some(fresh) = css::stevenson::crack_title_key(chunk) {
|
||||
*title_key = fresh;
|
||||
}
|
||||
css::lfsr::descramble_sector(title_key, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
// CSS SELF-recovers: the title key changes per VOB region and is
|
||||
// re-cracked constantly, but always FROM THE DATA ITSELF — no external
|
||||
// input. So the whole descramble-and-rekey is self-contained here (see
|
||||
// `css::descramble_region`), and CSS does not need the post-decrypt
|
||||
// recovery seam that AACS key-fetch / FMTS segment-skip use (those DO
|
||||
// consume external inputs a `decrypt_sectors` caller cannot supply).
|
||||
css::descramble_region(buf, title_key);
|
||||
0
|
||||
}
|
||||
};
|
||||
@@ -478,7 +449,7 @@ mod tests {
|
||||
/// not left scrambled.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_decrypt_attempt() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
unit[0] = b'M';
|
||||
unit[1] = b'P';
|
||||
unit[2] = b'L';
|
||||
@@ -528,7 +499,7 @@ mod tests {
|
||||
let mut u = 0;
|
||||
while u < len {
|
||||
v[u] |= 0xC0;
|
||||
u += aacs::ALIGNED_UNIT_LEN;
|
||||
u += aacs::content::ALIGNED_UNIT_LEN;
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -561,7 +532,7 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
|
||||
// base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content.
|
||||
let mut buf = original.clone();
|
||||
@@ -581,7 +552,7 @@ mod tests {
|
||||
decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped2,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"an undecryptable CONTENT unit IS counted as loss"
|
||||
);
|
||||
}
|
||||
@@ -594,12 +565,12 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap();
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"only the in-content unit (unit1) is checked; clear unit0 is skipped"
|
||||
);
|
||||
}
|
||||
@@ -613,7 +584,7 @@ mod tests {
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut keys_u = keys_g.clone();
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut g = original.clone();
|
||||
let mut u = original.clone();
|
||||
let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap();
|
||||
@@ -662,7 +633,7 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap();
|
||||
assert_eq!(
|
||||
@@ -680,7 +651,7 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let original = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext");
|
||||
@@ -691,7 +662,7 @@ mod tests {
|
||||
#[test]
|
||||
fn content_gate_none_keys_is_noop() {
|
||||
let mut keys = DecryptKeys::None;
|
||||
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(dropped, 0);
|
||||
@@ -713,6 +684,83 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a Stevenson-crackable scrambled CSS sector for `title_key` (mirrors
|
||||
/// `crackable_sector` in the css::mod tests): a periodic run in the clear
|
||||
/// header continues past 0x80 into the encrypted region, so
|
||||
/// `stevenson::crack_title_key` recovers the key. Distinct `seed` values give
|
||||
/// two sectors different cribs, standing in for two VOB regions.
|
||||
fn crackable_css_sector(title_key: &[u8; 5], seed: &[u8; 5]) -> Vec<u8> {
|
||||
const RUN_START: usize = 0x59;
|
||||
const SEED_OFFSET: usize = 0x54;
|
||||
const PERIOD: usize = 8;
|
||||
let mut plaintext = vec![0u8; 2048];
|
||||
plaintext[0x00..0x04].copy_from_slice(&css::PACK_START);
|
||||
plaintext[0x14] = 0x10; // scramble flag
|
||||
let pat: Vec<u8> = (0..PERIOD)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = pat[i % PERIOD];
|
||||
}
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
css::lfsr::scramble_sector(title_key, &mut plaintext);
|
||||
plaintext
|
||||
}
|
||||
|
||||
/// CHARACTERIZATION (recovery refactor safety net): the CSS arm's per-region
|
||||
/// re-crack (the `title_key` cache is stale for a new VOB region → restore
|
||||
/// ciphertext, `crack_title_key` this sector, re-descramble). Two crackable
|
||||
/// sectors scrambled under DIFFERENT keys sit back-to-back; the cache is
|
||||
/// primed to the FIRST key. Sector 0 rides the cache (crib matches); sector 1
|
||||
/// must trip the crib mismatch and re-crack to its own key. Both must land
|
||||
/// correct plaintext, and the cache must end on region 1's key.
|
||||
///
|
||||
/// This behaviour currently lives inline in `decrypt_sectors` (the `Css`
|
||||
/// arm). It is the delicate logic the recovery refactor will move to the
|
||||
/// input-stream seam, so it must stay green byte-for-byte across that move.
|
||||
#[test]
|
||||
fn css_region_change_recracks_the_title_key() {
|
||||
let key_a = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let key_b = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
|
||||
let sector_a = crackable_css_sector(&key_a, &[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
let sector_b = crackable_css_sector(&key_b, &[0x09, 0x08, 0x07, 0x06, 0x05]);
|
||||
|
||||
// Expected plaintext bodies: each sector descrambled under its true key.
|
||||
let mut plain_a = sector_a.clone();
|
||||
css::lfsr::descramble_sector(&key_a, &mut plain_a);
|
||||
let mut plain_b = sector_b.clone();
|
||||
css::lfsr::descramble_sector(&key_b, &mut plain_b);
|
||||
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
buf.extend_from_slice(§or_a);
|
||||
buf.extend_from_slice(§or_b);
|
||||
|
||||
// Cache primed to region A's key (as if A was the last crack). CSS
|
||||
// descramble-and-rekey lives in `css::descramble_region` (the recovery
|
||||
// seam calls it); the region change must re-crack region B's key.
|
||||
let mut ended = key_a;
|
||||
css::descramble_region(&mut buf, &mut ended);
|
||||
|
||||
assert_eq!(
|
||||
&buf[0x80..2048],
|
||||
&plain_a[0x80..2048],
|
||||
"sector 0 rides the cached key (crib matches, no re-crack)"
|
||||
);
|
||||
assert_eq!(
|
||||
&buf[2048 + 0x80..4096],
|
||||
&plain_b[0x80..2048],
|
||||
"sector 1 re-cracks its own region key and descrambles correctly"
|
||||
);
|
||||
// The cache must have advanced to a key that descrambles region B.
|
||||
let mut check_b = sector_b.clone();
|
||||
css::lfsr::descramble_sector(&ended, &mut check_b);
|
||||
assert_eq!(
|
||||
&check_b[0x80..2048],
|
||||
&plain_b[0x80..2048],
|
||||
"the ended cache key must round-trip region B's body"
|
||||
);
|
||||
}
|
||||
|
||||
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
|
||||
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
|
||||
#[test]
|
||||
@@ -721,7 +769,7 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let u = aacs::ALIGNED_UNIT_LEN;
|
||||
let u = aacs::content::ALIGNED_UNIT_LEN;
|
||||
let mut buf = vec![0u8; 3 * u];
|
||||
buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled
|
||||
buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear
|
||||
@@ -738,10 +786,14 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
||||
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
|
||||
assert_eq!(dropped, aacs::ALIGNED_UNIT_LEN, "only unit0 counts");
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"only unit0 counts"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trailing-partial reject is ALSO content-gated: a scrambled partial
|
||||
@@ -755,7 +807,7 @@ mod tests {
|
||||
};
|
||||
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
||||
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
buf.extend_from_slice(&scrambled_region(2048));
|
||||
// content far away → both the full unit and the partial are non-content.
|
||||
let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]);
|
||||
@@ -776,7 +828,7 @@ mod tests {
|
||||
read_data_key: None,
|
||||
};
|
||||
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
||||
let unit = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let tail = clear_ts_region(2048);
|
||||
let mut buf = unit;
|
||||
buf.extend_from_slice(&tail);
|
||||
@@ -784,7 +836,7 @@ mod tests {
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
|
||||
|
||||
assert_eq!(
|
||||
&buf[aacs::ALIGNED_UNIT_LEN..],
|
||||
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
||||
&tail[..],
|
||||
"clear trailing partial unit must be left unchanged"
|
||||
);
|
||||
@@ -801,7 +853,7 @@ mod tests {
|
||||
read_data_key: None,
|
||||
};
|
||||
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
|
||||
let unit = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let tail = scrambled_region(4096);
|
||||
let mut buf = unit;
|
||||
buf.extend_from_slice(&tail);
|
||||
@@ -835,7 +887,7 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN * 2);
|
||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
|
||||
let snapshot = buf.clone();
|
||||
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
|
||||
@@ -908,11 +960,12 @@ mod tests {
|
||||
/// fixed wrong key -> the body no longer matches the plaintext.
|
||||
#[test]
|
||||
fn css_descrambles_with_title_key() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5);
|
||||
let mut keys = DecryptKeys::Css { title_key };
|
||||
decrypt_sectors(&mut sector, &mut keys, 0).expect("CSS decrypt is Ok");
|
||||
// CSS descramble lives in `css::descramble_region` (the recovery seam
|
||||
// calls it); `decrypt_sectors` only flags CSS sectors for recovery.
|
||||
css::descramble_region(&mut sector, &mut title_key);
|
||||
assert_eq!(
|
||||
§or[0x80..2048],
|
||||
&plaintext[0x80..2048],
|
||||
@@ -941,8 +994,8 @@ mod tests {
|
||||
let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3);
|
||||
let mut buf = s0;
|
||||
buf.extend_from_slice(&s1);
|
||||
let mut keys = DecryptKeys::Css { title_key };
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-sector decrypt is Ok");
|
||||
let mut title_key = title_key;
|
||||
css::descramble_region(&mut buf, &mut title_key);
|
||||
assert_eq!(
|
||||
&buf[0x80..2048],
|
||||
&p0[0x80..2048],
|
||||
@@ -1020,8 +1073,8 @@ mod tests {
|
||||
buf.extend_from_slice(&s1);
|
||||
|
||||
// Cache primed to key_a only — exactly what the one-shot scan crack yields.
|
||||
let mut keys = DecryptKeys::Css { title_key: key_a };
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-region decrypt is Ok");
|
||||
let mut title_key = key_a;
|
||||
css::descramble_region(&mut buf, &mut title_key);
|
||||
|
||||
assert_eq!(
|
||||
&buf[0x80..2048],
|
||||
@@ -1034,13 +1087,10 @@ mod tests {
|
||||
"region B sector must descramble after the path re-cracks its own key"
|
||||
);
|
||||
// The cache must have advanced to region B's key.
|
||||
match keys {
|
||||
DecryptKeys::Css { title_key } => assert_eq!(
|
||||
title_key, key_b,
|
||||
"cache must hold region B's key after the rekey"
|
||||
),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(
|
||||
title_key, key_b,
|
||||
"cache must hold region B's key after the rekey"
|
||||
);
|
||||
}
|
||||
|
||||
/// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte
|
||||
@@ -1092,7 +1142,7 @@ mod tests {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let err = decrypt_sectors(&mut buf, &mut keys, 5)
|
||||
.expect_err("unit_key_idx 5 is out of range for a 1-key list");
|
||||
assert_eq!(
|
||||
@@ -1112,7 +1162,7 @@ mod tests {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
|
||||
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
|
||||
}
|
||||
@@ -1120,8 +1170,8 @@ mod tests {
|
||||
// ── Multi-CPS-unit key selection ──────────────────────────────────────
|
||||
|
||||
/// Encrypt an aligned unit with the AACS algorithm run in reverse so that
|
||||
/// `aacs::decrypt_unit` with the same key recovers the plaintext. Mirrors
|
||||
/// the `aacs_encrypt_unit` helper in `aacs::decrypt::tests`.
|
||||
/// `aacs::content::decrypt_unit` with the same key recovers the plaintext. Mirrors
|
||||
/// the `aacs_encrypt_unit` helper in `aacs::content::tests`.
|
||||
fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
@@ -1129,14 +1179,14 @@ mod tests {
|
||||
// the per-unit key so the recovered plaintext header matches.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = crate::aacs::decrypt::AACS_IV;
|
||||
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
let mut prev = crate::aacs::crypto::AACS_IV;
|
||||
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
@@ -1153,9 +1203,9 @@ mod tests {
|
||||
/// (offset 4 + k*192) so `ts_sync_destroyed` reports false and
|
||||
/// `decrypt_unit` verifies it as clear after decryption.
|
||||
fn clear_ts_unit() -> Vec<u8> {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
@@ -1171,7 +1221,7 @@ mod tests {
|
||||
/// garbage for content under key ≥ 1. The fix tries every key and
|
||||
/// accepts the one whose output passes the TS-sync verify.
|
||||
///
|
||||
/// Grounding: `for idx in try_order { … if aacs::decrypt_unit(&mut attempt, key) { … } }`
|
||||
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
|
||||
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
|
||||
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
|
||||
/// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed`
|
||||
@@ -1185,7 +1235,7 @@ mod tests {
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
||||
assert!(
|
||||
aacs::ts_sync_destroyed(&unit),
|
||||
aacs::content::ts_sync_destroyed(&unit),
|
||||
"encrypted unit must look scrambled before decrypt"
|
||||
);
|
||||
|
||||
@@ -1199,13 +1249,13 @@ mod tests {
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
||||
|
||||
assert!(
|
||||
!aacs::ts_sync_destroyed(&buf),
|
||||
!aacs::content::ts_sync_destroyed(&buf),
|
||||
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
||||
);
|
||||
// Every sync position must carry 0x47.
|
||||
assert_eq!(
|
||||
aacs::ts_sync_count(&buf),
|
||||
aacs::ts_packet_total(&buf),
|
||||
aacs::content::ts_sync_count(&buf),
|
||||
aacs::content::ts_packet_total(&buf),
|
||||
"all TS sync bytes must be restored after decrypting under key1"
|
||||
);
|
||||
}
|
||||
@@ -1230,12 +1280,12 @@ mod tests {
|
||||
let mut buf = unit;
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
||||
assert!(
|
||||
!aacs::ts_sync_destroyed(&buf),
|
||||
!aacs::content::ts_sync_destroyed(&buf),
|
||||
"single-key disc: TS syncs must be restored"
|
||||
);
|
||||
assert_eq!(
|
||||
aacs::ts_sync_count(&buf),
|
||||
aacs::ts_packet_total(&buf),
|
||||
aacs::content::ts_sync_count(&buf),
|
||||
aacs::content::ts_packet_total(&buf),
|
||||
"all TS sync bytes must be restored for single-key disc"
|
||||
);
|
||||
}
|
||||
@@ -1263,7 +1313,7 @@ mod tests {
|
||||
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
||||
let ciphertext = unit.clone();
|
||||
assert!(
|
||||
aacs::ts_sync_destroyed(&unit),
|
||||
aacs::content::ts_sync_destroyed(&unit),
|
||||
"encrypted unit must look scrambled going in"
|
||||
);
|
||||
|
||||
@@ -1277,7 +1327,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"the whole scrambled unit must be reported as dropped when no key validates"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1306,7 +1356,7 @@ mod tests {
|
||||
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
|
||||
let unit_b_ciphertext = unit_b.clone();
|
||||
|
||||
let mut buf = Vec::with_capacity(2 * aacs::ALIGNED_UNIT_LEN);
|
||||
let mut buf = Vec::with_capacity(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||
buf.extend_from_slice(&unit_a);
|
||||
buf.extend_from_slice(&unit_b);
|
||||
|
||||
@@ -1318,15 +1368,15 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
aacs::content::ALIGNED_UNIT_LEN,
|
||||
"exactly one unit's worth of bytes must be reported dropped"
|
||||
);
|
||||
assert!(
|
||||
!aacs::ts_sync_destroyed(&buf[..aacs::ALIGNED_UNIT_LEN]),
|
||||
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
|
||||
"the decryptable unit must come out clear"
|
||||
);
|
||||
assert_eq!(
|
||||
&buf[aacs::ALIGNED_UNIT_LEN..],
|
||||
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
||||
&unit_b_ciphertext[..],
|
||||
"the undecryptable unit must be restored to ciphertext"
|
||||
);
|
||||
|
||||
+79
-255
@@ -6,6 +6,18 @@ use crate::mpls;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
/// Stream-file extensions probed for a BD-family playlist clip, in priority
|
||||
/// order. A clip is normally `.m2ts`; AACS 2.1 (FMTS) discs name the main feature
|
||||
/// `.fmts` (an M2TS transport stream plus forensic variant segments) and 3D discs
|
||||
/// use `.ssif`. `.m2ts` is tried first, so a normal clip is unaffected — the
|
||||
/// fallback only runs when `.m2ts` is absent (exactly when `file_extents` errors).
|
||||
///
|
||||
/// Scope: these are all variants that live in `BDMV/STREAM/` and are reached
|
||||
/// through an MPLS playlist. HD-DVD's `.evo` does NOT belong here — HD-DVD is a
|
||||
/// different tree (`HVDVD_TS/`) with `.XPL` playlists and needs its own
|
||||
/// enumerator (a peer to `parse_playlist`), not another extension in this list.
|
||||
const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"];
|
||||
|
||||
impl Disc {
|
||||
/// Scan Blu-ray titles from MPLS playlists.
|
||||
pub(super) fn scan_bluray_titles(
|
||||
@@ -73,22 +85,36 @@ impl Disc {
|
||||
for play_item in &parsed.play_items {
|
||||
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
|
||||
let mut pkt_count: u32 = 0;
|
||||
let first_ref = seen_clips.insert(play_item.clip_id.clone());
|
||||
|
||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
||||
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
|
||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
||||
pkt_count = clip_info.source_packet_count;
|
||||
|
||||
// Mark the clip seen ONLY after its .clpi parses — a transient
|
||||
// read/parse failure on the first PlayItem referencing a clip
|
||||
// must not permanently suppress its extents/size for a later
|
||||
// PlayItem referencing the same clip that succeeds.
|
||||
let first_ref = seen_clips.insert(play_item.clip_id.clone());
|
||||
|
||||
// Only fetch/push the physical extents and add to the
|
||||
// total size the first time this clip_id is seen.
|
||||
if first_ref {
|
||||
total_size += pkt_count as u64 * 192;
|
||||
|
||||
// Get m2ts file extents from UDF allocation descriptors.
|
||||
// Get stream file extents from UDF allocation descriptors.
|
||||
// Dual-layer discs split files across layers — UDF knows the real layout.
|
||||
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
|
||||
if let Ok(file_exts) = udf_fs.file_extents(reader, &m2ts_path) {
|
||||
//
|
||||
// The clip's stream file is normally `.m2ts`, but AACS 2.1
|
||||
// (FMTS) discs name the main feature `.fmts` and 3D discs
|
||||
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
|
||||
// clip is unchanged — the fallback only runs when `.m2ts`
|
||||
// is absent, which is exactly when `file_extents` errors.
|
||||
let file_exts = CLIP_STREAM_EXTS.iter().find_map(|ext| {
|
||||
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
|
||||
udf_fs.file_extents(reader, &path).ok()
|
||||
});
|
||||
if let Some(file_exts) = file_exts {
|
||||
for (lba, sectors) in file_exts {
|
||||
if sectors > 0 && lba > 0 {
|
||||
extents.push(Extent {
|
||||
@@ -314,256 +340,7 @@ impl Disc {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc backing store
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// In-memory SectorSource backed by an absolute-LBA → 2048-byte
|
||||
/// sector map. Unmapped sectors read as zeroes (matches a freshly
|
||||
/// formatted region). Mirrors the `MapReader` used in `udf.rs`
|
||||
/// tests so fixtures are byte-for-byte interoperable.
|
||||
struct MemDisc {
|
||||
sectors: HashMap<u32, [u8; 2048]>,
|
||||
}
|
||||
|
||||
impl MemDisc {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sectors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||
self.sectors.insert(lba, data);
|
||||
}
|
||||
/// Write arbitrary-length bytes starting at `lba`, splitting across
|
||||
/// consecutive 2048-byte sectors (zero-padded last sector).
|
||||
fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
|
||||
for (i, chunk) in bytes.chunks(2048).enumerate() {
|
||||
let mut s = [0u8; 2048];
|
||||
s[..chunk.len()].copy_from_slice(chunk);
|
||||
self.put(lba + i as u32, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for MemDisc {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let need = count as usize * 2048;
|
||||
for i in 0..count as u32 {
|
||||
let off = i as usize * 2048;
|
||||
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
||||
buf[off..off + 2048].copy_from_slice(&s);
|
||||
}
|
||||
Ok(need)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// UDF image builder — produces a disc image `udf::read_filesystem`
|
||||
// can navigate. All field offsets are cited from ECMA-167 / the
|
||||
// exact bytes `udf.rs::read_filesystem` reads.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// Fixed layout. PART_START == META_START so file LBAs (physical
|
||||
/// partition relative) and ICB/dir LBAs (metadata relative) share
|
||||
/// one address space — both resolve to abs = PART_START + lba. This
|
||||
/// keeps fixtures small; `read_filesystem` takes the single-partition
|
||||
/// path (num_partition_maps == 1) so no metadata-partition file is
|
||||
/// needed.
|
||||
const PART_START: u32 = 2000;
|
||||
|
||||
/// One file's on-disc placement: metadata LBA of its ICB, the LBA of
|
||||
/// its (single contiguous) data extent, byte length, and whether the
|
||||
/// ICB encodes its allocation descriptor as a Long AD (16-byte, the
|
||||
/// real BD-ROM .m2ts layout) vs Short AD (8-byte).
|
||||
struct FileSpec {
|
||||
name: String,
|
||||
icb_lba: u32,
|
||||
data_lba: u32,
|
||||
size: u32,
|
||||
long_ad: bool,
|
||||
/// Optional explicit file contents written at `data_lba`.
|
||||
contents: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A directory node for the builder: its ICB LBA, the LBA where its
|
||||
/// FID list lives, child files, and child subdirectories.
|
||||
struct DirSpec {
|
||||
name: String,
|
||||
icb_lba: u32,
|
||||
dir_data_lba: u32,
|
||||
files: Vec<FileSpec>,
|
||||
subdirs: Vec<DirSpec>,
|
||||
}
|
||||
|
||||
/// Build an Extended File Entry ICB (tag 266) with one allocation
|
||||
/// descriptor. Offsets per `udf.rs`: tag@0, ICB-tag flags@34,
|
||||
/// info_length(u64)@56, l_ea@208, l_ad@212, ADs@216.
|
||||
fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
|
||||
let mut s = [0u8; 2048];
|
||||
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
|
||||
if long_ad {
|
||||
// ICB Tag flags low 3 bits = 1 → Long AD (16-byte stride).
|
||||
s[34..36].copy_from_slice(&1u16.to_le_bytes());
|
||||
}
|
||||
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
|
||||
let ad_size: u32 = if long_ad { 16 } else { 8 };
|
||||
s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad
|
||||
// Short/Long AD share length(4)@216 | lba(4)@220. extent_type 0
|
||||
// (recorded) is top 2 bits = 0, so raw == len.
|
||||
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
|
||||
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
||||
// Long AD's part_ref(2)@224 + impl_use(6)@226 stay zero.
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a directory ICB (tag 266) whose single short AD points at the
|
||||
/// directory's FID data.
|
||||
fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
|
||||
build_file_icb(dir_data_len, dir_data_lba, false)
|
||||
}
|
||||
|
||||
/// Append one File Identifier Descriptor (tag 257) to `buf`.
|
||||
/// Layout per `read_directory`: tag@0, file_chars@18, l_fi@19,
|
||||
/// ICB long_ad extent_location(LBA)@24, l_iu(u16)@36, name@(38+l_iu).
|
||||
/// Name uses UDF compression-id 8 (8-bit ASCII), so the on-disc name
|
||||
/// field is `[0x08, ascii_bytes...]` and l_fi = 1 + ascii.len().
|
||||
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||
let start = buf.len();
|
||||
let name_field: Vec<u8> = if is_parent {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut v = vec![0x08u8];
|
||||
v.extend_from_slice(name.as_bytes());
|
||||
v
|
||||
};
|
||||
let l_fi = name_field.len();
|
||||
let mut fid = vec![0u8; 38];
|
||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
|
||||
let mut file_chars = 0u8;
|
||||
if is_dir {
|
||||
file_chars |= 0x02;
|
||||
}
|
||||
if is_parent {
|
||||
file_chars |= 0x08;
|
||||
}
|
||||
fid[18] = file_chars;
|
||||
fid[19] = l_fi as u8;
|
||||
// ICB long_ad: extent_location LBA at offset 24.
|
||||
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
|
||||
// l_iu (u16) at offset 36 = 0.
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
|
||||
buf.extend_from_slice(&fid);
|
||||
buf.extend_from_slice(&name_field);
|
||||
// Pad to 4-byte alignment (FID stride = (38 + l_iu + l_fi + 3) & !3).
|
||||
let used = buf.len() - start;
|
||||
let pad = (used + 3) & !3;
|
||||
buf.resize(start + pad, 0);
|
||||
}
|
||||
|
||||
/// Recursively lay a DirSpec (and children) into the MemDisc, writing
|
||||
/// directory ICBs, FID lists, file ICBs, and file data.
|
||||
fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
|
||||
let mut fids = Vec::new();
|
||||
// Parent entry first (file_chars bit 0x08) — skipped by the parser
|
||||
// but present on real discs.
|
||||
push_fid(&mut fids, "", dir.icb_lba, true, true);
|
||||
for f in &dir.files {
|
||||
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
|
||||
disc.put(
|
||||
PART_START + f.icb_lba,
|
||||
build_file_icb(f.size, f.data_lba, f.long_ad),
|
||||
);
|
||||
if !f.contents.is_empty() {
|
||||
disc.put_bytes(PART_START + f.data_lba, &f.contents);
|
||||
}
|
||||
}
|
||||
for sub in &dir.subdirs {
|
||||
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
|
||||
}
|
||||
disc.put(
|
||||
PART_START + dir.icb_lba,
|
||||
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
|
||||
);
|
||||
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
|
||||
for sub in &dir.subdirs {
|
||||
lay_dir(disc, sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the static UDF anchor/VDS/FSD structure so `read_filesystem`
|
||||
/// reaches `root_icb_lba`. Single partition map → metadata_start ==
|
||||
/// partition_start == PART_START.
|
||||
fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
|
||||
// AVDP at sector 256, tag 2 (ECMA-167 §10.2).
|
||||
let mut avdp = [0u8; 2048];
|
||||
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
disc.put(256, avdp);
|
||||
|
||||
// Partition Descriptor (tag 5) at sector 32: partition_start@188.
|
||||
let mut pd = [0u8; 2048];
|
||||
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
|
||||
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
|
||||
disc.put(32, pd);
|
||||
|
||||
// Logical Volume Descriptor (tag 6) at sector 33:
|
||||
// num_partition_maps(u32)@268 = 1 (single map → no metadata part).
|
||||
let mut lvd = [0u8; 2048];
|
||||
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||
disc.put(33, lvd);
|
||||
|
||||
// Terminating Descriptor (tag 8) at sector 34 → ends VDS scan.
|
||||
let mut td = [0u8; 2048];
|
||||
td[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||
disc.put(34, td);
|
||||
|
||||
// File Set Descriptor (tag 256) at metadata_start (== PART_START):
|
||||
// root-dir ICB LBA at offset 404 (long_ad extent_location).
|
||||
let mut fsd = [0u8; 2048];
|
||||
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
|
||||
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
|
||||
disc.put(PART_START, fsd);
|
||||
}
|
||||
|
||||
fn file(name: &str, icb_lba: u32, data_lba: u32, size: u32, long_ad: bool) -> FileSpec {
|
||||
FileSpec {
|
||||
name: name.to_string(),
|
||||
icb_lba,
|
||||
data_lba,
|
||||
size,
|
||||
long_ad,
|
||||
contents: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_with(
|
||||
name: &str,
|
||||
icb_lba: u32,
|
||||
data_lba: u32,
|
||||
contents: Vec<u8>,
|
||||
long_ad: bool,
|
||||
) -> FileSpec {
|
||||
FileSpec {
|
||||
name: name.to_string(),
|
||||
icb_lba,
|
||||
data_lba,
|
||||
size: contents.len() as u32,
|
||||
long_ad,
|
||||
contents,
|
||||
}
|
||||
}
|
||||
|
||||
use crate::udf::fixture::*;
|
||||
// ---------------------------------------------------------------
|
||||
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
|
||||
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
|
||||
@@ -797,13 +574,29 @@ mod tests {
|
||||
u32, /*packets*/
|
||||
u32, /*data_lba*/
|
||||
)],
|
||||
) -> udf::UdfFs {
|
||||
make_bdmv_fs_ext(disc, clips, "m2ts")
|
||||
}
|
||||
|
||||
/// As [`make_bdmv_fs`] but the STREAM file carries `stream_ext` instead of
|
||||
/// `.m2ts` (e.g. "fmts" for an AACS 2.1 feature clip, "ssif" for 3D) — drives
|
||||
/// the [`CLIP_STREAM_EXTS`] fallback in `parse_playlist`.
|
||||
fn make_bdmv_fs_ext(
|
||||
disc: &mut MemDisc,
|
||||
clips: &[(
|
||||
&str,
|
||||
u32, /*sectors*/
|
||||
u32, /*packets*/
|
||||
u32, /*data_lba*/
|
||||
)],
|
||||
stream_ext: &str,
|
||||
) -> udf::UdfFs {
|
||||
// Layout LBAs: pick widely separated values to avoid collisions.
|
||||
let mut stream_files = Vec::new();
|
||||
let mut clipinf_files = Vec::new();
|
||||
let mut icb = 100u32;
|
||||
for (name, sectors, packets, data_lba) in clips {
|
||||
let m2ts = format!("{name}.m2ts");
|
||||
let m2ts = format!("{name}.{stream_ext}");
|
||||
// Size in bytes — file_extents derives sectors via div_ceil(2048).
|
||||
let size = sectors * 2048;
|
||||
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
|
||||
@@ -881,6 +674,37 @@ mod tests {
|
||||
assert_eq!(t.clips[0].source_packets, 4000);
|
||||
}
|
||||
|
||||
/// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The
|
||||
/// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the
|
||||
/// physical extent — before the fix the hard-coded `.m2ts` path errored,
|
||||
/// yielding empty extents (a silent empty rip and 0 encrypted samples for key
|
||||
/// resolution). Size still comes from the `.clpi`, which parses regardless.
|
||||
#[test]
|
||||
fn parse_playlist_fmts_clip_resolves_extent() {
|
||||
let mut disc = MemDisc::new();
|
||||
// Only a .fmts stream exists for clip 00001 (no .m2ts on disc).
|
||||
let udf = make_bdmv_fs_ext(&mut disc, &[("00001", 1000, 4000, 5000)], "fmts");
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 60 * 45000,
|
||||
}],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
assert_eq!(t.size_bytes, 4000 * 192, "size from .clpi source packets");
|
||||
assert_eq!(
|
||||
t.extents.len(),
|
||||
1,
|
||||
"the .fmts extent must be resolved via fallback"
|
||||
);
|
||||
assert_eq!(t.extents[0].start_lba, PART_START + 5000);
|
||||
assert_eq!(t.extents[0].sector_count, 1000);
|
||||
}
|
||||
|
||||
/// THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id
|
||||
/// from multiple PlayItems (seamless split / looped segment) must count
|
||||
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
|
||||
|
||||
+31
-28
@@ -86,9 +86,9 @@ impl AacsCertUnlocker<'_> {
|
||||
// MKB generation (best-effort) — forwarded to each source's
|
||||
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
||||
// (the default impl ignores it). A read failure leaves it `None`.
|
||||
let mkb_gen = aacs::read_mkb_from_drive(session.scsi_mut())
|
||||
let mkb_gen = aacs::inf::read_mkb_from_drive(session.scsi_mut())
|
||||
.ok()
|
||||
.and_then(|m| aacs::mkb_version(&m));
|
||||
.and_then(|m| aacs::mkb::mkb_version(&m));
|
||||
|
||||
// Host certs are keysource-served, never compiled in — unioned from the
|
||||
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
||||
@@ -111,13 +111,13 @@ impl AacsCertUnlocker<'_> {
|
||||
// through method calls, so clone the (cheap) identity first.
|
||||
let drive_id = session.drive_id.clone();
|
||||
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
|
||||
let unlocked = crate::unlock_bridge::run_unlockers(
|
||||
let (_, unlock_res) = crate::unlock_bridge::run_bus(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Aacs,
|
||||
&fu_certs,
|
||||
)
|
||||
.map_err(CertUnlockFailure::Unlock)?;
|
||||
);
|
||||
let unlocked = unlock_res.map_err(CertUnlockFailure::Unlock)?;
|
||||
// The cert handshake yields a VID on success; its absence is VidUnavailable.
|
||||
let Some(volume_id) = unlocked.vid else {
|
||||
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
|
||||
@@ -152,10 +152,10 @@ fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
|
||||
/// for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
match e {
|
||||
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
@@ -230,7 +230,10 @@ impl Disc {
|
||||
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
|
||||
/// [`crate::KeySource::host_certs`] so a source MAY return only
|
||||
/// generation-appropriate certs (the default ignores it).
|
||||
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
|
||||
fn collect_host_certs(
|
||||
opts: &ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
|
||||
// plugin uses the same one). Kept as a thin Disc method so the existing
|
||||
// collect_host_certs_* unit tests and call sites are unchanged.
|
||||
@@ -312,14 +315,14 @@ impl Disc {
|
||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let dh = aacs::disc_hash(&uk_ro_data);
|
||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::parse_content_cert);
|
||||
.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
|
||||
@@ -328,7 +331,7 @@ impl Disc {
|
||||
let version = cc
|
||||
.as_ref()
|
||||
.map(|c| c.version.major())
|
||||
.unwrap_or(aacs::AACS_MAJOR_UHD);
|
||||
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
|
||||
|
||||
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
|
||||
// Certificate bus-encryption bit set) carries bus encryption on its
|
||||
@@ -394,12 +397,12 @@ impl Disc {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
||||
let mkb_ver = aacs::mkb::mkb_version(&mkb_bytes);
|
||||
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_vid_only",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
disc_hash = %aacs::inf::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
has_vid = handshake.is_some(),
|
||||
@@ -410,7 +413,7 @@ impl Disc {
|
||||
version,
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
disc_hash: aacs::inf::disc_hash_hex(&dh),
|
||||
key_source: KeyOrigin::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: vec![],
|
||||
@@ -569,7 +572,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
||||
/// bus_encryption bit7@1, cc_id@14..20 (aacs/keys.rs parse_content_cert,
|
||||
/// bus_encryption bit7@1, cc_id@14..20 (aacs/inf.rs parse_content_cert,
|
||||
/// which requires ≥20 bytes and reads the bus flag from `data[1] >> 7`).
|
||||
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 20];
|
||||
@@ -581,7 +584,7 @@ mod tests {
|
||||
/// An MKB with one Type-and-Version record (type 0x10) carrying the
|
||||
/// version as BE u32 at record offset 8, followed by a recorded EOF
|
||||
/// record then trailing zero padding. mkb_content_len walks records
|
||||
/// and stops at the first padding (type 0) byte (aacs/keys.rs).
|
||||
/// and stops at the first padding (type 0) byte (aacs/inf.rs).
|
||||
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
// Type 0x10 record, length 16 (>= 12 so version is read).
|
||||
@@ -687,14 +690,14 @@ mod tests {
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(
|
||||
st.version,
|
||||
aacs::AACS_MAJOR_UHD,
|
||||
aacs::mkb::AACS_MAJOR_UHD,
|
||||
"no cert → default UHD (major 2)"
|
||||
);
|
||||
assert!(!st.bus_encryption);
|
||||
}
|
||||
|
||||
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
|
||||
/// and uppercase (aacs::disc_hash + disc_hash_hex). The state's
|
||||
/// and uppercase (aacs::inf::disc_hash + disc_hash_hex). The state's
|
||||
/// disc_hash must match independently computing it over the same bytes.
|
||||
#[test]
|
||||
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
|
||||
@@ -710,7 +713,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
let expected = aacs::disc_hash_hex(&aacs::disc_hash(&uk));
|
||||
let expected = aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk));
|
||||
assert_eq!(st.disc_hash, expected);
|
||||
assert!(st.disc_hash.starts_with("0x"));
|
||||
// uk_ro must be stashed verbatim for the external resolver.
|
||||
@@ -746,7 +749,7 @@ mod tests {
|
||||
// Real record stream is the single 16-byte type-0x10 record.
|
||||
assert_eq!(
|
||||
st.mkb.len(),
|
||||
aacs::mkb_content_len(&mkb),
|
||||
aacs::mkb::mkb_content_len(&mkb),
|
||||
"MKB must be trimmed to record-stream length, not the zero-pad"
|
||||
);
|
||||
assert_eq!(st.mkb.len(), 16);
|
||||
@@ -943,7 +946,7 @@ mod tests {
|
||||
// disc_hash must be computed over the DUPLICATE bytes.
|
||||
assert_eq!(
|
||||
st.disc_hash,
|
||||
aacs::disc_hash_hex(&aacs::disc_hash(&uk)),
|
||||
aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk)),
|
||||
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
|
||||
);
|
||||
assert_eq!(st.uk_ro, uk);
|
||||
@@ -965,8 +968,8 @@ mod tests {
|
||||
// the route fails gracefully (AacsNoHostCert), never panics.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn fake_cert(tag: u8) -> aacs::HostCert {
|
||||
aacs::HostCert {
|
||||
fn fake_cert(tag: u8) -> aacs::types::HostCert {
|
||||
aacs::types::HostCert {
|
||||
private_key: [tag; 20],
|
||||
certificate: vec![tag; 92],
|
||||
private_key_v2: None,
|
||||
@@ -975,15 +978,15 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||
struct CertSource(Vec<aacs::HostCert>);
|
||||
struct CertSource(Vec<aacs::types::HostCert>);
|
||||
impl crate::KeySource for CertSource {
|
||||
fn get_uk(
|
||||
&self,
|
||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||
) -> Result<Vec<crate::aacs::UnitKey>> {
|
||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> {
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::types::HostCert> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
@@ -1077,7 +1080,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
// The libfreemkv-side no-cert case carries the MKB generation.
|
||||
assert_eq!(
|
||||
|
||||
+85
-43
@@ -22,7 +22,6 @@ use crate::sector::{DecryptingSectorSource, SectorSource};
|
||||
use crate::udf::{self, DirEntry, UdfFs};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
|
||||
@@ -66,10 +65,9 @@ pub struct FileResult {
|
||||
pub path: PathBuf,
|
||||
/// Bytes written that decrypted cleanly.
|
||||
pub bytes_good: u64,
|
||||
/// Bytes lost to unreadable sectors (zero-filled holes).
|
||||
/// Bytes lost — unreadable sectors AND undecryptable units both land here
|
||||
/// (extract fails a bad decrypt loud, so it is zero-filled like a bad sector).
|
||||
pub bytes_unreadable: u64,
|
||||
/// Bytes lost to undecryptable AACS/CSS units (still ciphertext / dropped).
|
||||
pub bytes_undecryptable: u64,
|
||||
/// True when the file was fully written (renamed from `.partial`).
|
||||
pub complete: bool,
|
||||
}
|
||||
@@ -81,10 +79,8 @@ pub struct ExtractResult {
|
||||
pub files: Vec<FileResult>,
|
||||
/// Aggregate good bytes across all files.
|
||||
pub bytes_good: u64,
|
||||
/// Aggregate unreadable (bad-sector) bytes.
|
||||
/// Aggregate lost bytes — bad sectors AND undecryptable units (one bucket).
|
||||
pub bytes_unreadable: u64,
|
||||
/// Aggregate undecryptable (decrypt-loss) bytes.
|
||||
pub bytes_undecryptable: u64,
|
||||
/// True when every file completed and no loss was recorded.
|
||||
pub complete: bool,
|
||||
/// True when the run stopped early on an interrupt / progress halt.
|
||||
@@ -92,11 +88,10 @@ pub struct ExtractResult {
|
||||
}
|
||||
|
||||
impl ExtractResult {
|
||||
/// Total bytes lost (unreadable + undecryptable). A non-zero value means
|
||||
/// the extraction is holed; the CLI exits non-zero so a script can re-run
|
||||
/// through the `iso://` multipass path.
|
||||
/// Total bytes lost. A non-zero value means the extraction is holed; the CLI
|
||||
/// exits non-zero so a script can re-run through the `iso://` multipass path.
|
||||
pub fn bytes_lost(&self) -> u64 {
|
||||
self.bytes_unreadable + self.bytes_undecryptable
|
||||
self.bytes_unreadable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +192,6 @@ impl Disc {
|
||||
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
|
||||
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
|
||||
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
|
||||
let decrypt_loss = dec.decrypt_loss();
|
||||
|
||||
let mut result = ExtractResult::default();
|
||||
let total_bytes = required;
|
||||
@@ -232,26 +226,15 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire (rather than Relaxed) on these per-file delta loads:
|
||||
// `extract_tree` drives `dec` single-threaded so there is no race
|
||||
// today, and Acquire costs nothing on x86. Note this is only half
|
||||
// the synchronisation: the paired counter store
|
||||
// (sector/decrypting.rs `fetch_add`) is Relaxed, so an Acquire
|
||||
// load alone does NOT yet establish a happens-before edge. Before
|
||||
// file extraction is parallelised, upgrade that store to Release
|
||||
// (or stronger) so the delta cannot read a stale counter.
|
||||
let before_loss = decrypt_loss.load(Ordering::Acquire);
|
||||
let (mut fr, halted) =
|
||||
// A unit that fails to decrypt fails the read loud (extract runs
|
||||
// non-tolerate), so extract_one_file already zero-filled it and
|
||||
// counted it in bytes_unreadable — one 'lost' bucket covers both
|
||||
// media damage and decrypt failure.
|
||||
let (fr, halted) =
|
||||
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
|
||||
let after_loss = decrypt_loss.load(Ordering::Acquire);
|
||||
fr.bytes_undecryptable = after_loss.saturating_sub(before_loss);
|
||||
fr.bytes_good = fr.bytes_good.saturating_sub(fr.bytes_undecryptable);
|
||||
|
||||
result.bytes_good = result.bytes_good.saturating_add(fr.bytes_good);
|
||||
result.bytes_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable);
|
||||
result.bytes_undecryptable = result
|
||||
.bytes_undecryptable
|
||||
.saturating_add(fr.bytes_undecryptable);
|
||||
result.files.push(fr);
|
||||
if halted {
|
||||
result.halted = true;
|
||||
@@ -261,7 +244,6 @@ impl Disc {
|
||||
|
||||
result.complete = !result.halted
|
||||
&& result.bytes_unreadable == 0
|
||||
&& result.bytes_undecryptable == 0
|
||||
&& result.files.iter().all(|f| f.complete);
|
||||
Ok(result)
|
||||
}
|
||||
@@ -492,7 +474,6 @@ fn extract_one_file<S: SectorSource>(
|
||||
path: pf.host_rel.clone(),
|
||||
bytes_good: 0,
|
||||
bytes_unreadable: 0,
|
||||
bytes_undecryptable: 0,
|
||||
complete: false,
|
||||
};
|
||||
|
||||
@@ -851,8 +832,11 @@ mod tests {
|
||||
|
||||
struct MemDisc {
|
||||
sectors: HashMap<u32, [u8; 2048]>,
|
||||
/// Absolute LBAs that fail to read (bad-sector fixture).
|
||||
/// Absolute LBAs that fail to read (bad-sector fixture → DiscRead).
|
||||
bad: std::collections::HashSet<u32>,
|
||||
/// Absolute LBAs whose read fails to DECRYPT (no/wrong key fixture →
|
||||
/// DecryptFailed), exercising the undecryptable-unit loss path.
|
||||
decrypt_fail: std::collections::HashSet<u32>,
|
||||
}
|
||||
|
||||
impl MemDisc {
|
||||
@@ -860,6 +844,7 @@ mod tests {
|
||||
Self {
|
||||
sectors: HashMap::new(),
|
||||
bad: std::collections::HashSet::new(),
|
||||
decrypt_fail: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||
@@ -891,6 +876,9 @@ mod tests {
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if self.decrypt_fail.contains(&(lba + i)) {
|
||||
return Err(Error::DecryptFailed);
|
||||
}
|
||||
}
|
||||
for i in 0..count as u32 {
|
||||
let off = i as usize * 2048;
|
||||
@@ -1083,13 +1071,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Build a clear 6144-byte AACS unit (TS syncs at the 192-byte BD-TS
|
||||
/// stride) then encrypt it under `unit_key` so `aacs::decrypt_unit`
|
||||
/// stride) then encrypt it under `unit_key` so `aacs::content::decrypt_unit`
|
||||
/// recovers it cleanly (zero decrypt loss). Mirrors the encrypt helper in
|
||||
/// `sector/decrypting.rs` tests. `tag` distinguishes two units' payloads.
|
||||
fn encrypt_aacs_unit(unit_key: &[u8; 16], tag: u8) -> Vec<u8> {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < unit.len() {
|
||||
unit[off] = 0x47; // TS sync
|
||||
@@ -1101,14 +1089,14 @@ mod tests {
|
||||
// Flag encrypted via CPI bits (byte 0) before key derivation.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = crate::aacs::decrypt::AACS_IV;
|
||||
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
let mut prev = crate::aacs::crypto::AACS_IV;
|
||||
let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..blocks {
|
||||
let o = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
@@ -1124,7 +1112,7 @@ mod tests {
|
||||
|
||||
/// The plaintext that `encrypt_aacs_unit(_, tag)` decrypts back to.
|
||||
fn clear_aacs_unit(tag: u8) -> Vec<u8> {
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < unit.len() {
|
||||
unit[off] = 0x47;
|
||||
@@ -1412,6 +1400,64 @@ mod tests {
|
||||
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
|
||||
}
|
||||
|
||||
/// An UNDECRYPTABLE unit (DecryptFailed — wrong/missing key) is zero-filled
|
||||
/// and counted as loss through the public API exactly like a bad sector:
|
||||
/// the recovery-seam consolidation folded the old bytes_undecryptable bucket
|
||||
/// into bytes_unreadable, and the run must still report complete == false and
|
||||
/// bytes_lost() > 0 (this gates the CLI exit code / multipass re-run).
|
||||
#[test]
|
||||
fn undecryptable_unit_holes_file_and_accounts_loss() {
|
||||
let good = vec![0x55u8; 4 * 2048];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "STREAM".to_string(),
|
||||
icb_lba: 22,
|
||||
dir_data_lba: 23,
|
||||
files: vec![file("00001.m2ts", 24, 5000, good.clone(), true)],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let mut disc = build_disc(root);
|
||||
// The whole extent fails to decrypt (no/wrong key) rather than to read.
|
||||
for i in 0..4u32 {
|
||||
disc.decrypt_fail.insert(PART_START + 5000 + i);
|
||||
}
|
||||
let out = TmpDir::new("decryptfail");
|
||||
let res = clear_disc()
|
||||
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
|
||||
.expect("extract does not abort on an undecryptable unit");
|
||||
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
|
||||
assert_eq!(
|
||||
got.len(),
|
||||
good.len(),
|
||||
"holed file still sized to declared size"
|
||||
);
|
||||
assert!(
|
||||
got.iter().all(|&b| b == 0),
|
||||
"undecryptable range zero-filled"
|
||||
);
|
||||
assert!(
|
||||
!res.complete,
|
||||
"an undecryptable unit makes the rip incomplete"
|
||||
);
|
||||
assert!(
|
||||
res.bytes_lost() > 0,
|
||||
"decrypt loss counted, not reported clean"
|
||||
);
|
||||
assert_eq!(res.bytes_unreadable, good.len() as u64);
|
||||
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
|
||||
}
|
||||
|
||||
/// Path sanitization rejects a host-illegal component in a disc file name.
|
||||
#[test]
|
||||
fn sanitize_rejects_illegal_component() {
|
||||
@@ -1585,10 +1631,6 @@ mod tests {
|
||||
res.bytes_unreadable, 0,
|
||||
"per-extent unit base must keep the second extent off the hole path"
|
||||
);
|
||||
assert_eq!(
|
||||
res.bytes_undecryptable, 0,
|
||||
"clear units decrypt-restore clean"
|
||||
);
|
||||
assert!(
|
||||
res.complete,
|
||||
"a clean multi-extent AACS file extracts complete"
|
||||
@@ -1600,10 +1642,10 @@ mod tests {
|
||||
/// own batch starts are always unit-aligned; anchoring a later extent
|
||||
/// against the FIRST extent's base mis-aligns whenever the extents' starts
|
||||
/// differ by a non-multiple of 3 sectors. This is the exact arithmetic the
|
||||
/// decrypt-on-read gate (`aacs::is_unit_aligned`) performs.
|
||||
/// decrypt-on-read gate (`aacs::content::is_unit_aligned`) performs.
|
||||
#[test]
|
||||
fn per_extent_base_is_aligned_first_extent_base_is_not() {
|
||||
use crate::aacs::is_unit_aligned;
|
||||
use crate::aacs::content::is_unit_aligned;
|
||||
let ext_a_start = 7000u32; // first extent abs LBA
|
||||
let ext_b_start = 7004u32; // second extent abs LBA (Δ4 — not mult of 3)
|
||||
|
||||
|
||||
+1395
File diff suppressed because it is too large
Load Diff
+346
-95
@@ -13,6 +13,7 @@ mod dvd;
|
||||
pub mod dvd_audio_probe;
|
||||
mod encrypt;
|
||||
mod extract;
|
||||
mod hddvd;
|
||||
pub mod mapfile;
|
||||
mod patch;
|
||||
pub mod read_error;
|
||||
@@ -93,8 +94,16 @@ pub enum ContentFormat {
|
||||
pub enum DiscFormat {
|
||||
/// 4K UHD Blu-ray (HEVC 2160p)
|
||||
Uhd,
|
||||
/// UHD Blu-ray with AACS 2.1 FMTS content — the main feature is a `.fmts`
|
||||
/// clip (M2TS transport stream plus interleaved forensic variant segments).
|
||||
/// A BD-tree disc (enumerated by [`Disc::scan_bluray_titles`]); distinct
|
||||
/// from [`DiscFormat::Uhd`] only in the container + AACS generation.
|
||||
Fmts,
|
||||
/// Standard Blu-ray (1080p/1080i)
|
||||
BluRay,
|
||||
/// HD-DVD — `HVDVD_TS/` tree with `.evo` (Enhanced VOB, MPEG program stream)
|
||||
/// clips. A tree-level peer of DVD/BD, enumerated by its own scanner.
|
||||
HdDvd,
|
||||
/// DVD
|
||||
Dvd,
|
||||
/// Unknown
|
||||
@@ -1413,7 +1422,7 @@ impl KeyOrigin {
|
||||
#[derive(Default, Clone)]
|
||||
pub struct DriveCredentials {
|
||||
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
|
||||
pub host_certs: Vec<crate::aacs::HostCert>,
|
||||
pub host_certs: Vec<crate::aacs::types::HostCert>,
|
||||
}
|
||||
|
||||
/// Options for disc scanning.
|
||||
@@ -1481,13 +1490,10 @@ impl Disc {
|
||||
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
||||
|
||||
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
|
||||
let format = if udf_fs.find_dir("/BDMV").is_some() {
|
||||
DiscFormat::BluRay // full scan distinguishes UHD vs BD
|
||||
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||||
DiscFormat::Dvd
|
||||
} else {
|
||||
DiscFormat::Unknown
|
||||
};
|
||||
// Authoritative here — the same MKB-driven detector the full scan uses
|
||||
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
|
||||
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
|
||||
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
|
||||
let encrypted =
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
@@ -1644,12 +1650,13 @@ impl Disc {
|
||||
// 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();
|
||||
if let Err(e) = crate::unlock_bridge::run_unlockers(
|
||||
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,
|
||||
@@ -1781,8 +1788,8 @@ impl Disc {
|
||||
Ok((inf, mkb, version))
|
||||
}
|
||||
|
||||
/// AACS major version ([`crate::aacs::AACS_MAJOR_BD`] /
|
||||
/// [`crate::aacs::AACS_MAJOR_UHD`]) from the content certificate. Drives the
|
||||
/// AACS major version ([`crate::aacs::mkb::AACS_MAJOR_BD`] /
|
||||
/// [`crate::aacs::mkb::AACS_MAJOR_UHD`]) from the content certificate. Drives the
|
||||
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the
|
||||
/// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a
|
||||
/// server VUK then derives the correct unit keys).
|
||||
@@ -1798,7 +1805,7 @@ impl Disc {
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(crate::aacs::parse_content_cert)
|
||||
.and_then(crate::aacs::inf::parse_content_cert)
|
||||
{
|
||||
Some(c) => c.version.major(),
|
||||
None => {
|
||||
@@ -1808,7 +1815,7 @@ impl Disc {
|
||||
"no readable AACS content certificate; defaulting to the V20/UHD \
|
||||
Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)"
|
||||
);
|
||||
crate::aacs::AACS_MAJOR_UHD
|
||||
crate::aacs::mkb::AACS_MAJOR_UHD
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1817,7 +1824,7 @@ impl Disc {
|
||||
///
|
||||
/// `MKB_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
|
||||
/// zero-padded; the actual record stream is a few MiB. We read a bounded
|
||||
/// prefix, find the record-stream length via [`crate::aacs::mkb_content_len`]
|
||||
/// prefix, find the record-stream length via [`crate::aacs::mkb::mkb_content_len`]
|
||||
/// and return exactly that, growing the prefix if the records run past it.
|
||||
/// This avoids reading 100+ MiB of padding on every scan AND avoids the
|
||||
/// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
|
||||
@@ -1833,13 +1840,13 @@ impl Disc {
|
||||
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
|
||||
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let n = crate::aacs::mkb_content_len(&buf);
|
||||
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` =>
|
||||
// the whole file is already read. Otherwise the records may run
|
||||
// past the prefix — grow and retry, bounded by MAX_BYTES.
|
||||
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
|
||||
return Ok(crate::aacs::trim_mkb(buf));
|
||||
return Ok(crate::aacs::mkb::trim_mkb(buf));
|
||||
}
|
||||
want = (want * 2).min(MAX_BYTES);
|
||||
}
|
||||
@@ -1906,12 +1913,20 @@ impl Disc {
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
|
||||
// 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are
|
||||
// tree-level peers, each with its own enumerator; FMTS shares the BD
|
||||
// tree (a `.fmts` stream variant). Disc FORMAT is a separate axis
|
||||
// derived below from the AACS MKB generation, not the tree.
|
||||
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
|
||||
(
|
||||
Self::scan_bluray_titles(reader, &udf_fs),
|
||||
ContentFormat::BdTs,
|
||||
)
|
||||
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
|
||||
(
|
||||
Self::scan_hddvd_titles(reader, &udf_fs),
|
||||
ContentFormat::MpegPs,
|
||||
)
|
||||
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||||
(
|
||||
Self::scan_dvd_titles(reader, &udf_fs),
|
||||
@@ -1935,8 +1950,11 @@ impl Disc {
|
||||
crate::labels::apply(reader, &udf_fs, &mut titles);
|
||||
crate::labels::fill_defaults(&mut titles);
|
||||
|
||||
// 5. Derive format, layers, region
|
||||
let format = Self::detect_format(&titles);
|
||||
// 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and
|
||||
// layers. Region coding is not yet decoded from the disc — every disc
|
||||
// reports Region-free for now (correct for all UHD; a stub for
|
||||
// region-locked BD/DVD until region detection is implemented).
|
||||
let format = Self::detect_disc_format(reader, &udf_fs, &titles);
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
let region = DiscRegion::Free;
|
||||
|
||||
@@ -2025,10 +2043,10 @@ impl Disc {
|
||||
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
|
||||
/// composites. The capacity check is a hard "physically
|
||||
/// possible data on this disc" gate.
|
||||
/// 2. Among real titles, fewer clips first. A 1-clip playlist is
|
||||
/// the canonical main feature; multi-clip playlists are either
|
||||
/// chapter-stitched (small count) or virtual composites
|
||||
/// (large count). Fewer wins.
|
||||
/// 2. Among real titles, LARGEST physical size first — the main
|
||||
/// feature is the biggest real title on the disc. (This replaced
|
||||
/// the old clip-count ordering, which mis-ranked chapter-per-clip
|
||||
/// discs like Fast & Furious.)
|
||||
/// 3. Tiebreak on longer duration first.
|
||||
///
|
||||
/// **Effect on non-branching discs:** unchanged — the main movie
|
||||
@@ -2040,17 +2058,27 @@ impl Disc {
|
||||
b: &DiscTitle,
|
||||
capacity_bytes: u64,
|
||||
) -> std::cmp::Ordering {
|
||||
// A title bigger than the whole disc is a "play-all" composite artifact
|
||||
// (its declared size double-counts clips shared with other playlists) —
|
||||
// demote it below any real single title.
|
||||
let a_oversize = a.size_bytes > capacity_bytes;
|
||||
let b_oversize = b.size_bytes > capacity_bytes;
|
||||
a_oversize
|
||||
.cmp(&b_oversize)
|
||||
.then_with(|| a.clips.len().cmp(&b.clips.len()))
|
||||
// PRIMARY: largest physical size = the main feature. Robust where
|
||||
// duration and clip-count are not — a decoy "play-all" playlist runs
|
||||
// long (e.g. 1h31m) but is tiny (0.4 GB of reused/junk clips), and the
|
||||
// real feature is often chaptered into MANY clips (one per chapter),
|
||||
// which the old clip-count-ascending key wrongly demoted below 1-clip
|
||||
// bonus reels. Validated across 23 UHD/BD discs — fixes F9 / Fast Five
|
||||
// / Fast & Furious 6 / Furious 7 (feature was ranked ~#13–36), no
|
||||
// regressions on the 19 already correct.
|
||||
.then_with(|| b.size_bytes.cmp(&a.size_bytes))
|
||||
// Tiebreak for equal-size twins: longer duration, then richer audio —
|
||||
// the same feature authored as sibling playlists (a full-audio main
|
||||
// vs an audio-reduced twin, e.g. Fight Club's 00800 [DTS-HD MA + 13
|
||||
// tracks] vs 00004 [stereo AC-3 only]). Prefer lossless-multichannel.
|
||||
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
|
||||
// Same length + clip count = the same feature authored as multiple
|
||||
// playlists (a full-audio main vs an audio-reduced twin, e.g. Fight
|
||||
// Club's 00800 [DTS-HD MA + 13 tracks] vs 00004 [stereo AC-3 only]).
|
||||
// Prefer the richer audio so we never rip a stereo-only variant over
|
||||
// the lossless-multichannel main feature.
|
||||
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
|
||||
}
|
||||
|
||||
@@ -2086,6 +2114,55 @@ impl Disc {
|
||||
(lossless, max_ch, count)
|
||||
}
|
||||
|
||||
/// The disc format, from the two on-disc axes:
|
||||
/// * **tree** → HD-DVD (`HVDVD_TS/`) and DVD (`VIDEO_TS/`) are tree-level
|
||||
/// peers with their own enumerators;
|
||||
/// * **AACS MKB generation** → within the BD tree (`BDMV/`), the MKB Type
|
||||
/// record decides BD (1.0) / UHD (2.0) / FMTS (2.1). This is the
|
||||
/// authoritative, cheap signal (the Type record is the first bytes of
|
||||
/// `MKB_RO.inf`) — reusing [`crate::aacs::mkb::mkb_type`] /
|
||||
/// [`crate::aacs::mkb::MkbType::generation`], not a filesystem heuristic.
|
||||
///
|
||||
/// An unencrypted / MKB-less BD tree falls back to video resolution (still a
|
||||
/// BD-tree disc, so never below [`DiscFormat::BluRay`]).
|
||||
fn detect_disc_format(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &crate::udf::UdfFs,
|
||||
titles: &[DiscTitle],
|
||||
) -> DiscFormat {
|
||||
use crate::aacs::mkb::{AacsVersion, mkb_type};
|
||||
// Tree priority MUST match the title-scan dispatch (BDMV → HVDVD_TS →
|
||||
// VIDEO_TS): otherwise a disc carrying two trees would be classified as
|
||||
// one format but enumerated as another (e.g. BD titles tagged HdDvd).
|
||||
if udf_fs.find_dir("/BDMV").is_some() {
|
||||
// Only the Type-and-Version record (first record) is needed.
|
||||
if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) {
|
||||
match mkb_type(&mkb).map(|t| t.generation()) {
|
||||
Some(AacsVersion::V21) => return DiscFormat::Fmts,
|
||||
Some(AacsVersion::V20) => return DiscFormat::Uhd,
|
||||
Some(AacsVersion::V10) => return DiscFormat::BluRay,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Unencrypted / unreadable MKB: refine by resolution, but a BD-tree
|
||||
// disc is never below Blu-ray — only UHD can promote it. detect_format
|
||||
// is a general resolution classifier that can return Dvd for an SD
|
||||
// bonus/menu title, which must NOT tag a BDMV disc as DVD (that
|
||||
// mis-sizes the ECC-block sweep). Clamp anything but UHD up to BluRay.
|
||||
return match Self::detect_format(titles) {
|
||||
DiscFormat::Uhd => DiscFormat::Uhd,
|
||||
_ => DiscFormat::BluRay,
|
||||
};
|
||||
}
|
||||
if udf_fs.find_dir("/HVDVD_TS").is_some() {
|
||||
return DiscFormat::HdDvd;
|
||||
}
|
||||
if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||||
return DiscFormat::Dvd;
|
||||
}
|
||||
DiscFormat::Unknown
|
||||
}
|
||||
|
||||
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
|
||||
for title in titles.iter().take(3) {
|
||||
for stream in &title.streams {
|
||||
@@ -2201,7 +2278,7 @@ fn aligned_unit_keys_validate(
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
samples: &[Vec<u8>],
|
||||
) -> bool {
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
|
||||
let scrambled: Vec<&[u8]> = samples
|
||||
.iter()
|
||||
.map(|s| s.as_slice())
|
||||
@@ -2274,7 +2351,7 @@ impl Disc {
|
||||
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
|
||||
///
|
||||
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
|
||||
/// never consults [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) about
|
||||
/// never consults [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) about
|
||||
/// non-content bytes — filesystem data has no TS sync and would otherwise be
|
||||
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
|
||||
///
|
||||
@@ -2308,18 +2385,23 @@ impl Disc {
|
||||
/// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
|
||||
/// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
|
||||
pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
|
||||
// LibreDrive firmware-unlocked the drive iff a drive unlocker actually
|
||||
// succeeded at init (name recorded only on success).
|
||||
let ld_worked = drive.unlocker_name().is_some();
|
||||
// The drive-prep unlocker that actually ran (recorded on init):
|
||||
// "LibreDrive" (MediaTek) or "Renesas" — mutually exclusive per drive.
|
||||
let prep = drive.unlocker_name();
|
||||
// Only LibreDrive (MediaTek) removes AACS bus encryption AT THE DRIVE;
|
||||
// Renesas unlocks features but leaves the bus to the cert.
|
||||
let ld_removed_bus = prep == Some("LibreDrive");
|
||||
crate::unlock_bridge::unlocker_names()
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let did_work = match name {
|
||||
// The drive firmware unlock ran and succeeded.
|
||||
"LibreDrive" => ld_worked,
|
||||
// The AACS host-cert route removed the bus ONLY when LibreDrive
|
||||
// didn't (stock drive) AND AACS state was actually obtained.
|
||||
"AACS" => self.aacs.is_some() && !ld_worked,
|
||||
// Each firmware unlocker did work iff it was the one that ran.
|
||||
"LibreDrive" => ld_removed_bus,
|
||||
"Renesas" => prep == Some("Renesas"),
|
||||
// The AACS host-cert route removed the bus ONLY when the
|
||||
// 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(),
|
||||
// A newly-registered unlocker with no runtime signal wired
|
||||
@@ -2543,6 +2625,28 @@ impl Disc {
|
||||
self.ensure_decryptable_keys(raw, keys)
|
||||
}
|
||||
|
||||
/// Upfront FMTS (AACS 2.1) key gate, parallel to
|
||||
/// [`ensure_title_decryptable`](Self::ensure_title_decryptable). A 2.1 disc
|
||||
/// carries forensic variant segments that need segment (variant) keys the
|
||||
/// unit-key path cannot provide. When
|
||||
/// [`BYPASS_FMTS_KEY`](crate::aacs::segment::BYPASS_FMTS_KEY) is `false`,
|
||||
/// their absence is a hard upfront failure ([`Error::FmtsKeyMissing`]) — the
|
||||
/// same policy as a missing unit key, so a forensic-holed rip is refused, not
|
||||
/// produced. When `true` (the default today) the segments are skipped as
|
||||
/// expected loss and this passes. `raw` mode and non-FMTS discs always pass.
|
||||
pub fn ensure_forensic_segments_decryptable(&self, raw: bool) -> Result<()> {
|
||||
if raw || crate::aacs::segment::BYPASS_FMTS_KEY {
|
||||
return Ok(());
|
||||
}
|
||||
// A 2.1 (FMTS) disc carries forensic variant segments with no segment-key
|
||||
// source (none exists yet), so its variant segments cannot be opened.
|
||||
// Refuse upfront rather than emit a forensic-holed rip.
|
||||
if self.format == DiscFormat::Fmts {
|
||||
return Err(Error::FmtsKeyMissing);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
|
||||
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
|
||||
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
||||
@@ -2565,13 +2669,15 @@ impl Disc {
|
||||
aacs.unit_keys = keys;
|
||||
aacs.key_source = KeyOrigin::ExternalUk;
|
||||
} else if self.encrypted && self.css.is_none() {
|
||||
// FMTS is AACS 2.1, a UHD-family (bus-encrypted) format — not BD.
|
||||
let uhd_family = matches!(self.format, DiscFormat::Uhd | DiscFormat::Fmts);
|
||||
self.aacs = Some(AacsState {
|
||||
version: if self.format == DiscFormat::Uhd {
|
||||
crate::aacs::AACS_MAJOR_UHD
|
||||
version: if uhd_family {
|
||||
crate::aacs::mkb::AACS_MAJOR_UHD
|
||||
} else {
|
||||
crate::aacs::AACS_MAJOR_BD
|
||||
crate::aacs::mkb::AACS_MAJOR_BD
|
||||
},
|
||||
bus_encryption: self.format == DiscFormat::Uhd,
|
||||
bus_encryption: uhd_family,
|
||||
mkb_version: None,
|
||||
disc_hash: String::new(),
|
||||
key_source: KeyOrigin::ExternalUk,
|
||||
@@ -2667,7 +2773,7 @@ impl Disc {
|
||||
Key::Processing(pks) => supplied.processing_keys = pks,
|
||||
Key::Media(mks) => supplied.media_keys = mks,
|
||||
Key::Volume(vuk) => {
|
||||
supplied.disc_entry = Some(crate::aacs::DiscEntry {
|
||||
supplied.disc_entry = Some(crate::aacs::types::DiscEntry {
|
||||
disc_hash: aacs.disc_hash.clone(),
|
||||
title: String::new(),
|
||||
media_key: None,
|
||||
@@ -2685,8 +2791,8 @@ impl Disc {
|
||||
let uk_ro = aacs.uk_ro.clone();
|
||||
let version_u8 = aacs.version;
|
||||
|
||||
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
|
||||
let ctx = crate::aacs::ResolveContext {
|
||||
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
|
||||
let ctx = crate::aacs::resolve::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &volume_id,
|
||||
@@ -2700,7 +2806,7 @@ impl Disc {
|
||||
// reason-preserving wrapper threads the no-key cause out so the
|
||||
// decrypt gate can report E7017 (had derivation material but no VID)
|
||||
// vs E7022 (no usable material) instead of a flat AacsKeyRejected.
|
||||
let resolved = crate::aacs::resolve_keys_with_reason(&ctx, version_u8)
|
||||
let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8)
|
||||
.map_err(|_reason| crate::error::Error::AacsKeyRejected)?;
|
||||
|
||||
if resolved.unit_keys.is_empty() {
|
||||
@@ -3158,25 +3264,37 @@ impl Disc {
|
||||
// ISO file: if resuming and mapfile has Finished ranges, open existing;
|
||||
// otherwise create fresh and pre-size to total_bytes (sparse holes for
|
||||
// non-tried regions).
|
||||
let is_regular = std::fs::metadata(path)
|
||||
.map(|m| m.file_type().is_file())
|
||||
.unwrap_or(false);
|
||||
let file = if resume
|
||||
//
|
||||
// `is_regular` MUST be read from the OPEN file handle, not from
|
||||
// `metadata(path)` — on a fresh rip the path does not exist yet, so a
|
||||
// pre-create `metadata(path)` always fails (is_regular=false), which both
|
||||
// skips the pre-size AND makes `SweepSink::close` swallow a real
|
||||
// `sync_all()` failure on the just-written ISO as if it were /dev/null.
|
||||
let (file, is_regular) = if resume
|
||||
&& std::fs::metadata(path)
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
std::fs::OpenOptions::new()
|
||||
let f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::IoError { source: e })?
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
let reg = f
|
||||
.metadata()
|
||||
.map(|m| m.file_type().is_file())
|
||||
.unwrap_or(false);
|
||||
(f, reg)
|
||||
} else {
|
||||
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
|
||||
if is_regular {
|
||||
let reg = f
|
||||
.metadata()
|
||||
.map(|m| m.file_type().is_file())
|
||||
.unwrap_or(false);
|
||||
if reg {
|
||||
f.set_len(total_bytes)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
f
|
||||
(f, reg)
|
||||
};
|
||||
|
||||
// Wrap the raw `File` in our bounded-cache `WritebackFile`
|
||||
@@ -3203,7 +3321,7 @@ impl Disc {
|
||||
// decrypts and is AACS-keyed. Region read-starts are aligned DOWN to a
|
||||
// unit boundary in the loop below; a fresh sweep starts at LBA 0 (already
|
||||
// aligned), so alignment only bites on resume NonTried regions.
|
||||
const UNIT_SECTORS: u16 = (crate::aacs::ALIGNED_UNIT_LEN / 2048) as u16; // 3
|
||||
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
|
||||
if decrypt_is_aacs && batch % UNIT_SECTORS != 0 {
|
||||
batch = batch.saturating_add(UNIT_SECTORS - (batch % UNIT_SECTORS));
|
||||
}
|
||||
@@ -3285,7 +3403,7 @@ impl Disc {
|
||||
// sweep's NonTried region starts at 0, already unit-aligned; this only
|
||||
// shifts resume regions that begin mid-unit.
|
||||
let mut pos = if decrypt_is_aacs {
|
||||
let unit_bytes = crate::aacs::ALIGNED_UNIT_LEN as u64;
|
||||
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||
region_pos - (region_pos % unit_bytes)
|
||||
} else {
|
||||
region_pos
|
||||
@@ -3971,8 +4089,10 @@ const MIN_BATCH_SECTORS: u16 = 3;
|
||||
|
||||
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
|
||||
match format {
|
||||
DiscFormat::Uhd | DiscFormat::BluRay => 32,
|
||||
DiscFormat::Dvd => 16,
|
||||
// BD-family 64 KiB ECC block (32 × 2048). FMTS is a UHD BD disc.
|
||||
DiscFormat::Uhd | DiscFormat::Fmts | DiscFormat::BluRay => 32,
|
||||
// 32 KiB ECC block (16 × 2048) — DVD and HD-DVD.
|
||||
DiscFormat::Dvd | DiscFormat::HdDvd => 16,
|
||||
DiscFormat::Unknown => 32,
|
||||
}
|
||||
}
|
||||
@@ -4240,8 +4360,8 @@ mod tests {
|
||||
/// `sector::decrypting::tests::aacs_unaligned_start_lba_rejected`.
|
||||
#[test]
|
||||
fn aacs_sweep_batch_and_region_are_unit_aligned() {
|
||||
const UNIT_SECTORS: u16 = (crate::aacs::ALIGNED_UNIT_LEN / 2048) as u16; // 3
|
||||
let unit_bytes = crate::aacs::ALIGNED_UNIT_LEN as u64; // 6144
|
||||
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
|
||||
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64; // 6144
|
||||
|
||||
// (a) Batch rounding: ecc_sectors() for UHD/BD is 32, not a multiple of 3.
|
||||
// The decrypting-AACS path rounds it up to the next multiple of 3 (33).
|
||||
@@ -4388,14 +4508,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-branching disc: longest 1-clip title is the movie. Sort
|
||||
/// must not change behaviour — the existing "duration descending"
|
||||
/// expectation holds when no titles overflow capacity.
|
||||
/// Non-branching disc: largest title is the movie. With realistic sizes
|
||||
/// (bytes track duration for same-codec content) size-first yields the same
|
||||
/// ranking as duration — biggest/longest feature, then extra, then menu.
|
||||
#[test]
|
||||
fn canonical_order_preserves_natural_ranking_on_normal_disc() {
|
||||
const CAPACITY: u64 = 60_000_000_000;
|
||||
let mut titles = vec![
|
||||
title_with("00100.mpls", 600.0, 5_000_000_000, 1), // 10 min menu
|
||||
title_with("00100.mpls", 600.0, 500_000_000, 1), // 10 min menu (small)
|
||||
title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
|
||||
title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
|
||||
];
|
||||
@@ -4429,21 +4549,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Tiebreak: equal duration + equal capacity-validity → fewer
|
||||
/// clips wins. A chapter-stitched 3-clip movie should beat a
|
||||
/// 50-clip virtual composite of the same duration.
|
||||
#[test]
|
||||
fn canonical_order_fewer_clips_wins_tiebreak() {
|
||||
const CAPACITY: u64 = 100_000_000_000;
|
||||
let mut titles = vec![
|
||||
title_with("00050.mpls", 7200.0, 50_000_000_000, 50),
|
||||
title_with("00800.mpls", 7200.0, 50_000_000_000, 3),
|
||||
];
|
||||
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
|
||||
assert_eq!(titles[0].playlist, "00800.mpls");
|
||||
assert_eq!(titles[1].playlist, "00050.mpls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_uhd() {
|
||||
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
|
||||
@@ -4468,6 +4573,132 @@ mod tests {
|
||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
|
||||
}
|
||||
|
||||
/// An AACS MKB Type-and-Version record (0x10) carrying `raw_type` — the only
|
||||
/// record [`Disc::detect_disc_format`] reads to decide BD/UHD/FMTS.
|
||||
fn mkb_type_record(raw_type: u32) -> Vec<u8> {
|
||||
let mut v = vec![0x10, 0x00, 0x00, 0x0c]; // record type 0x10, rec_len 12
|
||||
v.extend_from_slice(&raw_type.to_be_bytes()); // MKBType @ body offset 0
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // version @ body offset 4
|
||||
v
|
||||
}
|
||||
|
||||
/// FORMAT derives from the AACS MKB generation, not the tree or filesystem:
|
||||
/// 2.1 → FMTS, 2.0 → UHD, 1.0 → BD — all from the MKB Type record.
|
||||
#[test]
|
||||
fn detect_format_from_mkb_generation() {
|
||||
use crate::udf::fixture::*;
|
||||
for (raw, expected) in [
|
||||
(0x4815_1003u32, DiscFormat::Fmts),
|
||||
(0x4814_1003u32, DiscFormat::Uhd),
|
||||
(0x0004_1003u32, DiscFormat::BluRay),
|
||||
] {
|
||||
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: "BDMV".into(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AACS".into(),
|
||||
icb_lba: 14,
|
||||
dir_data_lba: 15,
|
||||
files: vec![file_with(
|
||||
"MKB_RO.inf",
|
||||
16,
|
||||
5000,
|
||||
mkb_type_record(raw),
|
||||
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_eq!(
|
||||
Disc::detect_disc_format(&mut disc, &udf, &[]),
|
||||
expected,
|
||||
"MKB type {raw:#010x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// HD-DVD is a tree-level format — recognized from `HVDVD_TS/`, no MKB.
|
||||
#[test]
|
||||
fn detect_format_hddvd_from_tree() {
|
||||
use crate::udf::fixture::*;
|
||||
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: "HVDVD_TS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
assert_eq!(
|
||||
Disc::detect_disc_format(&mut disc, &udf, &[]),
|
||||
DiscFormat::HdDvd
|
||||
);
|
||||
}
|
||||
|
||||
/// Title selection is by largest physical size, NOT clip count or duration.
|
||||
/// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
|
||||
/// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
|
||||
/// clips, 1h31m, 0.4 GB). The old clip-count-ascending key put the bonus t1.
|
||||
#[test]
|
||||
fn canonical_title_order_picks_largest_feature() {
|
||||
fn title_sized(size_bytes: u64, duration_secs: f64, n_clips: usize) -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: String::new(),
|
||||
playlist_id: 0,
|
||||
duration_secs,
|
||||
size_bytes,
|
||||
clips: (0..n_clips)
|
||||
.map(|i| Clip {
|
||||
clip_id: format!("{i:05}"),
|
||||
in_time: 0,
|
||||
out_time: 0,
|
||||
duration_secs: 0.0,
|
||||
source_packets: 0,
|
||||
})
|
||||
.collect(),
|
||||
streams: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
let capacity = 66_000_000_000u64;
|
||||
let feature = title_sized(57_000_000_000, 7860.0, 11); // 2h11m, 11 chapters
|
||||
let bonus = title_sized(1_200_000_000, 600.0, 1); // 10m, 1 clip
|
||||
let decoy = title_sized(400_000_000, 5460.0, 91); // 1h31m but tiny (reused)
|
||||
let mut v = vec![bonus, decoy, feature];
|
||||
v.sort_by(|a, b| Disc::canonical_title_order(a, b, capacity));
|
||||
assert_eq!(
|
||||
v[0].size_bytes, 57_000_000_000,
|
||||
"the largest real title is the main feature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_format_default_bdts() {
|
||||
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
||||
@@ -4632,6 +4863,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_unit_keys_labels_fmts_as_uhd_family() {
|
||||
// FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK
|
||||
// on an FMTS disc must synthesize the UHD version + bus encryption, not
|
||||
// mislabel it AACS 1.0 / bus-off (which would break FMTS decryption on
|
||||
// the mapfile-recovered-UK path).
|
||||
let mut disc = make_test_disc(1000, "FMTS");
|
||||
disc.format = DiscFormat::Fmts;
|
||||
disc.encrypted = true;
|
||||
disc.inject_unit_keys(vec![(0u32, [0x22u8; 16])]);
|
||||
let aacs = disc.aacs.as_ref().expect("aacs state synthesized");
|
||||
assert_eq!(
|
||||
aacs.version,
|
||||
crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
"FMTS is AACS 2.x (UHD major), not BD"
|
||||
);
|
||||
assert!(aacs.bus_encryption, "FMTS is bus-encrypted like UHD");
|
||||
}
|
||||
|
||||
/// Build an AacsState carrying the given unit keys (other fields are inert
|
||||
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
|
||||
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
|
||||
@@ -4700,7 +4950,7 @@ mod tests {
|
||||
// The resolver classifies a device-keys-but-zero-VID context as
|
||||
// `VidUnavailable`; that reason rides on `aacs_error`.
|
||||
let supplied = crate::aacs::provider::SuppliedKey {
|
||||
device_keys: vec![crate::aacs::DeviceKey {
|
||||
device_keys: vec![crate::aacs::types::DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
@@ -4710,14 +4960,14 @@ mod tests {
|
||||
media_keys: Vec::new(),
|
||||
disc_entry: None,
|
||||
};
|
||||
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
|
||||
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
|
||||
// A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys)
|
||||
// so resolution proceeds to the path-try logic and fails for lack of a
|
||||
// VID — not because the .inf failed to parse.
|
||||
let mut uk_ro = vec![0u8; 40];
|
||||
uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32
|
||||
// num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file.
|
||||
let ctx = crate::aacs::ResolveContext {
|
||||
let ctx = crate::aacs::resolve::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &[0u8; 16], // the "no VID" sentinel
|
||||
@@ -4725,8 +4975,8 @@ mod tests {
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(
|
||||
crate::aacs::resolve_keys_with_reason(&ctx, 2).err(),
|
||||
Some(crate::aacs::ResolveFailure::VidUnavailable),
|
||||
crate::aacs::resolve::resolve_keys_with_reason(&ctx, 2).err(),
|
||||
Some(crate::aacs::resolve::ResolveFailure::VidUnavailable),
|
||||
"device keys + zero VID must classify as VidUnavailable"
|
||||
);
|
||||
|
||||
@@ -4750,8 +5000,8 @@ mod tests {
|
||||
media_keys: Vec::new(),
|
||||
disc_entry: None,
|
||||
};
|
||||
let provider_refs_none: [&dyn crate::aacs::KeyProvider; 1] = [&supplied_none];
|
||||
let ctx_none = crate::aacs::ResolveContext {
|
||||
let provider_refs_none: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied_none];
|
||||
let ctx_none = crate::aacs::resolve::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &[0u8; 16],
|
||||
@@ -4759,8 +5009,8 @@ mod tests {
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(
|
||||
crate::aacs::resolve_keys_with_reason(&ctx_none, 2).err(),
|
||||
Some(crate::aacs::ResolveFailure::NoMaterial),
|
||||
crate::aacs::resolve::resolve_keys_with_reason(&ctx_none, 2).err(),
|
||||
Some(crate::aacs::resolve::ResolveFailure::NoMaterial),
|
||||
"no key material must classify as NoMaterial"
|
||||
);
|
||||
|
||||
@@ -5030,8 +5280,8 @@ mod tests {
|
||||
let vuk = [0x5au8; 16];
|
||||
let enc0 = [0x12u8; 16];
|
||||
let enc1 = [0x34u8; 16];
|
||||
let exp0 = crate::aacs::decrypt_unit_key(&vuk, &enc0);
|
||||
let exp1 = crate::aacs::decrypt_unit_key(&vuk, &enc1);
|
||||
let exp0 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc0);
|
||||
let exp1 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc1);
|
||||
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
@@ -5116,7 +5366,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unit_key_validation_gates_on_real_ciphertext() {
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
|
||||
// No samples -> nothing to disprove against -> accept (sample-less paths
|
||||
// like resume / mapfile must be unaffected).
|
||||
@@ -5174,7 +5424,7 @@ mod tests {
|
||||
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
||||
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
||||
// that leaves any scrambled sample uncovered.
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
|
||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
@@ -5220,7 +5470,8 @@ mod tests {
|
||||
/// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the
|
||||
/// body with the fixed AACS IV.
|
||||
fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec<u8> {
|
||||
use crate::aacs::decrypt::{AACS_IV, ALIGNED_UNIT_LEN};
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::crypto::AACS_IV;
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
|
||||
|
||||
+19
-13
@@ -473,9 +473,7 @@ pub(super) fn recovery_read<R: SectorSource + ?Sized>(
|
||||
/// gets recorded NonTrimmed. Pure data structure — no I/O — so each phase
|
||||
/// helper is unit-testable by asserting the residual `SubRanges`.
|
||||
///
|
||||
/// Foundation for the phased `recover_section` orchestrator; not yet wired
|
||||
/// into the live loop (see the deferral note in the #50 work).
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
/// The residue tracker used by the phased `recover_section` orchestrator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(super) struct SubRanges {
|
||||
/// (pos, len) pairs, sorted by pos, non-overlapping, all non-zero len.
|
||||
@@ -1461,13 +1459,22 @@ impl Disc {
|
||||
if !bad.is_empty() {
|
||||
let n: usize = bad.len();
|
||||
for (lba, cnt) in bad {
|
||||
let _ = m.record(
|
||||
if let Err(e) = m.record(
|
||||
lba as u64 * 2048,
|
||||
cnt as u64 * 2048,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
) {
|
||||
tracing::warn!(
|
||||
lba,
|
||||
"reverify downgrade: mapfile record failed ({e}) — unit may stay mismarked as good"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(e) = m.flush() {
|
||||
tracing::warn!(
|
||||
"reverify downgrade: mapfile flush failed ({e}) — downgrade not persisted; a resume could mismark it good"
|
||||
);
|
||||
}
|
||||
let _ = m.flush();
|
||||
// The re-verify ran AFTER `pipe.finish()` snapshotted
|
||||
// `summary.stats`, so those stats still count the just-
|
||||
// downgraded units as good. Refresh from the mapfile so
|
||||
@@ -1547,14 +1554,13 @@ mod tests {
|
||||
assert!(on("true"));
|
||||
}
|
||||
|
||||
/// Transport failure (status=0xFF, USB-bridge crash) must be recognised by
|
||||
/// the gate `handle_read_failure` now checks FIRST, so it aborts the pass
|
||||
/// (wedged_exit + BreakOuter) instead of treating the bridge crash as an
|
||||
/// ordinary bad sector and hammering the crashed device for up to the
|
||||
/// per-range watchdog budget. `handle_read_failure` is not unit-testable in
|
||||
/// isolation, so this guards the classification predicate the production
|
||||
/// early-return keys off, and the contrast that an ordinary read error is
|
||||
/// NOT misclassified as a transport failure.
|
||||
/// Transport failure (status=0xFF, USB-bridge crash) must be recognised and
|
||||
/// abort the pass, rather than being treated as an ordinary bad sector and
|
||||
/// hammering the crashed device for up to the per-range watchdog budget. The
|
||||
/// transport-failure classification predicate is not unit-testable in
|
||||
/// isolation, so this guards the predicate the production early-return keys
|
||||
/// off, and the contrast that an ordinary read error is NOT misclassified as
|
||||
/// a transport failure.
|
||||
#[test]
|
||||
fn transport_failure_is_recognised_for_patch_abort() {
|
||||
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
|
||||
|
||||
+6
-18
@@ -196,22 +196,10 @@ impl ReadCtx {
|
||||
/// threshold is loose so we don't bail too early on a range that
|
||||
/// has scattered good sectors mixed in.
|
||||
///
|
||||
/// `damage_threshold_pct = 6` mirrors `disc/patch.rs`'s
|
||||
/// `PASSN_DAMAGE_THRESHOLD_PCT`. Pass N triggers the damage-skip
|
||||
/// at half the density Pass 1 uses (Pass 1 = 12%) because the
|
||||
/// patch loop's whole job is to chip away at bad ranges — being
|
||||
/// more eager to skip clustered bad sectors converges faster on
|
||||
/// the recoverable good sectors inside a range. The patch-side
|
||||
/// `compute_damage_skip` reads its threshold directly from
|
||||
/// `PASSN_DAMAGE_THRESHOLD_PCT`, which is an alias for this crate's
|
||||
/// `PATCH_DAMAGE_THRESHOLD_PCT`, so the two are always in sync.
|
||||
/// The patch loop's damage-skip is not yet unified with `handle_read_error`'s
|
||||
/// jump path. (v0.20.8 unification attempt found the unification
|
||||
/// itself blocked on the size-aware `range_remaining/4` cap that
|
||||
/// lives in `compute_damage_skip` but not in
|
||||
/// `handle_read_error::JumpAhead` — see
|
||||
/// `tests/passn_handler_ab.rs` for the A/B fixture that pins
|
||||
/// the divergence point.)
|
||||
/// `damage_threshold_pct = 6` is looser than Pass 1 (12%): Pass N triggers
|
||||
/// the damage-skip at half Pass 1 density because the patch loop exists to chip
|
||||
/// away at bad ranges, so being more eager to skip clustered bad sectors
|
||||
/// converges faster on the recoverable good sectors inside a range.
|
||||
pub fn for_patch(batch: u16) -> Self {
|
||||
Self {
|
||||
batch,
|
||||
@@ -443,8 +431,8 @@ const WEDGE_ABORT_THRESHOLD: u64 = 16;
|
||||
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
|
||||
|
||||
/// Single source of truth for the Pass-N damage-window threshold.
|
||||
/// Both [`ReadCtx::for_patch`] and `disc::patch::compute_damage_skip`
|
||||
/// reference this constant so the two damage-skip paths cannot drift.
|
||||
/// [`ReadCtx::for_patch`] reads this constant for the Pass-N damage-skip
|
||||
/// threshold.
|
||||
///
|
||||
/// 6% means: with a 16-entry sliding window, the damage-skip fires
|
||||
/// once 1 out of 16 recent reads has failed. Pass 1 uses a 12%
|
||||
|
||||
+12
-11
@@ -7,7 +7,7 @@
|
||||
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
|
||||
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
|
||||
//! clip-file units, then applies the standards-correct
|
||||
//! [`crate::aacs::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs).
|
||||
//! [`crate::aacs::content::unit_is_clean_ts`] gate (all-32 TS syncs).
|
||||
//!
|
||||
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
|
||||
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use crate::aacs::{self, ALIGNED_UNIT_LEN};
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::{self};
|
||||
use crate::consts::SECTOR_BYTES_U64;
|
||||
use crate::decrypt::DecryptKeys;
|
||||
use crate::sector::KeyFetch;
|
||||
@@ -57,7 +58,7 @@ pub enum ContainerKind {
|
||||
Ts,
|
||||
/// HD-DVD `.evo` — MPEG-2 program stream (pack-start `00 00 01 BA`).
|
||||
/// NOT yet enabled by enumeration; present so adding HD-DVD is a one-mapping
|
||||
/// change. See [`crate::aacs::unit_is_clean_ps`] for the (unvalidated) check.
|
||||
/// change. See [`crate::aacs::content::unit_is_clean_ps`] for the (unvalidated) check.
|
||||
Ps,
|
||||
}
|
||||
|
||||
@@ -187,8 +188,8 @@ impl UnitVerifier {
|
||||
/// The post-decrypt structural check for a clip's container.
|
||||
fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool {
|
||||
match self.containers[clip as usize] {
|
||||
ContainerKind::Ts => aacs::unit_is_clean_ts,
|
||||
ContainerKind::Ps => aacs::unit_is_clean_ps,
|
||||
ContainerKind::Ts => aacs::content::unit_is_clean_ts,
|
||||
ContainerKind::Ps => aacs::content::unit_is_clean_ps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,8 +302,8 @@ impl UnitVerifier {
|
||||
}
|
||||
|
||||
/// Can this fully-assembled unit be decrypted + verified? `accept` is the
|
||||
/// container's strict structural check ([`aacs::unit_is_clean_ts`] for TS,
|
||||
/// [`aacs::unit_is_clean_ps`] for PS) — the only format-specific part; the
|
||||
/// container's strict structural check ([`aacs::content::unit_is_clean_ts`] for TS,
|
||||
/// [`aacs::content::unit_is_clean_ps`] for PS) — the only format-specific part; the
|
||||
/// AACS crypto is container-agnostic. Returns the 3-state [`Decryptability`].
|
||||
fn decryptability(
|
||||
&mut self,
|
||||
@@ -318,7 +319,7 @@ impl UnitVerifier {
|
||||
// ENCRYPTED units that no key opens are ever flagged. (A real bad READ of
|
||||
// clear content is still caught by the normal SCSI read-error path; this
|
||||
// gate just won't false-flag it.)
|
||||
if !aacs::aacs_unit_encrypted(raw) {
|
||||
if !aacs::content::aacs_unit_encrypted(raw) {
|
||||
return if accept(raw) {
|
||||
Decryptability::Decryptable
|
||||
} else {
|
||||
@@ -363,7 +364,7 @@ impl UnitVerifier {
|
||||
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
|
||||
for k in &self.keys {
|
||||
let mut scratch = *raw;
|
||||
if aacs::decrypt_unit_checked(&mut scratch, k, accept) {
|
||||
if aacs::content::decrypt_unit_checked(&mut scratch, k, accept) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -523,13 +524,13 @@ mod tests {
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
unit[0] |= 0xC0; // CPI flag => reads as encrypted
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = crate::aacs::decrypt::AACS_IV;
|
||||
let mut prev = crate::aacs::crypto::AACS_IV;
|
||||
for i in 0..(ALIGNED_UNIT_LEN - 16) / 16 {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
|
||||
+7
-8
@@ -433,15 +433,14 @@ impl Drive {
|
||||
// that used to sit here was the v1.0.0-rc.1 regression — it skipped the
|
||||
// drive-prep for DVD, leaving DVDs riplocked at stock speed.
|
||||
self.init_ran = true;
|
||||
let r: Result<()> = match crate::unlock_bridge::run_unlockers(
|
||||
self.scsi.as_mut(),
|
||||
&self.drive_id,
|
||||
freemkv_unlock::DiscKind::Unknown,
|
||||
&[],
|
||||
) {
|
||||
let (matched, unlock_res) =
|
||||
crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id);
|
||||
let r: Result<()> = match unlock_res {
|
||||
Ok(unlocked) => {
|
||||
self.unlocker_name =
|
||||
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
|
||||
// Record WHICH drive-prep unlocker actually ran — "LibreDrive"
|
||||
// (MediaTek) or "Renesas" — not the ld-only identity lookup, so a
|
||||
// Renesas drive reports itself honestly rather than as nothing.
|
||||
self.unlocker_name = Some(matched.to_string());
|
||||
// Stash the OEM Volume ID the unlocker returned for the AACS
|
||||
// handshake phase (do_handshake reads it via `oem_vid()`). A
|
||||
// drive-prep unlocker always carries a VID; guard anyway.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
//!
|
||||
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
||||
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
||||
//! verified against libdvdnav's decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
|
||||
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
|
||||
//! verified against libdvdnav's command decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
|
||||
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
|
||||
@@ -133,7 +133,7 @@ const JP_JUMP_SS: u8 = 6;
|
||||
const JP_CALL_SS: u8 = 8;
|
||||
|
||||
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
|
||||
// the LinkSub form (libdvdnav `decoder.c` `eval_link_instruction`).
|
||||
// the LinkSub form (the DVD-Video VM link instruction).
|
||||
const LK_SUB: u8 = 1;
|
||||
const LK_PGCN: u8 = 4;
|
||||
const LK_PTTN: u8 = 5;
|
||||
@@ -159,7 +159,7 @@ fn be16(b: &[u8; 8], o: usize) -> u16 {
|
||||
((b[o] as u16) << 8) | b[o + 1] as u16
|
||||
}
|
||||
|
||||
// Compare-operand layouts ("if_version"s) per libdvdnav `decoder.c`. The op
|
||||
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
|
||||
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||
// operand *offsets* differ by command family.
|
||||
//
|
||||
@@ -205,7 +205,7 @@ pub fn decode(b: &[u8; 8]) -> Command {
|
||||
let cmd = b[1] & 0x0F;
|
||||
|
||||
// Compare predicate, with the operand layout for this command family
|
||||
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
|
||||
// (the DVD-Video VM command type dispatch).
|
||||
let compare = match (typ, direct) {
|
||||
(TYPE_SPECIAL, _) => if_v1(b),
|
||||
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
||||
@@ -372,7 +372,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
|
||||
// Regression for the link sub-op decode: 0 = NOP, 1 = LinkSub.
|
||||
#[test]
|
||||
fn link_subop_zero_is_nop_one_is_linksub() {
|
||||
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
||||
|
||||
@@ -88,6 +88,7 @@ pub const E_NO_DISC_KEY: u16 = 7022;
|
||||
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
||||
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
|
||||
pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025;
|
||||
pub const E_FMTS_KEY_MISSING: u16 = 7026;
|
||||
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
@@ -360,6 +361,15 @@ pub enum Error {
|
||||
/// time and no handshake runs.
|
||||
AacsBusKeyUnavailable,
|
||||
|
||||
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
||||
/// (variant) key is available to open them, and `BYPASS_FMTS_KEY` is `false`
|
||||
/// (strict mode). Raised UPFRONT — before the mux — exactly like a missing
|
||||
/// unit key, so a 2.1 disc that would rip with holes is refused rather than
|
||||
/// silently producing a forensic-holed output. When `BYPASS_FMTS_KEY` is
|
||||
/// `true` (the default today) this is never raised: the bulk decodes with the
|
||||
/// unit key and the forensic segments are skipped as expected loss.
|
||||
FmtsKeyMissing,
|
||||
|
||||
// Keydb (8xxx)
|
||||
KeydbConnect {
|
||||
host: String,
|
||||
@@ -573,6 +583,7 @@ impl Error {
|
||||
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
||||
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
||||
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
|
||||
Error::FmtsKeyMissing => E_FMTS_KEY_MISSING,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
@@ -1209,6 +1220,7 @@ mod tests {
|
||||
E_CSS_KEY_MISSING,
|
||||
E_AACS_NO_HOST_CERT,
|
||||
E_AACS_BUS_KEY_UNAVAILABLE,
|
||||
E_FMTS_KEY_MISSING,
|
||||
E_KEYDB_CONNECT,
|
||||
E_KEYDB_HTTP,
|
||||
E_KEYDB_INVALID,
|
||||
|
||||
+8
-8
@@ -58,7 +58,7 @@ pub struct DvdTitle {
|
||||
pub struct DvdCell {
|
||||
pub first_sector: u32,
|
||||
pub last_sector: u32,
|
||||
/// Raw cell-category byte at `cell_playback + 0` (libdvdread layout).
|
||||
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video IFO layout).
|
||||
/// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
|
||||
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
|
||||
/// seamless_angle (bit 0). Carried so the extent builder can recognise
|
||||
@@ -72,7 +72,7 @@ pub struct DvdCell {
|
||||
}
|
||||
|
||||
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the
|
||||
/// DVD-Video spec / libdvdread `cell_playback_t` layout. Byte-0 bitfields,
|
||||
/// DVD-Video IFO cell-playback layout. Byte-0 bitfields,
|
||||
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
|
||||
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
|
||||
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
|
||||
@@ -93,7 +93,7 @@ pub struct CellCategory {
|
||||
}
|
||||
|
||||
impl CellCategory {
|
||||
/// Decode the raw `cell_playback + 0` byte (libdvdread `read_cell_playback`).
|
||||
/// Decode the raw `cell_playback + 0` byte (DVD-Video IFO cell playback).
|
||||
pub fn decode(raw: u8) -> Self {
|
||||
CellCategory {
|
||||
block_mode: (raw >> 6) & 0x03,
|
||||
@@ -423,7 +423,7 @@ fn parse_vts(
|
||||
}
|
||||
|
||||
// VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
|
||||
// spec (libdvdread `vtsi_mat_t`). The offsets are constant; the sector
|
||||
// spec (the VTSI management table). The offsets are constant; the sector
|
||||
// values they point to are per-disc.
|
||||
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
|
||||
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
|
||||
@@ -580,7 +580,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
|
||||
_ => Codec::Unknown(coding_mode),
|
||||
};
|
||||
|
||||
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (libdvdread audio_attr_t)
|
||||
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (DVD-Video audio attributes)
|
||||
let sample_rate = match sample_rate_flag {
|
||||
0 => 48000,
|
||||
1 => 96000,
|
||||
@@ -1071,7 +1071,7 @@ mod tests {
|
||||
/// reads with — a co-edit of constant + helper would silently re-introduce
|
||||
/// the PAL-as-NTSC bug and every test would still pass. This test feeds
|
||||
/// `parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts
|
||||
/// (DVD spec / libdvdread `video_attr_t`: mpeg_version[7-6] video_format[5-4]
|
||||
/// (DVD-Video video attributes: mpeg_version[7-6] video_format[5-4]
|
||||
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
|
||||
/// bit positions drift, these fail.
|
||||
#[test]
|
||||
@@ -1238,7 +1238,7 @@ mod tests {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Added hardening tests. Grounded in the DVD-Video IFO spec
|
||||
// (dvd_udf / libdvdread ifo_types.h; http://dvd.sourceforge.net).
|
||||
// (DVD-Video IFO format; http://dvd.sourceforge.net).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BCD frame-rate flag: bits 7-6 of byte[3]. 0b01 = 25fps (PAL),
|
||||
@@ -1593,7 +1593,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// CellCategory decodes the libdvdread byte-0 bitfields: block_mode (7-6),
|
||||
/// CellCategory decodes the DVD-Video cell-category byte-0 bitfields: block_mode (7-6),
|
||||
/// block_type (5-4), seamless_play (3), interleaved (2),
|
||||
/// stc_discontinuity (1), seamless_angle (0).
|
||||
#[test]
|
||||
|
||||
+8
-2
@@ -449,7 +449,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
} else {
|
||||
// Benign per-item OK: trace-level (L4) only; the
|
||||
// apply-side rolling summary carries throughput.
|
||||
tracing::trace!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
|
||||
tracing::trace!(
|
||||
"Pipeline send: OK in {:.3}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -464,7 +467,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
|
||||
tracing::debug!(
|
||||
"Pipeline send: failed after {:.3}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e.0)
|
||||
|
||||
+131
-31
@@ -3,12 +3,13 @@
|
||||
//! libfreemkv performs NO key lookup. An application resolves a disc's keys
|
||||
//! through one or more [`KeySource`]s, each an adapter over a backing store (a
|
||||
//! keydb file, a key server, the mapfile cache). A source's job is to return the
|
||||
//! disc's terminal **Unit Keys** ([`crate::aacs::UnitKey`]). It knows what
|
||||
//! disc's terminal **Unit Keys** ([`crate::aacs::types::UnitKey`]). It knows what
|
||||
//! material it holds (a DK / MK / VUK / pre-decrypted UK) and what it must fetch
|
||||
//! from the disc (VID, MKB, encrypted title keys, content samples) to get there;
|
||||
//! it orchestrates the derivation by calling libfreemkv's own boil-down crypto
|
||||
//! primitives ([`crate::aacs::mk_from_dk`] / [`crate::aacs::vuk_from_mk`] /
|
||||
//! [`crate::aacs::uk_from_vuk`]) through the [`ResolveCtx`] handed to it.
|
||||
//! it orchestrates the derivation by calling libfreemkv's own derivation
|
||||
//! primitives ([`crate::aacs::derive::derive_media_key_from_dk`] /
|
||||
//! [`crate::aacs::derive::derive_vuk`] / [`crate::aacs::derive::decrypt_unit_key`])
|
||||
//! through the [`ResolveCtx`] handed to it.
|
||||
//!
|
||||
//! libfreemkv still OWNS the crypto: the boil-down primitives and the AES live
|
||||
//! here. A source owns only PATH ORCHESTRATION — deciding which primitive to
|
||||
@@ -17,7 +18,8 @@
|
||||
//! keeping key *policy* (which store, which order, online vs local) out of the
|
||||
//! library.
|
||||
|
||||
use crate::aacs::{HostCert, UnitKey, Vid};
|
||||
use crate::aacs::types::HostCert;
|
||||
use crate::aacs::types::{UnitKey, Vid};
|
||||
use crate::disc::Key;
|
||||
use crate::error::Error;
|
||||
|
||||
@@ -74,8 +76,8 @@ pub trait ResolveCtx {
|
||||
/// Raw MKB bytes (may be empty when not captured).
|
||||
fn mkb(&self) -> Result<&[u8], Error>;
|
||||
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
|
||||
/// way the library's resolver parses them ([`crate::aacs::parse_unit_key_ro`]),
|
||||
/// in on-disc order. Feed straight into [`crate::aacs::uk_from_vuk`].
|
||||
/// way the library's resolver parses them ([`crate::aacs::inf::parse_unit_key_ro`]),
|
||||
/// in on-disc order. Feed straight into [`crate::aacs::derive::decrypt_unit_key`].
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
|
||||
/// Up to `n` encrypted on-disc content sample units, for a source that
|
||||
/// validates a candidate server-side against real ciphertext.
|
||||
@@ -113,7 +115,8 @@ impl<'a> DiscInputsCtx<'a> {
|
||||
/// title keys — the parse failure is swallowed here, not surfaced as an
|
||||
/// error.
|
||||
pub fn new(inputs: &'a DiscInputs) -> Self {
|
||||
use crate::aacs::{AacsVersion, parse_unit_key_ro};
|
||||
use crate::aacs::inf::parse_unit_key_ro;
|
||||
use crate::aacs::mkb::AacsVersion;
|
||||
let enc_keys = if inputs.unit_key_ro.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -164,8 +167,8 @@ impl ResolveCtx for DiscInputsCtx<'_> {
|
||||
/// holds, orchestrates the derivation down to Unit Keys using the library's
|
||||
/// boil-down crypto primitives — never re-implementing AES. A source that holds
|
||||
/// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls
|
||||
/// [`crate::aacs::uk_from_vuk`]; one that holds device keys calls
|
||||
/// [`crate::aacs::mk_from_dk`] → [`crate::aacs::vuk_from_mk`] → `uk_from_vuk`.
|
||||
/// [`crate::aacs::derive::decrypt_unit_key`]; one that holds device keys calls
|
||||
/// [`crate::aacs::derive::derive_media_key_from_dk`] → [`crate::aacs::derive::derive_vuk`] → `decrypt_unit_key`.
|
||||
///
|
||||
/// Returning an empty `Vec` means "no key for this disc from this source"; an
|
||||
/// `Err` means the source itself failed (I/O, parse, network). The caller
|
||||
@@ -209,7 +212,7 @@ pub fn resolve_and_apply(
|
||||
}
|
||||
|
||||
/// Like [`resolve_and_apply`] but also returns a structured
|
||||
/// [`crate::aacs::ResolutionTrace`] recording, per source, what happened — for
|
||||
/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for
|
||||
/// applications to render. ZERO English; the trace is typed enums only.
|
||||
///
|
||||
/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly
|
||||
@@ -219,8 +222,8 @@ pub fn resolve_and_apply(
|
||||
/// success — so a wrong/partial key set is rejected and the loop continues.
|
||||
///
|
||||
/// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index
|
||||
/// from [`crate::aacs::uk_from_vuk`]; the library's canonical CPS-unit number is
|
||||
/// `position + 1` (matching [`crate::aacs::parse_unit_key_ro`]'s `(i + 1)`), so
|
||||
/// from [`crate::aacs::derive::decrypt_unit_key`]; the library's canonical CPS-unit number is
|
||||
/// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so
|
||||
/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved
|
||||
/// path. The number is cosmetic for descramble (the decrypt path strips it and
|
||||
/// tries every key) but is kept faithful to the resolver's convention.
|
||||
@@ -228,10 +231,10 @@ pub fn resolve_and_apply_traced(
|
||||
sources: &[Box<dyn KeySource>],
|
||||
inputs: &DiscInputs,
|
||||
disc: &mut crate::Disc,
|
||||
) -> (bool, crate::aacs::ResolutionTrace) {
|
||||
) -> (bool, crate::aacs::trace::ResolutionTrace) {
|
||||
use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep};
|
||||
|
||||
let mut trace = crate::aacs::ResolutionTrace::new();
|
||||
let mut trace = crate::aacs::trace::ResolutionTrace::new();
|
||||
|
||||
// The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the
|
||||
// disc's own AACS major), so the stride is the disc's single source of truth.
|
||||
@@ -341,18 +344,24 @@ pub fn key_fetch(
|
||||
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
||||
/// via `DiscInputs.samples`; it never reads the disc itself.
|
||||
///
|
||||
/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME
|
||||
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
|
||||
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
|
||||
/// clear unit proves nothing, so this collects only scrambled ones — probing
|
||||
/// several points spread across EACH extent so a title whose encrypted body
|
||||
/// starts late (or whose midpoint lands in clear nav) still yields samples.
|
||||
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
||||
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
|
||||
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||
/// unit sent to a key server yields nothing to validate against — the "0
|
||||
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
|
||||
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
|
||||
/// probing several points spread across EACH extent so a title whose encrypted
|
||||
/// body starts late (or whose midpoint lands in clear nav) still yields samples.
|
||||
/// CPI is read at each extent's `start_lba` (clip-file-anchored), so byte 0 is a
|
||||
/// real unit start and the flag is meaningful.
|
||||
pub fn read_encrypted_units(
|
||||
reader: &mut dyn crate::sector::SectorSource,
|
||||
title: &crate::disc::DiscTitle,
|
||||
n: usize,
|
||||
) -> Vec<Vec<u8>> {
|
||||
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
|
||||
// Probe several evenly-spaced points across EACH extent rather than only the
|
||||
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
||||
@@ -398,7 +407,7 @@ pub fn read_encrypted_units(
|
||||
break;
|
||||
}
|
||||
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
||||
if ts_sync_destroyed(u) {
|
||||
if aacs_unit_encrypted(u) {
|
||||
out.push(u.to_vec());
|
||||
if out.len() >= n {
|
||||
return out;
|
||||
@@ -413,7 +422,7 @@ pub fn read_encrypted_units(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::UnitKey;
|
||||
use crate::aacs::types::UnitKey;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||
@@ -454,7 +463,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0xABC".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_BD,
|
||||
version: crate::aacs::mkb::AACS_MAJOR_BD,
|
||||
mkb: vec![1, 2, 3],
|
||||
unit_key_ro: uk_ro,
|
||||
samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]],
|
||||
@@ -510,7 +519,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0x00".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_UHD,
|
||||
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -531,7 +540,7 @@ mod tests {
|
||||
DiscInputs {
|
||||
disc_hash: String::new(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_UHD,
|
||||
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -621,7 +630,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xEEu8; crate::aacs::ALIGNED_UNIT_LEN]];
|
||||
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
|
||||
let got = cb(&samples);
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -643,7 +652,7 @@ mod tests {
|
||||
/// finds the early scrambled band.
|
||||
#[test]
|
||||
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
|
||||
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||
use crate::error::Result;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
@@ -713,7 +722,98 @@ mod tests {
|
||||
"the probe-spread must sample the early scrambled band the midpoint misses"
|
||||
);
|
||||
for s in &samples {
|
||||
assert!(ts_sync_destroyed(s), "every sample is a scrambled unit");
|
||||
assert!(
|
||||
aacs_unit_encrypted(s),
|
||||
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
|
||||
/// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but
|
||||
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
|
||||
/// lack TS syncs; the old sampler collected these and the key server rejected
|
||||
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
|
||||
/// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would
|
||||
/// collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
||||
#[test]
|
||||
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||
use crate::error::Result;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
|
||||
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
|
||||
// `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd.
|
||||
struct MixSource {
|
||||
ext_start: u32,
|
||||
total_units: u32,
|
||||
}
|
||||
impl SectorSource for MixSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.ext_start + self.total_units * ALIGNED_UNIT_SECTORS + 64
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_r: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
for (i, chunk) in buf[..bytes].chunks_mut(ALIGNED_UNIT_LEN).enumerate() {
|
||||
if chunk.len() < ALIGNED_UNIT_LEN {
|
||||
break;
|
||||
}
|
||||
let abs = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32;
|
||||
if abs % 2 == 0 {
|
||||
chunk.fill(0x11); // CPI-clear (0x11 & 0xC0 == 0), no TS sync
|
||||
} else {
|
||||
chunk.fill(0xAB); // scrambled body (no TS sync)
|
||||
chunk[0] = 0xC0; // CPI set -> encrypted
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
let total_units = 400u32;
|
||||
let ext_start = 500u32;
|
||||
let mut src = MixSource {
|
||||
ext_start,
|
||||
total_units,
|
||||
};
|
||||
let title = crate::disc::DiscTitle {
|
||||
playlist: String::new(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
extents: vec![crate::disc::Extent {
|
||||
start_lba: ext_start,
|
||||
sector_count: total_units * ALIGNED_UNIT_SECTORS,
|
||||
}],
|
||||
content_format: crate::disc::ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
};
|
||||
|
||||
let samples = read_encrypted_units(&mut src, &title, 8);
|
||||
assert!(
|
||||
!samples.is_empty(),
|
||||
"the CPI-flagged (odd) units must still be collected"
|
||||
);
|
||||
for s in &samples {
|
||||
assert!(
|
||||
aacs_unit_encrypted(s),
|
||||
"only CPI-flagged units are selected"
|
||||
);
|
||||
assert_eq!(
|
||||
s[0] & 0xC0,
|
||||
0xC0,
|
||||
"a CPI-clear sync-destroyed unit must never be sampled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,7 +825,7 @@ mod tests {
|
||||
/// prior single-key fixtures passed regardless of stride.
|
||||
#[test]
|
||||
fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() {
|
||||
use crate::aacs::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
|
||||
use crate::aacs::mkb::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
|
||||
const UK_POS: usize = 64;
|
||||
let mut inf = vec![0u8; 200];
|
||||
inf[0..4].copy_from_slice(&(UK_POS as u32).to_be_bytes()); // uk_pos
|
||||
|
||||
+35
-21
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
||||
|
||||
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
||||
/// inside a `/BDMV/JAR/*` archive.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
@@ -77,11 +77,18 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
||||
/// would mislabel tracks.)
|
||||
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
|
||||
// Numbers already claimed by the map, per type.
|
||||
// Numbers already claimed by the map, per type. A map value of 0 is NOT a
|
||||
// claim: apply_labels binds on 1-based stream numbers, so 0 is unmatchable.
|
||||
// Treat 0 as "unmapped" here (defense in depth — parse_playback_config also
|
||||
// filters it) so such a stream gets a real synthesized number instead of an
|
||||
// orphan 0 that collides with / shadows a genuine stream 1.
|
||||
let mut taken_audio: Vec<u16> = Vec::new();
|
||||
let mut taken_sub: Vec<u16> = Vec::new();
|
||||
for info in infos {
|
||||
if let Some(&n) = stream_map.get(&info.id) {
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
match info.stream_type {
|
||||
StreamLabelType::Audio => taken_audio.push(n),
|
||||
StreamLabelType::Subtitle => taken_sub.push(n),
|
||||
@@ -94,8 +101,8 @@ fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>
|
||||
let mut out = Vec::with_capacity(infos.len());
|
||||
for info in infos {
|
||||
let n = match stream_map.get(&info.id).copied() {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
Some(n) if n != 0 => n,
|
||||
_ => {
|
||||
let (idx, taken) = match info.stream_type {
|
||||
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
||||
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
||||
@@ -277,27 +284,34 @@ mod tests {
|
||||
assert_eq!(nums[3], 2); // subtitle 2
|
||||
}
|
||||
|
||||
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
|
||||
/// This is documented in parse_playback_config: `if stream_num != 0`.
|
||||
/// Mutation: remove the `!= 0` guard → zero is stored in map.
|
||||
/// Spec: a map value of 0 is unmatchable (apply_labels is 1-based), so
|
||||
/// assign_stream_numbers must treat it as unmapped and synthesize a real
|
||||
/// 1-based number rather than emit an orphan 0.
|
||||
/// Mutation: read the map value verbatim → stream_number 0 leaks out.
|
||||
#[test]
|
||||
fn map_zero_stream_num_is_skipped() {
|
||||
// parse_playback_config skips zero; simulate that: the zero shouldn't
|
||||
// end up in the map. We test assign_stream_numbers with a zero-containing
|
||||
// map to verify it won't freeze the fallback counter at 1 forever.
|
||||
fn map_zero_stream_num_is_synthesized_not_emitted() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("a0".to_string(), 0u16); // zero — per spec, was filtered by parse_playback_config
|
||||
map.insert("a0".to_string(), 0u16); // 0 must not be treated as a claim
|
||||
let infos = vec![info("a0", StreamLabelType::Audio)];
|
||||
// If 0 IS in the map and assign_stream_numbers uses it, stream_number=0
|
||||
// is not matchable (apply_labels is 1-based). The fallback counter
|
||||
// would assign 1 instead. Test both paths:
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
|
||||
// This is a known limitation — the guard lives in parse_playback_config.
|
||||
// The test documents the ACTUAL behavior so a code change that introduces
|
||||
// the guard in assign_stream_numbers would be caught.
|
||||
// Current behavior: map wins → 0.
|
||||
assert_eq!(nums[0], 0);
|
||||
// 0 is treated as unmapped → the fallback counter assigns 1.
|
||||
assert_eq!(nums[0], 1);
|
||||
}
|
||||
|
||||
/// A stream genuinely mapped to 1 plus another stream whose map value is 0
|
||||
/// must NOT both land on 1: the 0-stream is synthesized past the claimed 1.
|
||||
#[test]
|
||||
fn map_zero_does_not_collide_with_a_real_stream_one() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("real".to_string(), 1u16);
|
||||
map.insert("bad".to_string(), 0u16);
|
||||
let infos = vec![
|
||||
info("real", StreamLabelType::Audio),
|
||||
info("bad", StreamLabelType::Audio),
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
assert_eq!(nums[0], 1); // the genuinely-mapped stream keeps 1
|
||||
assert_eq!(nums[1], 2); // the 0-stream is synthesized to the next free slot
|
||||
}
|
||||
|
||||
/// Spec: collision-avoidance works across audio AND subtitle independently.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ use std::collections::HashMap;
|
||||
|
||||
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
|
||||
/// `language_streams.txt` inside a `/BDMV/JAR/*` archive.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "menu_base.prop")
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
+10
-8
@@ -35,14 +35,16 @@ use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// dbp detect can't peek inside a jar without a SectorSource (the
|
||||
/// trait function only takes `&UdfFs`), so we trigger on the cheap
|
||||
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
|
||||
/// BD-J disc, but parse() does the real `com/dbp/` check and
|
||||
/// returns None on a mismatch — so this parser only ever consumes
|
||||
/// time on discs that fell through every earlier parser.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
jar::has_any_top_level_jar(udf)
|
||||
/// The real dbp signal is the `com/dbp/` package prefix inside a top-level
|
||||
/// jar's central directory. With a reader in `detect`, we check that directly
|
||||
/// (a cheap central-directory scan, no class decode) so this parser claims
|
||||
/// only dbp discs instead of firing on every BD-J disc. `parse()` repeats the
|
||||
/// check as belt-and-suspenders.
|
||||
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
jar::for_each_jar(reader, udf, |_entry, archive| {
|
||||
jar::has_path_prefix(archive, "com/dbp/").then_some(())
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
|
||||
|
||||
@@ -64,11 +64,15 @@ use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
// Cheap pre-check at the dir level; the real signal is
|
||||
// `com/bydeluxe/` inside any top-level jar's central directory,
|
||||
// which `parse()` confirms when given a `SectorSource`.
|
||||
jar::has_any_top_level_jar(udf)
|
||||
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
// The real signal is `com/bydeluxe/` inside a top-level jar's central
|
||||
// directory. With a reader in detect we check it directly (cheap
|
||||
// central-directory scan, no bytecode walk) so this parser claims only
|
||||
// Deluxe discs; `parse()` repeats the check.
|
||||
jar::for_each_jar(reader, udf, |_entry, archive| {
|
||||
jar::has_path_prefix(archive, "com/bydeluxe/").then_some(())
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
|
||||
@@ -26,21 +26,6 @@ const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// etc.
|
||||
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
|
||||
|
||||
/// True if `/BDMV/JAR/` contains at least one top-level `.jar` file
|
||||
/// (not under a subdir). Used by `detect()` in parsers whose real
|
||||
/// signal lives inside a jar — they can't open the jar without a
|
||||
/// `SectorSource`, so they use this cheap pre-check and do the real
|
||||
/// `com/<vendor>/` discriminator in `parse()`.
|
||||
pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
|
||||
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
||||
return false;
|
||||
};
|
||||
jar_dir
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
|
||||
}
|
||||
|
||||
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
|
||||
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
||||
/// produces, or `None` if every jar was visited without a hit.
|
||||
|
||||
+28
-15
@@ -20,6 +20,7 @@ pub(crate) mod jar;
|
||||
mod mpls_universal;
|
||||
mod paramount;
|
||||
mod pixelogic;
|
||||
mod png_filenames;
|
||||
pub(crate) mod text;
|
||||
pub mod vocab;
|
||||
pub(crate) mod xml;
|
||||
@@ -91,7 +92,11 @@ pub enum LabelQualifier {
|
||||
// the registry picks the highest-confidence parse result, falling back
|
||||
// to array order on confidence ties.
|
||||
|
||||
type DetectFn = fn(&UdfFs) -> bool;
|
||||
// `detect` takes the reader too, so a parser can look INSIDE a jar's central
|
||||
// directory (real vendor-prefix / project-file check) rather than firing on
|
||||
// "any jar present". Precise detection is what lets the registry scale to many
|
||||
// parsers without cross-parser collisions.
|
||||
type DetectFn = fn(&mut dyn SectorSource, &UdfFs) -> bool;
|
||||
type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>;
|
||||
|
||||
/// Per-parser claim of how reliable its output is. Used by the
|
||||
@@ -158,11 +163,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
("criterion", criterion::detect, criterion::parse),
|
||||
("pixelogic", pixelogic::detect, pixelogic::parse),
|
||||
("ctrm", ctrm::detect, ctrm::parse),
|
||||
// dbp and deluxe both detect on "any top-level .jar in /BDMV/JAR/"
|
||||
// (every BD-J disc trips that) and do the real vendor-prefix check
|
||||
// in parse(). Order between them is the tiebreaker on equal
|
||||
// confidence; dbp goes first because its parse path is cheaper
|
||||
// (constant-pool iteration vs. deluxe's bytecode walking).
|
||||
// dbp and deluxe now detect via the real `com/<vendor>/` central-directory
|
||||
// prefix (reader-backed), so they claim only their own discs. Order between
|
||||
// them is the tiebreaker on equal confidence; dbp goes first because its
|
||||
// parse path is cheaper (constant-pool iteration vs. deluxe's bytecode
|
||||
// walking).
|
||||
("dbp", dbp::detect, dbp::parse),
|
||||
("deluxe", deluxe::detect, deluxe::parse),
|
||||
// Universal MPLS fallback. Returns Confidence::Low so framework
|
||||
@@ -176,6 +181,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
mpls_universal::detect,
|
||||
mpls_universal::parse,
|
||||
),
|
||||
// Menu-graphic filename language hints (Low). AFTER mpls_universal so the
|
||||
// richer spec-derived floor wins the Low tie whenever it produces anything;
|
||||
// this only becomes the chosen parser when even MPLS yields nothing but the
|
||||
// menu artwork still names its languages. A last-resort language source.
|
||||
("png_filenames", png_filenames::detect, png_filenames::parse),
|
||||
];
|
||||
|
||||
/// Search disc for config files, extract labels, apply to streams.
|
||||
@@ -521,7 +531,7 @@ fn generate_audio_label_inner(
|
||||
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
let mut best: Option<(&'static str, ParseResult)> = None;
|
||||
for (name, detect, parse) in PARSERS {
|
||||
if !detect(udf) {
|
||||
if !detect(reader, udf) {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(parser = name, "label parser detected");
|
||||
@@ -788,7 +798,7 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis {
|
||||
let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new();
|
||||
|
||||
for (name, detect, parse) in PARSERS {
|
||||
if !detect(udf) {
|
||||
if !detect(reader, udf) {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(parser = name, "label parser detected");
|
||||
@@ -961,8 +971,9 @@ pub struct ChapterSummary {
|
||||
|
||||
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
|
||||
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
|
||||
/// present.
|
||||
fn jar_inventory(udf: &UdfFs) -> Vec<String> {
|
||||
/// present. `pub(crate)` so filename-based parsers (e.g. `png_filenames`)
|
||||
/// can scan menu-asset names without a reader.
|
||||
pub(crate) fn jar_inventory(udf: &UdfFs) -> Vec<String> {
|
||||
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -1042,12 +1053,14 @@ mod registry_tests {
|
||||
"dbp",
|
||||
"deluxe",
|
||||
"mpls_universal",
|
||||
"png_filenames",
|
||||
],
|
||||
"PARSERS array order changed — confirm dbp + deluxe stay just \
|
||||
before mpls_universal (loose detect, real check in parse), \
|
||||
stricter parsers (paramount/criterion/pixelogic/ctrm — all \
|
||||
file-presence gated detect) stay first, and mpls_universal \
|
||||
stays LAST as the universal Low-confidence fallback."
|
||||
"PARSERS array order changed — file-presence/reader-gated High \
|
||||
parsers (paramount/criterion/pixelogic/ctrm) stay first; dbp + \
|
||||
deluxe (now real com/<vendor>/ prefix detect) stay before \
|
||||
mpls_universal; mpls_universal stays the universal Low fallback; \
|
||||
png_filenames (Low, language-only hint) stays LAST so MPLS wins \
|
||||
the Low tie whenever it produces anything."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::udf::UdfFs;
|
||||
|
||||
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
||||
/// `.mpls` file. Cheap directory walk only — no sector reads.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelT
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "playlists.xml")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const REGIONS: &[&str] = &[
|
||||
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
|
||||
];
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "bluray_project.bin")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Menu-graphic filename language hints.
|
||||
//!
|
||||
//! Some BD-J discs encode per-language menu artwork with the language in the
|
||||
//! filename, e.g. `Dune_UHD01_Eng_Composite1.png`,
|
||||
//! `VForVendetta_UHD01_FRE_Composite2.png`. The `_UHD01_{LANG}_Composite`
|
||||
//! marker is authored deliberately, so the set of `{LANG}` tokens is the set
|
||||
//! of menu languages the disc ships.
|
||||
//!
|
||||
//! This is a language-only hint (no per-stream purpose/codec), so it runs at
|
||||
//! [`Confidence::Low`] — it never displaces a real framework parser, and it
|
||||
//! sits at the same tier as the MPLS floor. It is here so the pattern is a
|
||||
//! first-class, testable parser that keeps picking up discs as the corpus
|
||||
//! grows, rather than lost logic. Detection is precise: it fires only on the
|
||||
//! `_UHD01_{LANG}_Composite` grammar with a `{LANG}` the vocab recognizes.
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
super::jar_inventory(udf)
|
||||
.iter()
|
||||
.any(|f| filename_lang(f).is_some())
|
||||
}
|
||||
|
||||
pub fn parse(_reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let names = super::jar_inventory(udf);
|
||||
let labels = labels_from_filenames(&names);
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Low: language-only, derived from menu-asset filenames. A real framework
|
||||
// parser (and even the MPLS floor's per-stream data) is preferred; this is
|
||||
// a hint of which languages the disc menus offer.
|
||||
Some(ParseResult::low(labels))
|
||||
}
|
||||
|
||||
/// One audio [`StreamLabel`] per distinct menu language found, in first-seen
|
||||
/// order, numbered 1-based. Split out from `parse` so it is unit-testable
|
||||
/// without a `UdfFs`.
|
||||
fn labels_from_filenames(names: &[String]) -> Vec<StreamLabel> {
|
||||
let mut seen: Vec<&'static str> = Vec::new();
|
||||
for name in names {
|
||||
if let Some(code) = filename_lang(name) {
|
||||
if !seen.contains(&code) {
|
||||
seen.push(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, code)| StreamLabel {
|
||||
stream_number: (i as u16).saturating_add(1),
|
||||
stream_type: StreamLabelType::Audio,
|
||||
language: code.to_string(),
|
||||
name: String::new(),
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract the ISO-639-2 language code from a `{title}_UHD01_{LANG}_Composite`
|
||||
/// menu-graphic filename, or `None` if the name does not match the grammar or
|
||||
/// carries a `{LANG}` the vocab does not recognize.
|
||||
///
|
||||
/// The `_UHD01_` marker plus the `_Composite` suffix keep this from firing on
|
||||
/// unrelated PNGs (`KeyComposite4.png`, `LoadingComposite1.png` have no
|
||||
/// `_UHD01_{LANG}_` segment).
|
||||
fn filename_lang(name: &str) -> Option<&'static str> {
|
||||
// Case-fold once; the marker/suffix are matched case-insensitively.
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let marker = "_uhd01_";
|
||||
let m = lower.find(marker)?;
|
||||
let after = m + marker.len();
|
||||
// The language token runs from `after` up to the next `_`.
|
||||
let rest = &lower[after..];
|
||||
let end = rest.find('_')?;
|
||||
if !rest[end..].starts_with("_composite") {
|
||||
return None;
|
||||
}
|
||||
let token = &name[after..after + end];
|
||||
vocab::menu_lang(token)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_confirmed_samples() {
|
||||
assert_eq!(filename_lang("Dune_UHD01_Eng_Composite1.png"), Some("eng"));
|
||||
assert_eq!(filename_lang("Dune_UHD01_Ger_Composite2.png"), Some("deu"));
|
||||
assert_eq!(
|
||||
filename_lang("VForVendetta_UHD01_FRE_Composite2.png"),
|
||||
Some("fra")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_language_composites() {
|
||||
assert_eq!(filename_lang("KeyComposite4.png"), None);
|
||||
assert_eq!(filename_lang("LoadingComposite1.png"), None);
|
||||
assert_eq!(
|
||||
filename_lang("FourKWarningsComposite1_bt2020_HDR.png"),
|
||||
None
|
||||
);
|
||||
assert_eq!(filename_lang("Fast9_UPK75_Composite1.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_language_token_is_none() {
|
||||
// A UHD01 marker but a token the vocab does not recognize must not
|
||||
// produce a bogus language.
|
||||
assert_eq!(filename_lang("Movie_UHD01_Zzz_Composite1.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedups_and_numbers_distinct_languages() {
|
||||
let names = vec![
|
||||
"Dune_UHD01_Eng_Composite1.png".to_string(),
|
||||
"Dune_UHD01_Eng_Composite2.png".to_string(),
|
||||
"Dune_UHD01_Ger_Composite1.png".to_string(),
|
||||
"LoadingComposite1.png".to_string(),
|
||||
];
|
||||
let labels = labels_from_filenames(&names);
|
||||
assert_eq!(labels.len(), 2);
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
assert_eq!(labels[0].stream_number, 1);
|
||||
assert_eq!(labels[1].language, "deu");
|
||||
assert_eq!(labels[1].stream_number, 2);
|
||||
assert!(
|
||||
labels
|
||||
.iter()
|
||||
.all(|l| l.stream_type == StreamLabelType::Audio)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_matching_names_yields_empty() {
|
||||
let names = vec![
|
||||
"KeyComposite4.png".to_string(),
|
||||
"disc.properties".to_string(),
|
||||
];
|
||||
assert!(labels_from_filenames(&names).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,49 @@ const BARE_LANGS: &[(&str, &str)] = &[
|
||||
("galician", "glg"),
|
||||
];
|
||||
|
||||
/// Map a short menu-graphic language token (as embedded in authoring
|
||||
/// filenames like `Dune_UHD01_Eng_Composite1.png`) to an ISO-639-2/T code.
|
||||
///
|
||||
/// These filename tokens are compact 2/3-letter abbreviations, NOT the full
|
||||
/// language names [`lang`] handles, so they get their own certain table.
|
||||
/// Accepts the ISO-639-2/B spellings some tools emit (`ger`, `fre`, `chi`)
|
||||
/// and normalizes them to the /T code the rest of the pipeline uses (`deu`,
|
||||
/// `fra`, `zho`). Case-insensitive. Returns `None` for anything not in the
|
||||
/// table — never guesses, so an unrecognized token drops rather than
|
||||
/// mislabels.
|
||||
pub fn menu_lang(token: &str) -> Option<&'static str> {
|
||||
let t = token.trim().to_ascii_lowercase();
|
||||
let code = match t.as_str() {
|
||||
"eng" | "en" => "eng",
|
||||
"ger" | "deu" | "de" => "deu",
|
||||
"fre" | "fra" | "fr" => "fra",
|
||||
"spa" | "es" => "spa",
|
||||
"ita" | "it" => "ita",
|
||||
"por" | "pt" => "por",
|
||||
"jpn" | "jap" | "ja" => "jpn",
|
||||
"kor" | "ko" => "kor",
|
||||
"chi" | "zho" | "zh" => "zho",
|
||||
"rus" | "ru" => "rus",
|
||||
"dut" | "nld" | "nl" => "nld",
|
||||
"pol" | "pl" => "pol",
|
||||
"cze" | "ces" | "cs" => "ces",
|
||||
"dan" | "da" => "dan",
|
||||
"fin" | "fi" => "fin",
|
||||
"nor" | "no" => "nor",
|
||||
"swe" | "sv" => "swe",
|
||||
"hun" | "hu" => "hun",
|
||||
"gre" | "ell" | "el" => "ell",
|
||||
"tur" | "tr" => "tur",
|
||||
"ara" | "ar" => "ara",
|
||||
"hin" | "hi" => "hin",
|
||||
"tha" | "th" => "tha",
|
||||
"ukr" | "uk" => "ukr",
|
||||
"cat" | "ca" => "cat",
|
||||
_ => return None,
|
||||
};
|
||||
Some(code)
|
||||
}
|
||||
|
||||
// ── Purpose ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Classify a free-form English label string into a [`LabelPurpose`].
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
//! Access-unit assembly — a codec-parser helper.
|
||||
//!
|
||||
//! The contract a codec parser converts is `PES → access units (Frames)`. A
|
||||
//! *transport* stream hands the parser one AU per PES for free (BD aligns one
|
||||
//! access unit per PES; the TS demuxer reassembles to the
|
||||
//! `payload_unit_start_indicator`). A *program* stream does not — the PS muxer
|
||||
//! chops the elementary stream into fixed-size PES fragments with no AU
|
||||
//! alignment, and only the first fragment of an AU carries a PTS. So a parser
|
||||
//! that assumes one-AU-per-PES (h264/hevc/vc1, written against TS) mis-frames a
|
||||
//! program stream, while `mpeg2` — the DVD/PS codec — must reassemble across PES.
|
||||
//!
|
||||
//! [`AuAssembler`] is that reassembly, factored out so EVERY program-stream video
|
||||
//! parser shares one implementation instead of hand-rolling the buffer. The
|
||||
//! h264/hevc/vc1 parsers ([`Mode::StartCode`] / [`Mode::Vc1`]) and the MPEG-2
|
||||
//! parser ([`Mode::Mpeg2`], via [`AuAssembler::mpeg2`]) all drive it. It buffers
|
||||
//! PES-fragment bytes and emits one AU per codec AU boundary, carrying the
|
||||
//! AU-start timing/source forward. Since the boundary is a codec start code, it
|
||||
//! lives with the codec parser (which picks the marker); only the generic
|
||||
//! buffering + timing-carry is shared here.
|
||||
//!
|
||||
//! This is *inside* the parser, not a pipeline stage: the pipeline stays
|
||||
//! `Demuxer → PES → Parser → Frames`, and the demuxer stays codec-agnostic. Every
|
||||
//! stream a parser sees runs through one of these — self-framing codecs (MPEG-2,
|
||||
//! audio) use [`Mode::Passthrough`] so the parser code path is uniform.
|
||||
|
||||
use crate::disc::Codec;
|
||||
use crate::pes::SourcePos;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Safety cap on a single in-progress access unit. A real coded picture is far
|
||||
/// below this; a stream that never yields a second AU boundary is force-flushed
|
||||
/// at the cap rather than buffering without bound on hostile/corrupt input.
|
||||
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Cap on buffered timing/discontinuity marks. A real access unit spans a few
|
||||
/// hundred PES fragments at most; this bounds the mark deques so a run of
|
||||
/// zero-length (or start-code-free) timed fragments — which grow no buffer bytes
|
||||
/// and so never trip the `MAX_AU_BUFFER` mark-prune — cannot accumulate marks
|
||||
/// without bound on hostile/corrupt disc input.
|
||||
const MAX_MARKS: usize = 64 * 1024;
|
||||
|
||||
/// One AU-complete unit drained from the buffer: its elementary-stream bytes plus
|
||||
/// the timing/source/discontinuity of the fragment that opened the AU.
|
||||
pub(crate) struct AssembledAu {
|
||||
pub data: Vec<u8>,
|
||||
pub pts: Option<i64>,
|
||||
pub dts: Option<i64>,
|
||||
pub source: Option<SourcePos>,
|
||||
pub discontinuity: bool,
|
||||
}
|
||||
|
||||
/// VC-1 (SMPTE 421M Annex E) BDU start-code suffixes, `00 00 01 <type>`.
|
||||
const VC1_FRAME: u8 = 0x0D; // coded picture
|
||||
const VC1_ENTRY: u8 = 0x0E; // entry-point header
|
||||
const VC1_SEQ: u8 = 0x0F; // sequence header
|
||||
|
||||
/// MPEG-2 (ISO/IEC 13818-2) start-code suffixes, `00 00 01 <type>`.
|
||||
const MP2_PICTURE: u8 = 0x00; // picture_start_code
|
||||
const MP2_SEQ: u8 = 0xB3; // sequence_header_code
|
||||
const MP2_GOP: u8 = 0xB8; // group_start_code
|
||||
|
||||
/// How a stream's fragments become AU-complete units.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Mode {
|
||||
/// Split the elementary stream on the codec's single AU-delimiter start code
|
||||
/// `00 00 01 <marker>` (H.264 AUD `0x09`, HEVC AUD `0x46`). Every AU opens with
|
||||
/// exactly that code, so a plain split is correct.
|
||||
StartCode(u8),
|
||||
/// VC-1 has no single AU delimiter: an access unit is a `[sequence header?]
|
||||
/// [entry point?][frame][slices…]` group. The sequence-header (`0x0F`) and
|
||||
/// entry-point (`0x0E`) BDUs precede the frame (`0x0D`) they belong to, so a
|
||||
/// plain `0x0D` split would glue them onto the *previous* AU and strip every
|
||||
/// I-frame of its headers. The boundary is instead the next `0x0F`/`0x0E`/`0x0D`
|
||||
/// start code that follows a frame already seen in the current AU.
|
||||
Vc1,
|
||||
/// MPEG-2 access unit: `[sequence header?][GOP header?][picture][slices…]`.
|
||||
/// Structurally identical to [`Mode::Vc1`] — the sequence (`0xB3`) and GOP
|
||||
/// (`0xB8`) headers precede the picture (`0x00`) they introduce, so the
|
||||
/// boundary is the next picture / sequence / GOP start code that follows a
|
||||
/// picture already seen. Slice (`0x01..=0xAF`), extension (`0xB5`),
|
||||
/// user-data (`0xB2`) and sequence-end (`0xB7`) codes are NOT boundaries.
|
||||
Mpeg2,
|
||||
/// The codec self-frames (MPEG-2 reassembles in its own parser; audio resyncs
|
||||
/// on syncwords), so each fragment passes straight through as one unit. Lets
|
||||
/// the caller run EVERY stream through an assembler with no per-codec branch.
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
/// A timing/source mark taken at the absolute stream offset of a fragment that
|
||||
/// carried it, so it survives `buf.drain(..)` and can be attributed to the AU
|
||||
/// whose byte range contains it.
|
||||
struct Mark {
|
||||
off: u64,
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
source: Option<SourcePos>,
|
||||
}
|
||||
|
||||
/// Reassembles PES fragments into AU-complete units. One per stream; stateful
|
||||
/// across `push` calls.
|
||||
pub(crate) struct AuAssembler {
|
||||
mode: Mode,
|
||||
/// Buffered elementary-stream bytes not yet emitted as a complete AU.
|
||||
buf: Vec<u8>,
|
||||
/// Absolute stream offset of `buf[0]`, so marks (taken at absolute offsets)
|
||||
/// survive `buf.drain(..)`.
|
||||
base: u64,
|
||||
/// Timing/source marks, in fragment order.
|
||||
marks: VecDeque<Mark>,
|
||||
/// Absolute offsets of fragments flagged with an upstream discontinuity.
|
||||
disc_marks: VecDeque<u64>,
|
||||
/// Incremental boundary-scan cursor: the offset into `buf` up to which the
|
||||
/// current AU has already been searched for its end without finding one. Each
|
||||
/// `push` resumes the boundary search from here instead of rescanning the
|
||||
/// whole buffer, so reassembling one AU split across N PES fragments costs
|
||||
/// O(AU bytes) total, not O(AU bytes²/fragment). Reset to 0 whenever `buf[0]`
|
||||
/// moves (an AU drained, or leading bytes dropped).
|
||||
scan_pos: usize,
|
||||
/// Whether the current AU has already contained a coded frame/picture — the
|
||||
/// state the VC-1/MPEG-2 boundary rule carries across a resumed scan (their
|
||||
/// boundary is "the next opener after a frame is already seen"). Meaningless
|
||||
/// for `Mode::StartCode`. Reset with `scan_pos`.
|
||||
seen_unit: bool,
|
||||
/// Pre-sync opener-search cursor: the offset up to which the buffer has been
|
||||
/// searched for the FIRST AU opener with none found. Resumes the opener scan
|
||||
/// so a long run of junk with no start code (hostile/corrupt input) costs
|
||||
/// O(bytes) total, not O(buffer) per push. Reset when `buf[0]` moves.
|
||||
opener_pos: usize,
|
||||
}
|
||||
|
||||
impl AuAssembler {
|
||||
/// An assembler for `codec`. Video codecs whose parsers assume AU-complete PES
|
||||
/// (H.264 / HEVC / VC-1) get a [`Mode::StartCode`] assembler; MPEG-2 (self-
|
||||
/// reassembles) and audio/subtitle codecs (self-framing) get [`Mode::Passthrough`]
|
||||
/// so callers can run every stream through this uniformly.
|
||||
pub(crate) fn for_codec(codec: Codec) -> Self {
|
||||
let mode = match codec {
|
||||
Codec::H264 => Mode::StartCode(0x09), // access_unit_delimiter NAL (type 9)
|
||||
Codec::Hevc => Mode::StartCode(0x46), // AUD NAL (type 35 → (35 << 1) = 0x46)
|
||||
Codec::Vc1 => Mode::Vc1, // frame + preceding seq/entry headers
|
||||
_ => Mode::Passthrough,
|
||||
};
|
||||
Self {
|
||||
mode,
|
||||
// Passthrough never writes `buf` (one fragment → one unit); only the
|
||||
// reassembling modes need reserve. Avoids ~256 KiB per audio/subtitle
|
||||
// stream (and every TS/BD stream, which never feeds the assembler).
|
||||
buf: match mode {
|
||||
Mode::Passthrough => Vec::new(),
|
||||
_ => Vec::with_capacity(256 * 1024),
|
||||
},
|
||||
base: 0,
|
||||
marks: VecDeque::new(),
|
||||
disc_marks: VecDeque::new(),
|
||||
scan_pos: 0,
|
||||
seen_unit: false,
|
||||
opener_pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// An assembler that reassembles MPEG-2 access units. The MPEG-2 parser owns
|
||||
/// one of these directly (rather than hand-rolling the buffer): the demux
|
||||
/// layer runs MPEG-2 through [`Mode::Passthrough`] and hands each fragment to
|
||||
/// the parser, which feeds them here to be reframed on picture boundaries.
|
||||
pub(crate) fn mpeg2() -> Self {
|
||||
Self {
|
||||
mode: Mode::Mpeg2,
|
||||
buf: Vec::with_capacity(128 * 1024),
|
||||
base: 0,
|
||||
marks: VecDeque::new(),
|
||||
disc_marks: VecDeque::new(),
|
||||
scan_pos: 0,
|
||||
seen_unit: false,
|
||||
opener_pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one PES fragment the caller OWNS; return every AU now complete. For
|
||||
/// a self-framing (`Passthrough`) stream the payload is MOVED straight into
|
||||
/// the emitted unit with no copy — the common DVD/HD-DVD case (MPEG-2 video,
|
||||
/// all audio). A buffering mode copies into `buf` exactly as [`Self::push`].
|
||||
pub(crate) fn push_owned(
|
||||
&mut self,
|
||||
data: Vec<u8>,
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
source: Option<SourcePos>,
|
||||
discontinuity: bool,
|
||||
) -> Vec<AssembledAu> {
|
||||
if matches!(self.mode, Mode::Passthrough) {
|
||||
return vec![AssembledAu {
|
||||
data,
|
||||
pts,
|
||||
dts,
|
||||
source,
|
||||
discontinuity,
|
||||
}];
|
||||
}
|
||||
self.push(&data, pts, dts, source, discontinuity)
|
||||
}
|
||||
|
||||
/// Feed one PES fragment (borrowed); return every AU that is now complete.
|
||||
pub(crate) fn push(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
source: Option<SourcePos>,
|
||||
discontinuity: bool,
|
||||
) -> Vec<AssembledAu> {
|
||||
// Self-framing codecs pass through unchanged — one fragment, one unit,
|
||||
// its own timing. (This is exactly today's behaviour for mpeg2/audio.)
|
||||
if matches!(self.mode, Mode::Passthrough) {
|
||||
return vec![AssembledAu {
|
||||
data: data.to_vec(),
|
||||
pts,
|
||||
dts,
|
||||
source,
|
||||
discontinuity,
|
||||
}];
|
||||
}
|
||||
let off = self.base + self.buf.len() as u64;
|
||||
if pts.is_some() || dts.is_some() || source.is_some() {
|
||||
self.marks.push_back(Mark {
|
||||
off,
|
||||
pts,
|
||||
dts,
|
||||
source,
|
||||
});
|
||||
// Backstop: the `buf`-size cap prunes marks only when bytes accumulate.
|
||||
// A run of zero-length (or start-code-free) timed fragments grows no
|
||||
// bytes, so bound the deque directly — drop the oldest (stalest) mark,
|
||||
// which belongs to an already-emitted or lost AU. A real AU spans far
|
||||
// fewer fragments than this cap.
|
||||
if self.marks.len() > MAX_MARKS {
|
||||
self.marks.pop_front();
|
||||
}
|
||||
}
|
||||
if discontinuity {
|
||||
self.disc_marks.push_back(off);
|
||||
if self.disc_marks.len() > MAX_MARKS {
|
||||
self.disc_marks.pop_front();
|
||||
}
|
||||
}
|
||||
self.buf.extend_from_slice(data);
|
||||
self.drain(false)
|
||||
}
|
||||
|
||||
/// Emit the trailing in-progress AU at end of stream (no following boundary).
|
||||
pub(crate) fn flush(&mut self) -> Vec<AssembledAu> {
|
||||
if matches!(self.mode, Mode::Passthrough) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.drain(true)
|
||||
}
|
||||
|
||||
fn drain(&mut self, force: bool) -> Vec<AssembledAu> {
|
||||
if matches!(self.mode, Mode::Passthrough) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
// Locate the AU start code that opens the buffered run (resumes from
|
||||
// opener_pos so an unsynced junk run is scanned once, not per push).
|
||||
let Some(a0) = self.au_opener_resumable() else {
|
||||
// No AU boundary buffered. Bound memory: drop all but a 3-byte
|
||||
// tail (enough to catch a start-code prefix straddling the cut)
|
||||
// once over the cap; otherwise wait for more data.
|
||||
if self.buf.len() > MAX_AU_BUFFER {
|
||||
let drop = self.buf.len() - 3;
|
||||
self.buf.drain(..drop);
|
||||
self.base += drop as u64;
|
||||
self.reset_scan();
|
||||
self.drop_marks_before(self.base);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if a0 > 0 {
|
||||
// Leading bytes before the first AU boundary are a partial AU from
|
||||
// before we synced (or junk) — discard them and any stale marks.
|
||||
self.buf.drain(..a0);
|
||||
self.base += a0 as u64;
|
||||
self.reset_scan();
|
||||
self.drop_marks_before(self.base);
|
||||
continue;
|
||||
}
|
||||
// The AU runs from here (buf[0]) to the NEXT AU boundary. The search
|
||||
// resumes from `scan_pos` (bytes already searched with no boundary),
|
||||
// so one AU spread across many fragments is scanned once, not per push.
|
||||
let end = match self.au_boundary_resumable() {
|
||||
Some(next) => next,
|
||||
// No next boundary yet: on EOF (or over-cap backstop) the rest of
|
||||
// the buffer is this AU; otherwise wait for more data.
|
||||
None if force => self.buf.len(),
|
||||
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
|
||||
None => break,
|
||||
};
|
||||
if end == 0 {
|
||||
break;
|
||||
}
|
||||
let end_abs = self.base + end as u64;
|
||||
|
||||
// The AU's own timing/source: take the FIRST Some of each field
|
||||
// across every mark in this AU's range [base, end_abs), independently
|
||||
// — one PES fragment may carry the source while a later fragment of
|
||||
// the same AU carries the PTS (and vice versa), so reading only the
|
||||
// front mark would drop the other field. This restores the semantics
|
||||
// of the pre-consolidation separate pts/source mark deques.
|
||||
let (mut pts, mut dts, mut source) = (None, None, None);
|
||||
while self.marks.front().is_some_and(|m| m.off < end_abs) {
|
||||
let m = self.marks.pop_front().unwrap();
|
||||
pts = pts.or(m.pts);
|
||||
dts = dts.or(m.dts);
|
||||
source = source.or(m.source);
|
||||
}
|
||||
let mut discontinuity = false;
|
||||
if self.disc_marks.front().is_some_and(|&o| o < end_abs) {
|
||||
discontinuity = true;
|
||||
}
|
||||
while self.disc_marks.front().is_some_and(|&o| o < end_abs) {
|
||||
self.disc_marks.pop_front();
|
||||
}
|
||||
|
||||
let data = self.buf[..end].to_vec();
|
||||
self.buf.drain(..end);
|
||||
self.base += end as u64;
|
||||
self.reset_scan();
|
||||
out.push(AssembledAu {
|
||||
data,
|
||||
pts,
|
||||
dts,
|
||||
source,
|
||||
discontinuity,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reset the incremental boundary-scan cursor. Called whenever `buf[0]` moves
|
||||
/// (an AU drained, or leading bytes discarded) so the next scan starts fresh
|
||||
/// from the new AU opener.
|
||||
fn reset_scan(&mut self) {
|
||||
self.scan_pos = 0;
|
||||
self.seen_unit = false;
|
||||
self.opener_pos = 0;
|
||||
}
|
||||
|
||||
/// Locate the first AU opener in `buf`, resuming the search from `opener_pos`
|
||||
/// (bytes already searched with no opener) so a long unsynced run costs
|
||||
/// O(bytes) total, not O(buffer) per push. Advances `opener_pos` on a miss.
|
||||
fn au_opener_resumable(&mut self) -> Option<usize> {
|
||||
match au_opener_from(self.mode, &self.buf, self.opener_pos) {
|
||||
Some(o) => Some(o),
|
||||
None => {
|
||||
// Nothing yet; next call resumes here (back up 3 for a straddling
|
||||
// start-code prefix). Never advance past what is searchable.
|
||||
self.opener_pos = self.buf.len().saturating_sub(3).max(self.opener_pos);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the end of the AU that opens at `buf[0]`, resuming from `scan_pos`
|
||||
/// (and, for VC-1/MPEG-2, the carried `seen_unit`) instead of rescanning the
|
||||
/// whole buffer. On no boundary yet, advances `scan_pos`/`seen_unit` so the
|
||||
/// next call continues where this one stopped. Equivalent result to a
|
||||
/// from-scratch whole-buffer scan, but O(total AU bytes) across all pushes.
|
||||
fn au_boundary_resumable(&mut self) -> Option<usize> {
|
||||
match self.mode {
|
||||
Mode::StartCode(marker) => {
|
||||
// Stateless: the AU ends at the next delimiter after the opener at
|
||||
// buf[0]. Resume from the furthest searched offset (never before 4,
|
||||
// to skip the opening delimiter). find_start_code needs 4 bytes, so
|
||||
// back up 3 to catch a code straddling the previous buffer end.
|
||||
let from = self.scan_pos.max(4);
|
||||
match find_start_code(&self.buf, from, marker) {
|
||||
Some(e) => Some(e),
|
||||
None => {
|
||||
self.scan_pos = self.buf.len().saturating_sub(3).max(from);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Vc1 => self.scan_unit_boundary(VC1_FRAME, &[VC1_ENTRY, VC1_SEQ]),
|
||||
Mode::Mpeg2 => self.scan_unit_boundary(MP2_PICTURE, &[MP2_SEQ, MP2_GOP]),
|
||||
Mode::Passthrough => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumable form of the VC-1/MPEG-2 boundary rule: scan from `scan_pos`,
|
||||
/// carrying `seen_unit`; the AU ends at the next `frame` / `header` start code
|
||||
/// once a frame is already seen. Advances `scan_pos`/`seen_unit` when no
|
||||
/// boundary is found so the next push continues, not restarts.
|
||||
fn scan_unit_boundary(&mut self, frame: u8, headers: &[u8]) -> Option<usize> {
|
||||
let buf = &self.buf;
|
||||
let mut i = self.scan_pos;
|
||||
let mut seen = self.seen_unit;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 {
|
||||
let c = buf[i + 3];
|
||||
let is_frame = c == frame;
|
||||
if (is_frame || headers.contains(&c)) && i > 0 && seen {
|
||||
// The AU ends at the next frame/header once a frame is seen.
|
||||
return Some(i);
|
||||
}
|
||||
if is_frame {
|
||||
seen = true;
|
||||
}
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// No boundary yet. Persist the scan state so the next append resumes here
|
||||
// rather than rescanning from 0 (the i+=4 stride is preserved exactly).
|
||||
self.scan_pos = i;
|
||||
self.seen_unit = seen;
|
||||
None
|
||||
}
|
||||
|
||||
fn drop_marks_before(&mut self, off: u64) {
|
||||
while self.marks.front().is_some_and(|m| m.off < off) {
|
||||
self.marks.pop_front();
|
||||
}
|
||||
while self.disc_marks.front().is_some_and(|&o| o < off) {
|
||||
self.disc_marks.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset of the start code that opens the next AU in `buf` (at or after 0), or
|
||||
/// `None` if no AU-opening start code is buffered yet.
|
||||
fn au_opener_from(mode: Mode, buf: &[u8], from: usize) -> Option<usize> {
|
||||
match mode {
|
||||
Mode::StartCode(marker) => find_start_code(buf, from, marker),
|
||||
// Any of the three AU-opening BDU types opens a VC-1 access unit.
|
||||
Mode::Vc1 => find_vc1_start(buf, from),
|
||||
// A sequence header, GOP header, or picture opens an MPEG-2 access unit.
|
||||
Mode::Mpeg2 => find_mpeg2_start(buf, from),
|
||||
Mode::Passthrough => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the next `00 00 01 <marker>` start code at or after `from`.
|
||||
fn find_start_code(buf: &[u8], from: usize, marker: u8) -> Option<usize> {
|
||||
let mut i = from;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && buf[i + 3] == marker {
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the next VC-1 AU-opening BDU start code (`00 00 01` followed by a
|
||||
/// sequence header, entry point, or frame) at or after `from`.
|
||||
fn find_vc1_start(buf: &[u8], from: usize) -> Option<usize> {
|
||||
let mut i = from;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] == 0
|
||||
&& buf[i + 1] == 0
|
||||
&& buf[i + 2] == 1
|
||||
&& matches!(buf[i + 3], VC1_FRAME | VC1_ENTRY | VC1_SEQ)
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the next MPEG-2 AU-opening start code (`00 00 01` followed by a picture,
|
||||
/// sequence header, or GOP header) at or after `from`.
|
||||
fn find_mpeg2_start(buf: &[u8], from: usize) -> Option<usize> {
|
||||
let mut i = from;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] == 0
|
||||
&& buf[i + 1] == 0
|
||||
&& buf[i + 2] == 1
|
||||
&& matches!(buf[i + 3], MP2_PICTURE | MP2_SEQ | MP2_GOP)
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AUD: &[u8] = &[0x00, 0x00, 0x01, 0x09]; // H.264 access-unit delimiter
|
||||
|
||||
fn au(payload: u8, len: usize) -> Vec<u8> {
|
||||
let mut v = AUD.to_vec();
|
||||
v.extend(std::iter::repeat_n(payload, len));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_framing_codecs_pass_through_each_fragment_unchanged() {
|
||||
// MPEG-2 (self-reassembles in its parser) and audio (syncword resync) run
|
||||
// through a Passthrough assembler: every fragment emerges immediately as
|
||||
// one unit with its own timing — byte-identical to today's path.
|
||||
for codec in [Codec::Mpeg2, Codec::Ac3Plus, Codec::Dts, Codec::Lpcm] {
|
||||
let mut a = AuAssembler::for_codec(codec);
|
||||
let out = a.push(&[1, 2, 3, 4], Some(42), None, None, false);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"{codec:?} passes each fragment straight through"
|
||||
);
|
||||
assert_eq!(out[0].data, vec![1, 2, 3, 4]);
|
||||
assert_eq!(out[0].pts, Some(42));
|
||||
assert!(a.flush().is_empty(), "passthrough buffers nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_codecs_reassemble_across_fragments() {
|
||||
// H.264 buffers: one fragment is NOT a complete AU on its own.
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
assert!(
|
||||
a.push(&[0, 0, 1, 0x09, 0xAB], Some(1), None, None, false)
|
||||
.is_empty(),
|
||||
"holds an AU until the next boundary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_au_split_across_fragments_reassembles_with_start_pts() {
|
||||
// A single AU (AUD + 100 bytes) arrives as three fragments; only the
|
||||
// first carries a PTS. It must emit exactly ONE AU with that PTS.
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let full = au(0xAB, 100);
|
||||
assert!(
|
||||
a.push(&full[..40], Some(9000), None, None, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(a.push(&full[40..80], None, None, None, false).is_empty());
|
||||
assert!(a.push(&full[80..], None, None, None, false).is_empty());
|
||||
let out = a.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(
|
||||
out[0].pts,
|
||||
Some(9000),
|
||||
"AU carries its START pts, not 0/None"
|
||||
);
|
||||
assert_eq!(out[0].data, full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_aus_emit_when_the_second_boundary_arrives() {
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let au1 = au(0x11, 50);
|
||||
let au2 = au(0x22, 60);
|
||||
let mut buf = au1.clone();
|
||||
buf.extend_from_slice(&au2);
|
||||
// AU1 + AU2's opening AUD → AU1 completes, tagged pts1.
|
||||
let out = a.push(&buf[..au1.len() + 4], Some(1000), None, None, false);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].data, au1);
|
||||
assert_eq!(out[0].pts, Some(1000));
|
||||
a.push(&buf[au1.len() + 4..], None, None, None, false);
|
||||
let out2 = a.flush();
|
||||
assert_eq!(out2.len(), 1);
|
||||
assert_eq!(out2[0].data, au2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn au_merges_pts_and_source_from_different_fragments() {
|
||||
// One fragment of an AU may carry the source stamp while a later fragment
|
||||
// of the SAME AU carries the PTS (each PES gets a source; only the anchor
|
||||
// gets a PTS). The AU must keep BOTH — reading only the front mark would
|
||||
// drop whichever field the first fragment lacked.
|
||||
let src = crate::pes::SourcePos::at_byte(4242);
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let full = au(0xAB, 80);
|
||||
// Fragment 1: source only, no PTS.
|
||||
assert!(a.push(&full[..30], None, None, Some(src), false).is_empty());
|
||||
// Fragment 2 (same AU): PTS only, no source.
|
||||
assert!(
|
||||
a.push(&full[30..], Some(9000), None, None, false)
|
||||
.is_empty()
|
||||
);
|
||||
let out = a.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].pts, Some(9000), "PTS from the 2nd fragment retained");
|
||||
assert_eq!(
|
||||
out[0].source.map(|s| s.byte),
|
||||
Some(4242),
|
||||
"source from the 1st fragment retained"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discontinuity_flag_attaches_to_the_au_it_opens() {
|
||||
// A discontinuity-flagged fragment opens AU2; that flag must land on AU2,
|
||||
// not AU1 (the B1 resync gate keys off it).
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let au1 = au(0x11, 30);
|
||||
let au2 = au(0x22, 30);
|
||||
a.push(&au1, Some(1), None, None, false);
|
||||
// AU2 arrives flagged; its opening AUD completes AU1 first.
|
||||
let out = a.push(&au2, Some(2), None, None, true);
|
||||
assert_eq!(out.len(), 1, "AU1 completes when AU2's boundary arrives");
|
||||
assert!(!out[0].discontinuity, "AU1 is NOT the discontinuity");
|
||||
let out2 = a.flush();
|
||||
assert_eq!(out2.len(), 1);
|
||||
assert!(out2[0].discontinuity, "AU2 carries the discontinuity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_bytes_before_first_au_are_discarded() {
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let mut buf = vec![0xFF, 0xFF, 0xFF, 0xFF];
|
||||
buf.extend_from_slice(&au(0x33, 20));
|
||||
a.push(&buf, Some(500), None, None, false);
|
||||
let out = a.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].data, au(0x33, 20), "leading junk dropped, AU intact");
|
||||
}
|
||||
|
||||
// ── VC-1 AU grouping ──────────────────────────────────────────────────
|
||||
|
||||
fn bdu(ty: u8, payload: u8, len: usize) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x01, ty];
|
||||
v.extend(std::iter::repeat_n(payload, len));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vc1_i_frame_keeps_its_preceding_seq_and_entry_headers() {
|
||||
// An I-frame AU is [seq 0x0F][entry 0x0E][frame 0x0D][slices]; a following
|
||||
// P-frame is just [frame 0x0D][slices]. A plain 0x0D split would strand the
|
||||
// seq/entry headers on the P-frame's AU — the decode bug. The VC-1 mode must
|
||||
// group them with the I-frame that follows them.
|
||||
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||
let mut iframe = bdu(VC1_SEQ, 0xAA, 8);
|
||||
iframe.extend(bdu(VC1_ENTRY, 0xBB, 6));
|
||||
iframe.extend(bdu(VC1_FRAME, 0xCC, 20)); // frame + slice bytes
|
||||
let pframe = bdu(VC1_FRAME, 0xDD, 15);
|
||||
|
||||
// Feed the I-frame; it stays open until the P-frame's boundary arrives.
|
||||
assert!(a.push(&iframe, Some(9000), None, None, false).is_empty());
|
||||
let out = a.push(&pframe, Some(9376), None, None, false);
|
||||
assert_eq!(out.len(), 1, "I-frame AU completes at the P-frame boundary");
|
||||
assert_eq!(out[0].data, iframe, "I-frame AU retains seq+entry+frame");
|
||||
assert_eq!(out[0].pts, Some(9000));
|
||||
|
||||
let tail = a.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data, pframe, "P-frame is its own AU");
|
||||
assert_eq!(tail[0].pts, Some(9376));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vc1_consecutive_frames_split_one_per_au() {
|
||||
// Back-to-back frames with no headers between them each form their own AU.
|
||||
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||
let f1 = bdu(VC1_FRAME, 0x11, 30);
|
||||
let f2 = bdu(VC1_FRAME, 0x22, 40);
|
||||
let mut both = f1.clone();
|
||||
both.extend_from_slice(&f2);
|
||||
both.extend(bdu(VC1_FRAME, 0x33, 4)); // opening boundary of a 3rd frame
|
||||
let out = a.push(&both, Some(1), None, None, false);
|
||||
assert_eq!(out.len(), 2, "two complete frames emit");
|
||||
assert_eq!(out[0].data, f1);
|
||||
assert_eq!(out[1].data, f2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vc1_entry_point_without_seq_header_still_groups_with_frame() {
|
||||
// Mid-GOP open points can carry an entry-point header with no sequence
|
||||
// header; it must still attach to the frame that follows it.
|
||||
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||
let mut au = bdu(VC1_ENTRY, 0xEE, 5);
|
||||
au.extend(bdu(VC1_FRAME, 0xFF, 12));
|
||||
let mut done = a.push(&au, Some(500), None, None, false);
|
||||
// Next frame's opening boundary closes the entry+frame AU.
|
||||
done.extend(a.push(&bdu(VC1_FRAME, 0x00, 4), None, None, None, false));
|
||||
done.extend(a.flush());
|
||||
assert_eq!(done.len(), 2);
|
||||
assert_eq!(done[0].data, au, "entry+frame grouped");
|
||||
assert_eq!(done[0].pts, Some(500));
|
||||
}
|
||||
|
||||
// ── MPEG-2 AU grouping ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mpeg2_keeps_seq_and_gop_headers_with_their_picture() {
|
||||
// A GOP-opening AU is [seq 0xB3][gop 0xB8][picture 0x00][slices]; the next
|
||||
// picture (no headers) is its own AU. The seq/GOP headers must stay with
|
||||
// the picture they introduce, not glue onto the previous AU.
|
||||
let mut a = AuAssembler::mpeg2();
|
||||
let mut gop = bdu(MP2_SEQ, 0xAA, 10);
|
||||
gop.extend(bdu(MP2_GOP, 0xBB, 8));
|
||||
gop.extend(bdu(MP2_PICTURE, 0xCC, 20)); // picture + slice bytes
|
||||
let pic2 = bdu(MP2_PICTURE, 0xDD, 15);
|
||||
|
||||
assert!(a.push(&gop, Some(9000), None, None, false).is_empty());
|
||||
let out = a.push(&pic2, Some(9376), None, None, false);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"first AU completes at the next picture boundary"
|
||||
);
|
||||
assert_eq!(out[0].data, gop, "AU retains seq + GOP + picture");
|
||||
assert_eq!(out[0].pts, Some(9000));
|
||||
|
||||
let tail = a.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data, pic2, "second picture is its own AU");
|
||||
assert_eq!(tail[0].pts, Some(9376));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpeg2_slice_codes_are_not_au_boundaries() {
|
||||
// Slice start codes (0x01..=0xAF) inside a picture must not split the AU.
|
||||
let mut a = AuAssembler::mpeg2();
|
||||
let mut pic = bdu(MP2_PICTURE, 0x11, 4);
|
||||
pic.extend(bdu(0x01, 0x22, 10)); // slice 1
|
||||
pic.extend(bdu(0xAF, 0x33, 10)); // slice 175 (max slice code)
|
||||
let next = bdu(MP2_PICTURE, 0x44, 4); // opening boundary of the next AU
|
||||
let out = a.push(&[pic.clone(), next].concat(), Some(1), None, None, false);
|
||||
assert_eq!(out.len(), 1, "slices stay inside the one picture AU");
|
||||
assert_eq!(out[0].data, pic, "AU spans the picture and all its slices");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpeg2_reassembles_one_picture_split_across_fragments() {
|
||||
// A picture split across three PES fragments; only the first carries a PTS.
|
||||
let mut a = AuAssembler::mpeg2();
|
||||
let full = bdu(MP2_PICTURE, 0xEE, 100);
|
||||
assert!(a.push(&full[..40], Some(500), None, None, false).is_empty());
|
||||
assert!(a.push(&full[40..80], None, None, None, false).is_empty());
|
||||
assert!(a.push(&full[80..], None, None, None, false).is_empty());
|
||||
let out = a.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].pts, Some(500), "AU carries its START pts");
|
||||
assert_eq!(out[0].data, full);
|
||||
}
|
||||
|
||||
/// Split `stream` into fragments of `frag` bytes, push them through the given
|
||||
/// assembler mode, and return the reassembled AU byte-payloads.
|
||||
fn reassemble_with(mut a: AuAssembler, stream: &[u8], frag: usize) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < stream.len() {
|
||||
let end = (i + frag).min(stream.len());
|
||||
for au in a.push(&stream[i..end], None, None, None, false) {
|
||||
out.push(au.data);
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
for au in a.flush() {
|
||||
out.push(au.data);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumable_boundary_matches_from_scratch_across_all_fragmentations() {
|
||||
// The incremental scan_pos cursor must produce byte-identical AUs to a
|
||||
// whole-buffer rescan, at EVERY fragment granularity (this is what makes
|
||||
// the O(n) resume equivalent to the old O(n^2) from-scratch scan). Build a
|
||||
// multi-AU stream per codec, reassemble it fed 1 byte at a time up to
|
||||
// whole, and require one canonical result.
|
||||
let h264 = {
|
||||
let mut s = au(0x11, 40); // AU1 (AUD + payload)
|
||||
s.extend(au(0x22, 70)); // AU2
|
||||
s.extend(au(0x33, 25)); // AU3
|
||||
s
|
||||
};
|
||||
let vc1 = {
|
||||
let mut s = bdu(VC1_SEQ, 0xAA, 8);
|
||||
s.extend(bdu(VC1_ENTRY, 0xBB, 6));
|
||||
s.extend(bdu(VC1_FRAME, 0xCC, 50)); // I-frame AU
|
||||
s.extend(bdu(VC1_FRAME, 0xDD, 30)); // P-frame AU
|
||||
s.extend(bdu(VC1_FRAME, 0xEE, 20)); // P-frame AU
|
||||
s
|
||||
};
|
||||
let mpeg2 = {
|
||||
let mut s = bdu(MP2_SEQ, 0xAA, 10);
|
||||
s.extend(bdu(MP2_GOP, 0xBB, 8));
|
||||
s.extend(bdu(MP2_PICTURE, 0xCC, 60)); // GOP-opening picture AU
|
||||
s.extend(bdu(MP2_PICTURE, 0xDD, 40)); // picture AU
|
||||
s
|
||||
};
|
||||
// (label, stream, assembler factory). MPEG-2 uses the dedicated mpeg2()
|
||||
// assembler (Mode::Mpeg2); the AUD/VC-1 codecs use for_codec().
|
||||
type MakeAsm = fn() -> AuAssembler;
|
||||
let cases: [(&str, &[u8], MakeAsm); 3] = [
|
||||
("h264", &h264, || AuAssembler::for_codec(Codec::H264)),
|
||||
("vc1", &vc1, || AuAssembler::for_codec(Codec::Vc1)),
|
||||
("mpeg2", &mpeg2, AuAssembler::mpeg2),
|
||||
];
|
||||
for (label, stream, make) in cases {
|
||||
let whole = reassemble_with(make(), stream, stream.len());
|
||||
assert!(!whole.is_empty(), "{label}: baseline produced AUs");
|
||||
for frag in 1..=stream.len() {
|
||||
let got = reassemble_with(make(), stream, frag);
|
||||
assert_eq!(
|
||||
got, whole,
|
||||
"{label}: fragmented at {frag} differs from whole-buffer reassembly"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_deques_stay_bounded_on_zero_length_timed_fragments() {
|
||||
// A run of zero-length fragments that each carry a PTS (or a
|
||||
// discontinuity) grows no buffer bytes, so the buf-size cap never prunes
|
||||
// the mark deques. The MAX_MARKS backstop must bound them regardless.
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
for i in 0..(MAX_MARKS * 2) {
|
||||
a.push(&[], Some(i as i64), None, None, true);
|
||||
}
|
||||
assert!(
|
||||
a.marks.len() <= MAX_MARKS,
|
||||
"marks bounded at MAX_MARKS, got {}",
|
||||
a.marks.len()
|
||||
);
|
||||
assert!(
|
||||
a.disc_marks.len() <= MAX_MARKS,
|
||||
"disc_marks bounded at MAX_MARKS, got {}",
|
||||
a.disc_marks.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_cap_without_boundary_force_flushes() {
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let big = au(0x44, MAX_AU_BUFFER + 16);
|
||||
let emitted = a.push(&big, Some(1), None, None, false);
|
||||
assert!(
|
||||
!emitted.is_empty(),
|
||||
"over-cap AU is force-flushed, not buffered forever"
|
||||
);
|
||||
}
|
||||
}
|
||||
+46
-4
@@ -37,7 +37,7 @@ pub struct DtsParser {
|
||||
/// timestamp instead of the later PES's. Offsets are kept relative to the
|
||||
/// current `buf` start and rebased whenever bytes are drained from the
|
||||
/// front.
|
||||
pts_marks: Vec<(usize, i64)>,
|
||||
pts_marks: std::collections::VecDeque<(usize, i64)>,
|
||||
/// The `front_pts` of the PREVIOUS emitted access unit. When the current
|
||||
/// AU's `front_pts` differs, it began a new PES → re-base to it. When it is
|
||||
/// unchanged, this AU shares the previous AU's PES → advance one frame
|
||||
@@ -62,7 +62,7 @@ impl DtsParser {
|
||||
Self {
|
||||
buf: Vec::with_capacity(32768),
|
||||
pending_pts: 0,
|
||||
pts_marks: Vec::new(),
|
||||
pts_marks: std::collections::VecDeque::new(),
|
||||
last_front_pts: PTS_UNSET,
|
||||
next_pts_ns: PTS_UNSET,
|
||||
}
|
||||
@@ -194,7 +194,7 @@ impl CodecParser for DtsParser {
|
||||
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or_else(|| {
|
||||
self.pts_marks
|
||||
.last()
|
||||
.back()
|
||||
.map(|&(_, p)| p)
|
||||
.filter(|&p| p >= 0)
|
||||
.unwrap_or(if self.pending_pts >= 0 {
|
||||
@@ -230,7 +230,10 @@ impl CodecParser for DtsParser {
|
||||
// (see `front_pts`), so an AU whose core arrived in an earlier PES keeps
|
||||
// that core's timestamp even when its extensions / the following core
|
||||
// arrive (with a later PTS) in this same parse() call.
|
||||
self.pts_marks.push((self.buf.len(), pts_ns));
|
||||
// (pts_marks is bounded implicitly: an empty PES returns above without
|
||||
// pushing a mark, and a non-empty run grows `buf`, which is cleared —
|
||||
// along with pts_marks — once it exceeds MAX_AU_BYTES.)
|
||||
self.pts_marks.push_back((self.buf.len(), pts_ns));
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
let mut frames = Vec::new();
|
||||
@@ -915,6 +918,37 @@ mod tests {
|
||||
assert_eq!(dts_core_sample_rate(&core), 48_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_core_sfreq_table_matches_the_dca_spec() {
|
||||
// Lock the SFREQ → sample-rate table to ffmpeg's authoritative
|
||||
// `avpriv_dca_sample_rates` (ETSI TS 102 114 Table 6-4). The high-rate
|
||||
// triad in particular — 48 k / 96 k / 192 k at indices 13/14/15 — must not
|
||||
// be shifted; a wrong entry would compute an N× frame duration and
|
||||
// reintroduce PTS drift on a 96/192 kHz DTS stream.
|
||||
let mut core = make_dts_core(512);
|
||||
let set_sfreq = |c: &mut [u8], idx: u8| c[8] = (c[8] & !0x3C) | ((idx & 0x0F) << 2);
|
||||
for (idx, want) in [
|
||||
(1u8, 8_000u32),
|
||||
(2, 16_000),
|
||||
(3, 32_000),
|
||||
(6, 11_025),
|
||||
(7, 22_050),
|
||||
(8, 44_100),
|
||||
(11, 12_000),
|
||||
(12, 24_000),
|
||||
(13, 48_000),
|
||||
(14, 96_000),
|
||||
(15, 192_000),
|
||||
] {
|
||||
set_sfreq(&mut core, idx);
|
||||
assert_eq!(
|
||||
dts_core_sample_rate(&core),
|
||||
want,
|
||||
"SFREQ {idx} must be {want} Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_pes_rebases_to_its_own_pts_no_drift() {
|
||||
// Regression for the drift bug: a global running clock overshot a
|
||||
@@ -922,6 +956,14 @@ mod tests {
|
||||
// PES arrives whose PTS is BEHIND where accumulated frame durations
|
||||
// would put a running clock, the AU must re-base to that PES's OWN
|
||||
// timestamp — tracking the container, not drifting ahead of it.
|
||||
//
|
||||
// The re-base can make one emitted PTS sit just below the previous AU's
|
||||
// (a fresh PES whose PTS lands under the within-PES cursor). That is
|
||||
// CORRECT here and is NOT a muxer defect: the parser reports the true
|
||||
// container timestamps, and the mkv muxer applies the strictly-monotonic
|
||||
// per-track nudge to AUDIO at emit time (`mkv::block_ts` / `monotonic_ts`,
|
||||
// tested in `mkv.rs`), so the written block DTS is always monotonic. The
|
||||
// alternative — clamping in the parser — is what reintroduced the drift.
|
||||
let mut parser = DtsParser::new();
|
||||
// PES A: core1 + core2 (2 frames), pts 90000.
|
||||
let mut pes_a = make_dts_core(512);
|
||||
|
||||
+100
-3
@@ -52,6 +52,10 @@ pub struct H264Parser {
|
||||
// the stale avcC copy after a mid-title redefinition.
|
||||
cur_sps: Option<Vec<u8>>,
|
||||
cur_pps: Option<Vec<u8>>,
|
||||
/// Display-order PTS reconstruction, enabled only on the program-stream
|
||||
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
|
||||
/// the BD/UHD transport path, which carries a per-frame PTS.
|
||||
reorder: Option<super::reorder::SparsePtsReorder>,
|
||||
}
|
||||
|
||||
impl Default for H264Parser {
|
||||
@@ -68,6 +72,25 @@ impl H264Parser {
|
||||
pps: None,
|
||||
cur_sps: None,
|
||||
cur_pps: None,
|
||||
reorder: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable display-order PTS reconstruction for a program-stream source.
|
||||
/// No-op (leaves timestamps as parsed) for a transport-stream source.
|
||||
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
|
||||
if enabled {
|
||||
self.reorder = Some(super::reorder::SparsePtsReorder::new());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Route a finished frame through the PTS reorderer when enabled, else emit
|
||||
/// it directly (unchanged transport-stream behaviour).
|
||||
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
|
||||
match self.reorder.as_mut() {
|
||||
Some(r) => r.push(explicit, frame),
|
||||
None => vec![frame],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +175,8 @@ impl CodecParser for H264Parser {
|
||||
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||
let pts_ns = explicit_pts.unwrap_or(0);
|
||||
|
||||
// Single pass: detect IDR keyframes, seed/strip param sets, and convert
|
||||
// Annex B (start-code prefixed) NALUs to length-prefixed NALUs (MKV with
|
||||
@@ -236,7 +260,7 @@ impl CodecParser for H264Parser {
|
||||
}
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
let frame = Frame {
|
||||
// Coding-type only: H.264 field order is not decoded here, so
|
||||
// `field_order()` stays `None` — honestly absent, never guessed.
|
||||
coding: coding_type.map(PictureInfo::coding_type_only),
|
||||
@@ -248,7 +272,15 @@ impl CodecParser for H264Parser {
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
};
|
||||
self.finish(explicit_pts, frame)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
match self.reorder.as_mut() {
|
||||
Some(r) => r.flush(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
@@ -667,6 +699,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end sparse-PTS reconstruction through the REAL parser + reorder:
|
||||
/// a program-stream source (`with_ps_reorder(true)`) that stamps a PTS only
|
||||
/// on each GOP's I-frame must yield distinct, display-ordered PTS for every
|
||||
/// frame — the property the mkv muxer needs so a decoder derives monotonic
|
||||
/// DTS. Without the reorder the non-anchor frames all collapse to one PTS.
|
||||
#[test]
|
||||
fn h264_ps_reorder_reconstructs_distinct_display_pts() {
|
||||
use super::super::coding::CodingType;
|
||||
// slice bodies: 0x88 → I (IDR), 0x98 → P, 0x9C → B (non-IDR).
|
||||
// Decode order of a classic single-B GOP: I P B P B.
|
||||
let gop = |anchor_pts: Option<i64>| {
|
||||
vec![
|
||||
(NAL_SLICE_IDR, 0x88u8, anchor_pts),
|
||||
(NAL_SLICE_NON_IDR, 0x98, None),
|
||||
(NAL_SLICE_NON_IDR, 0x9C, None),
|
||||
(NAL_SLICE_NON_IDR, 0x98, None),
|
||||
(NAL_SLICE_NON_IDR, 0x9C, None),
|
||||
]
|
||||
};
|
||||
|
||||
let feed = |reorder: bool| -> Vec<super::super::Frame> {
|
||||
let mut p = H264Parser::new().with_ps_reorder(reorder);
|
||||
let mut out = Vec::new();
|
||||
// Two GOPs; the second I carries an anchor 5 frames later (90 kHz:
|
||||
// 5 * 3750 = 18750 ticks) so the reorder can calibrate a duration.
|
||||
for (nal, body, pts) in gop(Some(0)).into_iter().chain(gop(Some(18750))) {
|
||||
out.extend(p.parse(&make_pes(h264_nal(nal, &[body]), pts)));
|
||||
}
|
||||
out.extend(p.flush());
|
||||
out
|
||||
};
|
||||
|
||||
// With reorder ON: all 10 frames emitted, every PTS distinct.
|
||||
let recon = feed(true);
|
||||
assert_eq!(recon.len(), 10, "no frame dropped");
|
||||
let mut pts: Vec<i64> = recon.iter().map(|f| f.pts_ns).collect();
|
||||
let n = pts.len();
|
||||
pts.sort_unstable();
|
||||
pts.dedup();
|
||||
assert_eq!(
|
||||
pts.len(),
|
||||
n,
|
||||
"reconstructed PTS are all distinct (no DTS collision)"
|
||||
);
|
||||
|
||||
// The GOP's first-displayed frame is the I; the B in decode position 2
|
||||
// must display BEFORE the P in decode position 1 (classic reorder).
|
||||
let g1 = &recon[0..5];
|
||||
assert_eq!(g1[0].coding.unwrap().coding_type(), CodingType::I);
|
||||
assert!(
|
||||
g1[2].pts_ns < g1[1].pts_ns,
|
||||
"B (decode idx 2) displays before its forward-anchor P (decode idx 1)"
|
||||
);
|
||||
assert_eq!(g1[0].pts_ns, 0, "GOP anchor locks the I to its true PTS");
|
||||
|
||||
// With reorder OFF (transport-stream behaviour): the non-anchor frames
|
||||
// collapse to a single colliding PTS — the bug this fix removes.
|
||||
let raw = feed(false);
|
||||
let collisions = raw.iter().filter(|f| f.pts_ns == 0).count();
|
||||
assert!(
|
||||
collisions >= 8,
|
||||
"without reorder the sparse-PTS frames collide on 0 (got {collisions})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (Fight Club bug, H.264 variant): PPS id 0 = body A (→ avcC),
|
||||
/// redefined to B, then switched BACK to A. A streaming decoder is on B; the
|
||||
/// revert to A == avcC must still be emitted in-band or the A-segment
|
||||
|
||||
+35
-3
@@ -166,6 +166,10 @@ pub struct HevcParser {
|
||||
// colour-volume metadata is ever fabricated.
|
||||
sei_mastering: Option<MasteringDisplay>,
|
||||
sei_content_light: Option<ContentLightLevel>,
|
||||
/// Display-order PTS reconstruction, enabled only on the program-stream
|
||||
/// path where the source stamps a PTS once per GOP. `None` on the BD/UHD
|
||||
/// transport path (the common HEVC case), which carries a per-frame PTS.
|
||||
reorder: Option<super::reorder::SparsePtsReorder>,
|
||||
}
|
||||
|
||||
/// Mastering Display Colour Volume payload (Rec. ITU-T H.265 D.2.28),
|
||||
@@ -234,6 +238,25 @@ impl HevcParser {
|
||||
pts_wrap_offset: 0,
|
||||
sei_mastering: None,
|
||||
sei_content_light: None,
|
||||
reorder: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable display-order PTS reconstruction for a program-stream source.
|
||||
/// No-op (leaves timestamps as parsed) for a transport-stream source.
|
||||
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
|
||||
if enabled {
|
||||
self.reorder = Some(super::reorder::SparsePtsReorder::new());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Route a finished frame through the PTS reorderer when enabled, else emit
|
||||
/// it directly (unchanged transport-stream behaviour).
|
||||
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
|
||||
match self.reorder.as_mut() {
|
||||
Some(r) => r.push(explicit, frame),
|
||||
None => vec![frame],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +466,8 @@ impl CodecParser for HevcParser {
|
||||
// block timecode monotonic in storage order, which presents B-frames in
|
||||
// decode order (visible judder / wrong frames) and breaks PTS-based
|
||||
// seeking. Fall back to DTS only if PTS is somehow absent.
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||
let pts_ns = explicit_pts.unwrap_or(0);
|
||||
|
||||
// Auto-detect a non-seamless clip boundary from the bitstream. freemkv
|
||||
// reads a BD title's clips as ONE concatenated sector stream and the
|
||||
@@ -651,7 +675,7 @@ impl CodecParser for HevcParser {
|
||||
// from the first coded picture before writing the track header). `None`
|
||||
// until both SEI present → SDR / no-SEI tracks carry nothing.
|
||||
let hdr10 = self.hdr10();
|
||||
vec![Frame {
|
||||
let frame = Frame {
|
||||
// Coding-type only: HEVC field order (pic_struct, from a pic_timing
|
||||
// SEI) is not decoded here, so field_order() stays None — honestly
|
||||
// absent, never guessed. HDR10 metadata is attached when measured.
|
||||
@@ -666,7 +690,15 @@ impl CodecParser for HevcParser {
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
};
|
||||
self.finish(explicit_pts, frame)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
match self.reorder.as_mut() {
|
||||
Some(r) => r.flush(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod lpcm;
|
||||
pub mod mpeg2;
|
||||
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||
pub mod pgs;
|
||||
/// Display-order PTS reconstruction for sparse-PTS program-stream video.
|
||||
pub(crate) mod reorder;
|
||||
/// Shared MPEG/Annex-B start-code scanning helpers.
|
||||
pub(crate) mod startcode;
|
||||
/// Dolby TrueHD / Atmos elementary-stream parser.
|
||||
@@ -166,10 +168,14 @@ pub fn parser_for_codec(
|
||||
is_dvd_ps: bool,
|
||||
) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
||||
// `is_dvd_ps` marks a program-stream source (DVD VOB / HD-DVD EVO), whose
|
||||
// video is timestamped only at GOP granularity. On that path the H.264 /
|
||||
// HEVC / VC-1 parsers reconstruct a display-order PTS per frame; on the
|
||||
// BD/UHD transport path (per-frame PTS) they leave timestamps untouched.
|
||||
Codec::H264 => Box::new(h264::H264Parser::new().with_ps_reorder(is_dvd_ps)),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new().with_ps_reorder(is_dvd_ps)),
|
||||
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
|
||||
Codec::Vc1 => Box::new(vc1::Vc1Parser::new()),
|
||||
Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)),
|
||||
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||
|
||||
+132
-267
@@ -26,13 +26,10 @@
|
||||
//! - Extension (seq/pic):00 00 01 B5
|
||||
//! - GOP header: 00 00 01 B8
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::coding::{CodingType, Mpeg2Coding, PictureInfo};
|
||||
use super::startcode::find_start_code;
|
||||
use super::{CodecParser, Frame, pts_to_ns};
|
||||
use crate::mux::ts::PesPacket;
|
||||
use crate::pes::SourcePos;
|
||||
|
||||
/// Sequence header start code suffix.
|
||||
const SEQ_HEADER_CODE: u8 = 0xB3;
|
||||
@@ -49,10 +46,10 @@ const PICTURE_CODE: u8 = 0x00;
|
||||
/// Picture coding type: I-frame.
|
||||
const PICTURE_TYPE_I: u8 = 1;
|
||||
|
||||
/// Hard cap on the access-unit reassembly buffer. A real MPEG-2 frame is well
|
||||
/// under 1 MiB (DVD I-frames ~100 KB); past this cap a corrupt stream that
|
||||
/// never produces a second access-unit boundary is force-flushed as a single
|
||||
/// frame rather than driving unbounded allocation.
|
||||
/// The access-unit reassembly cap now lives in [`crate::mux::au_assembly`] (the
|
||||
/// `AuAssembler` owns cross-PES buffering); this mirror exists only so the
|
||||
/// force-flush test below can size an over-cap fixture against the same bound.
|
||||
#[cfg(test)]
|
||||
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Cap on frames held awaiting the first PES PTS anchor. A DVD stamps a PTS in
|
||||
@@ -94,20 +91,11 @@ pub struct Mpeg2Parser {
|
||||
/// Raw bytes of the last seen sequence header (+ sequence extension if
|
||||
/// present), captured for MKV codecPrivate.
|
||||
seq_header: Option<Vec<u8>>,
|
||||
/// Unemitted elementary-stream bytes: the in-progress access unit plus any
|
||||
/// lookahead needed to detect the next AU boundary.
|
||||
buf: Vec<u8>,
|
||||
/// Absolute ES byte offset of `buf[0]`. Used to associate PES PTS marks
|
||||
/// (recorded by absolute offset) with the access units they belong to.
|
||||
base_offset: u64,
|
||||
/// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES
|
||||
/// that carried a timestamp, in ascending offset order.
|
||||
pts_marks: VecDeque<(u64, i64)>,
|
||||
/// `(absolute ES offset of a PES's first byte, SourcePos)` for every PES
|
||||
/// that carried byte-exact provenance, parallel to `pts_marks` and drained
|
||||
/// by the SAME mark-drain invariant. Attaches the source position to each
|
||||
/// access unit so the index carries it — never reconstructed.
|
||||
source_marks: VecDeque<(u64, SourcePos)>,
|
||||
/// Reassembles PES fragments into complete access units (one coded picture
|
||||
/// with its leading sequence/GOP headers) and carries each AU's start
|
||||
/// timing / source / discontinuity forward — the shared machinery the
|
||||
/// H.264/HEVC/VC-1 parsers also use, in its MPEG-2 mode.
|
||||
au_asm: crate::mux::au_assembly::AuAssembler,
|
||||
/// Full-frame presentation interval (ns) at the sequence-header display rate
|
||||
/// (`1/frame_rate`). The field period is half this. Per-frame durations are
|
||||
/// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and
|
||||
@@ -122,6 +110,10 @@ pub struct Mpeg2Parser {
|
||||
/// without ever reordering emitted blocks (B-frames keep decode order; only
|
||||
/// their PTS is lower).
|
||||
gop_buf: Vec<BufferedPicture>,
|
||||
/// Running total of `data` bytes buffered in `gop_buf` — the byte-cap counter,
|
||||
/// incremented on each push and reset when the GOP flushes. Avoids re-summing
|
||||
/// the whole buffer per picture (which would be O(pictures²)).
|
||||
gop_bytes: usize,
|
||||
/// Total field-display periods of all frames already emitted, in display
|
||||
/// order — the running base for each new frame's display time.
|
||||
emitted_fields: u64,
|
||||
@@ -129,15 +121,6 @@ pub struct Mpeg2Parser {
|
||||
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
|
||||
/// audio. None until the first PES timestamp is seen.
|
||||
origin_pts_ns: Option<i64>,
|
||||
/// B1: absolute ES offsets at which a concealed/lost-gap PES began, parallel
|
||||
/// to `pts_marks`/`source_marks` and drained by the SAME mark-drain invariant.
|
||||
/// MPEG-2 emits whole GOPs asynchronously, so a per-PES flag can't ride
|
||||
/// through to the right frame (the PES that carries the gap completes the
|
||||
/// PREVIOUS picture); associating by OFFSET instead stamps `discontinuity` on
|
||||
/// the access unit whose own bytes begin after the gap — the first post-gap
|
||||
/// picture — surviving GOP buffering + temporal reorder. The consumer's
|
||||
/// ResyncGate then arms at that exact picture, mid-GOP if need be.
|
||||
disc_marks: VecDeque<u64>,
|
||||
}
|
||||
|
||||
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
|
||||
@@ -165,16 +148,13 @@ impl Mpeg2Parser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
seq_header: None,
|
||||
buf: Vec::with_capacity(128 * 1024),
|
||||
base_offset: 0,
|
||||
pts_marks: VecDeque::new(),
|
||||
source_marks: VecDeque::new(),
|
||||
au_asm: crate::mux::au_assembly::AuAssembler::mpeg2(),
|
||||
frame_duration_ns: 0,
|
||||
progressive_sequence: false,
|
||||
gop_buf: Vec::new(),
|
||||
gop_bytes: 0,
|
||||
emitted_fields: 0,
|
||||
origin_pts_ns: None,
|
||||
disc_marks: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,195 +179,98 @@ impl Mpeg2Parser {
|
||||
parse_aspect_ratio(hdr)
|
||||
}
|
||||
|
||||
/// Drain every complete access unit from `buf`, returning one Frame each.
|
||||
/// When `force` is true (EOF flush, or buffer-cap backstop) the trailing
|
||||
/// in-progress access unit is emitted even without a following boundary.
|
||||
fn drain_complete_aus(&mut self, force: bool) -> Vec<Frame> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
// An access unit must contain a coded picture; without one there is
|
||||
// nothing to emit yet (leading sequence/GOP headers wait for it).
|
||||
let Some(pic) = find_code(&self.buf, 0, PICTURE_CODE) else {
|
||||
// No coded picture in an over-cap buffer means we are
|
||||
// accumulating unparseable data (a stream with no picture
|
||||
// start codes). Drop all but a 3-byte tail — enough to catch a
|
||||
// start-code prefix straddling the boundary — and advance the
|
||||
// absolute offset so the PES-mark invariant holds. Mirrors the
|
||||
// post-picture buffer backstop in the AU-boundary search below.
|
||||
if self.buf.len() > MAX_AU_BUFFER {
|
||||
let drop = self.buf.len() - 3;
|
||||
self.base_offset += drop as u64;
|
||||
self.buf.drain(..drop);
|
||||
let cutoff = self.base_offset;
|
||||
while let Some(&(off, _)) = self.pts_marks.front() {
|
||||
if off < cutoff {
|
||||
self.pts_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&(off, _)) = self.source_marks.front() {
|
||||
if off < cutoff {
|
||||
self.source_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&off) = self.disc_marks.front() {
|
||||
if off < cutoff {
|
||||
self.disc_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
};
|
||||
// The current AU ends where the next one begins: the first
|
||||
// picture / sequence / GOP start code after this picture.
|
||||
let end = match find_au_start(&self.buf, pic + 4) {
|
||||
Some(b) => b,
|
||||
None if force => self.buf.len(),
|
||||
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
|
||||
None => break, // AU not yet complete — await the next boundary
|
||||
};
|
||||
if end == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Phase 1 — read everything from `buf` before any mutation of self
|
||||
// (the slice borrow must end before we touch self fields).
|
||||
let hdr = extract_seq_header(&self.buf[..end]);
|
||||
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new
|
||||
// GOP, resetting temporal_reference to 0.
|
||||
let gop_boundary = find_code(&self.buf[..end], 0, GOP_CODE).is_some()
|
||||
|| find_code(&self.buf[..end], 0, SEQ_HEADER_CODE).is_some();
|
||||
// picture_coding_type: the full 3-bit value (bits 5-3 of buf[pic+5]).
|
||||
// 0 when the picture header is truncated (no coding type available).
|
||||
let raw_coding_type = if pic + 5 < end {
|
||||
(self.buf[pic + 5] >> 3) & 0x07
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// temporal_reference: the 10 bits immediately after the picture
|
||||
// start code = display order within the GOP.
|
||||
let tr = if pic + 5 < end {
|
||||
(((self.buf[pic + 4] as u64) << 2) | ((self.buf[pic + 5] as u64) >> 6)) & 0x3FF
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end_abs = self.base_offset + end as u64;
|
||||
let data = self.buf[..end].to_vec();
|
||||
|
||||
// Phase 2 — mutate self.
|
||||
if let Some(h) = hdr {
|
||||
self.progressive_sequence = parse_progressive_sequence(&h);
|
||||
self.seq_header = Some(h);
|
||||
if let Some((num, den)) = self.frame_rate() {
|
||||
if num > 0 {
|
||||
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Decode the picture coding extension ONCE here and fold every
|
||||
// per-picture datum (coding type + tff/rff/progressive_frame/
|
||||
// frame_picture, plus the sequence's progressive flag) into one
|
||||
// codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and
|
||||
// `field_order()` all derive from it; nothing downstream re-parses
|
||||
// the elementary stream.
|
||||
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
|
||||
let info = PictureInfo::mpeg2(
|
||||
coding_type_from_raw(raw_coding_type),
|
||||
Mpeg2Coding {
|
||||
top_field_first: tff,
|
||||
repeat_first_field: rff,
|
||||
progressive_frame,
|
||||
progressive_sequence: self.progressive_sequence,
|
||||
frame_picture,
|
||||
},
|
||||
);
|
||||
let keyframe = info.keyframe();
|
||||
|
||||
// An explicit PES PTS for this access unit, if any. By the mark-drain
|
||||
// invariant the front mark's offset is >= this AU's start, so a front
|
||||
// mark inside [start, end) is this AU's own timestamp.
|
||||
let explicit = self
|
||||
.pts_marks
|
||||
.front()
|
||||
.filter(|&&(off, _)| off < end_abs)
|
||||
.map(|&(_, p)| p);
|
||||
|
||||
// Byte-exact source provenance for this AU, by the same mark-drain
|
||||
// invariant as the PTS: the front source mark inside [start, end)
|
||||
// belongs to this access unit.
|
||||
let src = self
|
||||
.source_marks
|
||||
.front()
|
||||
.filter(|&&(off, _)| off < end_abs)
|
||||
.map(|&(_, s)| s);
|
||||
|
||||
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
|
||||
// pictures display before the next GOP's), so flush it before
|
||||
// starting the new one. `temporal_reference` resets to 0 at the
|
||||
// boundary, keeping each GOP's display order self-contained.
|
||||
if gop_boundary && !self.gop_buf.is_empty() {
|
||||
self.flush_gop(&mut out);
|
||||
}
|
||||
// A concealed-gap mark inside this AU's range [start, end_abs) means
|
||||
// this picture's own bytes begin after the gap — the first post-gap
|
||||
// AU. Same front-mark invariant as PTS/source. Carries through GOP
|
||||
// buffering/reorder to the ResyncGate (which arms at this picture).
|
||||
let discontinuity = self.disc_marks.front().is_some_and(|&off| off < end_abs);
|
||||
self.gop_buf.push(BufferedPicture {
|
||||
tr,
|
||||
info,
|
||||
explicit_pts: explicit,
|
||||
frame: Frame {
|
||||
pts_ns: 0,
|
||||
keyframe,
|
||||
discontinuity,
|
||||
data,
|
||||
duration_ns: None,
|
||||
coding: Some(info),
|
||||
source: src,
|
||||
},
|
||||
});
|
||||
// Safety cap: a stream with no GOP/sequence boundaries would buffer
|
||||
// unbounded. Force-flush a pathologically long run as its own GOP.
|
||||
if self.gop_buf.len() >= MAX_PENDING_FRAMES {
|
||||
self.flush_gop(&mut out);
|
||||
}
|
||||
self.buf.drain(..end);
|
||||
self.base_offset = end_abs;
|
||||
// Drop PTS marks fully consumed by the emitted AU; keep the mark at
|
||||
// the boundary (it belongs to the next AU).
|
||||
while let Some(&(off, _)) = self.pts_marks.front() {
|
||||
if off < end_abs {
|
||||
self.pts_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&(off, _)) = self.source_marks.front() {
|
||||
if off < end_abs {
|
||||
self.source_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&off) = self.disc_marks.front() {
|
||||
if off < end_abs {
|
||||
self.disc_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
/// Process one reassembled access unit (from [`AuAssembler`]): decode its
|
||||
/// per-picture coding info, capture a new sequence header, and buffer the
|
||||
/// picture into the current GOP for display-order timestamping. The AU's
|
||||
/// timing / source / discontinuity were already attributed by the assembler.
|
||||
fn process_au(&mut self, au: crate::mux::au_assembly::AssembledAu, out: &mut Vec<Frame>) {
|
||||
let data = au.data;
|
||||
// An access unit must contain a coded picture; a fragment that assembled
|
||||
// without one (only headers, or truncated at EOF) yields nothing.
|
||||
let Some(pic) = find_code(&data, 0, PICTURE_CODE) else {
|
||||
return;
|
||||
};
|
||||
let end = data.len();
|
||||
// Capture a sequence header for codecPrivate; a new one replaces the
|
||||
// stored value and re-locks the frame duration.
|
||||
if let Some(h) = extract_seq_header(&data) {
|
||||
self.progressive_sequence = parse_progressive_sequence(&h);
|
||||
self.seq_header = Some(h);
|
||||
if let Some((num, den)) = self.frame_rate() {
|
||||
if num > 0 {
|
||||
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
|
||||
if force {
|
||||
self.flush_gop(&mut out);
|
||||
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new GOP,
|
||||
// resetting temporal_reference to 0.
|
||||
let gop_boundary = find_code(&data, 0, GOP_CODE).is_some()
|
||||
|| find_code(&data, 0, SEQ_HEADER_CODE).is_some();
|
||||
// picture_coding_type: the full 3-bit value (bits 5-3 of data[pic+5]).
|
||||
// 0 when the picture header is truncated (no coding type available).
|
||||
let raw_coding_type = if pic + 5 < end {
|
||||
(data[pic + 5] >> 3) & 0x07
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// temporal_reference: the 10 bits immediately after the picture start
|
||||
// code = display order within the GOP.
|
||||
let tr = if pic + 5 < end {
|
||||
(((data[pic + 4] as u64) << 2) | ((data[pic + 5] as u64) >> 6)) & 0x3FF
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Decode the picture coding extension ONCE here and fold every
|
||||
// per-picture datum (coding type + tff/rff/progressive_frame/
|
||||
// frame_picture, plus the sequence's progressive flag) into one
|
||||
// codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and
|
||||
// `field_order()` all derive from it; nothing downstream re-parses the
|
||||
// elementary stream.
|
||||
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
|
||||
let info = PictureInfo::mpeg2(
|
||||
coding_type_from_raw(raw_coding_type),
|
||||
Mpeg2Coding {
|
||||
top_field_first: tff,
|
||||
repeat_first_field: rff,
|
||||
progressive_frame,
|
||||
progressive_sequence: self.progressive_sequence,
|
||||
frame_picture,
|
||||
},
|
||||
);
|
||||
let keyframe = info.keyframe();
|
||||
|
||||
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
|
||||
// pictures display before the next GOP's), so flush it before starting
|
||||
// the new one. `temporal_reference` resets to 0 at the boundary, keeping
|
||||
// each GOP's display order self-contained.
|
||||
if gop_boundary && !self.gop_buf.is_empty() {
|
||||
self.flush_gop(out);
|
||||
}
|
||||
self.gop_bytes += data.len();
|
||||
self.gop_buf.push(BufferedPicture {
|
||||
tr,
|
||||
info,
|
||||
explicit_pts: au.pts,
|
||||
frame: Frame {
|
||||
pts_ns: 0,
|
||||
keyframe,
|
||||
// The assembler attributes the concealed-gap flag to the AU whose
|
||||
// own bytes begin after the gap — the first post-gap picture — so
|
||||
// it rides through GOP buffering/reorder to the ResyncGate.
|
||||
discontinuity: au.discontinuity,
|
||||
data,
|
||||
duration_ns: None,
|
||||
coding: Some(info),
|
||||
source: au.source,
|
||||
},
|
||||
});
|
||||
// Safety cap: a stream with no GOP/sequence boundaries would buffer
|
||||
// unbounded. Force-flush a pathologically long run as its own GOP —
|
||||
// bounded by BOTH the frame count and the total buffered bytes, so a
|
||||
// crafted stream of few-but-huge pictures cannot over-allocate either.
|
||||
if self.gop_buf.len() >= MAX_PENDING_FRAMES || self.gop_bytes >= MAX_PENDING_BYTES {
|
||||
self.flush_gop(out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
|
||||
@@ -403,6 +286,8 @@ impl Mpeg2Parser {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
// The GOP is fully drained below; reset the running byte counter.
|
||||
self.gop_bytes = 0;
|
||||
let field_period = self.frame_duration_ns / 2;
|
||||
if field_period <= 0 {
|
||||
// No sequence header / frame rate yet (malformed lead-in): emit in
|
||||
@@ -448,31 +333,31 @@ impl CodecParser for Mpeg2Parser {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Record this PES's timestamp against the absolute offset of its first
|
||||
// ES byte, BEFORE appending. MKV block timecodes are presentation
|
||||
// timestamps; prefer PTS (DTS shows B-frames in decode order — judder
|
||||
// and broken seeking), falling back to DTS only when PTS is absent.
|
||||
let off = self.base_offset + self.buf.len() as u64;
|
||||
if let Some(ts) = pes.pts.or(pes.dts) {
|
||||
self.pts_marks.push_back((off, pts_to_ns(ts)));
|
||||
// Feed the fragment to the assembler, which reframes the elementary
|
||||
// stream on picture boundaries and hands back each complete access unit
|
||||
// with its start timing. MKV block timecodes are presentation timestamps;
|
||||
// prefer PTS (DTS shows B-frames in decode order — judder and broken
|
||||
// seeking), falling back to DTS only when PTS is absent.
|
||||
let pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||
let aus = self
|
||||
.au_asm
|
||||
.push(&pes.data, pts, None, pes.source, pes.discontinuity);
|
||||
let mut out = Vec::new();
|
||||
for au in aus {
|
||||
self.process_au(au, &mut out);
|
||||
}
|
||||
if let Some(src) = pes.source {
|
||||
self.source_marks.push_back((off, src));
|
||||
}
|
||||
// A concealed/lost gap on this PES marks the access unit its bytes begin —
|
||||
// associated by offset (like PTS/source) so it lands on the first post-gap
|
||||
// picture, not the previous one that completes when this PES arrives.
|
||||
if pes.discontinuity {
|
||||
self.disc_marks.push_back(off);
|
||||
}
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
self.drain_complete_aus(false)
|
||||
out
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// drain_complete_aus(true) force-completes the trailing access unit and
|
||||
// flushes the final GOP, so nothing is left buffered at EOF.
|
||||
self.drain_complete_aus(true)
|
||||
// Force-complete the trailing access unit, then flush the final GOP so
|
||||
// nothing is left buffered at EOF.
|
||||
let mut out = Vec::new();
|
||||
for au in self.au_asm.flush() {
|
||||
self.process_au(au, &mut out);
|
||||
}
|
||||
self.flush_gop(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
@@ -518,25 +403,6 @@ fn find_code(data: &[u8], from: usize, want: u8) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the next access-unit boundary at or after `from`: the position of a
|
||||
/// picture (0x00), sequence header (0xB3), or GOP (0xB8) start code. Extension
|
||||
/// (0xB5), slice (0x01..=0xAF), user-data (0xB2) and sequence-end (0xB7) codes
|
||||
/// belong to the current access unit and are NOT boundaries.
|
||||
fn find_au_start(data: &[u8], from: usize) -> Option<usize> {
|
||||
let mut pos = from;
|
||||
while let Some(sc) = find_start_code(data, pos) {
|
||||
if sc + 3 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
let code = data[sc + 3];
|
||||
if code == PICTURE_CODE || code == SEQ_HEADER_CODE || code == GOP_CODE {
|
||||
return Some(sc);
|
||||
}
|
||||
pos = sc + 4;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse horizontal and vertical resolution from sequence header bytes.
|
||||
/// The sequence header must start with 00 00 01 B3.
|
||||
fn parse_resolution(hdr: &[u8]) -> Option<(u16, u16)> {
|
||||
@@ -1546,13 +1412,12 @@ mod tests {
|
||||
let mut data = make_picture_header(PICTURE_TYPE_I);
|
||||
// > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP.
|
||||
data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024));
|
||||
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||
assert!(
|
||||
frames.is_empty(),
|
||||
"over-cap AU is force-COMPLETED (bounded) but buffered in its GOP"
|
||||
);
|
||||
let frames = parser.flush();
|
||||
assert_eq!(frames.len(), 1, "force-flushed at EOF, not dropped");
|
||||
// The AU assembler force-completes the ~8 MiB AU (no boundary), and the
|
||||
// GOP byte cap (MAX_PENDING_BYTES) then force-flushes that oversized GOP
|
||||
// during parse rather than buffering it unbounded.
|
||||
let mut frames = parser.parse(&make_pes(data, Some(0)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 1, "over-cap AU force-flushed, not dropped");
|
||||
assert!(frames[0].keyframe);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Display-order PTS reconstruction for sparse-PTS program-stream video.
|
||||
//!
|
||||
//! MPEG program streams (DVD VOB, HD-DVD EVO) timestamp video at GOP
|
||||
//! granularity: only one access unit per GOP carries a PES PTS, and the rest
|
||||
//! arrive with none. The H.264 / HEVC / VC-1 parsers collapse a missing PTS to
|
||||
//! `0` (`pes.pts.or(dts).unwrap_or(0)`), so on such a source every non-anchor
|
||||
//! frame lands on the same block timestamp. A decoder then cannot order them and
|
||||
//! reports "non monotonically increasing dts". (The MPEG-2 parser already avoids
|
||||
//! this by reconstructing per-picture PTS from `temporal_reference`; these three
|
||||
//! codecs carry no such field.)
|
||||
//!
|
||||
//! [`SparsePtsReorder`] reconstructs a display-order PTS for every frame from two
|
||||
//! signals the parsers already provide — the coded picture type (I/P/B) and the
|
||||
//! sparse anchor PTS — plus a per-frame duration self-calibrated from the spacing
|
||||
//! between consecutive GOP anchors (no external frame-rate needed). It mirrors
|
||||
//! the MPEG-2 parser's GOP-buffered origin-locking, but derives display order via
|
||||
//! the classic single-anchor-delay rule instead of `temporal_reference`:
|
||||
//!
|
||||
//! - In DECODE order an anchor (I/P) is stored before the B-frames that
|
||||
//! reference it forward, so decode `I P B P B` displays as `I B P B P`.
|
||||
//! - The rule that produces that mapping: an anchor is displayed only after the
|
||||
//! previously-held anchor; a B-frame displays immediately. This is exact for
|
||||
//! the classic (non-hierarchical) GOP structures HD-DVD H.264/VC-1 use.
|
||||
//!
|
||||
//! This reconstruction is applied ONLY on the program-stream path
|
||||
//! (`ContentFormat::MpegPs`). BD/UHD transport streams carry a per-frame PTS and
|
||||
//! are never routed through it, so the primary decode path is untouched.
|
||||
|
||||
use super::Frame;
|
||||
use super::coding::CodingType;
|
||||
|
||||
/// Fallback per-frame duration (ns) when the anchor spacing cannot calibrate one
|
||||
/// (a stream with a single GOP, or no anchor PTS at all): 24000/1001 fps film,
|
||||
/// the dominant HD-DVD cadence. Only affects intra-GOP spacing — each GOP's
|
||||
/// origin is re-locked to its own anchor PTS, so a wrong fallback cannot drift
|
||||
/// the timeline across GOPs.
|
||||
const FALLBACK_FRAME_DUR_NS: i64 = 1_001_000_000 / 24;
|
||||
|
||||
/// Force-complete the current GOP once it reaches this many buffered pictures
|
||||
/// even without a keyframe. A GOP is normally a few dozen frames; a stream that
|
||||
/// never signals a keyframe (open-GOP recovery-point coding, or crafted/corrupt
|
||||
/// disc bytes) would otherwise buffer every access unit — the whole title — in
|
||||
/// RAM. Mirrors the MPEG-2 parser's `MAX_PENDING_FRAMES` backstop so no
|
||||
/// reassembly buffer grows unbounded on disc-controlled input.
|
||||
const MAX_GOP_FRAMES: usize = 600;
|
||||
|
||||
/// Byte cap on the buffered GOP, complementing [`MAX_GOP_FRAMES`]. A GOP holds a
|
||||
/// couple hundred MB at most in practice; this force-completes a run of
|
||||
/// few-but-huge access units so a crafted/corrupt stream cannot over-allocate
|
||||
/// (the AU assembler caps each frame at 8 MiB, so 600 frames alone could reach
|
||||
/// ~5 GiB without this).
|
||||
const MAX_GOP_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// One buffered coded picture awaiting its GOP's completion.
|
||||
struct Pending {
|
||||
/// Explicit PES PTS (ns) for this AU, or `None` when the source omitted it.
|
||||
explicit: Option<i64>,
|
||||
/// Coded picture type; `P` (anchor) when the parser could not determine it,
|
||||
/// so an unknown frame is never mis-placed as a bi-predicted B.
|
||||
ctype: CodingType,
|
||||
frame: Frame,
|
||||
}
|
||||
|
||||
/// A completed GOP, buffered until the NEXT GOP's anchor is known so a per-frame
|
||||
/// duration can be calibrated from the two anchors before its frames are emitted.
|
||||
struct Gop {
|
||||
pend: Vec<Pending>,
|
||||
/// Display index (0-based) of each `pend` entry, in `pend` (decode) order.
|
||||
dispidx: Vec<i64>,
|
||||
/// Display-frame count (== `pend.len()`).
|
||||
count: i64,
|
||||
/// The anchor: `(explicit_pts, dispidx)` of the first buffered frame that
|
||||
/// carried an explicit PTS, used to lock the display origin. `None` when the
|
||||
/// GOP carried no PTS at all (origin then continues from the running base).
|
||||
anchor: Option<(i64, i64)>,
|
||||
}
|
||||
|
||||
/// Reconstructs display-order PTS for a sparse-PTS video elementary stream.
|
||||
pub(crate) struct SparsePtsReorder {
|
||||
/// Frames of the GOP currently accumulating, in decode order.
|
||||
cur: Vec<Pending>,
|
||||
/// Total `data` bytes buffered in `cur` — the byte-cap counter, reset each
|
||||
/// time `cur` is drained into a completed GOP.
|
||||
cur_bytes: usize,
|
||||
/// The previously-completed GOP, held one step so its duration can be
|
||||
/// calibrated from the next GOP's anchor before it is emitted.
|
||||
held: Option<Gop>,
|
||||
/// Self-calibrated per-frame display duration (ns); 0 until two anchors seen.
|
||||
dur_ns: i64,
|
||||
/// Display time (ns) at which the next emitted GOP should begin, when its own
|
||||
/// anchor is absent. Advanced by each emitted GOP.
|
||||
next_start_ns: i64,
|
||||
}
|
||||
|
||||
impl SparsePtsReorder {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
cur: Vec::new(),
|
||||
cur_bytes: 0,
|
||||
held: None,
|
||||
dur_ns: 0,
|
||||
next_start_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one parsed frame with its explicit PES PTS (or `None`). Returns any
|
||||
/// frames whose display PTS is now finalized (emitted in decode order).
|
||||
pub(crate) fn push(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
|
||||
let ctype = frame
|
||||
.coding
|
||||
.map(|c| c.coding_type())
|
||||
.unwrap_or(CodingType::P);
|
||||
// A keyframe opens a new GOP: the picture already accumulated in `cur` is
|
||||
// a complete GOP. Complete it (this frame belongs to the NEW GOP). Also
|
||||
// force-complete a pathologically long run that never signalled a
|
||||
// keyframe — bounded by BOTH frame count and total buffered bytes, so a
|
||||
// crafted/corrupt stream of few-but-huge access units cannot buffer
|
||||
// without bound.
|
||||
let mut out = Vec::new();
|
||||
let over_cap = self.cur.len() >= MAX_GOP_FRAMES || self.cur_bytes >= MAX_GOP_BYTES;
|
||||
if (frame.keyframe || over_cap) && !self.cur.is_empty() {
|
||||
out = self.complete_current_gop();
|
||||
}
|
||||
self.cur_bytes += frame.data.len();
|
||||
self.cur.push(Pending {
|
||||
explicit,
|
||||
ctype,
|
||||
frame,
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Flush all buffered frames at end of stream.
|
||||
pub(crate) fn flush(&mut self) -> Vec<Frame> {
|
||||
let mut out = self.complete_current_gop();
|
||||
if let Some(gop) = self.held.take() {
|
||||
out.extend(self.emit_gop(gop));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Move `cur` into a completed [`Gop`]; if a GOP was already held, calibrate
|
||||
/// the duration from the two anchors and emit the held one.
|
||||
fn complete_current_gop(&mut self) -> Vec<Frame> {
|
||||
if self.cur.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pend = std::mem::take(&mut self.cur);
|
||||
self.cur_bytes = 0;
|
||||
let dispidx = display_indices(pend.iter().map(|p| p.ctype));
|
||||
let count = pend.len() as i64;
|
||||
let anchor = pend
|
||||
.iter()
|
||||
.zip(&dispidx)
|
||||
.find_map(|(p, &d)| p.explicit.map(|pts| (pts, d)));
|
||||
let gop = Gop {
|
||||
pend,
|
||||
dispidx,
|
||||
count,
|
||||
anchor,
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
match self.held.take() {
|
||||
Some(held) => {
|
||||
// Calibrate a per-frame duration from the two anchors' spacing,
|
||||
// spread across the held GOP's display-frame count. Approximate
|
||||
// (assumes both anchors sit at a similar relative display slot),
|
||||
// but each GOP re-locks its own origin, so the estimate only sets
|
||||
// intra-GOP spacing.
|
||||
if self.dur_ns == 0 {
|
||||
if let (Some((p_held, _)), Some((p_next, _))) = (held.anchor, gop.anchor) {
|
||||
let span = p_next - p_held;
|
||||
if span > 0 && held.count > 0 {
|
||||
self.dur_ns = (span / held.count).max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
out = self.emit_gop(held);
|
||||
self.held = Some(gop);
|
||||
}
|
||||
None => self.held = Some(gop),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Assign each frame in `gop` its display PTS and return them in decode order.
|
||||
fn emit_gop(&mut self, gop: Gop) -> Vec<Frame> {
|
||||
let dur = if self.dur_ns > 0 {
|
||||
self.dur_ns
|
||||
} else {
|
||||
FALLBACK_FRAME_DUR_NS
|
||||
};
|
||||
// Lock the display origin: prefer the GOP's own anchor PTS (back out its
|
||||
// display offset); otherwise continue from the running base.
|
||||
let origin = match gop.anchor {
|
||||
Some((pts, didx)) => pts - didx * dur,
|
||||
None => self.next_start_ns,
|
||||
};
|
||||
let Gop {
|
||||
pend,
|
||||
dispidx,
|
||||
count,
|
||||
..
|
||||
} = gop;
|
||||
let mut out = Vec::with_capacity(pend.len());
|
||||
for (mut p, didx) in pend.into_iter().zip(dispidx) {
|
||||
p.frame.pts_ns = origin + didx * dur;
|
||||
// Carry the calibrated per-frame duration so the muxer emits a
|
||||
// BlockDuration and the back-patched Segment Duration covers the
|
||||
// final frame (the source gives no duration on this path).
|
||||
p.frame.duration_ns = Some(dur as u64);
|
||||
out.push(p.frame);
|
||||
}
|
||||
// Next GOP with no anchor continues after this one's last display slot.
|
||||
self.next_start_ns = origin + count * dur;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Display index (0-based, decode order in → decode order out) for a GOP's coded
|
||||
/// picture types via the classic single-anchor-delay reorder: an anchor (I/P) is
|
||||
/// displayed only after the previously-held anchor; a B displays immediately.
|
||||
/// Decode `I P B P B` → display indices `[0, 2, 1, 4, 3]` (display `I B P B P`).
|
||||
fn display_indices(types: impl Iterator<Item = CodingType>) -> Vec<i64> {
|
||||
let types: Vec<CodingType> = types.collect();
|
||||
let mut disp = vec![0i64; types.len()];
|
||||
let mut held: Option<usize> = None;
|
||||
let mut cursor = 0i64;
|
||||
for (i, &c) in types.iter().enumerate() {
|
||||
match c {
|
||||
CodingType::I | CodingType::P => {
|
||||
if let Some(h) = held {
|
||||
disp[h] = cursor;
|
||||
cursor += 1;
|
||||
}
|
||||
held = Some(i);
|
||||
}
|
||||
CodingType::B => {
|
||||
disp[i] = cursor;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(h) = held {
|
||||
disp[h] = cursor;
|
||||
}
|
||||
disp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mux::codec::coding::PictureInfo;
|
||||
|
||||
fn frame(ctype: CodingType, keyframe: bool) -> Frame {
|
||||
Frame {
|
||||
keyframe,
|
||||
coding: Some(PictureInfo::coding_type_only(ctype)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_indices_map_classic_gop() {
|
||||
use CodingType::*;
|
||||
// decode I P B P B -> display I B P B P
|
||||
let d = display_indices([I, P, B, P, B].into_iter());
|
||||
assert_eq!(d, vec![0, 2, 1, 4, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_indices_all_anchors_are_identity() {
|
||||
use CodingType::*;
|
||||
let d = display_indices([I, P, P, P].into_iter());
|
||||
assert_eq!(d, vec![0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_monotonic_display_pts_from_one_anchor_per_gop() {
|
||||
use CodingType::*;
|
||||
// Two GOPs of 5 frames, decode order I P B P B, anchor PTS only on the
|
||||
// GOP's I (0 ns, then ~5-frames-later). Frame duration should calibrate
|
||||
// to the spacing/5 and every frame get a distinct increasing display PTS.
|
||||
let dur = 41_708_333i64;
|
||||
let mut r = SparsePtsReorder::new();
|
||||
let mut got: Vec<i64> = Vec::new();
|
||||
// GOP 1: anchor on the I at t=0.
|
||||
for (k, (ct, pts)) in [(I, Some(0i64)), (P, None), (B, None), (P, None), (B, None)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let out = r.push(pts, frame(ct, k == 0));
|
||||
got.extend(out.iter().map(|f| f.pts_ns));
|
||||
}
|
||||
// GOP 2: anchor on the I at t = 5*dur (its true display time).
|
||||
for (k, (ct, pts)) in [
|
||||
(I, Some(5 * dur)),
|
||||
(P, None),
|
||||
(B, None),
|
||||
(P, None),
|
||||
(B, None),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let out = r.push(pts, frame(ct, k == 0));
|
||||
got.extend(out.iter().map(|f| f.pts_ns));
|
||||
}
|
||||
got.extend(r.flush().iter().map(|f| f.pts_ns));
|
||||
|
||||
// Ten frames out, none dropped.
|
||||
assert_eq!(got.len(), 10, "all frames emitted");
|
||||
// The calibrated duration is (5*dur)/5 = dur.
|
||||
// GOP 1 decode order I P B P B -> display indices 0 2 1 4 3 -> PTS:
|
||||
assert_eq!(
|
||||
&got[0..5],
|
||||
&[0, 2 * dur, dur, 4 * dur, 3 * dur],
|
||||
"GOP1 display PTS in decode order"
|
||||
);
|
||||
// GOP 2 re-locks origin to 5*dur.
|
||||
assert_eq!(
|
||||
&got[5..10],
|
||||
&[5 * dur, 7 * dur, 6 * dur, 9 * dur, 8 * dur],
|
||||
"GOP2 display PTS continue monotonically per display order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_flushes_a_gop_that_exceeds_the_byte_cap() {
|
||||
use CodingType::*;
|
||||
// Few-but-huge access units with no keyframe must not accumulate past the
|
||||
// byte cap: a handful of ~MAX_GOP_BYTES/4-sized frames force-completes the
|
||||
// GOP well before the frame-count cap, bounding memory.
|
||||
let big = MAX_GOP_BYTES / 4 + 1;
|
||||
let mut r = SparsePtsReorder::new();
|
||||
let mut emitted = 0usize;
|
||||
// Enough huge frames to trigger several byte-cap completions (a GOP is
|
||||
// held one step for duration calibration, so the first emit lands after
|
||||
// the second cap fires) — well under the 600-frame count cap.
|
||||
for i in 0..16 {
|
||||
let mut f = frame(P, false);
|
||||
f.data = vec![0u8; big];
|
||||
emitted += r.push((i == 0).then_some(0), f).len();
|
||||
}
|
||||
assert!(
|
||||
emitted >= 1,
|
||||
"byte cap force-flushed (emitted {emitted}) before the frame-count cap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_flushes_a_gop_that_never_signals_a_keyframe() {
|
||||
use CodingType::*;
|
||||
// A stream that never flags a keyframe (open-GOP recovery points, or a
|
||||
// crafted/corrupt disc) must not buffer the whole title: the cap
|
||||
// force-completes GOPs so frames are emitted well before flush().
|
||||
let mut r = SparsePtsReorder::new();
|
||||
let mut emitted = 0usize;
|
||||
for i in 0..(MAX_GOP_FRAMES * 3) {
|
||||
let pts = (i == 0).then_some(0);
|
||||
emitted += r.push(pts, frame(P, false)).len();
|
||||
}
|
||||
assert!(
|
||||
emitted >= MAX_GOP_FRAMES,
|
||||
"cap force-flushed GOPs before EOF (emitted {emitted})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pts_collisions_within_a_gop() {
|
||||
use CodingType::*;
|
||||
// Every frame distinct in DISPLAY order — the property the mkv muxer
|
||||
// needs so a decoder can derive monotonic DTS.
|
||||
let mut r = SparsePtsReorder::new();
|
||||
let mut all: Vec<i64> = Vec::new();
|
||||
for gop in 0..3 {
|
||||
for (k, ct) in [I, P, B, P, B].into_iter().enumerate() {
|
||||
let pts = (k == 0).then_some(gop as i64 * 5 * 41_708_333);
|
||||
all.extend(r.push(pts, frame(ct, k == 0)).iter().map(|f| f.pts_ns));
|
||||
}
|
||||
}
|
||||
all.extend(r.flush().iter().map(|f| f.pts_ns));
|
||||
let mut sorted = all.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), all.len(), "no two frames share a display PTS");
|
||||
}
|
||||
}
|
||||
+35
-3
@@ -103,6 +103,10 @@ pub struct Vc1Parser {
|
||||
cur_entry_point: Option<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Display-order PTS reconstruction, enabled only on the program-stream
|
||||
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
|
||||
/// the BD/UHD transport path, which carries a per-frame PTS.
|
||||
reorder: Option<super::reorder::SparsePtsReorder>,
|
||||
}
|
||||
|
||||
impl Default for Vc1Parser {
|
||||
@@ -120,6 +124,25 @@ impl Vc1Parser {
|
||||
cur_entry_point: None,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
reorder: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable display-order PTS reconstruction for a program-stream source.
|
||||
/// No-op (leaves timestamps as parsed) for a transport-stream source.
|
||||
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
|
||||
if enabled {
|
||||
self.reorder = Some(super::reorder::SparsePtsReorder::new());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Route a finished frame through the PTS reorderer when enabled, else emit
|
||||
/// it directly (unchanged transport-stream behaviour).
|
||||
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
|
||||
match self.reorder.as_mut() {
|
||||
Some(r) => r.push(explicit, frame),
|
||||
None => vec![frame],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +192,8 @@ impl CodecParser for Vc1Parser {
|
||||
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||
let ts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||
let ts_ns = explicit_pts.unwrap_or(0);
|
||||
let mut has_seq_header = false;
|
||||
let mut has_entry_point = false;
|
||||
let mut frame_start: Option<usize> = None;
|
||||
@@ -321,7 +345,7 @@ impl CodecParser for Vc1Parser {
|
||||
vc1_frame_coding_type(data.get(fs + 4..)?, self.cur_seq_header.as_deref())
|
||||
});
|
||||
|
||||
vec![Frame {
|
||||
let frame = Frame {
|
||||
// Coding-type only: VC-1 field order is not decoded here, so
|
||||
// field_order() stays None — honestly absent, never guessed.
|
||||
coding: coding_type.map(PictureInfo::coding_type_only),
|
||||
@@ -333,7 +357,15 @@ impl CodecParser for Vc1Parser {
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
};
|
||||
self.finish(explicit_pts, frame)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
match self.reorder.as_mut() {
|
||||
Some(r) => r.flush(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
|
||||
+19
-3
@@ -204,6 +204,14 @@ impl DemuxThread {
|
||||
}
|
||||
} else {
|
||||
let _ = recycle_tx.send(buf);
|
||||
// No demuxer (a BdTs title with zero streams): still send
|
||||
// an empty batch so an early consumer disconnect is
|
||||
// detected here too, exactly like the ts/ps branches above.
|
||||
// Without it this worker reads the whole disc even after
|
||||
// the consumer has dropped.
|
||||
if tx.send(DemuxBatch::Ts(Vec::new())).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush tail packets at EOF.
|
||||
@@ -479,7 +487,7 @@ mod tests {
|
||||
#[test]
|
||||
fn no_demuxer_configured_still_recycles_and_eofs() {
|
||||
// With neither ts nor ps set, the worker must still recycle buffers
|
||||
// and terminate with Eof — never emit a spurious Ts/Ps batch.
|
||||
// and terminate with Eof — also forward an empty batch per buffer for disconnect detection.
|
||||
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
|
||||
let (rc_tx, rc_rx) = bounded::<Vec<u8>>(4);
|
||||
let (_dt, rx) = DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, None, None).unwrap();
|
||||
@@ -492,8 +500,16 @@ mod tests {
|
||||
drop(pf_tx);
|
||||
|
||||
let batches = collect_batches(&rx, Duration::from_secs(5));
|
||||
assert_eq!(batches.len(), 1, "only the Eof sentinel");
|
||||
assert!(matches!(batches[0], DemuxBatch::Eof));
|
||||
// The no-demuxer branch now forwards an empty Ts batch per buffer for
|
||||
// early consumer-disconnect detection (same rationale as the ts/ps
|
||||
// branches), then the Eof sentinel.
|
||||
assert_eq!(
|
||||
batches.len(),
|
||||
2,
|
||||
"empty Ts disconnect-probe batch, then Eof"
|
||||
);
|
||||
assert!(matches!(batches[0], DemuxBatch::Ts(ref v) if v.is_empty()));
|
||||
assert!(matches!(batches[1], DemuxBatch::Eof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+9
-5
@@ -1520,11 +1520,15 @@ mod tests {
|
||||
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
|
||||
lba - ext_start
|
||||
);
|
||||
// Non-tail reads must be a whole number of units; the only
|
||||
// permitted short read is the final partial unit (here COUNT is a
|
||||
// multiple of ALIGN, so every read should be unit-multiple unless
|
||||
// it shrank below one unit — which is itself a single unit).
|
||||
let _ = count;
|
||||
// Non-tail reads must be a whole number of units; the only permitted
|
||||
// short read is a final partial unit (below one unit). Assert it
|
||||
// rather than documenting it — a mid-stream non-unit-multiple read
|
||||
// would straddle AACS unit boundaries and decrypt under the wrong
|
||||
// alignment.
|
||||
assert!(
|
||||
count as u32 % ALIGN == 0 || (count as u32) < ALIGN,
|
||||
"read count {count} is neither a whole number of units nor a sub-unit tail"
|
||||
);
|
||||
}
|
||||
|
||||
// At least one error was skipped (the bad unit) and a SectorSkipped
|
||||
|
||||
+104
-2
@@ -557,6 +557,14 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
track_uids: Vec<u64>,
|
||||
/// Segment duration in seconds (from `Info`), for the BPS denominator.
|
||||
duration_secs: f64,
|
||||
/// Byte offset of the DURATION element's 8-byte payload when it was written
|
||||
/// as a patch-later placeholder — the source supplied no duration (e.g.
|
||||
/// HD-DVD, whose `.MAP` timemaps are not parsed). `None` when a real duration
|
||||
/// was written up-front. Back-patched at `finish()` from the muxed timeline.
|
||||
duration_patch_pos: Option<u64>,
|
||||
/// Highest block timestamp (TimestampScale ticks) written across all tracks —
|
||||
/// the muxed runtime, used to back-patch the DURATION placeholder.
|
||||
max_block_ticks: i64,
|
||||
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
|
||||
/// is unreliable, so the channel count written in the track header is
|
||||
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
|
||||
@@ -766,11 +774,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::TIMESTAMP_SCALE,
|
||||
TIMESTAMP_SCALE_NS as u64,
|
||||
)?;
|
||||
if duration_secs > 0.0 {
|
||||
let duration_patch_pos = if duration_secs > 0.0 {
|
||||
// Duration is expressed in TimestampScale ticks (not ms).
|
||||
let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64;
|
||||
ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?;
|
||||
}
|
||||
None
|
||||
} else {
|
||||
// The source declared no duration (e.g. HD-DVD — its `.MAP` timemaps
|
||||
// are not parsed). Reserve a DURATION placeholder now and back-patch
|
||||
// it at finish() from the muxed timeline, so the file still declares a
|
||||
// runtime instead of showing "unknown". The 8-byte float payload sits
|
||||
// 3 bytes in (2-byte ID `0x4489` + 1-byte size `0x88`).
|
||||
let pos = writer.stream_position()?;
|
||||
ebml::write_float(&mut writer, ebml::DURATION, 0.0)?;
|
||||
Some(pos + 3)
|
||||
};
|
||||
// Stamp the freemkv version so any muxed file is traceable to the build
|
||||
// that produced it (MediaInfo "Writing application"/"library").
|
||||
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
|
||||
@@ -1007,6 +1025,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
track_bytes: vec![0u64; tracks.len()],
|
||||
track_uids,
|
||||
duration_secs,
|
||||
duration_patch_pos,
|
||||
max_block_ticks: 0,
|
||||
ac3_channel_fixups,
|
||||
opening_capture: None,
|
||||
})
|
||||
@@ -1196,6 +1216,14 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// Committed to writing this frame — record its (monotonic) timestamp so
|
||||
// the next block on this track is forced strictly later.
|
||||
self.last_pts_ticks.insert(track_idx, pts_ticks);
|
||||
// Track the highest block timestamp so a missing source duration can be
|
||||
// back-patched from the real muxed runtime at finish().
|
||||
// Track the block END (start + its own duration when known), not just
|
||||
// the start, so a back-patched Segment Duration covers the final frame's
|
||||
// full presentation instead of understating the runtime by one frame.
|
||||
let block_end_ticks =
|
||||
pts_ticks + duration_ns.map_or(0, |d| (d as i64 / TIMESTAMP_SCALE_NS).max(1));
|
||||
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
|
||||
|
||||
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
|
||||
match duration_ns {
|
||||
@@ -1268,6 +1296,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
if self.frame_count == 0 {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
// The source declared no duration up-front (DURATION was reserved as a
|
||||
// placeholder). Derive the real runtime from the muxed timeline so the
|
||||
// Segment declares it — and so the BPS tags below can be computed.
|
||||
if self.duration_patch_pos.is_some() && self.max_block_ticks > 0 {
|
||||
self.duration_secs =
|
||||
self.max_block_ticks as f64 * TIMESTAMP_SCALE_NS as f64 / 1_000_000_000.0;
|
||||
}
|
||||
// Close final cluster
|
||||
self.end_cluster()?;
|
||||
|
||||
@@ -1322,6 +1357,30 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
||||
self.writer.write_all(&offset.to_be_bytes())?;
|
||||
}
|
||||
// Back-patch the DURATION placeholder (source supplied no duration) with
|
||||
// the real runtime from the muxed timeline. The CUES-void and seek-to-end
|
||||
// below re-seek absolutely, so no position restore is needed here.
|
||||
if let Some(pos) = self.duration_patch_pos {
|
||||
if self.max_block_ticks > 0 {
|
||||
self.writer.seek(std::io::SeekFrom::Start(pos))?;
|
||||
self.writer
|
||||
.write_all(&(self.max_block_ticks as f64).to_be_bytes())?;
|
||||
} else {
|
||||
// The timeline never advanced past tick 0 (a degenerate
|
||||
// single-frame recovery at t=0 with no per-frame duration): we
|
||||
// can't derive a runtime, so DON'T leave a literal DURATION=0.0
|
||||
// (players read that as a zero-length/corrupt file). Void the
|
||||
// whole 11-byte DURATION element (ID 2 + size 1 + 8-byte payload)
|
||||
// so the Segment simply omits it, as an unknown-duration source
|
||||
// did before the back-patch. `pos` is the payload start (+3 from
|
||||
// the element start), so back up 3.
|
||||
self.writer.seek(std::io::SeekFrom::Start(pos - 3))?;
|
||||
ebml::write_id(&mut self.writer, ebml::VOID)?;
|
||||
ebml::write_size(&mut self.writer, 9)?; // 11 - 1 (Void id) - 1 (size)
|
||||
self.writer.write_all(&[0u8; 9])?;
|
||||
}
|
||||
}
|
||||
|
||||
// Neutralise the unused CUES Seek entry. The entry is a fixed 21-byte
|
||||
// Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id +
|
||||
// SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID)
|
||||
@@ -2852,6 +2911,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Scan for the first DURATION element (`0x4489`, 8-byte float payload) and
|
||||
/// return its value in TimestampScale ticks.
|
||||
fn find_duration_ticks(data: &[u8]) -> Option<f64> {
|
||||
data.windows(3)
|
||||
.position(|w| w == [0x44, 0x89, 0x88])
|
||||
.and_then(|i| data.get(i + 3..i + 11))
|
||||
.map(|b| f64::from_be_bytes(b.try_into().unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_placeholder_voided_when_timeline_never_advances() {
|
||||
// Degenerate recovery: a source with no declared duration muxes exactly
|
||||
// one keyframe at tick 0 with no per-frame duration, so max_block_ticks
|
||||
// stays 0. The reserved DURATION placeholder must be VOIDED (element
|
||||
// omitted) rather than left as a bogus 0.0 that players read as a
|
||||
// zero-length file.
|
||||
let tracks = [make_video_track()];
|
||||
let one_frame = vec![(0usize, 0i64, true, vec![0xAAu8; 16])];
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &one_frame);
|
||||
assert_eq!(
|
||||
find_duration_ticks(&data),
|
||||
None,
|
||||
"no DURATION element (placeholder voided), not a 0.0 duration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_backpatched_from_timeline_when_source_gives_none() {
|
||||
// `mux_to_bytes` muxes with `duration_secs = 0.0` (as an HD-DVD title
|
||||
// does), so DURATION is reserved as a placeholder and must be
|
||||
// back-patched from the muxed timeline at finish() — not left 0/absent.
|
||||
let tracks = [make_video_track()];
|
||||
let frames = frames_for(5.0, 1.0); // ~5 s of 24 fps video
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||
let dur = find_duration_ticks(&data).expect("DURATION element present");
|
||||
let expect = 5.0 * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64; // ~50000 ticks
|
||||
assert!(dur > 0.0, "duration back-patched from timeline, not 0");
|
||||
assert!(
|
||||
(dur - expect).abs() < 60.0,
|
||||
"duration ~ real runtime: got {dur} ticks, expected ~{expect}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekhead_points_to_real_elements() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod resolve;
|
||||
// accessors and an alternate `DemuxThread` spawn path. They are kept as
|
||||
// part of the parser/demux surface and covered by unit tests; allow the
|
||||
// dead-code lint rather than delete still-relevant scaffolding.
|
||||
pub(crate) mod au_assembly;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod codec;
|
||||
pub(crate) mod demux_sink;
|
||||
|
||||
+190
-17
@@ -75,6 +75,24 @@ pub struct PipelinedPesStream {
|
||||
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
||||
/// references the gate must protect). Indexed by stream index.
|
||||
is_video: Vec<bool>,
|
||||
/// Per-track access-unit assembler. On the PS path a program-stream video AU
|
||||
/// is split across many fixed-size PES fragments; this reassembles them to the
|
||||
/// codec's AU boundary so the parser sees AU-complete PES — the same shape the
|
||||
/// TS demuxer already delivers via PUSI. Self-framing codecs (MPEG-2, audio)
|
||||
/// use passthrough, so every track runs through it uniformly. Indexed by
|
||||
/// stream index. (TS titles are AU-complete already, so this is a passthrough
|
||||
/// there too — `consume_ts` does not use it.)
|
||||
au_asm: Vec<super::au_assembly::AuAssembler>,
|
||||
}
|
||||
|
||||
/// The `Codec` of a stream, for configuring its [`AuAssembler`].
|
||||
fn stream_codec(s: &crate::disc::Stream) -> crate::disc::Codec {
|
||||
use crate::disc::Stream;
|
||||
match s {
|
||||
Stream::Video(v) => v.codec,
|
||||
Stream::Audio(a) => a.codec,
|
||||
Stream::Subtitle(sub) => sub.codec,
|
||||
}
|
||||
}
|
||||
|
||||
impl PipelinedPesStream {
|
||||
@@ -102,6 +120,11 @@ impl PipelinedPesStream {
|
||||
let resync = (0..title.streams.len())
|
||||
.map(|_| super::resync::ResyncGate::new())
|
||||
.collect();
|
||||
let au_asm = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s)))
|
||||
.collect();
|
||||
Self {
|
||||
title,
|
||||
parsers,
|
||||
@@ -115,6 +138,7 @@ impl PipelinedPesStream {
|
||||
dropped_nav_packets: 0,
|
||||
resync,
|
||||
is_video,
|
||||
au_asm,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,23 +278,50 @@ impl PipelinedPesStream {
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let pes = PesPacket {
|
||||
// Carry the PS demuxer's byte-exact source stamp through to the
|
||||
// codec parser, exactly as the TS path does — provenance must
|
||||
// survive the PsPacket → PesPacket seam so the frame's `source`
|
||||
// reaches the mux/index (FVI `src`), never reconstructed.
|
||||
source: ps.source,
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data,
|
||||
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
|
||||
discontinuity: false,
|
||||
// Carry the PS demuxer's byte-exact source stamp through to the codec
|
||||
// parser, exactly as the TS path does — provenance must survive the
|
||||
// PsPacket → PesPacket seam so the frame's `source` reaches the
|
||||
// mux/index (FVI `src`), never reconstructed.
|
||||
let (pts_i64, dts_i64, src) = (
|
||||
ps.pts.map(|p| p as i64),
|
||||
ps.dts.map(|d| d as i64),
|
||||
ps.source,
|
||||
);
|
||||
// Reassemble the PS fragments into AU-complete PES for this track
|
||||
// (passthrough for self-framing codecs — MPEG-2/audio), so the parser
|
||||
// sees exactly the AU-complete shape a transport stream delivers. The
|
||||
// AU-start PTS/source survive the reassembly. A track with no assembler
|
||||
// (only reachable via a hand-built `pid_to_track` outrunning the stream
|
||||
// list) passes the fragment straight through. (PS path: no AACS conceal
|
||||
// → no continuity-gap flag.)
|
||||
let pkts: Vec<PesPacket> = match self.au_asm.get_mut(track) {
|
||||
Some(asm) => asm
|
||||
.push_owned(ps.data, pts_i64, dts_i64, src, false)
|
||||
.into_iter()
|
||||
.map(|au| PesPacket {
|
||||
source: au.source,
|
||||
pid,
|
||||
pts: au.pts,
|
||||
dts: au.dts,
|
||||
data: au.data,
|
||||
discontinuity: au.discontinuity,
|
||||
})
|
||||
.collect(),
|
||||
None => vec![PesPacket {
|
||||
source: src,
|
||||
pid,
|
||||
pts: pts_i64,
|
||||
dts: dts_i64,
|
||||
data: ps.data,
|
||||
discontinuity: false,
|
||||
}],
|
||||
};
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
for pes in &pkts {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,11 +366,30 @@ impl Stream for PipelinedPesStream {
|
||||
let pending = &mut self.pending_frames;
|
||||
let resync = &mut self.resync;
|
||||
let is_video = &self.is_video;
|
||||
let au_asm = &mut self.au_asm;
|
||||
for (pid, parser) in self.parsers.iter_mut() {
|
||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||
continue;
|
||||
};
|
||||
for frame in parser.flush() {
|
||||
// First: the trailing access unit(s) the PS assembler
|
||||
// buffered past the final fragment (the last AU has no
|
||||
// following boundary). Parse them, THEN drain the parser's
|
||||
// own internal buffer (MPEG-2 final GOP, DTS-HD tail).
|
||||
let mut frames = Vec::new();
|
||||
let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default();
|
||||
for au in tail {
|
||||
let pes = PesPacket {
|
||||
source: au.source,
|
||||
pid: *pid,
|
||||
pts: au.pts,
|
||||
dts: au.dts,
|
||||
data: au.data,
|
||||
discontinuity: au.discontinuity,
|
||||
};
|
||||
frames.extend(parser.parse(&pes));
|
||||
}
|
||||
frames.extend(parser.flush());
|
||||
for frame in frames {
|
||||
let emit = match resync.get_mut(track) {
|
||||
Some(gate) => gate.admit(
|
||||
is_video.get(track).copied().unwrap_or(false),
|
||||
@@ -801,6 +871,109 @@ mod tests {
|
||||
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
|
||||
}
|
||||
|
||||
/// Build a single-video-stream title on `codec`, a [`CountingParser`] (1 frame
|
||||
/// per PES it is handed), and feed three 0xE0 program-stream fragments that
|
||||
/// together form TWO H.264 access units (AUD-delimited); only AU-start
|
||||
/// fragments carry a PTS. Returns every emitted frame.
|
||||
fn run_ps_fragments(codec: Codec) -> Vec<crate::pes::PesFrame> {
|
||||
let mut title = DiscTitle::empty();
|
||||
title.streams.push(crate::disc::Stream::Video(VideoStream {
|
||||
pid: crate::mux::ps::DVD_VIDEO_PID,
|
||||
codec,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
}));
|
||||
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
|
||||
crate::mux::ps::DVD_VIDEO_PID,
|
||||
Box::new(CountingParser {
|
||||
per_pes: 1,
|
||||
flush_n: 0,
|
||||
cp: None,
|
||||
}),
|
||||
)];
|
||||
let pid_to_track = vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)];
|
||||
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||
|
||||
let frag = |pts, data: &[u8]| PsPacket {
|
||||
source: None,
|
||||
stream_id: 0xE0,
|
||||
sub_stream_id: None,
|
||||
pts,
|
||||
dts: None,
|
||||
data: data.to_vec(),
|
||||
};
|
||||
tx.send(DemuxBatch::Ps(vec![
|
||||
frag(Some(9_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA]), // AU1: AUD + slice head
|
||||
frag(None, &[0xBB, 0xCC]), // AU1: slice tail (no PTS)
|
||||
frag(Some(18_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD]), // AU2 opener (AUD closes AU1)
|
||||
]))
|
||||
.unwrap();
|
||||
tx.send(DemuxBatch::Eof).unwrap();
|
||||
|
||||
let mut out = Vec::new();
|
||||
while let Some(f) = stream.read().unwrap() {
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// PS-path integration: an H.264 access unit split across several fixed-size
|
||||
/// PES fragments (only the first with a PTS) must be REJOINED so the parser
|
||||
/// sees one AU-complete PES with the AU-START pts — not one bogus per-fragment
|
||||
/// frame each with pts 0 (the HD-DVD truncation/corruption bug). The
|
||||
/// `CountingParser` makes it observable: 3 fragments forming 2 AUs → 2 frames.
|
||||
#[test]
|
||||
fn ps_h264_au_split_across_fragments_reassembles_to_one_frame() {
|
||||
let frames = run_ps_fragments(Codec::H264);
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
2,
|
||||
"3 fragments → 2 access units, not 3 frames"
|
||||
);
|
||||
assert_eq!(frames[0].track, 0);
|
||||
assert_eq!(
|
||||
frames[0].data,
|
||||
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA, 0xBB, 0xCC],
|
||||
"AU1 = fragment1 + fragment2 rejoined"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[0].pts, 9_000,
|
||||
"AU carries its START pts, not the mid-fragment None→0"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[1].data,
|
||||
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD],
|
||||
"AU2 flushed at EOF (no following boundary)"
|
||||
);
|
||||
assert_eq!(frames[1].pts, 18_000);
|
||||
}
|
||||
|
||||
/// Contrast: a self-framing codec (MPEG-2 reassembles in its own parser) uses
|
||||
/// a Passthrough assembler — the SAME three fragments pass straight through as
|
||||
/// three frames, byte-identical to the pre-assembler behaviour. This proves the
|
||||
/// reassembly is gated by codec and does not disturb the DVD/MPEG-2 path.
|
||||
#[test]
|
||||
fn ps_self_framing_codec_is_not_reassembled() {
|
||||
let frames = run_ps_fragments(Codec::Mpeg2);
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
3,
|
||||
"MPEG-2 passthrough: one frame per fragment"
|
||||
);
|
||||
assert_eq!(frames[0].pts, 9_000);
|
||||
assert_eq!(
|
||||
frames[1].pts, 0,
|
||||
"mid-fragment has no PTS under passthrough"
|
||||
);
|
||||
assert_eq!(frames[2].pts, 18_000);
|
||||
}
|
||||
|
||||
/// A batch with no trackable packets must NOT terminate the stream early:
|
||||
/// pump_one_batch loops to the next batch. Here an empty-but-untracked
|
||||
/// batch is followed by a real frame batch — the consumer must skip the
|
||||
|
||||
+249
-5
@@ -27,6 +27,12 @@ const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1;
|
||||
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
||||
/// elementary stream; expected to be dropped on every disc.
|
||||
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
|
||||
/// Extended stream id (0xFD) — the H.222.0 escape whereby the real stream id is
|
||||
/// the `stream_id_extension` carried in the PES extension. HD-DVD `.evo` puts its
|
||||
/// VC-1 video (and HD audio) here (Shaun of the Dead: VC-1 on `0xFD` ext `0x55`);
|
||||
/// a transport stream never uses it. The elementary-stream bytes follow the PES
|
||||
/// header exactly like any other PES — only the routing key differs.
|
||||
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
||||
|
||||
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
||||
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
||||
@@ -73,9 +79,16 @@ pub const DVD_VIDEO_PID: u16 = 0xE0;
|
||||
/// source of truth shared with `Disc::scan_dvd_titles`
|
||||
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
||||
/// function so demuxer output routes through the title's `pid_to_track`.
|
||||
///
|
||||
/// HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on
|
||||
/// `private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses
|
||||
/// (DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely
|
||||
/// additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just
|
||||
/// like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks
|
||||
/// `0xC0..0xC3`) routes each track to its own distinct PID.
|
||||
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
match sub_stream_id {
|
||||
0x80..=0x8F | 0xA0..=0xA7 => Some(0xBD00 | sub_stream_id as u16),
|
||||
0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -90,6 +103,17 @@ pub fn dvd_subtitle_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical PID for an HD-DVD extended-stream-id (`0xFD`) stream, keyed by its
|
||||
/// `stream_id_extension`: `0xFD00 | ext`. Disjoint from the DVD video (`0xE0`) and
|
||||
/// `private_stream_1` (`0xBD00..`) PID spaces, so several elementary streams
|
||||
/// multiplexed on `0xFD` (VC-1 video, MLP/TrueHD audio) never collide. The
|
||||
/// scanner's head probe and `PsPacket::dvd_pid` derive the same PID from the same
|
||||
/// `stream_id_extension`, so demux output routes through the title's
|
||||
/// `pid_to_track`.
|
||||
pub fn hddvd_extended_pid(stream_id_extension: u8) -> u16 {
|
||||
0xFD00 | stream_id_extension as u16
|
||||
}
|
||||
|
||||
impl PsPacket {
|
||||
/// Map this packet to the canonical DVD PID assigned by
|
||||
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
||||
@@ -112,6 +136,12 @@ impl PsPacket {
|
||||
let sub = self.sub_stream_id?;
|
||||
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
||||
}
|
||||
// HD-DVD extended-stream-id (0xFD): route by the stream_id_extension
|
||||
// (carried in `sub_stream_id`) to a distinct `0xFD00 | ext` PID, so a
|
||||
// disc that puts several elementary streams on 0xFD keeps them apart.
|
||||
// The codec (VC-1 etc.) is decided by the scanner's head probe, not
|
||||
// here — this only assigns a stable routing key.
|
||||
EXTENDED_STREAM_ID => self.sub_stream_id.map(hddvd_extended_pid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -347,9 +377,74 @@ fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
|
||||
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||
fn is_pes_stream_id(id: u8) -> bool {
|
||||
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc.
|
||||
// We parse anything in the payload-bearing PES range.
|
||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id)
|
||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc. — plus the HD-DVD
|
||||
// extended-stream-id (0xFD), which carries VC-1 video / HD audio.
|
||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id) || id == EXTENDED_STREAM_ID
|
||||
}
|
||||
|
||||
/// For an extended-stream-id (`0xFD`) PES, walk the optional PES-header fields to
|
||||
/// the PES extension and read the 7-bit `stream_id_extension` — the real stream
|
||||
/// id. `data` starts at the PES start code; the optional fields live in
|
||||
/// `data[9..header_end]` (all bounds-checked against `header_end`). Returns `None`
|
||||
/// if the extension is absent or malformed.
|
||||
fn parse_stream_id_extension(data: &[u8], flags2: u8, header_end: usize) -> Option<u8> {
|
||||
let get = |p: usize| -> Option<u8> {
|
||||
if p < header_end {
|
||||
data.get(p).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut pos = 9usize;
|
||||
let pts_dts = (flags2 >> 6) & 0x03;
|
||||
if pts_dts & 0x02 != 0 {
|
||||
pos += 5; // PTS
|
||||
}
|
||||
if pts_dts == 0x03 {
|
||||
pos += 5; // DTS
|
||||
}
|
||||
if flags2 & 0x20 != 0 {
|
||||
pos += 6; // ESCR
|
||||
}
|
||||
if flags2 & 0x10 != 0 {
|
||||
pos += 3; // ES_rate
|
||||
}
|
||||
if flags2 & 0x08 != 0 {
|
||||
pos += 1; // DSM_trick_mode
|
||||
}
|
||||
if flags2 & 0x04 != 0 {
|
||||
pos += 1; // additional_copy_info
|
||||
}
|
||||
if flags2 & 0x02 != 0 {
|
||||
pos += 2; // PES_CRC
|
||||
}
|
||||
if flags2 & 0x01 == 0 {
|
||||
return None; // no PES_extension
|
||||
}
|
||||
let ext_flags = get(pos)?;
|
||||
pos += 1;
|
||||
if ext_flags & 0x80 != 0 {
|
||||
pos += 16; // PES_private_data
|
||||
}
|
||||
if ext_flags & 0x40 != 0 {
|
||||
// pack_header_field: 1-byte length + that many bytes.
|
||||
pos += 1 + get(pos)? as usize;
|
||||
}
|
||||
if ext_flags & 0x20 != 0 {
|
||||
pos += 2; // program_packet_sequence_counter
|
||||
}
|
||||
if ext_flags & 0x10 != 0 {
|
||||
pos += 2; // P-STD_buffer
|
||||
}
|
||||
if ext_flags & 0x01 == 0 {
|
||||
return None; // no PES_extension_flag_2
|
||||
}
|
||||
// PES_extension_field_length (7 bits, marker in the top bit), then the
|
||||
// stream_id_extension byte: present when its top bit (the extension flag) is 0.
|
||||
let _field_len = get(pos)? & 0x7F;
|
||||
pos += 1;
|
||||
let b = get(pos)?;
|
||||
(b & 0x80 == 0).then_some(b & 0x7F)
|
||||
}
|
||||
|
||||
/// Parse a single PES packet from a byte slice that starts at the start code.
|
||||
@@ -416,10 +511,30 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
|
||||
|
||||
// For private stream 1, the first payload byte is the sub-stream ID,
|
||||
// followed by a sub-header whose length depends on the sub-stream type.
|
||||
let (sub_stream_id, es_data) = if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
||||
let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID {
|
||||
// HD-DVD extended-stream-id: the real stream id lives in the
|
||||
// stream_id_extension inside the PES extension. There is no leading
|
||||
// sub-header byte on the payload (unlike private_stream_1), so the ES
|
||||
// is the payload verbatim.
|
||||
(
|
||||
parse_stream_id_extension(data, data[7], header_end),
|
||||
payload.to_vec(),
|
||||
)
|
||||
} else if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
||||
let sub_id = payload[0];
|
||||
let skip = match sub_id {
|
||||
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
|
||||
// HD-DVD Dolby Digital Plus (E-AC-3): the sub-header is the same
|
||||
// 4-byte shape as DVD AC-3 — sub_id + number_of_frames(1) +
|
||||
// first_access_unit_pointer(2). Verified empirically on ANCHORMAN
|
||||
// EVO: across every 0xC0..=0xC7 packet the 0x0B77 E-AC-3 syncword
|
||||
// sits `first_access_unit_pointer` bytes past this 4-byte header
|
||||
// (the leading bytes are the tail of the previous frame). Stripping
|
||||
// exactly these 4 bytes on EVERY packet yields a clean, continuous
|
||||
// E-AC-3 elementary stream that the ac3 parser reassembles across
|
||||
// PES boundaries; a shorter skip would splice the sub-header bytes
|
||||
// into a straddling frame and corrupt it.
|
||||
0xC0..=0xC7 => 4,
|
||||
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
|
||||
_ => 1,
|
||||
};
|
||||
@@ -836,6 +951,90 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_extracts_stream_id_extension() {
|
||||
// SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension
|
||||
// only), header_data_length=3, optional bytes 0x0F/... — build the
|
||||
// minimal well-formed variant: ext_flags=0x01 (PES_extension_flag_2),
|
||||
// field_len=0x81, stream_id_extension=0x55. Payload is the ES.
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
let opt = [0x01u8, 0x81, 0x55];
|
||||
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let len = (3 + opt.len() + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
|
||||
pkt.extend_from_slice(&opt);
|
||||
pkt.extend_from_slice(&es);
|
||||
|
||||
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||
assert_eq!(parsed.stream_id, EXTENDED_STREAM_ID);
|
||||
assert_eq!(
|
||||
parsed.sub_stream_id,
|
||||
Some(0x55),
|
||||
"stream_id_extension extracted from PES extension"
|
||||
);
|
||||
// ES is the payload verbatim — no leading sub-header byte stripped.
|
||||
assert_eq!(parsed.data, es);
|
||||
// Routes to the extended-stream-id PID space.
|
||||
assert_eq!(parsed.dvd_pid(), Some(hddvd_extended_pid(0x55)));
|
||||
assert_eq!(parsed.dvd_pid(), Some(0xFD55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_skips_pts_and_dts_before_the_extension() {
|
||||
// The common real case: an AU-opening 0xFD VC-1 video PES carries a PTS
|
||||
// (and often DTS) in the optional-header region, which the parser must
|
||||
// SKIP (PTS +5, DTS +5) to reach the PES_extension → stream_id_extension.
|
||||
// Both branches were previously untested (flags2 there was 0x01, skipping
|
||||
// everything), so an off-by-one in the skip would silently misroute video.
|
||||
let build = |flags2: u8, skip: usize| {
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
// optional region: `skip` bytes (PTS/DTS placeholders) then
|
||||
// ext_flags=0x01, field_len=0x81, stream_id_extension=0x55.
|
||||
let mut opt = vec![0xFFu8; skip];
|
||||
opt.extend_from_slice(&[0x01, 0x81, 0x55]);
|
||||
let es = [0xDEu8, 0xAD];
|
||||
let len = (3 + opt.len() + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
// flags1=0x80, flags2, header_data_length = optional region length.
|
||||
pkt.extend_from_slice(&[0x80, flags2, opt.len() as u8]);
|
||||
pkt.extend_from_slice(&opt);
|
||||
pkt.extend_from_slice(&es);
|
||||
pkt
|
||||
};
|
||||
// PTS present (pts_dts bits = 10 → flags2 0x80) + PES_extension (0x01).
|
||||
let pts_only = parse_pes_packet(&build(0x81, 5)).expect("parses");
|
||||
assert_eq!(
|
||||
pts_only.sub_stream_id,
|
||||
Some(0x55),
|
||||
"extension found after skipping a 5-byte PTS"
|
||||
);
|
||||
// PTS+DTS present (pts_dts bits = 11 → flags2 0xC0) + PES_extension.
|
||||
let pts_dts = parse_pes_packet(&build(0xC1, 10)).expect("parses");
|
||||
assert_eq!(
|
||||
pts_dts.sub_stream_id,
|
||||
Some(0x55),
|
||||
"extension found after skipping a 10-byte PTS+DTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_without_extension_yields_no_sub_id() {
|
||||
// A 0xFD PES that declares no PES_extension (flags2=0x00) can't carry a
|
||||
// stream_id_extension → sub_stream_id None, and dvd_pid falls through.
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
let es = [0x11u8, 0x22];
|
||||
let len = (3 + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||
pkt.extend_from_slice(&es);
|
||||
|
||||
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||
assert_eq!(parsed.sub_stream_id, None);
|
||||
assert_eq!(parsed.dvd_pid(), None);
|
||||
assert_eq!(parsed.data, es);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_pid_matches_scanner_assignment() {
|
||||
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
||||
@@ -1175,6 +1374,51 @@ mod tests {
|
||||
assert_eq!(dvd_audio_pid(0xA8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hddvd_ddplus_substream_maps_to_bd_pid() {
|
||||
// HD-DVD Dolby Digital Plus sub-ids 0xC0..=0xC7 map to 0xBD00|sub,
|
||||
// distinct per track and disjoint from the DVD audio space. A DVD never
|
||||
// emits these, so the range is purely additive.
|
||||
assert_eq!(dvd_audio_pid(0xC0), Some(0xBDC0));
|
||||
assert_eq!(dvd_audio_pid(0xC3), Some(0xBDC3));
|
||||
assert_eq!(dvd_audio_pid(0xC7), Some(0xBDC7));
|
||||
// Just outside the range.
|
||||
assert_eq!(dvd_audio_pid(0xBF), None);
|
||||
assert_eq!(dvd_audio_pid(0xC8), None);
|
||||
// Four DD+ tracks (ANCHORMAN) get four distinct PIDs.
|
||||
let pids: Vec<u16> = (0xC0u8..=0xC3).map(|s| dvd_audio_pid(s).unwrap()).collect();
|
||||
assert_eq!(pids, vec![0xBDC0, 0xBDC1, 0xBDC2, 0xBDC3]);
|
||||
// And route through dvd_pid on a private_stream_1 packet.
|
||||
assert_eq!(mk(0xBD, Some(0xC0)).dvd_pid(), Some(0xBDC0));
|
||||
assert_eq!(mk(0xBD, Some(0xC3)).dvd_pid(), Some(0xBDC3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hddvd_ddplus_pes_strips_4byte_subheader_to_syncword() {
|
||||
// A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte
|
||||
// sub-header (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer
|
||||
// must strip exactly those 4 bytes so es_data begins at the E-AC-3
|
||||
// payload — here the 0x0B77 syncword sits right after the sub-header.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let mut data = vec![
|
||||
0x00, 0x00, 0x01, 0xBD, // private stream 1
|
||||
0x00, 0x0B, // PES_packet_length = 11 (flags2 + hdl1 + 8 payload)
|
||||
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
|
||||
0xC0, // sub-stream id: DD+ track 0
|
||||
0x01, 0x00, 0x00, // num_frames(1) + access_unit_ptr(2)
|
||||
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + payload
|
||||
];
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
||||
let p = demuxer.feed(&data);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0].sub_stream_id, Some(0xC0));
|
||||
assert_eq!(
|
||||
p[0].data,
|
||||
vec![0x0B, 0x77, 0xDE, 0xAD],
|
||||
"4-byte DD+ sub-header stripped; es_data starts at the syncword"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_subtitle_pid_range_boundaries() {
|
||||
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
||||
|
||||
+14
-6
@@ -358,6 +358,12 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
// case must NOT raise a false E7023.
|
||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Upfront FMTS gate, parallel to the unit-key gate above. With
|
||||
// BYPASS_FMTS_KEY this is a no-op and a 2.1 disc's forensic units are
|
||||
// concealed as ordinary decrypt loss below; without it, a 2.1 disc
|
||||
// lacking segment keys fails here rather than emitting a holed mux.
|
||||
disc.ensure_forensic_segments_decryptable(opts.raw)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||
// by probing the first DECRYPTED access units of the chosen title.
|
||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||
@@ -638,15 +644,17 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
.tolerate_decrypt_loss();
|
||||
// Install the fresh-key-on-failure callback (if any) so a unit no held key
|
||||
// decrypts is re-tried via the application's key source before being counted
|
||||
// as loss.
|
||||
// as loss. An AACS 2.1 forensic-segment unit that no key opens is just an
|
||||
// undecryptable unit like any other: concealed and counted as decrypt loss —
|
||||
// a loss is a loss, no FMTS special casing.
|
||||
if let Some(cb) = fetch {
|
||||
decrypting = decrypting.with_key_fetch(cb);
|
||||
}
|
||||
// Grab the decrypt-loss counter before the decorator is moved into the
|
||||
// producer thread. It tracks bytes of scrambled AACS units no key could
|
||||
// decrypt — silent loss the demux drops; the consuming stream surfaces it
|
||||
// through `lost_bytes()` so the mux abort gate sees a partial decrypt
|
||||
// failure rather than a clean rip.
|
||||
// Grab the loss counters before the decorator is moved into the producer
|
||||
// thread. It tracks bytes of scrambled AACS units no key could decrypt —
|
||||
// silent loss the demux drops; the consuming stream surfaces it through
|
||||
// `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather
|
||||
// than a clean rip. Forensic (2.1) undecryptable units land here too.
|
||||
let decrypt_loss = decrypting.decrypt_loss();
|
||||
|
||||
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
|
||||
|
||||
+5
-1
@@ -167,6 +167,10 @@ impl PesAssembler {
|
||||
self.buffer.clear();
|
||||
self.active = false;
|
||||
self.header_remaining = 0;
|
||||
// A dropped partial PES is a gap in the elementary stream — flag
|
||||
// it so the NEXT completed PES carries a discontinuity, matching
|
||||
// every other partial-drop path in this file (lines 395/479/504).
|
||||
self.pending_discontinuity = true;
|
||||
return;
|
||||
}
|
||||
self.buffer.extend_from_slice(data);
|
||||
@@ -366,7 +370,7 @@ impl TsDemuxer {
|
||||
|
||||
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
|
||||
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
|
||||
// adaptation-field discontinuity_indicator (see `aacs::fill_null_ts_unit`).
|
||||
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
|
||||
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
|
||||
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
|
||||
// an exact multiple of 16 packets and a loss at the very start of a PID
|
||||
|
||||
+96
-160
@@ -38,16 +38,6 @@ use super::SectorSource;
|
||||
/// so it can ride the mux highway's producer thread.
|
||||
pub type KeyFetch = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
|
||||
|
||||
/// Cap on how many times one decorator will call the fetch closure over its
|
||||
/// lifetime — bounds key-server traffic to roughly O(distinct CPS units) even
|
||||
/// if scrambled units keep arriving. A disc has only a handful of unit keys.
|
||||
const MAX_FETCH_CALLS: usize = 16;
|
||||
|
||||
/// Cap on how many still-scrambled sample units are handed to the fetch
|
||||
/// closure per call — a few samples are plenty for a key service to identify
|
||||
/// and validate the key, and it bounds the request size.
|
||||
const MAX_FETCH_SAMPLES: usize = 8;
|
||||
|
||||
/// Cap on how many per-unit decrypt-verify-failure diagnostics one read emits.
|
||||
/// The diagnostic runs only on the failure (cold) path and bounds log volume so
|
||||
/// a large undecryptable range can't flood the device log; the first few units
|
||||
@@ -109,21 +99,12 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
///
|
||||
/// [`decrypt_loss`]: Self::decrypt_loss
|
||||
decrypt_dropped: Arc<AtomicU64>,
|
||||
/// Optional "fetch a fresh key for THIS data" callback (see [`KeyFetch`]).
|
||||
/// `None` for the common case (keys fully resolved up front); set via
|
||||
/// [`with_key_fetch`](Self::with_key_fetch) by an application that wants
|
||||
/// to ask its key source for a key when a unit fails to decrypt.
|
||||
fetch: Option<KeyFetch>,
|
||||
/// Fingerprints (hash over the unit ciphertext) of failing units a fetch
|
||||
/// already returned NO new key for. A later failure re-asks the source only
|
||||
/// for units NOT in this set — so on a multi-CPS disc the source is still
|
||||
/// asked for the *second* CPS unit's key even after the first came back dry
|
||||
/// (the old global latch blocked that), while the *same* failing unit is
|
||||
/// never re-fetched (and the total is still bounded by `MAX_FETCH_CALLS`).
|
||||
fetch_dry: std::collections::HashSet<u64>,
|
||||
/// How many times the fetch closure has been invoked, capped at
|
||||
/// [`MAX_FETCH_CALLS`].
|
||||
fetch_calls: usize,
|
||||
/// The miss policy (see [`crate::sector::recovery::Recover`]) — a generic,
|
||||
/// scheme-neutral recovery the input stream (L3) installs and this decorator
|
||||
/// (L2) executes at the one seam when a content unit will not decrypt. `None`
|
||||
/// = no recovery (a miss is loss). Installed via
|
||||
/// [`with_key_fetch`](Self::with_key_fetch).
|
||||
recovery: Option<crate::sector::recovery::Recover>,
|
||||
/// Verify-only mode: a read decrypt-CHECKS a scratch copy of the bytes (to
|
||||
/// detect undecryptable units) but NEVER mutates `buf` — the inner
|
||||
/// ciphertext is returned unchanged. This is what makes a multipass sweep
|
||||
@@ -146,7 +127,7 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
scratch: Vec<u8>,
|
||||
/// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit
|
||||
/// that genuinely won't decrypt is NOT a read failure: it is overwritten with
|
||||
/// valid NULL TS packets ([`crate::aacs::fill_null_ts_unit`]), tallied into
|
||||
/// valid NULL TS packets ([`crate::aacs::content::fill_null_ts_unit`]), tallied into
|
||||
/// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and
|
||||
/// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an
|
||||
/// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not
|
||||
@@ -173,9 +154,10 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
unit_key_idx: 0,
|
||||
unit_base: 0,
|
||||
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
||||
fetch: None,
|
||||
fetch_dry: std::collections::HashSet::new(),
|
||||
fetch_calls: 0,
|
||||
// No recovery by default. CSS self-decrypts in `decrypt_sectors`
|
||||
// (needs no external input); AACS installs a key-fetch via
|
||||
// `with_key_fetch`.
|
||||
recovery: None,
|
||||
verify_only: false,
|
||||
content_ranges: None,
|
||||
scratch: Vec::new(),
|
||||
@@ -197,7 +179,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
/// (sorted/merged `(start_lba, sector_count)` — see
|
||||
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
|
||||
/// Units outside content (UDF filesystem / BDMV nav) pass through untouched,
|
||||
/// so [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) is never consulted
|
||||
/// so [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) is never consulted
|
||||
/// about non-content bytes. Whole-disc readers (sweep / patch) set this; the
|
||||
/// mux leaves it unset because it only ever reads title extents.
|
||||
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
|
||||
@@ -238,7 +220,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
/// for [`DecryptKeys::Aacs`]; ignored otherwise. The library makes no network
|
||||
/// call — `cb` is the application's seam to its key source.
|
||||
pub fn with_key_fetch(mut self, cb: KeyFetch) -> Self {
|
||||
self.fetch = Some(cb);
|
||||
self.recovery = Some(crate::sector::recovery::key_fetch(cb));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -286,86 +268,6 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the still-scrambled aligned units in `buf`, hand them to the
|
||||
/// fetch callback, add any returned keys not already held to the AACS
|
||||
/// pool (the CACHE — every later unit this pass, and any later read, reuses
|
||||
/// them), and re-decrypt `buf`. Returns the post-retry dropped-byte count
|
||||
/// (equal to `prev_dropped` when the callback could not help). The re-decrypt
|
||||
/// is content-gated identically to the first read so a non-content unit is
|
||||
/// never re-attempted. Caller guarantees the keys are `DecryptKeys::Aacs`, a
|
||||
/// callback is installed, and the call budget is not yet spent.
|
||||
fn fetch_failed_units(
|
||||
&mut self,
|
||||
buf: &mut [u8],
|
||||
lba: u32,
|
||||
content: Option<&[(u32, u32)]>,
|
||||
prev_dropped: usize,
|
||||
) -> usize {
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the
|
||||
// exact on-disc ciphertext no held key could open. A trailing partial
|
||||
// unit (chunks_exact remainder) can't be a whole scrambled unit, so
|
||||
// skipping it is correct.
|
||||
let mut samples: Vec<Vec<u8>> = Vec::new();
|
||||
for chunk in buf.chunks_exact(unit_len) {
|
||||
if crate::aacs::aacs_unit_needs_decrypt(chunk) {
|
||||
samples.push(chunk.to_vec());
|
||||
if samples.len() >= MAX_FETCH_SAMPLES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if samples.is_empty() {
|
||||
return prev_dropped;
|
||||
}
|
||||
// Skip the call when EVERY failing unit here is one a prior fetch already
|
||||
// came back empty for — re-asking the identical ciphertext only burns a
|
||||
// key-server request. A unit we have NOT asked about yet (e.g. a second
|
||||
// CPS unit on a multi-CPS disc) still gets its one chance, where the old
|
||||
// global `fetch_spent` latch wrongly blocked it.
|
||||
let fps: Vec<u64> = samples.iter().map(|s| Self::sample_fp(s)).collect();
|
||||
if fps.iter().all(|fp| self.fetch_dry.contains(fp)) {
|
||||
return prev_dropped;
|
||||
}
|
||||
// Ask the application's key source for keys that open this ciphertext.
|
||||
self.fetch_calls += 1;
|
||||
let fresh = match self.fetch.as_ref() {
|
||||
Some(cb) => cb(&samples),
|
||||
None => return prev_dropped,
|
||||
};
|
||||
// Add only keys we don't already hold (dedup by value).
|
||||
let mut added = 0usize;
|
||||
if let DecryptKeys::Aacs { unit_keys, .. } = &mut self.keys {
|
||||
for k in fresh {
|
||||
if !unit_keys.iter().any(|(_, have)| *have == k) {
|
||||
let idx = unit_keys.len() as u32;
|
||||
unit_keys.push((idx, k));
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added == 0 {
|
||||
// Nothing new for THESE units — remember them so we don't re-ask the
|
||||
// same ciphertext, but leave the door open for other units.
|
||||
self.fetch_dry.extend(fps);
|
||||
return prev_dropped;
|
||||
}
|
||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
||||
// genuine loss. A retry error must not mask the original count.
|
||||
Self::decrypt_buf(buf, &mut self.keys, self.unit_key_idx, lba, content)
|
||||
.unwrap_or(prev_dropped)
|
||||
}
|
||||
|
||||
/// Stable per-run fingerprint of a failing unit's ciphertext, for the
|
||||
/// `fetch_dry` set. `DefaultHasher` is fixed-seed, so equal samples map to
|
||||
/// equal fingerprints within a process — all the dedup needs.
|
||||
fn sample_fp(sample: &[u8]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
sample.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Emit a bounded, structured diagnostic for each undecryptable unit in a
|
||||
/// failed verify read. Called only on the failure (cold) path. On a fresh
|
||||
/// rip `buf` holds the post-decrypt bytes straight off the drive, so the
|
||||
@@ -391,7 +293,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
content: Option<&[(u32, u32)]>,
|
||||
keys: &DecryptKeys,
|
||||
) {
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
let unit_sectors = (unit_len / 2048) as u32;
|
||||
// Only AACS produces decrypt-verify failures; None / CSS never reach here
|
||||
// with a non-zero dropped count.
|
||||
@@ -415,12 +317,12 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
// A unit that decrypted is no longer sync-destroyed; a CPI-clear or
|
||||
// non-content unit is gated out. Only undecryptable in-content units
|
||||
// that are flagged encrypted carry signal.
|
||||
if !in_content || !crate::aacs::aacs_unit_needs_decrypt(chunk) {
|
||||
if !in_content || !crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
continue;
|
||||
}
|
||||
let all_zero = chunk.iter().all(|&b| b == 0);
|
||||
let ts_sync = crate::aacs::ts_sync_count(chunk);
|
||||
let ts_total = crate::aacs::ts_packet_total(chunk);
|
||||
let ts_sync = crate::aacs::content::ts_sync_count(chunk);
|
||||
let ts_total = crate::aacs::content::ts_packet_total(chunk);
|
||||
let mut seen = [false; 256];
|
||||
for &b in chunk {
|
||||
seen[b as usize] = true;
|
||||
@@ -431,10 +333,10 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
for (_, k) in unit_keys.iter() {
|
||||
let mut attempt = chunk.to_vec();
|
||||
if let Some(ref rdk_key) = rdk {
|
||||
crate::aacs::decrypt_bus(&mut attempt, rdk_key);
|
||||
crate::aacs::content::decrypt_bus(&mut attempt, rdk_key);
|
||||
}
|
||||
crate::aacs::decrypt_unit(&mut attempt, k);
|
||||
let s = crate::aacs::ts_sync_count(&attempt);
|
||||
crate::aacs::content::decrypt_unit(&mut attempt, k);
|
||||
let s = crate::aacs::content::ts_sync_count(&attempt);
|
||||
if s > best_sync {
|
||||
best_sync = s;
|
||||
}
|
||||
@@ -497,7 +399,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// units (else its readable units are wrongly rejected → "Decryption
|
||||
// failed" on exactly those titles).
|
||||
if matches!(self.keys, DecryptKeys::Aacs { .. })
|
||||
&& !crate::aacs::is_unit_aligned(lba, self.unit_base)
|
||||
&& !crate::aacs::content::is_unit_aligned(lba, self.unit_base)
|
||||
{
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
@@ -521,8 +423,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// recover a unit no held key opened.
|
||||
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
|
||||
let content_ref = content.as_deref();
|
||||
// Whether a fresh-key fetch is still worth attempting on this decorator.
|
||||
let fetch_viable = self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
|
||||
// Copy out the small Copy fields the seam needs, so the `&mut self.recovery`
|
||||
// borrow below does not collide with reads of other `self` fields. The
|
||||
// recovery closure self-limits (its budget lives in its captures), so the
|
||||
// decorator simply calls it whenever there is a miss.
|
||||
let unit_key_idx = self.unit_key_idx;
|
||||
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
|
||||
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
|
||||
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
|
||||
@@ -537,11 +442,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// * NORMAL (mux / --no-raw): decrypt `buf` in place, same retry.
|
||||
// The fetch re-decrypt targets the post-decrypt buffer (scratch / buf),
|
||||
// whose still-scrambled units ARE the failures.
|
||||
let dropped = if self.verify_only {
|
||||
let outcome = if self.verify_only {
|
||||
let mut scratch = std::mem::take(&mut self.scratch);
|
||||
scratch.clear();
|
||||
scratch.extend_from_slice(&buf[..n]);
|
||||
let mut d = match Self::decrypt_buf(
|
||||
let d = match Self::decrypt_buf(
|
||||
&mut scratch,
|
||||
&mut self.keys,
|
||||
self.unit_key_idx,
|
||||
@@ -554,24 +459,45 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if d > 0 && fetch_viable {
|
||||
d = self.fetch_failed_units(&mut scratch, lba, content_ref, d);
|
||||
}
|
||||
let o = match (d, self.recovery.as_mut()) {
|
||||
(0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d },
|
||||
(d, Some(r)) => {
|
||||
let rctx = crate::sector::recovery::RecoverCtx {
|
||||
unit_key_idx,
|
||||
lba,
|
||||
content: content.clone(),
|
||||
prev_dropped: d,
|
||||
};
|
||||
r(&mut scratch, &mut self.keys, &rctx)
|
||||
}
|
||||
};
|
||||
self.scratch = scratch;
|
||||
d
|
||||
o
|
||||
} else {
|
||||
let mut d = Self::decrypt_buf(
|
||||
let d = Self::decrypt_buf(
|
||||
&mut buf[..n],
|
||||
&mut self.keys,
|
||||
self.unit_key_idx,
|
||||
lba,
|
||||
content_ref,
|
||||
)?;
|
||||
if d > 0 && fetch_viable {
|
||||
d = self.fetch_failed_units(&mut buf[..n], lba, content_ref, d);
|
||||
match (d, self.recovery.as_mut()) {
|
||||
(0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d },
|
||||
(d, Some(r)) => {
|
||||
let rctx = crate::sector::recovery::RecoverCtx {
|
||||
unit_key_idx,
|
||||
lba,
|
||||
content: content.clone(),
|
||||
prev_dropped: d,
|
||||
};
|
||||
r(&mut buf[..n], &mut self.keys, &rctx)
|
||||
}
|
||||
}
|
||||
d
|
||||
};
|
||||
// A loss is a loss: whatever recovery could not decrypt (a missing unit
|
||||
// key, or an AACS 2.1 forensic-segment unit with no variant key — same
|
||||
// thing to the read path) is concealed and counted the same way.
|
||||
let dropped = outcome.dropped;
|
||||
if dropped > 0 {
|
||||
self.decrypt_dropped
|
||||
.fetch_add(dropped as u64, Ordering::Relaxed);
|
||||
@@ -585,7 +511,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// rip stays fail-loud. Ciphertext is never passed downstream: it is
|
||||
// replaced by null packets, not emitted.
|
||||
if self.tolerate_decrypt_loss && !self.verify_only {
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
let mut concealed = 0usize;
|
||||
let mut first_lba = lba;
|
||||
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
|
||||
@@ -604,11 +530,12 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// un-restored ciphertext. In-content gating already happened in
|
||||
// `decrypt_buf`, which restored only failed units to ciphertext;
|
||||
// clear nav and decrypted tails pass through clean.
|
||||
if crate::aacs::aacs_unit_still_ciphertext(chunk) {
|
||||
if crate::aacs::content::aacs_unit_still_ciphertext(chunk) {
|
||||
if concealed == 0 {
|
||||
first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS;
|
||||
first_lba =
|
||||
lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS;
|
||||
}
|
||||
crate::aacs::fill_null_ts_unit(chunk);
|
||||
crate::aacs::content::fill_null_ts_unit(chunk);
|
||||
concealed += 1;
|
||||
}
|
||||
}
|
||||
@@ -631,8 +558,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// flags, loudly. Cryptographically unreachable in practice.
|
||||
let mut forced = 0usize;
|
||||
for chunk in buf[..n].chunks_mut(unit_len) {
|
||||
if chunk.len() == unit_len && crate::aacs::aacs_unit_needs_decrypt(chunk) {
|
||||
crate::aacs::fill_null_ts_unit(chunk);
|
||||
if chunk.len() == unit_len
|
||||
&& crate::aacs::content::aacs_unit_needs_decrypt(chunk)
|
||||
{
|
||||
crate::aacs::content::fill_null_ts_unit(chunk);
|
||||
forced += 1;
|
||||
}
|
||||
}
|
||||
@@ -653,6 +582,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
// fails (no clean data to mux). Scheme-agnostic (only AACS reaches a
|
||||
// non-zero count); clear filesystem (gated out) and zero-fill (not
|
||||
// scrambled) never get here.
|
||||
//
|
||||
// An undecryptable unit is an undecryptable unit whatever the scheme —
|
||||
// a missing unit key or an AACS 2.1 forensic-segment unit with no
|
||||
// variant key both land here and fail the verify read the same way.
|
||||
// (`dropped > 0` already holds inside the enclosing block.)
|
||||
if DECRYPT_VERIFY_READ {
|
||||
// FACT-FINDING: on a fresh rip these bytes came straight off the
|
||||
// drive, so each failing unit's signature (all-zero? entropy?
|
||||
@@ -1253,12 +1187,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Build a clear 6144-byte AACS unit (TS syncs at the BD-TS stride) then
|
||||
/// encrypt it under `unit_key` so `aacs::decrypt_unit` recovers it. Mirrors
|
||||
/// encrypt it under `unit_key` so `aacs::content::decrypt_unit` recovers it. Mirrors
|
||||
/// the encrypt helper in `crate::decrypt`'s tests.
|
||||
fn encrypt_aacs_unit(unit_key: &[u8; 16]) -> Vec<u8> {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < unit.len() {
|
||||
unit[off] = 0x47;
|
||||
@@ -1267,14 +1201,14 @@ mod tests {
|
||||
// CPI bits on byte 0 so it reads as encrypted; set before key derivation.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = crate::aacs::decrypt::AACS_IV;
|
||||
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
let mut prev = crate::aacs::crypto::AACS_IV;
|
||||
let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..blocks {
|
||||
let o = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
@@ -1347,7 +1281,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
|
||||
"the undecryptable unit is tallied as loss before the read errors"
|
||||
);
|
||||
|
||||
@@ -1358,7 +1292,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
2 * crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||
2 * crate::aacs::content::ALIGNED_UNIT_LEN as u64,
|
||||
"loss must accumulate across reads"
|
||||
);
|
||||
|
||||
@@ -1392,7 +1326,7 @@ mod tests {
|
||||
// One unit encrypted under real_key, plus one trailing CLEAR (TS-sync)
|
||||
// unit so we can confirm conceal touches ONLY the undecryptable unit.
|
||||
let enc = encrypt_aacs_unit(&real_key);
|
||||
let mut clear = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let mut clear = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut o = 4;
|
||||
while o < clear.len() {
|
||||
clear[o] = 0x47;
|
||||
@@ -1441,13 +1375,13 @@ mod tests {
|
||||
// The undecryptable unit is tallied as loss.
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
|
||||
"the concealed unit is still counted as loss"
|
||||
);
|
||||
|
||||
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
|
||||
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
|
||||
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 0;
|
||||
while off + 192 <= unit0.len() {
|
||||
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
|
||||
@@ -1456,12 +1390,13 @@ mod tests {
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!crate::aacs::ts_sync_destroyed(unit0),
|
||||
!crate::aacs::content::ts_sync_destroyed(unit0),
|
||||
"concealed unit reads as well-formed TS, not scrambled"
|
||||
);
|
||||
|
||||
// Unit 1 (clear) passed through untouched.
|
||||
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let unit1 = &buf
|
||||
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
|
||||
}
|
||||
|
||||
@@ -1496,7 +1431,7 @@ mod tests {
|
||||
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
|
||||
let mut expected_tail = good_tail.clone();
|
||||
assert!(
|
||||
crate::aacs::decrypt_unit(&mut expected_tail, &good_key),
|
||||
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key),
|
||||
"padding-tail must decrypt under good_key"
|
||||
);
|
||||
|
||||
@@ -1542,12 +1477,12 @@ mod tests {
|
||||
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
|
||||
"exactly one unit (the undecryptable one) is counted as loss"
|
||||
);
|
||||
|
||||
// Unit A → NULL TS (concealed).
|
||||
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 0;
|
||||
while off + 192 <= unit0.len() {
|
||||
assert_eq!(unit0[off + 4], 0x47, "unit A null packet sync at {off}");
|
||||
@@ -1562,7 +1497,8 @@ mod tests {
|
||||
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact (NOT
|
||||
// overwritten with NULL TS). This is the silent-data-loss the old
|
||||
// majority-vote predicate caused.
|
||||
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let unit1 = &buf
|
||||
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
assert_eq!(
|
||||
unit1,
|
||||
&expected_tail[..],
|
||||
@@ -1587,8 +1523,8 @@ mod tests {
|
||||
/// `mux::ts` reads as a concealed gap.
|
||||
#[test]
|
||||
fn null_ts_fill_is_well_formed_and_invisible_to_real_pids() {
|
||||
let mut unit = vec![0xAAu8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
crate::aacs::fill_null_ts_unit(&mut unit);
|
||||
let mut unit = vec![0xAAu8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
crate::aacs::content::fill_null_ts_unit(&mut unit);
|
||||
// 32 packets, each: sync 0x47, PID 0x1FFF, adaptation-only (0b10) with a
|
||||
// discontinuity_indicator in the adaptation field.
|
||||
let mut off = 0;
|
||||
@@ -1679,7 +1615,7 @@ mod tests {
|
||||
"callback must be invoked once with the failing unit"
|
||||
);
|
||||
assert!(
|
||||
crate::aacs::ts_sync_destroyed(&got[0]),
|
||||
crate::aacs::content::ts_sync_destroyed(&got[0]),
|
||||
"the sample handed to the callback is the still-scrambled ciphertext"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1703,7 +1639,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
nocb_loss.load(Ordering::Relaxed),
|
||||
crate::aacs::ALIGNED_UNIT_LEN as u64,
|
||||
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
|
||||
"without a fetch callback the undecryptable unit is loss"
|
||||
);
|
||||
}
|
||||
@@ -1784,7 +1720,7 @@ mod tests {
|
||||
"fetch fired for BOTH units — the dry result for A did not latch off B"
|
||||
);
|
||||
assert!(
|
||||
!crate::aacs::ts_sync_destroyed(&buf2),
|
||||
!crate::aacs::content::ts_sync_destroyed(&buf2),
|
||||
"unit B is decrypted after its on-demand fetch"
|
||||
);
|
||||
}
|
||||
@@ -1899,7 +1835,7 @@ mod tests {
|
||||
let mut u = 0;
|
||||
while u < bytes {
|
||||
buf[u] |= 0xC0;
|
||||
u += crate::aacs::ALIGNED_UNIT_LEN;
|
||||
u += crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -1998,7 +1934,7 @@ mod tests {
|
||||
let mut u = 0;
|
||||
while u < b {
|
||||
buf[u] |= 0xC0; // CPI bits → reads as encrypted
|
||||
u += crate::aacs::ALIGNED_UNIT_LEN;
|
||||
u += crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
pub mod decrypting;
|
||||
pub mod file;
|
||||
pub mod prefetched;
|
||||
pub mod recovery;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
|
||||
@@ -358,7 +358,6 @@ impl PrefetchedSectorSource {
|
||||
// and suppresses `self`'s own `Drop` (which would otherwise
|
||||
// double-`join`), leaving NO extra live endpoint behind. This
|
||||
// is the panic-free equivalent of the `Option::take` approach.
|
||||
let total = self.total_sectors;
|
||||
let me = std::mem::ManuallyDrop::new(self);
|
||||
// SAFETY: `me` is `ManuallyDrop`, so none of these fields will
|
||||
// be dropped by `me`. Each `ptr::read` performs exactly one
|
||||
@@ -367,7 +366,7 @@ impl PrefetchedSectorSource {
|
||||
let producer = unsafe { std::ptr::read(&me.producer) };
|
||||
let rx = unsafe { std::ptr::read(&me.rx) };
|
||||
let recycle = unsafe { std::ptr::read(&me.recycle_tx) };
|
||||
(rx, recycle, PrefetchShell { producer, total })
|
||||
(rx, recycle, PrefetchShell { producer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,8 +375,6 @@ impl PrefetchedSectorSource {
|
||||
/// producer, even though the channels have been peeled off.
|
||||
pub struct PrefetchShell {
|
||||
producer: Option<JoinHandle<()>>,
|
||||
#[allow(dead_code)]
|
||||
total: u32,
|
||||
}
|
||||
|
||||
impl Drop for PrefetchShell {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! The recovery seam: what a read does when a content unit will not decrypt.
|
||||
//!
|
||||
//! Per-format miss policy does NOT belong in the generic decrypt decorator
|
||||
//! (L2). The input stream (L3, e.g. [`crate::mux::disc::DiscStream`]) knows what
|
||||
//! it is reading and installs a [`Recover`] at construction; the decorator
|
||||
//! executes it at the one seam and honours the returned outcome. This keeps
|
||||
//! "a DVD re-cracks, a BD/UHD fetches a fresh key" out of the decryptor, where
|
||||
//! it would otherwise smear across `if`-branches.
|
||||
//!
|
||||
//! The recovery type ([`Recover`]) names **no encryption scheme**. It is a
|
||||
//! generic `FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome` that
|
||||
//! operates on the generic [`DecryptKeys`] the whole decrypt path already uses,
|
||||
//! so a scheme is never baked into the type — only into the factory that builds
|
||||
//! a recovery:
|
||||
//! * [`none`] — no recovery; a miss is loss (raw sweep / clear).
|
||||
//! * [`key_fetch`] — AACS key-fetch: hand the failing ciphertext to the
|
||||
//! application's key source and add any returned keys to the pool. An AACS
|
||||
//! 2.1 forensic-segment unit that no key opens is just an undecryptable unit
|
||||
//! like any other — a loss is a loss, with no FMTS-specific branch here.
|
||||
//!
|
||||
//! CSS is deliberately NOT on this seam — and the reason is precise: this seam is
|
||||
//! for recovery that needs something `decrypt_sectors` does not have (an EXTERNAL
|
||||
//! key source for AACS, a segment map for FMTS). CSS's title key changes per VOB
|
||||
//! region and is re-cracked constantly, but always FROM THE DATA ITSELF — no
|
||||
//! external input — so CSS SELF-recovers inside `decrypt_sectors` (see
|
||||
//! [`crate::css::descramble_region`]). The generic type here would accept a CSS
|
||||
//! recovery, but CSS has no reason to use it.
|
||||
|
||||
use crate::decrypt::DecryptKeys;
|
||||
use crate::sector::KeyFetch;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The result of running a recovery on a read's still-scrambled units: how many
|
||||
/// bytes remain loss after recovery ran. A loss is a loss — an undecryptable
|
||||
/// unit is concealed and counted the same whatever the scheme (an AACS 2.1
|
||||
/// forensic-segment unit with no variant key is just another undecryptable
|
||||
/// unit).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct MissOutcome {
|
||||
/// Bytes that remain loss after recovery.
|
||||
pub dropped: usize,
|
||||
}
|
||||
|
||||
impl MissOutcome {
|
||||
/// All `n` bytes are loss.
|
||||
fn loss(n: usize) -> Self {
|
||||
Self { dropped: n }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap on how many times one recovery will call its fetch closure over its
|
||||
/// lifetime — bounds key-server traffic to ~O(distinct CPS units) even if
|
||||
/// scrambled units keep arriving. A disc has only a handful of unit keys.
|
||||
const MAX_FETCH_CALLS: usize = 16;
|
||||
|
||||
/// Cap on how many still-scrambled sample units are handed to the fetch closure
|
||||
/// per call — a few samples suffice for a key service to identify and validate
|
||||
/// the key, and it bounds the request size.
|
||||
const MAX_FETCH_SAMPLES: usize = 8;
|
||||
|
||||
/// Stable per-run fingerprint of a failing unit's ciphertext, for the dedup set.
|
||||
/// `DefaultHasher` is fixed-seed, so equal samples map to equal fingerprints
|
||||
/// within a process — all the dedup needs.
|
||||
fn sample_fp(sample: &[u8]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
sample.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Re-decrypt `buf` after the key pool grew, content-gated identically to the
|
||||
/// first read so a non-content unit is never re-attempted. Mirrors the
|
||||
/// decorator's `decrypt_buf` dispatch.
|
||||
fn redecrypt(
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
unit_key_idx: usize,
|
||||
lba: u32,
|
||||
content: Option<&[(u32, u32)]>,
|
||||
prev_dropped: usize,
|
||||
) -> usize {
|
||||
match content {
|
||||
Some(ranges) => {
|
||||
crate::decrypt::decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges)
|
||||
}
|
||||
None => crate::decrypt::decrypt_sectors(buf, keys, unit_key_idx),
|
||||
}
|
||||
.unwrap_or(prev_dropped)
|
||||
}
|
||||
|
||||
/// What a read hands a recovery on a miss: the disc's decrypt parameters and how
|
||||
/// many bytes the held keys could not decrypt. Scheme-neutral — a recovery reads
|
||||
/// only the generic [`DecryptKeys`] and these fields.
|
||||
pub struct RecoverCtx {
|
||||
/// Which AACS unit-key index the read decrypts with (ignored by non-AACS).
|
||||
pub unit_key_idx: usize,
|
||||
/// Base LBA of the read.
|
||||
pub lba: u32,
|
||||
/// The encrypted-content extent map, when the read is content-gated.
|
||||
pub content: Option<Arc<[(u32, u32)]>>,
|
||||
/// Bytes the held keys could not decrypt before recovery ran.
|
||||
pub prev_dropped: usize,
|
||||
}
|
||||
|
||||
/// A recovery: given a read's still-scrambled `buf` and the **generic**
|
||||
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
|
||||
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
|
||||
/// — the installed recovery decides what to do with the generic keys, so any
|
||||
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
|
||||
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
|
||||
/// state (the AACS dedup set / call budget) lives in the closure's captures with
|
||||
/// no lock; `Send` so it can ride the mux highway's producer thread.
|
||||
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
|
||||
|
||||
/// The AACS key-fetch step used by [`key_fetch`]: gather the
|
||||
/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and
|
||||
/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget.
|
||||
/// Returns the post-retry dropped-byte count.
|
||||
fn aacs_fetch_step(
|
||||
dry: &mut HashSet<u64>,
|
||||
calls: &mut usize,
|
||||
fetch: &KeyFetch,
|
||||
buf: &mut [u8],
|
||||
keys: &mut DecryptKeys,
|
||||
ctx: &RecoverCtx,
|
||||
) -> usize {
|
||||
let prev_dropped = ctx.prev_dropped;
|
||||
if *calls >= MAX_FETCH_CALLS {
|
||||
return prev_dropped;
|
||||
}
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
|
||||
// on-disc ciphertext no held key could open. A trailing partial unit
|
||||
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
|
||||
// correct.
|
||||
let mut samples: Vec<Vec<u8>> = Vec::new();
|
||||
for chunk in buf.chunks_exact(unit_len) {
|
||||
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
samples.push(chunk.to_vec());
|
||||
if samples.len() >= MAX_FETCH_SAMPLES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if samples.is_empty() {
|
||||
return prev_dropped;
|
||||
}
|
||||
// Skip the call when EVERY failing unit here is one a prior fetch already
|
||||
// came back empty for — re-asking identical ciphertext only burns a request.
|
||||
// A unit not asked about yet (e.g. a second CPS unit) still gets its chance.
|
||||
let fps: Vec<u64> = samples.iter().map(|s| sample_fp(s)).collect();
|
||||
if fps.iter().all(|fp| dry.contains(fp)) {
|
||||
return prev_dropped;
|
||||
}
|
||||
*calls += 1;
|
||||
let fresh = (fetch)(&samples);
|
||||
// Add only keys we don't already hold (dedup by value).
|
||||
let mut added = 0usize;
|
||||
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||
for k in fresh {
|
||||
if !unit_keys.iter().any(|(_, have)| *have == k) {
|
||||
let idx = unit_keys.len() as u32;
|
||||
unit_keys.push((idx, k));
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added == 0 {
|
||||
// Nothing new for THESE units — remember them so we don't re-ask the same
|
||||
// ciphertext, but leave the door open for other units.
|
||||
dry.extend(fps);
|
||||
return prev_dropped;
|
||||
}
|
||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
||||
// genuine loss. A retry error must not mask the original count.
|
||||
redecrypt(
|
||||
buf,
|
||||
keys,
|
||||
ctx.unit_key_idx,
|
||||
ctx.lba,
|
||||
ctx.content.as_deref(),
|
||||
prev_dropped,
|
||||
)
|
||||
}
|
||||
|
||||
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
|
||||
/// caller that wants an explicit "give up" recovery has one.
|
||||
pub fn none() -> Recover {
|
||||
Box::new(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
|
||||
}
|
||||
|
||||
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
|
||||
/// source for a key that opens the failing ciphertext and add it to the pool.
|
||||
pub fn key_fetch(fetch: KeyFetch) -> Recover {
|
||||
let mut dry: HashSet<u64> = HashSet::new();
|
||||
let mut calls: usize = 0;
|
||||
Box::new(move |buf, keys, ctx| {
|
||||
MissOutcome::loss(aacs_fetch_step(
|
||||
&mut dry, &mut calls, &fetch, buf, keys, ctx,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A 6144-byte aligned unit that reads as still-scrambled: CPI bits set on
|
||||
/// byte 0 (so `aacs_unit_encrypted` flags it) and every 192-byte TS-sync
|
||||
/// probe position forced off 0x47. `tag` varies the whole body so distinct
|
||||
/// tags produce distinct fingerprints (mirrors decrypt.rs `scrambled_region`).
|
||||
fn scrambled_unit(tag: u8) -> Vec<u8> {
|
||||
let len = ALIGNED_UNIT_LEN;
|
||||
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ tag).collect();
|
||||
let mut off = 4;
|
||||
while off < len {
|
||||
v[off] = 0xA5; // never a 0x47 sync
|
||||
off += 192;
|
||||
}
|
||||
v[0] |= 0xC0; // CPI: reads as encrypted content
|
||||
v
|
||||
}
|
||||
|
||||
/// A recovery context reading at clip-relative `lba` with `prev` bytes the
|
||||
/// held keys could not decrypt.
|
||||
fn ctx(lba: u32, prev: usize) -> RecoverCtx {
|
||||
RecoverCtx {
|
||||
unit_key_idx: 0,
|
||||
lba,
|
||||
content: None,
|
||||
prev_dropped: prev,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_recovers_nothing() {
|
||||
let mut r = none();
|
||||
let mut buf = scrambled_unit(0x33);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
|
||||
assert_eq!(out.dropped, 6144);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_fetch_adds_returned_keys_to_the_pool() {
|
||||
// The fetch returns one key; it must be appended to the (empty) pool. We
|
||||
// assert the pool grew (the decrypt itself is exercised end-to-end by the
|
||||
// decorator's integration tests); here we pin the seam's key-plumbing.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = Arc::clone(&calls);
|
||||
let fetch: KeyFetch = Arc::new(move |samples: &[Vec<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
assert!(!samples.is_empty(), "failing ciphertext is forwarded");
|
||||
vec![[0xAB; 16]]
|
||||
});
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut buf = scrambled_unit(0x33);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
|
||||
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(unit_keys.len(), 1, "returned key added to the pool");
|
||||
assert_eq!(unit_keys[0].1, [0xAB; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_fetch_does_not_re_ask_dry_ciphertext() {
|
||||
// A fetch that returns nothing marks the ciphertext dry; a second miss on
|
||||
// the SAME ciphertext must not call the fetch again.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = Arc::clone(&calls);
|
||||
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
Vec::new() // never helps
|
||||
});
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = scrambled_unit(0x44);
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
|
||||
r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"identical dry ciphertext is not re-asked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_fetch_call_budget_bounds_fetches() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = Arc::clone(&calls);
|
||||
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
Vec::new()
|
||||
});
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
// Distinct ciphertext each time so the dry-set never short-circuits; only
|
||||
// the internal call budget should stop the fetch. The closure self-limits,
|
||||
// so the decorator can call it unconditionally.
|
||||
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
|
||||
let mut buf = scrambled_unit(i);
|
||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||
}
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
MAX_FETCH_CALLS,
|
||||
"fetch is capped at MAX_FETCH_CALLS"
|
||||
);
|
||||
}
|
||||
}
|
||||
+221
-47
@@ -325,53 +325,6 @@ impl UdfFs {
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// All sector ranges that contain data (metadata + all files including STREAM).
|
||||
/// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps.
|
||||
pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure sectors
|
||||
let meta_end = self.metadata_start.saturating_add(self.metadata_sectors);
|
||||
ranges.push((0, meta_end));
|
||||
|
||||
// Walk entire tree including STREAM directories
|
||||
self.collect_all_file_ranges(reader, &self.root, &mut ranges)?;
|
||||
|
||||
// Merge overlapping/adjacent ranges and sort
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
let merged = merge_ranges(&ranges);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_all_file_ranges(
|
||||
&self,
|
||||
reader: &mut dyn SectorSource,
|
||||
entry: &DirEntry,
|
||||
ranges: &mut Vec<(u32, u32)>,
|
||||
) -> Result<()> {
|
||||
for child in &entry.entries {
|
||||
if child.is_dir {
|
||||
self.collect_all_file_ranges(reader, child, ranges)?;
|
||||
} else {
|
||||
// Include the ICB sector
|
||||
ranges.push((self.meta_to_abs(child.meta_lba)?, 1));
|
||||
|
||||
// Include ALL file data extents (large m2ts files have many)
|
||||
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
|
||||
for (data_lba, data_len) in extents {
|
||||
let abs_start = match self.partition_start.checked_add(data_lba) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
let sector_count = (data_len as u64).div_ceil(2048) as u32;
|
||||
ranges.push((abs_start, sector_count));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_file_ranges(
|
||||
&self,
|
||||
reader: &mut dyn SectorSource,
|
||||
@@ -2391,3 +2344,224 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared UDF image fixtures for tests across the `disc::*` format scanners.
|
||||
///
|
||||
/// Builds an in-memory disc image that [`read_filesystem`] can navigate — a
|
||||
/// [`MemDisc`] SectorSource plus a [`DirSpec`] tree laid out via [`lay_dir`] and
|
||||
/// [`build_udf_skeleton`]. Format-agnostic: BD (`bluray.rs`), HD-DVD
|
||||
/// (`hddvd.rs`), and the format detector (`disc/mod.rs`) all build their own
|
||||
/// trees (`BDMV/`, `HVDVD_TS/`, `AACS/…`) on top of these primitives, so each
|
||||
/// format's tests live in that format's file, not piled into one.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fixture {
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// PART_START == META_START: file LBAs (partition-relative) and ICB/dir LBAs
|
||||
/// (metadata-relative) share one address space (abs = PART_START + lba), so
|
||||
/// `read_filesystem` takes the single-partition path.
|
||||
pub(crate) const PART_START: u32 = 2000;
|
||||
|
||||
/// In-memory `SectorSource` (absolute-LBA → 2048-byte sector map); unmapped
|
||||
/// sectors read as zeroes.
|
||||
pub(crate) struct MemDisc {
|
||||
sectors: HashMap<u32, [u8; 2048]>,
|
||||
}
|
||||
|
||||
impl MemDisc {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
sectors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||
self.sectors.insert(lba, data);
|
||||
}
|
||||
/// Write arbitrary-length bytes at `lba`, split across 2048-byte sectors.
|
||||
pub(crate) fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
|
||||
for (i, chunk) in bytes.chunks(2048).enumerate() {
|
||||
let mut s = [0u8; 2048];
|
||||
s[..chunk.len()].copy_from_slice(chunk);
|
||||
self.put(lba + i as u32, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for MemDisc {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let need = count as usize * 2048;
|
||||
for i in 0..count as u32 {
|
||||
let off = i as usize * 2048;
|
||||
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
||||
buf[off..off + 2048].copy_from_slice(&s);
|
||||
}
|
||||
Ok(need)
|
||||
}
|
||||
}
|
||||
|
||||
/// One file's placement: ICB metadata LBA, data-extent LBA, byte length,
|
||||
/// Long-AD (16-byte, real BD-ROM layout) vs Short-AD, optional contents.
|
||||
pub(crate) struct FileSpec {
|
||||
pub(crate) name: String,
|
||||
pub(crate) icb_lba: u32,
|
||||
pub(crate) data_lba: u32,
|
||||
pub(crate) size: u32,
|
||||
pub(crate) long_ad: bool,
|
||||
pub(crate) contents: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A directory node: ICB LBA, FID-list LBA, child files and subdirectories.
|
||||
pub(crate) struct DirSpec {
|
||||
pub(crate) name: String,
|
||||
pub(crate) icb_lba: u32,
|
||||
pub(crate) dir_data_lba: u32,
|
||||
pub(crate) files: Vec<FileSpec>,
|
||||
pub(crate) subdirs: Vec<DirSpec>,
|
||||
}
|
||||
|
||||
/// Build an Extended File Entry ICB (tag 266) with one allocation descriptor.
|
||||
pub(crate) fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
|
||||
let mut s = [0u8; 2048];
|
||||
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
|
||||
if long_ad {
|
||||
s[34..36].copy_from_slice(&1u16.to_le_bytes()); // ICB flags → Long AD
|
||||
}
|
||||
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
|
||||
let ad_size: u32 = if long_ad { 16 } else { 8 };
|
||||
s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad
|
||||
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
|
||||
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
|
||||
build_file_icb(dir_data_len, dir_data_lba, false)
|
||||
}
|
||||
|
||||
/// Append one File Identifier Descriptor (tag 257) to `buf`.
|
||||
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||
let start = buf.len();
|
||||
let name_field: Vec<u8> = if is_parent {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut v = vec![0x08u8];
|
||||
v.extend_from_slice(name.as_bytes());
|
||||
v
|
||||
};
|
||||
let l_fi = name_field.len();
|
||||
let mut fid = vec![0u8; 38];
|
||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
|
||||
let mut file_chars = 0u8;
|
||||
if is_dir {
|
||||
file_chars |= 0x02;
|
||||
}
|
||||
if is_parent {
|
||||
file_chars |= 0x08;
|
||||
}
|
||||
fid[18] = file_chars;
|
||||
fid[19] = l_fi as u8;
|
||||
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes()); // ICB long_ad LBA @24
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes()); // l_iu @36
|
||||
buf.extend_from_slice(&fid);
|
||||
buf.extend_from_slice(&name_field);
|
||||
let used = buf.len() - start;
|
||||
let pad = (used + 3) & !3;
|
||||
buf.resize(start + pad, 0);
|
||||
}
|
||||
|
||||
/// Recursively lay a [`DirSpec`] into the [`MemDisc`].
|
||||
pub(crate) fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
|
||||
let mut fids = Vec::new();
|
||||
push_fid(&mut fids, "", dir.icb_lba, true, true);
|
||||
for f in &dir.files {
|
||||
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
|
||||
disc.put(
|
||||
PART_START + f.icb_lba,
|
||||
build_file_icb(f.size, f.data_lba, f.long_ad),
|
||||
);
|
||||
if !f.contents.is_empty() {
|
||||
disc.put_bytes(PART_START + f.data_lba, &f.contents);
|
||||
}
|
||||
}
|
||||
for sub in &dir.subdirs {
|
||||
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
|
||||
}
|
||||
disc.put(
|
||||
PART_START + dir.icb_lba,
|
||||
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
|
||||
);
|
||||
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
|
||||
for sub in &dir.subdirs {
|
||||
lay_dir(disc, sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the static UDF anchor/VDS/FSD so `read_filesystem` reaches
|
||||
/// `root_icb_lba` (single partition map → metadata_start == PART_START).
|
||||
pub(crate) fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
|
||||
let mut avdp = [0u8; 2048];
|
||||
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
disc.put(256, avdp);
|
||||
|
||||
let mut pd = [0u8; 2048];
|
||||
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
|
||||
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
|
||||
disc.put(32, pd);
|
||||
|
||||
let mut lvd = [0u8; 2048];
|
||||
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||
disc.put(33, lvd);
|
||||
|
||||
let mut td = [0u8; 2048];
|
||||
td[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||
disc.put(34, td);
|
||||
|
||||
let mut fsd = [0u8; 2048];
|
||||
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
|
||||
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
|
||||
disc.put(PART_START, fsd);
|
||||
}
|
||||
|
||||
pub(crate) fn file(
|
||||
name: &str,
|
||||
icb_lba: u32,
|
||||
data_lba: u32,
|
||||
size: u32,
|
||||
long_ad: bool,
|
||||
) -> FileSpec {
|
||||
FileSpec {
|
||||
name: name.to_string(),
|
||||
icb_lba,
|
||||
data_lba,
|
||||
size,
|
||||
long_ad,
|
||||
contents: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn file_with(
|
||||
name: &str,
|
||||
icb_lba: u32,
|
||||
data_lba: u32,
|
||||
contents: Vec<u8>,
|
||||
long_ad: bool,
|
||||
) -> FileSpec {
|
||||
FileSpec {
|
||||
name: name.to_string(),
|
||||
icb_lba,
|
||||
data_lba,
|
||||
size: contents.len() as u32,
|
||||
long_ad,
|
||||
contents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-12
@@ -9,6 +9,7 @@ use freemkv_unlock as fu;
|
||||
fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId {
|
||||
fu::DriveId {
|
||||
vendor_id: drive_id.vendor_id.clone(),
|
||||
product_id: drive_id.product_id.clone(),
|
||||
product_revision: drive_id.product_revision.clone(),
|
||||
vendor_specific: drive_id.vendor_specific.clone(),
|
||||
firmware_date: drive_id.firmware_date.clone(),
|
||||
@@ -89,7 +90,7 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
|
||||
}
|
||||
|
||||
/// Map libfreemkv's host certs (keysource-collected) to the unlock contract's.
|
||||
pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCert> {
|
||||
pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu::HostCert> {
|
||||
certs
|
||||
.iter()
|
||||
.map(|c| fu::HostCert {
|
||||
@@ -101,27 +102,70 @@ pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCer
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// News up the unlockers, build the context for `kind`, and run the FIRST
|
||||
/// matching one — returning its `Result` so the caller can both consume what it
|
||||
/// learned (vid / bus_key / drive_unlocked) AND render the specific failure
|
||||
/// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
|
||||
/// NotApplicable)` when nothing matched. `host_certs` are collected by the
|
||||
/// caller — lazily, only for AACS; pass `&[]` for the drive-prep / CSS kinds.
|
||||
pub(crate) fn run_unlockers(
|
||||
/// Result of a capability dispatch: `(matched_name, result)`. `matched_name` is
|
||||
/// the unlocker that handled it (or `""` if none did) — lets the caller record
|
||||
/// WHICH unlocker ran (e.g. `LibreDrive` vs `Renesas`), distinct from the ld-only
|
||||
/// identity lookup [`unlocker_name`]. Iterating stops at the first unlocker whose
|
||||
/// capability method returns anything other than `NotApplicable` — i.e. an actual
|
||||
/// unlock (`Ok`) OR a real failure such as a dead bus (`Err(Transport)`), which
|
||||
/// the caller must surface rather than skip.
|
||||
type Dispatch = (
|
||||
&'static str,
|
||||
std::result::Result<fu::Unlocked, fu::UnlockError>,
|
||||
);
|
||||
|
||||
/// Try each unlocker's `capability` (`unlock_features` or `unlock_bus`) in
|
||||
/// registration order, stopping at the first that doesn't decline. Shared by
|
||||
/// [`run_features`] and [`run_bus`].
|
||||
fn dispatch(
|
||||
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
kind: fu::DiscKind,
|
||||
host_certs: &[fu::HostCert],
|
||||
) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
|
||||
capability: impl Fn(
|
||||
&dyn fu::Unlocker,
|
||||
&mut dyn fu::scsi::ScsiTransport,
|
||||
&fu::UnlockCtx,
|
||||
) -> std::result::Result<fu::Unlocked, fu::UnlockError>,
|
||||
) -> Dispatch {
|
||||
let id = to_fu_drive_id(drive_id);
|
||||
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
|
||||
let mut adapter = ScsiAdapter(scsi);
|
||||
for u in fu::all_unlockers() {
|
||||
if u.matches(&ctx) {
|
||||
return u.unlock(&mut adapter, &ctx);
|
||||
match capability(u.as_ref(), &mut adapter, &ctx) {
|
||||
// This unlocker doesn't provide the capability for this drive/disc —
|
||||
// try the next one.
|
||||
Err(fu::UnlockError::NotApplicable) => continue,
|
||||
// An actual unlock, or a real failure (e.g. Transport) — stop here.
|
||||
other => return (u.name(), other),
|
||||
}
|
||||
}
|
||||
Err(fu::UnlockError::NotApplicable)
|
||||
("", Err(fu::UnlockError::NotApplicable))
|
||||
}
|
||||
|
||||
/// Drive-prep: unlock DRIVE FEATURES (riplock/speed, OEM VID). `host_certs` are
|
||||
/// not needed for features — pass `&[]`; `kind` is `Unknown` at drive-prep.
|
||||
pub(crate) fn run_features(
|
||||
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
) -> Dispatch {
|
||||
dispatch(scsi, drive_id, fu::DiscKind::Unknown, &[], |u, s, c| {
|
||||
u.unlock_features(s, c)
|
||||
})
|
||||
}
|
||||
|
||||
/// Content: remove BUS ENCRYPTION for the mounted disc. Called only when the bus
|
||||
/// isn't already clear (the `oem_vid`/`bus_encryption_removed` gate). `host_certs`
|
||||
/// are the caller-collected certs for the AACS route; `kind` selects Aacs vs Css.
|
||||
pub(crate) fn run_bus(
|
||||
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
kind: fu::DiscKind,
|
||||
host_certs: &[fu::HostCert],
|
||||
) -> Dispatch {
|
||||
dispatch(scsi, drive_id, kind, host_certs, |u, s, c| {
|
||||
u.unlock_bus(s, c)
|
||||
})
|
||||
}
|
||||
|
||||
/// The names of every REGISTERED unlocker, in dispatch order. Registry-driven —
|
||||
|
||||
+41
-41
@@ -84,9 +84,9 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
];
|
||||
|
||||
// Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
|
||||
let mut plain = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut plain = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut offset = 4;
|
||||
while offset < aacs::ALIGNED_UNIT_LEN {
|
||||
while offset < aacs::content::ALIGNED_UNIT_LEN {
|
||||
plain[offset] = 0x47; // TS sync byte
|
||||
offset += 192;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
// Step 3: AES-CBC encrypt bytes 16..6144
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
||||
let mut prev = aacs_iv;
|
||||
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
@@ -131,29 +131,29 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
}
|
||||
|
||||
// Verify it looks encrypted (body TS syncs scrambled)
|
||||
assert!(aacs::ts_sync_destroyed(&plain));
|
||||
assert!(aacs::content::ts_sync_destroyed(&plain));
|
||||
|
||||
// Now decrypt
|
||||
let result = aacs::decrypt_unit(&mut plain, &unit_key);
|
||||
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||
assert!(
|
||||
result,
|
||||
"decrypt_unit should return true on valid encrypted unit"
|
||||
);
|
||||
assert!(
|
||||
!aacs::ts_sync_destroyed(&plain),
|
||||
!aacs::content::ts_sync_destroyed(&plain),
|
||||
"decrypted unit should read as clear (TS syncs restored)"
|
||||
);
|
||||
|
||||
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
|
||||
let mut sync_count = 0;
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
if plain[off] == 0x47 {
|
||||
sync_count += 1;
|
||||
}
|
||||
off += 192;
|
||||
}
|
||||
let expected_syncs = (aacs::ALIGNED_UNIT_LEN - 4) / 192 + 1;
|
||||
let expected_syncs = (aacs::content::ALIGNED_UNIT_LEN - 4) / 192 + 1;
|
||||
assert_eq!(
|
||||
sync_count, expected_syncs,
|
||||
"TS sync bytes not recovered: got {}, expected {}",
|
||||
@@ -176,11 +176,11 @@ fn aacs_disc_hash_deterministic() {
|
||||
let data1 = b"Unit_Key_RO.inf test data for deterministic hashing";
|
||||
let data2 = b"Different data should produce different hash";
|
||||
|
||||
let hash1a = aacs::disc_hash(data1);
|
||||
let hash1b = aacs::disc_hash(data1);
|
||||
let hash1a = aacs::inf::disc_hash(data1);
|
||||
let hash1b = aacs::inf::disc_hash(data1);
|
||||
assert_eq!(hash1a, hash1b, "disc_hash not deterministic on same input");
|
||||
|
||||
let hash2 = aacs::disc_hash(data2);
|
||||
let hash2 = aacs::inf::disc_hash(data2);
|
||||
assert_ne!(
|
||||
hash1a, hash2,
|
||||
"different inputs should produce different hashes"
|
||||
@@ -190,7 +190,7 @@ fn aacs_disc_hash_deterministic() {
|
||||
assert_eq!(hash1a.len(), 20);
|
||||
|
||||
// Verify disc_hash_hex formatting
|
||||
let hex = aacs::disc_hash_hex(&hash1a);
|
||||
let hex = aacs::inf::disc_hash_hex(&hash1a);
|
||||
assert!(hex.starts_with("0x"), "hex should start with 0x prefix");
|
||||
assert_eq!(
|
||||
hex.len(),
|
||||
@@ -225,7 +225,7 @@ fn aacs_decrypt_unit_key_roundtrip() {
|
||||
encrypted_uk.copy_from_slice(&block);
|
||||
|
||||
// Decrypt with the public API
|
||||
let decrypted = aacs::decrypt_unit_key(&vuk, &encrypted_uk);
|
||||
let decrypted = aacs::derive::decrypt_unit_key(&vuk, &encrypted_uk);
|
||||
assert_eq!(
|
||||
decrypted, original_unit_key,
|
||||
"decrypt_unit_key did not recover original unit key"
|
||||
@@ -246,7 +246,7 @@ fn aacs_vuk_derivation_roundtrip() {
|
||||
0x30,
|
||||
];
|
||||
|
||||
let vuk = aacs::derive_vuk(&media_key, &volume_id);
|
||||
let vuk = aacs::derive::derive_vuk(&media_key, &volume_id);
|
||||
|
||||
// VUK should be non-zero and different from both inputs
|
||||
assert_ne!(vuk, [0u8; 16], "VUK should not be all zeros");
|
||||
@@ -254,7 +254,7 @@ fn aacs_vuk_derivation_roundtrip() {
|
||||
assert_ne!(vuk, volume_id, "VUK should differ from volume_id");
|
||||
|
||||
// Verify determinism
|
||||
let vuk2 = aacs::derive_vuk(&media_key, &volume_id);
|
||||
let vuk2 = aacs::derive::derive_vuk(&media_key, &volume_id);
|
||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||
}
|
||||
|
||||
@@ -263,14 +263,14 @@ fn aacs_vuk_derivation_roundtrip() {
|
||||
fn aacs_ts_sync_destroyed_detection() {
|
||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||
// scrambled. (Flag bits play no role.)
|
||||
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
clear[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!aacs::ts_sync_destroyed(&clear),
|
||||
!aacs::content::ts_sync_destroyed(&clear),
|
||||
"clear unit (syncs intact) must not be scrambled"
|
||||
);
|
||||
|
||||
@@ -279,21 +279,21 @@ fn aacs_ts_sync_destroyed_detection() {
|
||||
flagged[0] = 0xC0; // copy-control bits
|
||||
flagged[7] = 0xC0; // TSC bits
|
||||
assert!(
|
||||
!aacs::ts_sync_destroyed(&flagged),
|
||||
!aacs::content::ts_sync_destroyed(&flagged),
|
||||
"flag bits must not be read as encryption"
|
||||
);
|
||||
|
||||
// A scrambled body (syncs destroyed) → scrambled.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
assert!(
|
||||
aacs::ts_sync_destroyed(&scrambled),
|
||||
aacs::content::ts_sync_destroyed(&scrambled),
|
||||
"unit with no intact TS syncs must read as scrambled"
|
||||
);
|
||||
|
||||
// Too short
|
||||
let short = vec![0xFFu8; 100];
|
||||
assert!(
|
||||
!aacs::ts_sync_destroyed(&short),
|
||||
!aacs::content::ts_sync_destroyed(&short),
|
||||
"short buffer should not be detected"
|
||||
);
|
||||
}
|
||||
@@ -303,10 +303,10 @@ fn aacs_ts_sync_destroyed_detection() {
|
||||
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
@@ -315,8 +315,8 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
let original = unit.clone();
|
||||
let key = [0xAA; 16];
|
||||
|
||||
assert!(!aacs::ts_sync_destroyed(&unit));
|
||||
let result = aacs::decrypt_unit(&mut unit, &key);
|
||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
||||
let result = aacs::content::decrypt_unit(&mut unit, &key);
|
||||
assert!(result, "clear unit should return true");
|
||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
||||
}
|
||||
@@ -370,17 +370,17 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
0x10,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
// TS sync bytes every 192 bytes starting at offset 4
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
plaintext[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts).
|
||||
// Index-arithmetic — clearer with a counted loop than an enumerate chain.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 16..aacs::ALIGNED_UNIT_LEN {
|
||||
for i in 16..aacs::content::ALIGNED_UNIT_LEN {
|
||||
if plaintext[i] == 0 {
|
||||
plaintext[i] = (i % 251) as u8;
|
||||
}
|
||||
@@ -402,7 +402,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
ref_aes_cbc_encrypt(
|
||||
&dk,
|
||||
&CROSS_AACS_IV,
|
||||
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
|
||||
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
|
||||
// Sanity: ciphertext should differ
|
||||
@@ -413,7 +413,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
);
|
||||
|
||||
// -- Decrypt with the library --
|
||||
let ok = aacs::decrypt_unit(&mut plaintext, &unit_key);
|
||||
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||
assert!(
|
||||
ok,
|
||||
"decrypt_unit returned false (TS sync verification failed)"
|
||||
@@ -436,9 +436,9 @@ fn aacs_cross_validation_alternate_key() {
|
||||
0x08,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut plaintext = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
plaintext[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
@@ -455,10 +455,10 @@ fn aacs_cross_validation_alternate_key() {
|
||||
ref_aes_cbc_encrypt(
|
||||
&dk,
|
||||
&CROSS_AACS_IV,
|
||||
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
|
||||
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
|
||||
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
|
||||
assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key));
|
||||
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(&plaintext[..], &expected[..]);
|
||||
@@ -473,15 +473,15 @@ fn aacs_bus_decrypt_cross_validation() {
|
||||
0x00,
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0..aacs::ALIGNED_UNIT_LEN {
|
||||
for i in 0..aacs::content::ALIGNED_UNIT_LEN {
|
||||
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
|
||||
}
|
||||
let expected = plaintext.clone();
|
||||
|
||||
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
|
||||
for sector_start in (0..aacs::ALIGNED_UNIT_LEN).step_by(2048) {
|
||||
for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) {
|
||||
ref_aes_cbc_encrypt(
|
||||
&read_data_key,
|
||||
&CROSS_AACS_IV,
|
||||
@@ -490,7 +490,7 @@ fn aacs_bus_decrypt_cross_validation() {
|
||||
}
|
||||
assert_ne!(&plaintext[16..32], &expected[16..32]);
|
||||
|
||||
aacs::decrypt_bus(&mut plaintext, &read_data_key);
|
||||
aacs::content::decrypt_bus(&mut plaintext, &read_data_key);
|
||||
assert_eq!(
|
||||
plaintext, expected,
|
||||
"bus decrypt did not recover original plaintext"
|
||||
@@ -738,7 +738,7 @@ fn aacs_parse_unit_key_ro_minimal() {
|
||||
data[key_pos + i] = (0xA0 + i) as u8;
|
||||
}
|
||||
|
||||
let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10);
|
||||
let result = aacs::inf::parse_unit_key_ro(&data, aacs::mkb::AacsVersion::V10);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"parse_unit_key_ro should succeed on valid data"
|
||||
@@ -751,6 +751,6 @@ fn aacs_parse_unit_key_ro_minimal() {
|
||||
assert_eq!(ukf.disc_hash.len(), 20);
|
||||
|
||||
// disc_hash should be deterministic
|
||||
let hash = aacs::disc_hash(&data);
|
||||
let hash = aacs::inf::disc_hash(&data);
|
||||
assert_eq!(ukf.disc_hash, hash);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use libfreemkv::{aacs, decrypt::DecryptKeys};
|
||||
#[test]
|
||||
fn decrypt_sectors_with_aacs_keys_works() {
|
||||
// Build an encrypted aligned unit
|
||||
let mut unit = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
|
||||
// Set encryption flag (bits 6-7 of byte 0)
|
||||
unit[0] |= 0xC0;
|
||||
@@ -19,7 +19,7 @@ fn decrypt_sectors_with_aacs_keys_works() {
|
||||
for (i, byte) in unit
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.take(aacs::ALIGNED_UNIT_LEN)
|
||||
.take(aacs::content::ALIGNED_UNIT_LEN)
|
||||
.skip(1)
|
||||
{
|
||||
*byte = ((i * 3 + 7) & 0xFF) as u8;
|
||||
@@ -28,7 +28,7 @@ fn decrypt_sectors_with_aacs_keys_works() {
|
||||
let unit_key: [u8; 16] = [0xAAu8; 16];
|
||||
|
||||
// Encrypt the unit using AACS algorithm
|
||||
aacs::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
|
||||
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
|
||||
|
||||
// Now we have encrypted data - create DecryptKeys with actual keys
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
@@ -83,23 +83,23 @@ fn decrypt_sectors_with_css_keys_works() {
|
||||
#[test]
|
||||
fn aacs_encryption_flag_detection() {
|
||||
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||
assert!(!aacs::ts_sync_destroyed(&unit));
|
||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
||||
|
||||
// Flag bits on a synced unit do not make it look encrypted.
|
||||
unit[0] = 0xC0;
|
||||
unit[7] = 0xC0;
|
||||
assert!(!aacs::ts_sync_destroyed(&unit));
|
||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
||||
|
||||
// Scrambled body (syncs gone) → encrypted.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(aacs::ts_sync_destroyed(&scrambled));
|
||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
assert!(aacs::content::ts_sync_destroyed(&scrambled));
|
||||
}
|
||||
|
||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||
|
||||
Reference in New Issue
Block a user