mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4b878dcc06 | ||
|
|
7511b0b808 | ||
|
|
707989bd68 | ||
|
|
57b2f0c4f9 | ||
|
|
47406a7e32 | ||
|
|
03cbda741a | ||
|
|
ec59ffe105 | ||
|
|
02132f52b5 | ||
|
|
ca8d046f2a | ||
|
|
f33a9f6c22 | ||
|
|
a9aee0d5a4 | ||
|
|
e16f207efa | ||
|
|
e5540da6d4 | ||
|
|
9b49beabee | ||
|
|
b0037f2db9 | ||
|
|
90070c81bd | ||
|
|
b8c2054249 | ||
|
|
c4357d9d87 | ||
|
|
63c1d5db63 | ||
|
|
e91ce25db3 | ||
|
|
2c94ce97c6 | ||
|
|
e7998a1376 | ||
|
|
ba6ac3ccdb | ||
|
|
1d1fe984d7 | ||
|
|
9298534b4b | ||
|
|
6799890379 | ||
|
|
f36163ad02 | ||
|
|
73ae12ed1f | ||
|
|
2f47b552e8 | ||
|
|
89c4acdf8f | ||
|
|
1d5a730853 | ||
|
|
0a0a8f1327 | ||
|
|
e396dcaab7 | ||
|
|
f6f68cb9dc | ||
|
|
bdafec8081 | ||
|
|
71f6cb17a8 | ||
|
|
326868ec64 | ||
|
|
ebb3227294 | ||
|
|
e8b3c719e6 | ||
|
|
4e65f87b71 | ||
|
|
c5e4fa7109 | ||
|
|
b67eaab3a8 | ||
|
|
dfc54891c5 | ||
|
|
4169cf7f4f | ||
|
|
edd316b8a8 | ||
|
|
c57ac14ad1 | ||
|
|
9154ac12d6 | ||
|
|
ec3d072ec7 | ||
|
|
bbf04c3d51 | ||
|
|
d4f8c86cbc | ||
|
|
a4da4dbe24 | ||
|
|
e6dfe54e10 | ||
|
|
c68d40887a | ||
|
|
ad9ea4c199 | ||
|
|
9422909773 | ||
|
|
53e2e9a6ce | ||
|
|
ab289fb5c4 | ||
|
|
dc3b7970e7 | ||
|
|
7cac0a1b15 | ||
|
|
5bd7da0f6f | ||
|
|
dd23f3f3c9 | ||
|
|
c7925a293d | ||
|
|
37a8d8f710 | ||
|
|
48bea05472 | ||
|
|
04ae2704f1 | ||
|
|
26fc70b91b | ||
|
|
a061694ba0 | ||
|
|
45c0bc943c | ||
|
|
c9c64e2cbf | ||
|
|
8ac18d857d | ||
|
|
0a006bbfa6 | ||
|
|
11554c1722 | ||
|
|
321442632a | ||
|
|
46797c01a3 | ||
|
|
790a40138b | ||
|
|
4e545e6e46 | ||
|
|
afe55c7871 | ||
|
|
7114e46018 | ||
|
|
ccb1b16ffb | ||
|
|
a5268d15ca | ||
|
|
accf21b03d | ||
|
|
07396ec19d | ||
|
|
da3859d0c5 | ||
|
|
d334ab5749 | ||
|
|
0b2e83e523 | ||
|
|
abd7af3907 | ||
|
|
7716a2cb17 | ||
|
|
391e1ae39c |
+351
-2
@@ -1,5 +1,354 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
- **AACS 2.1 Media Key Variant support.** The Media Key Variant scheme is now
|
||||
detected and parsed from the real MKB record types found on variant discs —
|
||||
`0x2d` (Encrypted Media Key Variant Data), `0x2f` (Variant Key Data table,
|
||||
65,535 × 16), and `0x0c` (variant cvalues, one per subset-difference slot) —
|
||||
replacing the earlier placeholder `0x82`/`0x83` types, which were a guess and
|
||||
appear on no real MKB. The V2.0→V2.1 upgrade detection and fixtures are updated
|
||||
accordingly, so a genuine AACS 2.1 variant disc now resolves.
|
||||
- **`resolve_candidate`** — one composed, pure-derivation boil-down for a
|
||||
candidate key at any ladder rung (DK/PK/MK/VUK → terminal unit keys), parsing
|
||||
`Unit_Key_RO.inf` at the disc's declared AACS version and returning every CPS
|
||||
unit key. Consumers stop re-composing the ladder; every client hardens a single
|
||||
implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`mk_from_dk` does the real Subset-Difference walk again.** It previously ran
|
||||
the Media-Key-Variant path, which needed an integrator KCD absent in-tree and
|
||||
errored for every real disc — effectively dead for both consumers. It now
|
||||
performs the genuine device-key SD walk; the Volume ID enters at the VUK step
|
||||
(where it belongs), not the MK step. This revives the DK→MK fallback across the
|
||||
toolchain (`freemkv-keysources` adopts the corrected two-argument call).
|
||||
- **autorip: a down online key service is no longer reported as a missing key.**
|
||||
When the online key source resolves no key for an encrypted disc, autorip now
|
||||
runs one bounded reachability probe (SSRF-pinned, ~8 s, no redirects) and
|
||||
distinguishes a transient outage (transport error / 502·503·504 → down; 429 →
|
||||
rate-limited) from a genuine no-key (any real HTTP answer → up). A transient
|
||||
verdict triggers a bounded key-resolution retry (3 attempts, 8/16/32 s backoff)
|
||||
and, if the service stays down, parks the disc in a distinct retryable state
|
||||
("Key service unavailable — temporary outage, not a missing key; will retry.")
|
||||
instead of the permanent "no keys found". Never hammers the drive or service.
|
||||
|
||||
### Performance
|
||||
|
||||
- **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`)
|
||||
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
|
||||
path.
|
||||
|
||||
### autorip
|
||||
|
||||
- **Clear stuck move errors from the System tab.** Each move-queue error now has
|
||||
a ✕ to dismiss it, plus Clear all and Refresh — so a resolved or stale error
|
||||
can be cleared without restarting the container (the mover re-records any that
|
||||
are still genuinely failing on its next tick).
|
||||
|
||||
## [1.2.1] — 2026-07-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVD DTS audio no longer muxes with non-monotonic timestamps.** A DVD
|
||||
Program Stream packs several DTS core frames into one PES packet; the parser
|
||||
stamped every access unit with that single PES timestamp and no per-frame
|
||||
duration, so consecutive frames collided on one PTS and a strict decode/remux
|
||||
(ffmpeg) rejected the track — `non monotonically increasing dts to muxer`.
|
||||
The DTS parser now derives each core frame's duration from its header
|
||||
(`(NBLKS+1)*32` samples ÷ the `SFREQ` sample rate) and re-bases to each PES's
|
||||
own container timestamp, advancing by a frame duration only *within* a single
|
||||
PES — so the track stays monotonic and does not drift past its real length on
|
||||
a feature-long title. The UHD DTS-HD MA path (one access unit per PES) is
|
||||
unaffected: each unit keeps its own PES timestamp, preserving the 1.2.0 per-PES
|
||||
attribution. Completes the DVD DTS fix begun in 1.2.0 (which corrected the
|
||||
silent-track routing, exposing this timing bug). Note: genuinely corrupt
|
||||
source DTS frames — valid framing, bad audio blocks — are passed through
|
||||
faithfully; freemkv never fabricates or drops audio it can't prove is bad.
|
||||
|
||||
## [1.2.0] — 2026-07-01
|
||||
|
||||
### Breaking
|
||||
|
||||
The disc's AACS version is now carried through the key-resolution path as the
|
||||
single source of truth for the `Unit_Key_RO` stride (AACS-1.0 = 48-byte,
|
||||
AACS-2.x = 64-byte), so keys are always read at the disc's own layout. That
|
||||
threaded one new value through three public signatures. In-tree consumers
|
||||
(`freemkv`, `autorip`, `freemkv-keysources`) are updated; external callers must
|
||||
adjust:
|
||||
|
||||
- **`DiscInputs` gains a `version: u8` field** (between `volume_id` and `mkb`).
|
||||
Code constructing it with a struct literal must add the field. It is normally
|
||||
obtained from `Disc::inputs()`, not constructed by hand.
|
||||
- **`keysource::DiscInputsCtx::new` takes one argument, not two** — the version
|
||||
is now read from `inputs.version` (`new(inputs)` instead of
|
||||
`new(inputs, version)`).
|
||||
- **`disc::read_aacs_inputs` / `read_aacs_inputs_from_drive` return a 3-tuple**
|
||||
`(inf, mkb, version)` instead of `(inf, mkb)`.
|
||||
- **`PassProgress` is no longer `Copy` and gains a `located: LocatedProgress`
|
||||
field.** It now carries a `Vec` (the rendered bad-range drilldown), so it's
|
||||
`Clone` only — still built once per throttled emission and passed by reference
|
||||
to `Progress::report`. Struct-literal constructors must add the field (empty:
|
||||
`located: Default::default()`). New public types `LocatedRange` /
|
||||
`LocatedProgress`.
|
||||
|
||||
These are source-breaking for external crates.io consumers. Shipped under a
|
||||
minor bump (1.2.0): libfreemkv's surface is not yet frozen and the only known
|
||||
consumers are the in-tree toolchain crates.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pass-N marginal-sector recovery specialists.** The patch pass gained a
|
||||
roster of parameterized recovery techniques — read speed (max/min), cache
|
||||
bypass (FUA), and traversal (linear fwd/rev, bisect, cache-prime, oscillate,
|
||||
per-sector speed-sweep) — each targeting a distinct physical failure mode of
|
||||
marginal media. A per-rip **decayed (EWMA) scorecard** grades every technique
|
||||
by its recent recovery rate and re-orders them best-first, so the engine
|
||||
hardcodes no conclusion: a technique that fits *this* disc floats to the front
|
||||
and one that doesn't self-deprioritises (but is never dropped). Every read is
|
||||
wedge-safe and deadline-bounded; the existing fast/deep recovery behavior is
|
||||
unchanged (the specialists are additive, tried only on the hardened residue).
|
||||
- **Opt-in flat-pool recovery scheduler (`FREEMKV_PATCH_FLAT`).** Collapses the
|
||||
breadth-first recovery tiers into one flat pool so every technique gets a shot
|
||||
at each bad range immediately, scorecard-ordered — a data-driven bandit for a
|
||||
hardened residual (e.g. a late resume) where the tiered ladder would spend a
|
||||
long time on cheap techniques before reaching the specialists. Unset keeps the
|
||||
proven tier ladder as the default.
|
||||
- **`PassProgress` is the complete, mapfile-free progress contract.** Every
|
||||
emission now carries the fully-rendered "where is the damage" drilldown
|
||||
(`located`): the bad ranges annotated with chapter + movie-time offset, the
|
||||
main-feature at-risk time, the section count and the largest gap — computed by
|
||||
the library from its in-memory mapfile + title. A client (autorip, a future
|
||||
GUI/CLI) renders the disc map + at-risk time straight from it and never parses
|
||||
the mapfile, so a mapfile→mapdb change is invisible to clients. Adds
|
||||
`disc::locate_ranges`, the one-shot `disc::progress_snapshot_from_mapfile`
|
||||
(builds a snapshot from a mapfile on disk so a boundary/verdict paint stays
|
||||
mapfile-free client-side), and `consts::MILLIS_PER_SEC`.
|
||||
|
||||
- **`PatchOptions::fast_capture` — breadth-first patch recovery.** A fast-capture
|
||||
pass reads each bad range once at the full batch and leaves every failed block
|
||||
`NonTrimmed` for a later pass — no bisect, no re-read, no per-sector grind — so
|
||||
a first retry pass grabs the readable blocks (a sweep's good skip-ahead
|
||||
overshoot) of EVERY range before any single range's slow per-sector recovery.
|
||||
No data is dropped: a failed block stays `NonTrimmed` (retried by a granular
|
||||
pass), never `Unreadable`. A transport fault still aborts. `Disc::copy`'s
|
||||
internal patch leaves it `false` (single-call full recovery).
|
||||
|
||||
- **Mux loss concealment — a logged gap still produces a decode-clean file.**
|
||||
When a unit genuinely cannot be decrypted on the mux read path (a key the disc
|
||||
never yielded, after the rip's own decrypt-verify already failed loud and
|
||||
re-read), the mux no longer passes ciphertext downstream or emits a broken
|
||||
frame. The undecryptable aligned unit is concealed as NULL transport-stream
|
||||
packets (PID 0x1FFF, invisible to every real stream), and the codec layer
|
||||
**drops forward to the next keyframe** so no frame with a dangling reference
|
||||
reaches the muxer. An ffmpeg deep scan of the result is clean — no missing
|
||||
references, no partial frames. The loss is tallied and logged, never silently
|
||||
dropped, and the mux always completes. Audio and subtitle tracks have no
|
||||
cross-frame references, so only the directly-affected frames are dropped there.
|
||||
Decrypt-verify remains a **rip** gate (fail loud → re-read), never a mux gate.
|
||||
- **`Disc::unlocker_matrix()` — registry-driven unlocker did-work report.** Returns
|
||||
each registered unlocker's name alongside a `did_work` flag recording whether it
|
||||
performed authentication steps during the current rip. Callers (autorip, the CLI)
|
||||
surface this so an operator can confirm at a glance which unlock paths —
|
||||
LibreDrive firmware, AACS, CSS — actually ran, with no hardcoded names on the
|
||||
caller side.
|
||||
|
||||
### Changed
|
||||
|
||||
- **One hex parser.** All hex parsing (keys, IDs, key-source inputs) routes
|
||||
through a single `libfreemkv::hex` parser instead of several ad-hoc decoders,
|
||||
so length/odd-nibble/invalid-digit handling is identical everywhere.
|
||||
- **Robust encrypted-unit sampling + a single MKB framing walker.** Up-front
|
||||
AACS sampling tolerates content layouts that previously yielded too few
|
||||
encrypted units to resolve a key, and the Media Key Block is now walked by one
|
||||
framing routine shared across the in-band and out-of-band readers (no
|
||||
divergent record-stride logic). AACS resolution hardened around these paths.
|
||||
- **One reader, one `DiscInputs`.** `Disc::inputs()` is now the single, complete
|
||||
source of a disc's AACS inputs (inf, MKB, VID, disc_hash, version), and
|
||||
`read_aacs_inputs*` returns the version alongside inf+MKB. Both the CLI and
|
||||
autorip resolve through `Disc::inputs()`; the duplicate out-of-band readers
|
||||
(autorip's `key_files()`/`volume_id()`) and the stale mapfile-VID read are
|
||||
removed. AACS file paths and the AACS major versions are now named constants
|
||||
(`aacs::PATH_*`, `aacs::AACS_MAJOR_*`, `AacsVersion::major`/`from_major`) so a
|
||||
fallback or stride change lives in exactly one place.
|
||||
- **Pass-N recovery rebuilt as a bounded, never-hang handler chain.** The 1.1.0
|
||||
patch loop retried each bad range sector-by-sector until a per-range budget was
|
||||
exhausted, with no escape from a wedged drive short of the watchdog firing after
|
||||
tens of minutes. 1.2.0 replaces that with a two-tier handler chain dispatched
|
||||
breadth-first, largest bad range first:
|
||||
- **Jump** (lead tier): reads each range in large forward-skipping batches to
|
||||
quickly locate readable islands — clearing a multi-gigabyte dead spot in
|
||||
seconds rather than sector-by-sector.
|
||||
- **Bisect** (trailing tier): binary-searches the boundaries of each remaining
|
||||
bad block, converging to within a single sector of the last-readable LBA.
|
||||
Boundary-probe reads are exempt from the early-yield stall so the boundary
|
||||
walk always completes.
|
||||
- **Handler scorecard**: handlers that make progress stay at the front of the
|
||||
rotation per rip; an idle handler is ranked last so proven performers lead.
|
||||
- **Wedge detection**: a pass-level streak counter tracks consecutive
|
||||
wedge-family senses (HARDWARE ERROR / ILLEGAL REQUEST) across section
|
||||
boundaries. At the threshold the pass aborts and a soft un-wedge
|
||||
(`Drive::spin_cycle()` — START STOP UNIT, no eject) runs before the next retry
|
||||
pass, instead of grinding at near-zero throughput until the pass watchdog
|
||||
fires.
|
||||
|
||||
No data is dropped: a block that neither handler recovers in a pass stays
|
||||
`NonTrimmed` for the next pass.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVD DTS/LPCM audio tracks no longer mux silent.** On DVD-Video the
|
||||
`private_stream_1` sub-stream id's low nibble is the audio-stream *number*
|
||||
(shared across codecs), not a per-codec ordinal. A DTS or LPCM track that
|
||||
wasn't the disc's first audio stream got a sub-id one too low, so the demux
|
||||
routing key (`0xBD00 | sub_id`) never matched and every packet was dropped —
|
||||
the track appeared in the container but played silent (AC-3 at position 0
|
||||
worked by coincidence). Audio sub-stream ids are now assigned by positional
|
||||
stream number, so a DTS 5.0 track after an AC-3 5.1 track routes correctly.
|
||||
- **ISO mux no longer drops real video at content-fragment tails.** A title's
|
||||
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
|
||||
of the 6144-byte aligned unit to the next fragment. The decrypt-verify
|
||||
demanded the TS sync byte on *all 32* source packets, so it rejected such a
|
||||
tail unit over its legitimate padding — discarding the real video packets it
|
||||
contained. On a flawless rip this surfaced as a small phantom "loss" at mux
|
||||
(and, once retries were exhausted, a truncated MKV). Unit acceptance is now
|
||||
**padding-aware**: only packets whose *source* (pre-decrypt) bytes are
|
||||
non-zero must restore their TS sync; the zero padding is excluded from the
|
||||
check and emitted as clean zeros. A full content unit still requires all 32
|
||||
(unchanged — no wrong-key relaxation), and a unit whose *non-zero* tail fails
|
||||
to decrypt is still rejected as a genuine bad read.
|
||||
- **ISO online key resolution now sends the Media Key Block.** Capturing a
|
||||
disc's AACS inputs at scan read the MKB with a full `read_file` of the
|
||||
~128 MiB `MKB_RO`/`MKB_RW` allocation, which fails on file-backed readers —
|
||||
leaving the MKB empty, so `Disc::inputs()` shipped `mkb=0` to an online key
|
||||
service and the request was rejected (no key → no decrypt). Scan now reads the
|
||||
MKB through the same bounded prefix-grow + trim reader as the out-of-band
|
||||
path, so `Disc::inputs()` is the single complete source of AACS inputs — one
|
||||
reader for every caller.
|
||||
- **Read-time key-fetch parses `Unit_Key_RO.inf` at the disc's own AACS stride.**
|
||||
The on-demand fetch (for a CPS unit not sampled up front) hardcoded the V20
|
||||
64-byte stride, so an AACS-1.0 (V10) disc whose key arrived as a VUK derived
|
||||
the wrong unit keys. `DiscInputs` now carries the disc's `version`, and the
|
||||
context parses at the matching stride — the disc is the single source of truth
|
||||
for its own stride (no separate version argument to drift).
|
||||
- **A dry key-fetch for one unit no longer blocks fetching a different unit.**
|
||||
A global "fetch spent" latch meant that once the key service returned nothing
|
||||
for one CPS unit's ciphertext, no further unit was ever asked — so a multi-CPS
|
||||
disc could strand a unit whose key the service *would* have served. Replaced
|
||||
with a per-unit "already-asked-dry" set (still bounded by the fetch budget).
|
||||
- **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near
|
||||
`u32::MAX` can't panic (matches `udf::merge_ranges`).
|
||||
- **Audio no longer corrupts at a stream discontinuity.** At a transport-stream
|
||||
discontinuity — a continuity-counter break, an adaptation-field
|
||||
discontinuity_indicator, or a concealed-loss gap — the AC-3 / DTS / TrueHD
|
||||
parsers held a *truncated* partial access unit and spliced the post-gap bytes
|
||||
onto it, manufacturing a corrupt frame (ffmpeg "exponent out of range" /
|
||||
"Failed to decode block code(s)" / "Invalid data found") and, for TrueHD, a
|
||||
non-monotonic timestamp band on multi-segment titles. The video path already
|
||||
resynced via the keyframe gate; the audio parsers now do too — on a
|
||||
discontinuity they drop the un-completable partial and resync on the next
|
||||
syncword, rebasing the timestamp from the post-gap PES. A discontinuity becomes
|
||||
a clean single-frame gap instead of a corrupt splice. Audio has no inter-frame
|
||||
references, so dropping the truncated partial is the complete fix; the approach
|
||||
matches FFmpeg's parser layer and GStreamer's `tsdemux`.
|
||||
- **Drive-prep firmware unlock skipped for DVD discs.** An
|
||||
`if disc_is_dvd() { return }` guard in `Drive::init()` (present since
|
||||
1.0.0-rc.1) bypassed the entire drive-prep unlock step for DVDs. That unlock is
|
||||
what removes riplock and raises the drive to maximum read speed — a drive-level,
|
||||
disc-independent feature — so every DVD rip ran at riplock speed (~0.4× rated,
|
||||
multi-hour ETA). The guard is removed; all disc types now go through the full
|
||||
drive-prep sequence. UHD and Blu-ray were unaffected (they already ran through
|
||||
the unlock path).
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### Added
|
||||
@@ -7,8 +356,8 @@
|
||||
- **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`)
|
||||
before it is signed off as good. A unit that neither a held nor a
|
||||
freshly-fetched key decrypts is treated exactly like a bad read — re-read by
|
||||
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
|
||||
"silent bad read" class where a sector reads OK but its ciphertext is subtly
|
||||
wrong. **Fail-safe:** it only ever downgrades a unit it is *confident* is bad;
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.1.0"
|
||||
version = "1.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -10,6 +10,9 @@ keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
categories = ["hardware-support", "multimedia"]
|
||||
# Keep internal AI-instruction / private notes out of the published crate.
|
||||
exclude = ["CLAUDE.md"]
|
||||
# OFF crates.io: libfreemkv git-deps freemkv-unlock (firmware, never published),
|
||||
# so libfreemkv itself can only be consumed by git tag. Clients git-tag-pin it.
|
||||
publish = false
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
@@ -22,6 +25,10 @@ sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
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.3.0" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
[](https://crates.io/crates/libfreemkv)
|
||||
[](https://docs.rs/libfreemkv)
|
||||
[](LICENSE)
|
||||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Firmware-clean core: drive-unlock support is plugged in via the `Unlocker` trait, with concrete unlockers shipped as separate crates.
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
|
||||
|
||||
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Drive prep routes through the pluggable unlock seam — register an unlocker and `init()` drives it; with none registered the library rips via the host-certificate AACS handshake.
|
||||
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
|
||||
Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
## Install
|
||||
|
||||
Consumed by git tag (not published to crates.io):
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "1.0.0-rc.1"
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -32,7 +32,7 @@ use std::path::Path;
|
||||
// Open drive — identified via INQUIRY
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // wait for disc
|
||||
drive.init()?; // route through the unlock seam (if an unlocker is registered)
|
||||
drive.init()?; // unlock + prep (handled internally)
|
||||
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
@@ -100,7 +100,7 @@ loop {
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, pluggable unlock seam, speed control, eject
|
||||
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
@@ -134,8 +134,8 @@ Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (
|
||||
```text
|
||||
Drive — open, identify, init, single-shot read
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||
└── Unlocker seam — pluggable trait + registry; concrete unlockers
|
||||
live in the separate freemkv-unlock repo
|
||||
└── unlock_bridge — private seam to the freemkv-unlock crate
|
||||
(firmware / AACS cert / CSS bus-auth unlockers)
|
||||
|
||||
Disc — scan titles, streams, AACS/CSS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
|
||||
@@ -1,308 +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:
|
||||
//! the device-key path walks the MKB's Media-Key-Variant chain, the
|
||||
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither
|
||||
//! needs a VID (the VID enters at `vuk_from_mk`).
|
||||
|
||||
use super::keys::{decrypt_unit_key, derive_media_key_from_pk, derive_vuk};
|
||||
use super::types::DeviceKey;
|
||||
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
||||
|
||||
/// 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]);
|
||||
|
||||
/// 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 device keys via the Media Key Variant chain.
|
||||
///
|
||||
/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments
|
||||
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the
|
||||
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the
|
||||
/// resolver treats `Km` as the media key and derives the VUK from it as
|
||||
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The
|
||||
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
|
||||
/// keeps this primitive at the "media key" level so the chain composes.
|
||||
///
|
||||
/// Because the integrator KCD is unavailable in-tree (the placeholder is
|
||||
/// rejected by the variant chain), this returns `Err` for every real disc today
|
||||
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver
|
||||
/// also leaves unreachable in production. All variant-chain failures collapse to
|
||||
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing
|
||||
/// at this boundary, and the variant error carries no English to preserve.
|
||||
pub fn mk_from_dk(
|
||||
device_keys: &[DeviceKey],
|
||||
mkb: &[u8],
|
||||
vid: Vid,
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
let records = walk_mkb(mkb);
|
||||
match derive_media_key_variant(
|
||||
&records,
|
||||
device_keys,
|
||||
&KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
&vid.0,
|
||||
) {
|
||||
Ok((km, _kvu)) => Ok(MediaKey(km)),
|
||||
Err(_) => 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),
|
||||
}
|
||||
}
|
||||
|
||||
#[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)` for the placeholder-KCD
|
||||
/// path that production also leaves unreachable — never a wrong key, never a
|
||||
/// panic — on both an empty MKB and a non-variant MKB.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_without_integrator_kcd() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → not a variant MKB → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16]));
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// A variant-looking MKB (0x82 record) still cannot complete without the
|
||||
// integrator KCD, so it also errors — never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
||||
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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,7 +77,7 @@ 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:
|
||||
@@ -163,6 +113,101 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
|
||||
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
|
||||
}
|
||||
|
||||
/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal-
|
||||
/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt
|
||||
/// bytes.
|
||||
///
|
||||
/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the
|
||||
/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A
|
||||
/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32
|
||||
/// packets are real content, the other 21 source-zero padding) carries only 11 TS
|
||||
/// syncs after decrypt, so the majority vote calls it "destroyed" and
|
||||
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
|
||||
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
|
||||
/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0
|
||||
/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop).
|
||||
///
|
||||
/// The correct, padding-aware notion of "still ciphertext", checkable on the
|
||||
/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses:
|
||||
/// * A genuinely-FAILED unit was restored to on-disc ciphertext by
|
||||
/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a
|
||||
/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4.
|
||||
/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero
|
||||
/// (content) packet carries `0x47`; padding packets are all-zero.
|
||||
///
|
||||
/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet
|
||||
/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its
|
||||
/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces
|
||||
/// to the strict all-32 check, so the common cases are unchanged: a fully-clear /
|
||||
/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is.
|
||||
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
|
||||
if !aacs_unit_encrypted(unit) {
|
||||
return false;
|
||||
}
|
||||
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
|
||||
let limit = ALIGNED_UNIT_LEN.min(unit.len());
|
||||
let mut off = 0;
|
||||
while off + PKT <= limit {
|
||||
// A packet whose 188-byte payload is all-zero is padding (source zeros) —
|
||||
// excluded from the verdict, exactly as `decrypt_unit` excludes it. Any
|
||||
// other (content) packet that lacks its TS sync is un-restored ciphertext.
|
||||
let payload = &unit[off + 4..off + PKT];
|
||||
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
|
||||
return true;
|
||||
}
|
||||
off += PKT;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
|
||||
/// source packets — the [A2] mux loss-concealment fill for a content unit that
|
||||
/// genuinely would not decrypt.
|
||||
///
|
||||
/// Zero-filling such a unit is wrong at the TS layer: a run of `0x00` bytes
|
||||
/// carries no `0x47` sync, so the demuxer loses packet framing and can mis-parse
|
||||
/// the *next* unit if a stray `0x47` appears mid-zero. Instead we lay down 32
|
||||
/// well-formed BD source packets, each a TS null packet (PID `0x1FFF`) carrying
|
||||
/// an adaptation-field **discontinuity_indicator**:
|
||||
///
|
||||
/// ```text
|
||||
/// [4-byte TP_extra_header = 0][47 1F FF 20 B7 80 + 182 bytes 0xFF stuffing]
|
||||
/// ^sync ^PID ^AF-only ^af_len=183 ^disc_indicator
|
||||
/// ```
|
||||
///
|
||||
/// The demuxer stays byte-synced on the 192-byte stride, and because PID
|
||||
/// `0x1FFF` matches no elementary stream every null packet is silently dropped.
|
||||
/// The discontinuity_indicator is the B1 loss SIGNAL: `mux::ts` recognises a
|
||||
/// `0x1FFF` packet with that bit set as a concealed gap and forces a discontinuity
|
||||
/// on every tracked PID's next PES (the codec consumer then drops forward to the
|
||||
/// next keyframe). This is CC-INDEPENDENT — unlike the real PID's continuity
|
||||
/// counter it survives a loss that is an exact multiple of 16 packets, or a loss
|
||||
/// at a PID's very start. NEVER emits ciphertext; lossless framing, not fabricated
|
||||
/// content.
|
||||
pub fn fill_null_ts_unit(unit: &mut [u8]) {
|
||||
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
|
||||
let mut off = 0;
|
||||
while off + PKT <= unit.len() {
|
||||
// TP_extra_header (arrival timestamp / copy-control) — zero is fine; the
|
||||
// demuxer never reads it for a PID it does not track.
|
||||
unit[off..off + 4].fill(0);
|
||||
// 188-byte TS null packet: sync, PID 0x1FFF (no PUSI/TEI).
|
||||
unit[off + 4] = TS_SYNC; // 0x47
|
||||
unit[off + 5] = 0x1F; // PID high (top 5 bits of 0x1FFF, flags clear)
|
||||
unit[off + 6] = 0xFF; // PID low
|
||||
// adaptation_field_control = 0b10 (AF only, no payload), CC = 0.
|
||||
unit[off + 7] = 0x20;
|
||||
// adaptation_field_length = 183: the AF (its flags byte + 182 stuffing)
|
||||
// fills the rest of the 188-byte packet.
|
||||
unit[off + 8] = 0xB7;
|
||||
// AF flags: discontinuity_indicator (0x80) — the concealed-gap signal.
|
||||
unit[off + 9] = 0x80;
|
||||
// Stuffing: 0xFF is the conventional adaptation-field fill.
|
||||
unit[off + 10..off + PKT].fill(0xFF);
|
||||
off += PKT;
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
|
||||
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
|
||||
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
|
||||
@@ -258,7 +303,66 @@ pub fn unit_is_clean_ps(unit: &[u8]) -> bool {
|
||||
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
||||
/// there is no flag to clear.
|
||||
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
decrypt_unit_checked(unit, unit_key, unit_is_clean_ts)
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
if !aacs_unit_encrypted(unit) {
|
||||
return true; // CPI flag clear → plaintext, pass through untouched
|
||||
}
|
||||
|
||||
// PADDING-AWARE acceptance. The question this answers is "did we read good,
|
||||
// decryptable data?" — NOT "are all 32 packets present". A content fragment
|
||||
// can END mid-unit, with the disc zero-padding the rest of the aligned unit
|
||||
// to the next fragment. Such a tail unit is `[real encrypted packets][source
|
||||
// zeros]`: the real packets decrypt perfectly, but the strict all-32
|
||||
// `unit_is_clean_ts` would reject the whole unit over the padding tail and
|
||||
// discard real video. AES ciphertext is high-entropy, so a SOURCE-zero packet
|
||||
// (all 192 bytes zero before decrypt) can only be padding, never content.
|
||||
//
|
||||
// So: a packet whose SOURCE bytes are all zero is padding — excluded from the
|
||||
// verify and emitted as clean zeros. Every other (content) packet must
|
||||
// restore its TS sync. A full content unit has no zero-source packets, so this
|
||||
// is byte-identical to the old all-32 check (no regression, no wrong-key
|
||||
// hole). The discriminator between a legitimate short tail and a genuine
|
||||
// misread is exactly this: a misread leaves the failing packets' SOURCE
|
||||
// non-zero (real ciphertext that won't decrypt) → still rejected.
|
||||
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
|
||||
let npkt = ALIGNED_UNIT_LEN / PKT;
|
||||
let mut pad = [false; ALIGNED_UNIT_LEN / PKT];
|
||||
for (p, slot) in pad.iter_mut().enumerate().take(npkt) {
|
||||
let off = p * PKT;
|
||||
*slot = unit[off..off + PKT].iter().all(|&b| b == 0);
|
||||
}
|
||||
|
||||
// Save original first 16 bytes (the plaintext seed / header) and derive the
|
||||
// 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);
|
||||
let mut decrypt_key = [0u8; 16];
|
||||
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
|
||||
// garbage from AES-decrypting zeros, but the source was zero so a clean zero
|
||||
// fill is lossless and gives the demux a tidy gap instead of garbage).
|
||||
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
|
||||
let off = p * PKT;
|
||||
if is_pad {
|
||||
for b in unit[off..off + PKT].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
} else if unit[off + 4] != TS_SYNC {
|
||||
return false; // a real content packet failed → genuinely undecryptable
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
|
||||
@@ -433,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() {
|
||||
@@ -703,6 +809,101 @@ mod tests {
|
||||
unit
|
||||
}
|
||||
|
||||
// ── Padding-aware IsDecryptable (fragment-tail recovery) ───────────────
|
||||
//
|
||||
// A content fragment can end mid-unit, with the disc zero-padding the rest
|
||||
// of the aligned unit to the next fragment (proven on Dunkirk: ~11 real
|
||||
// video packets + source-zero pad). `decrypt_unit` must accept such a unit
|
||||
// — its real packets decrypt; the source-zero tail is padding, not content
|
||||
// — while still REJECTING a unit whose undecryptable tail is non-zero (a
|
||||
// genuine misread / wrong key). The discriminator is the SOURCE bytes of the
|
||||
// failing packets: zero ⇒ padding (still decryptable), non-zero ⇒ bad data.
|
||||
|
||||
/// Encrypt a full clear unit under `unit_key`, then overwrite the tail (from
|
||||
/// packet `keep` onward) with `fill`. `0x00` models disc fragment padding; a
|
||||
/// non-zero `fill` models a corrupt/misread tail.
|
||||
fn tail_filled_unit(unit_key: &[u8; 16], keep_pkts: usize, fill: u8) -> Vec<u8> {
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, unit_key);
|
||||
for b in unit[keep_pkts * BD_SOURCE_PACKET_BYTES..].iter_mut() {
|
||||
*b = fill;
|
||||
}
|
||||
unit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decryptable_full_content_unit_under_correct_key() {
|
||||
let key = [0x5Au8; 16];
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &key);
|
||||
assert!(
|
||||
decrypt_unit(&mut unit, &key),
|
||||
"full clean unit is decryptable"
|
||||
);
|
||||
assert_eq!(ts_sync_count(&unit), 32, "all 32 syncs restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_tail_with_source_zero_pad_is_decryptable() {
|
||||
// 11 real content packets, then source-zero padding (the Dunkirk shape).
|
||||
let key = [0x5Au8; 16];
|
||||
let mut unit = tail_filled_unit(&key, 11, 0x00);
|
||||
assert!(
|
||||
decrypt_unit(&mut unit, &key),
|
||||
"real prefix + source-zero pad IS decryptable"
|
||||
);
|
||||
for p in 0..11 {
|
||||
assert_eq!(
|
||||
unit[p * BD_SOURCE_PACKET_BYTES + 4],
|
||||
TS_SYNC,
|
||||
"content pkt {p} restored its sync"
|
||||
);
|
||||
}
|
||||
// Padding emitted as clean zeros, not decrypted garbage.
|
||||
for p in 11..32 {
|
||||
let off = p * BD_SOURCE_PACKET_BYTES;
|
||||
assert!(
|
||||
unit[off..off + BD_SOURCE_PACKET_BYTES]
|
||||
.iter()
|
||||
.all(|&b| b == 0),
|
||||
"padding pkt {p} zeroed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_tail_with_nonzero_garbage_is_not_decryptable() {
|
||||
// Same shape, but the tail is NON-zero — a genuine misread, not padding.
|
||||
let key = [0x5Au8; 16];
|
||||
let mut unit = tail_filled_unit(&key, 11, 0xC3);
|
||||
assert!(
|
||||
!decrypt_unit(&mut unit, &key),
|
||||
"real prefix + non-zero garbage tail is NOT decryptable (misread)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_full_unit_is_not_decryptable() {
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &[0x11u8; 16]);
|
||||
assert!(
|
||||
!decrypt_unit(&mut unit, &[0x22u8; 16]),
|
||||
"wrong key on a full content unit is rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpi_clear_unit_passes_through_decryptable() {
|
||||
// CPI-clear (plaintext) unit: decryptable by definition, untouched.
|
||||
let mut unit = clear_unit(); // byte 0 high bits clear
|
||||
let before = unit.clone();
|
||||
assert!(
|
||||
decrypt_unit(&mut unit, &[0u8; 16]),
|
||||
"clear unit passes through as decryptable"
|
||||
);
|
||||
assert_eq!(unit, before, "clear unit left untouched");
|
||||
}
|
||||
|
||||
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -1197,4 +1398,60 @@ mod tests {
|
||||
// 6144 = 32 packets.
|
||||
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
|
||||
}
|
||||
|
||||
/// Direct coverage for the padding-aware conceal predicate. It must conceal
|
||||
/// ONLY genuinely-undecryptable ciphertext — never a decrypted unit (full or
|
||||
/// short padding-tail), a clear/non-encrypted unit, or an all-zero unit.
|
||||
#[test]
|
||||
fn aacs_unit_still_ciphertext_is_padding_aware() {
|
||||
let pkt = BD_SOURCE_PACKET_BYTES;
|
||||
// Build a 32-packet aligned unit. `cpi` sets the AACS CPI bits (byte 0).
|
||||
// Per packet: b'S' = decrypted TS (0x47 sync + non-zero payload),
|
||||
// b'C' = ciphertext (non-zero payload, no sync), b'P' = zero padding.
|
||||
let build = |cpi: bool, kinds: &[u8]| {
|
||||
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
if cpi {
|
||||
u[0] = 0xC0; // CPI bits in the packet-0 header (not the payload)
|
||||
}
|
||||
for (i, &k) in kinds.iter().enumerate() {
|
||||
let off = i * pkt;
|
||||
match k {
|
||||
b'S' => {
|
||||
u[off + 4] = TS_SYNC;
|
||||
for b in &mut u[off + 5..off + pkt] {
|
||||
*b = 0x10;
|
||||
}
|
||||
}
|
||||
b'C' => {
|
||||
// Scrambled: non-zero payload, no 0x47 at the sync position.
|
||||
for b in &mut u[off + 4..off + pkt] {
|
||||
*b = 0x5A;
|
||||
}
|
||||
}
|
||||
_ => {} // b'P' → leave zero
|
||||
}
|
||||
}
|
||||
u
|
||||
};
|
||||
|
||||
// Not encrypted (CPI clear) → never concealed, even if it looks scrambled.
|
||||
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'C'; 32])));
|
||||
// All-zero unit (CPI clear) → not encrypted → false.
|
||||
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'P'; 32])));
|
||||
// Fully decrypted (all packets carry their sync) → false.
|
||||
assert!(!aacs_unit_still_ciphertext(&build(true, &[b'S'; 32])));
|
||||
// Fully ciphertext (no packet carries its sync) → true.
|
||||
assert!(aacs_unit_still_ciphertext(&build(true, &[b'C'; 32])));
|
||||
// Decrypted SHORT padding-tail: 11 content packets + 21 zero padding. The
|
||||
// majority vote would mis-flag it (<16 syncs); the padding-aware predicate
|
||||
// skips the zero padding and sees every non-zero packet has its sync.
|
||||
let mut tail = [b'P'; 32];
|
||||
for k in tail.iter_mut().take(11) {
|
||||
*k = b'S';
|
||||
}
|
||||
assert!(
|
||||
!aacs_unit_still_ciphertext(&build(true, &tail)),
|
||||
"a decrypted short padding-tail must NOT be flagged as ciphertext"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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), matching
|
||||
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
//! Host-certificate collection — the one libfreemkv-side concern left from the
|
||||
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
|
||||
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
|
||||
//! keysource concern) and hands them across the seam.
|
||||
|
||||
/// Union the host certificates a scan can offer the drive: the explicit
|
||||
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
|
||||
/// keysource-served, never compiled in. `mkb` lets a source pick a
|
||||
/// generation-appropriate cert (the default impl ignores it).
|
||||
pub fn collect_host_certs(
|
||||
opts: &crate::disc::ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> 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());
|
||||
}
|
||||
for src in &opts.key_sources {
|
||||
host_certs.extend(src.host_certs(mkb));
|
||||
}
|
||||
host_certs
|
||||
}
|
||||
+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. libaacs (unit_key.c) validates 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 (matches libaacs content_cert.c):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (libaacs: `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,
|
||||
})
|
||||
}
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
//! 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): [libaacs] `mkb.c` — not in the public spec.
|
||||
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`. This matches
|
||||
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
|
||||
/// reads `0x04`.
|
||||
///
|
||||
/// 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). libaacs `MKB_20_CATEGORY_C`.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content). libaacs `MKB_21_CATEGORY_C`.
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
-55
@@ -13,59 +13,67 @@
|
||||
//!
|
||||
//! 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).
|
||||
//! - `[libaacs]` — the libaacs reference implementation, cited only where the spec
|
||||
//! is silent (the `0x86` verify record and the Category-C MKBType names).
|
||||
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod keys;
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
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;
|
||||
|
||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
|
||||
// Structured, English-free resolution trace.
|
||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
||||
/// or changing a fallback in one place can then never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
||||
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
|
||||
// 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, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full,
|
||||
decrypt_unit_try_keys, 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::{
|
||||
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() {
|
||||
@@ -87,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).
|
||||
|
||||
+100
-1163
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());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -31,7 +31,7 @@ impl ResolutionTrace {
|
||||
// ── Unlock phase ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's
|
||||
/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim.
|
||||
/// `name()` (a stable, product-neutral identifier), carried verbatim.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UnlockStep {
|
||||
pub who: String,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,10 @@ pub const SECTOR_BYTES: usize = 2048;
|
||||
/// the workspace reads as `sectors * SECTOR_BYTES_U64` with no per-site cast.
|
||||
pub const SECTOR_BYTES_U64: u64 = SECTOR_BYTES as u64;
|
||||
|
||||
/// Milliseconds per second. For turning a byte count ÷ bytes-per-second into a
|
||||
/// movie-time figure (`bytes / bps * MILLIS_PER_SEC`) without a bare `1000.0`.
|
||||
pub const MILLIS_PER_SEC: f64 = 1_000.0;
|
||||
|
||||
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
|
||||
/// Blu-ray — prefixed by the format, not a disc type.
|
||||
pub const TS_PACKET_BYTES: usize = 188;
|
||||
|
||||
-838
@@ -1,838 +0,0 @@
|
||||
//! CSS drive bus-authentication — read-unlock primitive.
|
||||
//!
|
||||
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
|
||||
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
|
||||
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
|
||||
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
|
||||
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
|
||||
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
|
||||
//! [`super::crack_key`]).
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// ── CryptKey tables ───────────────────────────────────────────────────────
|
||||
|
||||
const CRYPT_TAB0: [u8; 256] = [
|
||||
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
|
||||
0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24,
|
||||
0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9,
|
||||
0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88,
|
||||
0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9,
|
||||
0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC,
|
||||
0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB,
|
||||
0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1,
|
||||
0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47,
|
||||
0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B,
|
||||
0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8,
|
||||
0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7,
|
||||
0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D,
|
||||
0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC,
|
||||
0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25,
|
||||
0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C,
|
||||
];
|
||||
|
||||
const CRYPT_TAB1: [u8; 256] = [
|
||||
0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F,
|
||||
0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C,
|
||||
0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9,
|
||||
0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0,
|
||||
0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9,
|
||||
0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74,
|
||||
0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B,
|
||||
0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79,
|
||||
0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7,
|
||||
0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63,
|
||||
0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98,
|
||||
0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF,
|
||||
0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D,
|
||||
0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64,
|
||||
0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5,
|
||||
0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4,
|
||||
];
|
||||
|
||||
const CRYPT_TAB2: [u8; 256] = [
|
||||
0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77,
|
||||
0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4,
|
||||
0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11,
|
||||
0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28,
|
||||
0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31,
|
||||
0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C,
|
||||
0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63,
|
||||
0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71,
|
||||
0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F,
|
||||
0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B,
|
||||
0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0,
|
||||
0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7,
|
||||
0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35,
|
||||
0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC,
|
||||
0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D,
|
||||
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
|
||||
];
|
||||
|
||||
const CRYPT_TAB3: [u8; 256] = [
|
||||
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
|
||||
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
|
||||
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
|
||||
0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53,
|
||||
0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02,
|
||||
0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF,
|
||||
0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90,
|
||||
0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02,
|
||||
0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30,
|
||||
0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4,
|
||||
0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F,
|
||||
0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8,
|
||||
0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02,
|
||||
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
|
||||
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
|
||||
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
|
||||
];
|
||||
|
||||
const VARIANTS: [u8; 32] = [
|
||||
0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42,
|
||||
0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01,
|
||||
];
|
||||
|
||||
const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28];
|
||||
|
||||
const PERM_CHALLENGE: [[usize; 10]; 3] = [
|
||||
[1, 3, 0, 7, 5, 2, 9, 6, 4, 8],
|
||||
[6, 1, 9, 3, 8, 5, 7, 4, 0, 2],
|
||||
[4, 0, 3, 5, 7, 2, 8, 6, 1, 9],
|
||||
];
|
||||
|
||||
const PERM_VARIANT: [[u8; 32]; 2] = [
|
||||
[
|
||||
0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F,
|
||||
0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11,
|
||||
0x17, 0x15,
|
||||
],
|
||||
[
|
||||
0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04,
|
||||
0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09,
|
||||
0x05, 0x0D,
|
||||
],
|
||||
];
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// CSS bus-auth **unlock** primitive.
|
||||
///
|
||||
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
|
||||
/// what actually unlocks scrambled-sector reads), then a best-effort,
|
||||
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
|
||||
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
|
||||
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
|
||||
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
|
||||
pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
|
||||
let t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
|
||||
let r = unlock_css_reads_inner(drive, lba);
|
||||
tracing::info!(
|
||||
target: "freemkv::css",
|
||||
phase = "unlock_css_reads",
|
||||
lba,
|
||||
ok = r.is_ok(),
|
||||
elapsed_ms = t0.elapsed().as_millis() as u64,
|
||||
"end"
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
|
||||
tracing::debug!(target: "freemkv::css", "css unlock: begin");
|
||||
// The bus-auth challenge-response sets the drive's Authentication Success
|
||||
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
|
||||
// ONLY step required to unlock reads; a failure here is fatal — we
|
||||
// genuinely cannot read scrambled sectors.
|
||||
let (agid, _bus_key) = bus_auth(drive).inspect_err(|e| {
|
||||
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth failed");
|
||||
})?;
|
||||
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth ok");
|
||||
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
|
||||
// of its read-unlock to it. The bytes are unused (the descramble key is
|
||||
// recovered keylessly) and a failure is NON-FATAL — the gate is already
|
||||
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
|
||||
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
|
||||
if let Err(e) = read_disc_key(drive, agid) {
|
||||
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
|
||||
}
|
||||
tracing::debug!(target: "freemkv::css", "css unlock: ok");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
||||
|
||||
fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F
|
||||
for agid in 0..4u8 {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
// alloc_len = 0 (no data transfer)
|
||||
cdb[10] = (agid << 6) | 0x3F;
|
||||
let mut buf = [0u8; 8];
|
||||
let _ = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
}
|
||||
|
||||
// Allocate AGID
|
||||
let mut buf = [0u8; 8];
|
||||
scsi.execute(
|
||||
&report_key_cdb(0, 0x00, 8),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let agid = (buf[7] >> 6) & 0x03;
|
||||
|
||||
// Host sends challenge. The spec wants a fresh per-session random nonce,
|
||||
// not a fixed constant — a predictable challenge weakens the bus-auth
|
||||
// handshake.
|
||||
let mut host_challenge = [0u8; 10];
|
||||
{
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut host_challenge);
|
||||
}
|
||||
let mut hc_buf = [0u8; 16];
|
||||
hc_buf[0] = 0x00;
|
||||
hc_buf[1] = 0x0E;
|
||||
for i in 0..10 {
|
||||
hc_buf[4 + i] = host_challenge[9 - i];
|
||||
}
|
||||
scsi.execute(
|
||||
&send_key_cdb(agid, 0x01, 16),
|
||||
crate::scsi::DataDirection::ToDevice,
|
||||
&mut hc_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Get Key1 from drive
|
||||
let mut dk_buf = [0u8; 12];
|
||||
scsi.execute(
|
||||
&report_key_cdb(agid, 0x02, 12),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut dk_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let mut key1 = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
key1[i] = dk_buf[4 + (4 - i)];
|
||||
}
|
||||
|
||||
// Brute-force variant (0-31)
|
||||
let mut variant: Option<u8> = None;
|
||||
for v in 0..32u8 {
|
||||
if crypt_key(0, v, &host_challenge) == key1 {
|
||||
variant = Some(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let variant = variant.ok_or(Error::CssAuthFailed)?;
|
||||
|
||||
// Get drive challenge
|
||||
let mut dc_buf = [0u8; 16];
|
||||
scsi.execute(
|
||||
&report_key_cdb(agid, 0x01, 16),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut dc_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let mut drive_challenge = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
drive_challenge[i] = dc_buf[4 + (9 - i)];
|
||||
}
|
||||
|
||||
// Compute Key2 and send it
|
||||
let key2 = crypt_key(1, variant, &drive_challenge);
|
||||
let mut hk_buf = [0u8; 12];
|
||||
hk_buf[0] = 0x00;
|
||||
hk_buf[1] = 0x0A;
|
||||
for i in 0..5 {
|
||||
hk_buf[4 + i] = key2[4 - i];
|
||||
}
|
||||
scsi.execute(
|
||||
&send_key_cdb(agid, 0x03, 12),
|
||||
crate::scsi::DataDirection::ToDevice,
|
||||
&mut hk_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Bus key = CryptKey(2, variant, key1 || key2)
|
||||
let mut combined = [0u8; 10];
|
||||
combined[..5].copy_from_slice(&key1);
|
||||
combined[5..].copy_from_slice(&key2);
|
||||
let bus_key = crypt_key(2, variant, &combined);
|
||||
|
||||
Ok((agid, bus_key))
|
||||
}
|
||||
|
||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
||||
|
||||
/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
|
||||
/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
|
||||
/// effect. The returned block contents are not used — the descramble title key
|
||||
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
|
||||
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
|
||||
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
|
||||
fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
|
||||
let alloc_len: u16 = 2048 + 4;
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
|
||||
// bytes 2-5: address = 0
|
||||
cdb[6] = 0; // layer
|
||||
cdb[7] = 0x02; // format = disc key
|
||||
cdb[8] = (alloc_len >> 8) as u8;
|
||||
cdb[9] = alloc_len as u8;
|
||||
cdb[10] = agid << 6;
|
||||
|
||||
let mut buf = vec![0u8; alloc_len as usize];
|
||||
let dvd_result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
dvd_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── CSSCryptKey ───────────────────────────────────────────────────────────
|
||||
|
||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
|
||||
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
|
||||
// 0..3 and variant in 0..32; the asserts document the contract for the
|
||||
// pub(crate) test entry point test_crypt_key and turn a would-be
|
||||
// out-of-bounds panic into an explicit precondition violation.
|
||||
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
|
||||
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
||||
let perm = &PERM_CHALLENGE[key_type];
|
||||
let mut scratch = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
scratch[i] = challenge[perm[i]];
|
||||
}
|
||||
|
||||
let css_variant = match key_type {
|
||||
0 => variant as usize,
|
||||
1 => PERM_VARIANT[0][variant as usize] as usize,
|
||||
_ => PERM_VARIANT[1][variant as usize] as usize,
|
||||
};
|
||||
|
||||
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
|
||||
|
||||
let mut tmp1 = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
|
||||
}
|
||||
|
||||
let mut lfsr0: u32 = ((tmp1[0] as u32) << 17)
|
||||
| ((tmp1[1] as u32) << 9)
|
||||
| (((tmp1[2] as u32) & !7) << 1)
|
||||
| 8
|
||||
| (tmp1[2] as u32 & 7);
|
||||
|
||||
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
|
||||
|
||||
let mut bits = [0u8; 30];
|
||||
let mut carry: u32 = 0;
|
||||
for idx in (0..30).rev() {
|
||||
let mut val: u8 = 0;
|
||||
for bit in 0..8u8 {
|
||||
let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1;
|
||||
lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF;
|
||||
|
||||
let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1;
|
||||
lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF;
|
||||
|
||||
let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1);
|
||||
carry = (combined >> 1) & 1;
|
||||
val |= ((combined & 1) as u8) << bit;
|
||||
}
|
||||
bits[idx] = val;
|
||||
}
|
||||
|
||||
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
|
||||
let mut tmp2 = [0u8; 5];
|
||||
|
||||
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = scratch[i]; // original challenge, NOT modified tmp1
|
||||
}
|
||||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 2
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[20 + i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = tmp1[i];
|
||||
}
|
||||
tmp2[4] ^= tmp2[0];
|
||||
}
|
||||
|
||||
// Round 3 (uses CRYPT_TAB0)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[15 + i] ^ tmp2[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
|
||||
tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
|
||||
term = tmp2[i];
|
||||
}
|
||||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 4 (uses CRYPT_TAB0)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[10 + i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
|
||||
tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
|
||||
term = tmp1[i];
|
||||
}
|
||||
tmp2[4] ^= tmp2[0];
|
||||
}
|
||||
|
||||
// Round 5
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[5 + i] ^ tmp2[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = tmp2[i];
|
||||
}
|
||||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 6
|
||||
let mut key = [0u8; 5];
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = tmp1[i];
|
||||
}
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders ────────────────────────────────────────────────────
|
||||
|
||||
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[8] = (alloc_len >> 8) as u8;
|
||||
cdb[9] = alloc_len as u8;
|
||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
||||
cdb
|
||||
}
|
||||
|
||||
fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_SEND_KEY;
|
||||
cdb[8] = (param_len >> 8) as u8;
|
||||
cdb[9] = param_len as u8;
|
||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
||||
cdb
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
|
||||
/// raw key material. Scan every source file for a `tracing` field that
|
||||
/// binds a forbidden key name to a value-producing expression (`= expr`
|
||||
/// or `%expr` / `?expr`). The only allowed forms are a string literal
|
||||
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
|
||||
///
|
||||
/// This is a source-scan test (not a runtime capture) so it stays cheap
|
||||
/// and catches re-introductions at compile/CI time.
|
||||
#[test]
|
||||
fn no_key_bytes_in_instrumentation() {
|
||||
use std::path::Path;
|
||||
|
||||
// Forbidden field names whose VALUES must never be logged.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"title_key",
|
||||
"disc_key",
|
||||
"unit_key",
|
||||
"vuk",
|
||||
"player_key",
|
||||
"bus_key",
|
||||
];
|
||||
|
||||
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, forbidden, violations);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
// Only inspect tracing instrumentation lines.
|
||||
if !(trimmed.contains("tracing::")
|
||||
|| trimmed.starts_with("debug!")
|
||||
|| trimmed.starts_with("info!")
|
||||
|| trimmed.starts_with("warn!")
|
||||
|| trimmed.starts_with("trace!")
|
||||
|| trimmed.starts_with("error!"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// This guard test itself contains the forbidden names.
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
|
||||
&& line.contains("FORBIDDEN")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &name in forbidden {
|
||||
// A fingerprint field (`<name>_fp = ...`) is allowed.
|
||||
// Match `<name>` followed by optional fingerprint
|
||||
// suffix then `=` and a value that is NOT a string
|
||||
// literal redaction marker.
|
||||
if let Some(idx) = line.find(name) {
|
||||
let after = &line[idx + name.len()..];
|
||||
let after = after.trim_start();
|
||||
// `<name>_fp` / `<name>_id` etc. are safe.
|
||||
if after.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
// Must be a field binding `name = ...`.
|
||||
let Some(rest) = after.strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
// Redaction string literal is the only allowed value.
|
||||
if rest.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
|
||||
violations.push(format!(
|
||||
"{}:{}: forbidden key field `{}` logged with a value: {}",
|
||||
path.display(),
|
||||
lineno + 1,
|
||||
name,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan this crate's `src` plus the sibling workspace crates so the
|
||||
// key-material logging guard covers every crate that can reach the
|
||||
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
|
||||
// when building the crate standalone) are simply skipped.
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest.parent().unwrap_or(manifest);
|
||||
let mut violations = Vec::new();
|
||||
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
|
||||
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
|
||||
let dir = workspace.join(sibling).join("src");
|
||||
if dir.is_dir() {
|
||||
scan_dir(&dir, FORBIDDEN, &mut violations);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"key material logged in instrumentation:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_is_deterministic() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
let r1 = crypt_key(0, v, &challenge);
|
||||
let r2 = crypt_key(0, v, &challenge);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_nonzero() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
||||
|
||||
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
|
||||
/// the 10 challenge bytes). A non-permutation would drop/duplicate
|
||||
/// challenge bytes, weakening or corrupting the bus key derivation.
|
||||
///
|
||||
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
|
||||
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
|
||||
/// exactly once.
|
||||
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
|
||||
/// "covers 0..10" assert fires.
|
||||
#[test]
|
||||
fn perm_challenge_rows_are_permutations() {
|
||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
||||
let mut seen = [false; 10];
|
||||
for &idx in perm.iter() {
|
||||
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
|
||||
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
|
||||
seen[idx] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_CHALLENGE[{row}] misses an index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
|
||||
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
|
||||
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
|
||||
/// would make two variants indistinguishable.
|
||||
///
|
||||
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
|
||||
/// VARIANTS[css_variant] (0..32).
|
||||
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
|
||||
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
|
||||
#[test]
|
||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
||||
let mut seen = [false; 32];
|
||||
for &v in perm.iter() {
|
||||
let v = v as usize;
|
||||
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
|
||||
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
|
||||
seen[v] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_VARIANT[{row}] misses a value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
||||
|
||||
/// crypt_key result depends on every challenge byte. The challenge is
|
||||
/// permuted into `scratch` and folded through the LFSR seeding and the 6
|
||||
/// XOR rounds. Flipping any single challenge byte must change the output.
|
||||
///
|
||||
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
|
||||
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
|
||||
/// 0..5).
|
||||
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
|
||||
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
|
||||
/// entry — and some challenge byte stops mattering; this fails.
|
||||
#[test]
|
||||
fn crypt_key_depends_on_every_challenge_byte() {
|
||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let base_out = crypt_key(0, 5, &base);
|
||||
for i in 0..10 {
|
||||
let mut c = base;
|
||||
c[i] ^= 0x55;
|
||||
assert_ne!(
|
||||
crypt_key(0, 5, &c),
|
||||
base_out,
|
||||
"flipping challenge byte {i} did not change the bus-key derivation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
|
||||
/// variants on a fixed challenge. bus_auth brute-forces the variant by
|
||||
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
|
||||
/// collided, the wrong variant could be selected and the whole auth
|
||||
/// derail.
|
||||
///
|
||||
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
|
||||
/// which feeds every round; distinct variants give distinct cse-driven
|
||||
/// keys in practice.
|
||||
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
|
||||
/// outputs collapse to one value; the distinctness assert fires.
|
||||
#[test]
|
||||
fn crypt_key_type0_distinct_per_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut outs = Vec::new();
|
||||
for v in 0..32u8 {
|
||||
let k = crypt_key(0, v, &challenge);
|
||||
assert!(
|
||||
!outs.contains(&k),
|
||||
"variant {v} collides with an earlier variant"
|
||||
);
|
||||
outs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
/// crypt_key enforces its documented precondition `key_type < 3` via
|
||||
/// debug_assert (active in test builds). A key_type of 3 would index
|
||||
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
|
||||
/// explicit precondition panic.
|
||||
///
|
||||
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
|
||||
/// rows (indices 0,1,2).
|
||||
/// Mutation: delete the debug_assert AND the match-arm guard — but the
|
||||
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
|
||||
/// differently; with the assert in place this test pins the contract.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(3, 0, &challenge);
|
||||
}
|
||||
|
||||
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
|
||||
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
|
||||
///
|
||||
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
|
||||
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
|
||||
/// panic, but unguarded); the assert documents/enforces the contract.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(0, 32, &challenge);
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
|
||||
|
||||
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
|
||||
/// byte 0 = operation code 0xA4
|
||||
/// bytes 8-9 = allocation length, big-endian
|
||||
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
|
||||
/// All other bytes are zero.
|
||||
///
|
||||
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
|
||||
/// key format the low 6 bits.
|
||||
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
|
||||
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
|
||||
/// `agid << 5` -> the AGID-position assert fails.
|
||||
#[test]
|
||||
fn report_key_cdb_matches_mmc_layout() {
|
||||
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
|
||||
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
|
||||
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
|
||||
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b10 << 6) | 0x04,
|
||||
"AGID in bits 6-7, format in bits 0-5"
|
||||
);
|
||||
// Every other byte must be zero.
|
||||
for (i, &b) in cdb.iter().enumerate() {
|
||||
if ![0, 8, 9, 10].contains(&i) {
|
||||
assert_eq!(b, 0, "CDB byte {i} must be zero");
|
||||
}
|
||||
}
|
||||
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
|
||||
}
|
||||
|
||||
/// The key format field is masked to 6 bits: a format with high bits set
|
||||
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
|
||||
/// bits = 0x3F, AGID = 0.
|
||||
///
|
||||
/// Grounding: `(agid << 6) | (format & 0x3F)`.
|
||||
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
|
||||
/// byte 10 would be 0xFF not 0x3F, this fails.
|
||||
#[test]
|
||||
fn report_key_cdb_masks_format_to_6_bits() {
|
||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
||||
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
|
||||
}
|
||||
|
||||
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
||||
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
|
||||
/// 10.
|
||||
///
|
||||
/// Grounding: MMC SEND KEY CDB layout.
|
||||
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
|
||||
/// swap bytes 8/9 -> length assert fails.
|
||||
#[test]
|
||||
fn send_key_cdb_matches_mmc_layout() {
|
||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
||||
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
|
||||
assert_eq!(cdb[8], 0x00, "param_len high byte");
|
||||
assert_eq!(cdb[9], 0x0C, "param_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b11 << 6) | 0x03,
|
||||
"AGID bits 6-7, format bits 0-5"
|
||||
);
|
||||
assert_eq!(cdb.len(), 12);
|
||||
}
|
||||
|
||||
/// Allocation length larger than 255 must split across bytes 8 (high) and
|
||||
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
|
||||
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
|
||||
/// = 0x08, byte 9 = 0x04.
|
||||
///
|
||||
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
|
||||
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
|
||||
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
|
||||
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
|
||||
/// this asserts the high byte is present.
|
||||
#[test]
|
||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
||||
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
|
||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -24,13 +24,12 @@ use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
||||
/// `*p = TAB1[*p] ^ (i_t5 & 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) indicates encryption. This
|
||||
/// descrambler CLEARS that flag after unscrambling, so a descrambled sector
|
||||
/// reads as `sector[0x14] & 0x30 == 0`; callers and the 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
|
||||
|
||||
+46
-1
@@ -14,7 +14,6 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod lfsr;
|
||||
pub mod stevenson;
|
||||
pub(crate) mod tables;
|
||||
@@ -272,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 (libdvdcss's 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
|
||||
|
||||
@@ -260,9 +260,6 @@ 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.
|
||||
///
|
||||
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
||||
|
||||
+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
|
||||
|
||||
+12
-11
@@ -922,18 +922,19 @@ mod tests {
|
||||
2,
|
||||
"DTS 2.0 nibble must decode to 2 channels"
|
||||
);
|
||||
// PIDs route via the per-codec sub-id table: AC-3 #0 → 0x80 → 0xBD80,
|
||||
// DTS #0 → 0x88 → 0xBD88. Distinct (no 0xBD00 collision) AND the exact
|
||||
// canonical PIDs.
|
||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
|
||||
assert_eq!(audios[1].pid, 0xBD88, "DTS #0 → 0xBD88");
|
||||
// PIDs route via the positional sub-id table: AC-3 @ pos 0 → 0x80 →
|
||||
// 0xBD80, DTS @ pos 1 → 0x89 → 0xBD89 (the shared audio-stream number
|
||||
// in the low nibble, NOT a per-codec ordinal). Distinct AND the exact
|
||||
// canonical wire PIDs the demux routes on.
|
||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
|
||||
assert_eq!(audios[1].pid, 0xBD89, "DTS @ pos 1 → 0xBD89");
|
||||
assert_ne!(audios[0].pid, audios[1].pid);
|
||||
}
|
||||
|
||||
/// LPCM SCAN ROUTING (audit §2 / §5 #6): the 0xA0..=0xA7 PID range was never
|
||||
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) must get
|
||||
/// sub_stream_id 0xA0 → PID 0xBDA0 via `dvd_audio_pid`, distinct from the
|
||||
/// AC-3 0xBD80 space, with its real channel count preserved.
|
||||
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) at audio
|
||||
/// position 1 must get sub_stream_id 0xA1 → PID 0xBDA1 via `dvd_audio_pid`,
|
||||
/// distinct from the AC-3 0xBD80 space, with its real channel count preserved.
|
||||
#[test]
|
||||
fn scan_dvd_titles_lpcm_routes_to_a0_pid_range() {
|
||||
let mut disc = MemDisc::new();
|
||||
@@ -978,10 +979,10 @@ mod tests {
|
||||
assert_eq!(audios.len(), 2);
|
||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||
assert_eq!(audios[1].codec, Codec::Lpcm, "coding_mode 4 → LPCM");
|
||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
|
||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
|
||||
assert_eq!(
|
||||
audios[1].pid, 0xBDA0,
|
||||
"LPCM #0 → 0xBDA0 (the 0xA0 sub-id range), NOT the AC-3 space"
|
||||
audios[1].pid, 0xBDA1,
|
||||
"LPCM @ pos 1 → 0xBDA1 (the 0xA0 sub-id range | position), NOT the AC-3 space"
|
||||
);
|
||||
assert_eq!(
|
||||
audios[1].channels.count(),
|
||||
|
||||
+265
-219
@@ -11,47 +11,90 @@ use crate::udf;
|
||||
pub(super) struct HandshakeResult {
|
||||
pub volume_id: [u8; 16],
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// When `read_data_key` is `None` because the bus-key read FAILED (as opposed
|
||||
/// to a path that never attempts it), the error code from `read_data_keys`.
|
||||
/// Carried so the downstream bus-key gate can log WHY the bus key is missing
|
||||
/// instead of a bare "unavailable" — the difference between a diagnosable log
|
||||
/// and archaeology.
|
||||
pub read_data_key_err: Option<u16>,
|
||||
/// True when the VID came from an unlocker (in `freemkv-unlock`) that
|
||||
/// unlocked the drive. Such a drive serves CLEAR
|
||||
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same
|
||||
/// end state a successful cert handshake's `read_data_key` provides, just via
|
||||
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
|
||||
/// bus-removal: bus encryption is unremovable only when NEITHER the firmware
|
||||
/// unlocked the drive NOR the cert handshake yielded a bus key. Without this,
|
||||
/// a SUCCESSFUL unlock (VID present, `read_data_key: None`) paradoxically trips
|
||||
/// the gate and blocks ALL key resolution (incl. the online source).
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
|
||||
/// the external firmware [`crate::unlock::Unlocker`]s.
|
||||
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
|
||||
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
|
||||
/// encryption is gone when ANY of these holds:
|
||||
/// - the disc never had it (`!bus_encryption`): nothing to remove;
|
||||
/// - file/ISO reads (`handshake == None`): content is already clear at read time;
|
||||
/// - an unlocker unlocked the drive (`drive_unlocked`): it serves clear
|
||||
/// content;
|
||||
/// - the cert handshake produced the bus key (`read_data_key`).
|
||||
///
|
||||
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
|
||||
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
|
||||
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
|
||||
/// for external firmware unlockers (and keeps their unit tests trivially
|
||||
/// fakeable). So the firmware path stays transport-level and registry-routed,
|
||||
/// while this cert path is an in-tree Drive-level peer invoked directly by
|
||||
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
|
||||
/// [`crate::unlock::UnlockError`] taxonomy.
|
||||
/// Add a NEW removal mechanism HERE, never in the gate.
|
||||
fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResult>) -> bool {
|
||||
if !bus_encryption {
|
||||
return true; // never had it → nothing to remove
|
||||
}
|
||||
match handshake {
|
||||
None => true, // file/ISO: clear at read time
|
||||
Some(h) => h.drive_unlocked || h.read_data_key.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// libfreemkv-side driver for the AACS cert route. It owns the host-cert
|
||||
/// collection (a keysource concern that stays in libfreemkv) and then dispatches
|
||||
/// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the
|
||||
/// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch
|
||||
/// the same way at their own call sites; this one carries the host certs.
|
||||
struct AacsCertUnlocker<'a> {
|
||||
opts: &'a ScanOptions,
|
||||
}
|
||||
|
||||
/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv-
|
||||
/// side "no host cert at all" case (which carries the disc MKB generation for
|
||||
/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`].
|
||||
enum CertUnlockFailure {
|
||||
/// No host cert was available from any source — detected in libfreemkv
|
||||
/// before the unlocker runs, so the MKB generation is still known.
|
||||
NoHostCert { mkb: Option<u32> },
|
||||
/// The AACS unlocker ran and reported a specific failure.
|
||||
Unlock(freemkv_unlock::UnlockError),
|
||||
}
|
||||
|
||||
impl AacsCertUnlocker<'_> {
|
||||
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
|
||||
/// host certs from the key sources + credentials, try each (wedge-guarded),
|
||||
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
|
||||
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
|
||||
/// no-VID outcome.
|
||||
/// host certs from the key sources + credentials, then hand them to the AACS
|
||||
/// unlocker (via the `freemkv-unlock` dispatch), which tries each cert
|
||||
/// (wedge-guarded) and on success yields the Volume ID + `read_data_key`
|
||||
/// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID
|
||||
/// outcome.
|
||||
fn authenticate(
|
||||
&self,
|
||||
session: &mut crate::drive::Drive,
|
||||
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
|
||||
) -> std::result::Result<HandshakeResult, CertUnlockFailure> {
|
||||
use crate::aacs;
|
||||
use crate::unlock::UnlockError;
|
||||
use freemkv_unlock::UnlockError;
|
||||
|
||||
// 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)
|
||||
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
|
||||
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
|
||||
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
|
||||
// the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
|
||||
// caller, preserving the graceful path-1 disc-hash → VUK fallback). This
|
||||
// is detected here, where the MKB generation is still in hand.
|
||||
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
|
||||
if host_certs.is_empty() {
|
||||
tracing::warn!(
|
||||
@@ -59,122 +102,70 @@ impl AacsCertUnlocker<'_> {
|
||||
phase = "handshake_no_host_cert",
|
||||
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
|
||||
);
|
||||
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen });
|
||||
return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen });
|
||||
}
|
||||
let host_cert_count = host_certs.len();
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_start",
|
||||
host_cert_count,
|
||||
"handshake starting"
|
||||
);
|
||||
|
||||
// Cert-attempt wedge guard. An earlier version fired up to 16 AACS
|
||||
// authenticate attempts back-to-back with no pause — 80-160 SCSI
|
||||
// REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive
|
||||
// consumer optical drives into a fast-fail firmware wedge (every CDB
|
||||
// returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap
|
||||
// attempts, sleep between, bail early on the drive's wedge sense.
|
||||
const MAX_CERT_ATTEMPTS: usize = 3;
|
||||
const PER_CERT_BACKOFF_MS: u64 = 1000;
|
||||
let mut last_err_code: Option<u16> = None;
|
||||
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
if idx > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
|
||||
}
|
||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
Ok(vid) => vid,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_vid_read_failed",
|
||||
cert_index = idx,
|
||||
error_code = e.code(),
|
||||
"auth ok but volume ID read failed"
|
||||
);
|
||||
return Err(UnlockError::VidUnavailable);
|
||||
}
|
||||
};
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok()
|
||||
.map(|(rdk, _)| rdk);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_ok",
|
||||
cert_index = idx,
|
||||
has_read_data_key = read_data_key.is_some(),
|
||||
);
|
||||
return Ok(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
last_err_code = Some(e.code());
|
||||
// Read the wedge sense off the structured ScsiSense, NOT
|
||||
// `e.code()` (a flat constant for every ScsiError). On
|
||||
// ILLEGAL_REQUEST the drive is signalling it won't talk to us
|
||||
// — trying more certs worsens the wedge, so bail immediately.
|
||||
let sense = e.scsi_sense();
|
||||
if sense.map(|s| s.is_illegal_request()).unwrap_or(false) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_wedge_detected",
|
||||
cert_index = idx,
|
||||
sense_key = sense.map(|s| s.sense_key),
|
||||
asc = sense.map(|s| s.asc),
|
||||
ascq = sense.map(|s| s.ascq),
|
||||
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
|
||||
);
|
||||
return Err(UnlockError::HandshakeRejected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "vid_cert_rejected",
|
||||
host_cert_count,
|
||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
||||
last_error_code = last_err_code,
|
||||
"The drive rejected the AACS host certificate, so no Volume ID was obtained."
|
||||
// Hand the collected certs to the AACS unlocker. The cert-route bus
|
||||
// removal depends on the read_data_key, NOT a drive unlock. The
|
||||
// borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
|
||||
// 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 (_, unlock_res) = crate::unlock_bridge::run_bus(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Aacs,
|
||||
&fu_certs,
|
||||
);
|
||||
Err(UnlockError::HandshakeRejected)
|
||||
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));
|
||||
};
|
||||
Ok(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key: unlocked.bus_key,
|
||||
// The generic `Unlocked` contract carries no bus-key error code; the
|
||||
// AACS-specific "why the read_data_key read failed" diagnostic does
|
||||
// not cross the seam. The bus-key gate keys off presence, not cause.
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: unlocked.drive_unlocked,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the
|
||||
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
|
||||
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
|
||||
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
|
||||
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
|
||||
use crate::unlock::UnlockError;
|
||||
/// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert`
|
||||
/// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash →
|
||||
/// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the
|
||||
/// `<no host cert>` sentinel.)
|
||||
fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
|
||||
use freemkv_unlock::UnlockError;
|
||||
match e {
|
||||
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
|
||||
CertUnlockFailure::NoHostCert { .. }
|
||||
| CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert {
|
||||
path: "<no host cert>".into(),
|
||||
},
|
||||
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
|
||||
UnlockError::HandshakeRejected
|
||||
| UnlockError::CertRevoked { .. }
|
||||
| UnlockError::FirmwareNotUnlockable
|
||||
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
|
||||
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable,
|
||||
CertUnlockFailure::Unlock(
|
||||
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
|
||||
) => Error::AacsHostCertRejected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
|
||||
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
|
||||
/// for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
match e {
|
||||
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
|
||||
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
|
||||
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
|
||||
UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected,
|
||||
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => {
|
||||
UnlockOutcome::NoUsableHostCert { mkb: None }
|
||||
}
|
||||
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable,
|
||||
CertUnlockFailure::Unlock(
|
||||
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
|
||||
) => UnlockOutcome::HandshakeRejected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +174,11 @@ impl Disc {
|
||||
/// a structured `HandshakeResult` for downstream key resolution.
|
||||
///
|
||||
/// VID acquisition runs through [`Self::do_handshake_cert`], which first
|
||||
/// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID
|
||||
/// uses the OEM VID a firmware unlocker may have stashed at drive `init()`
|
||||
/// (a drive-functionality capability decoupled from the host cert + HRL)
|
||||
/// and falls back to the cert-based mutual-auth handshake when no
|
||||
/// unlocker serves one. The cert path also yields `read_data_key`,
|
||||
/// required for AACS 2.0 bus decryption.
|
||||
/// and falls back to the cert-based mutual-auth handshake (dispatched to the
|
||||
/// `freemkv-unlock` AACS unlocker) when none is present. The cert path also
|
||||
/// yields `read_data_key`, required for AACS 2.0 bus decryption.
|
||||
///
|
||||
/// Returns `(handshake, error)`:
|
||||
/// * `(Some(_), None)` — VID acquired
|
||||
@@ -219,11 +210,11 @@ impl Disc {
|
||||
|
||||
/// Cert-based AACS handshake — the cert route for VID acquisition.
|
||||
///
|
||||
/// Before running the cert mutual-auth, this asks the pluggable
|
||||
/// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker
|
||||
/// unlocks *drive functionality*, not just the disc: VID retrieval via
|
||||
/// the drive's OEM CDB is a capability separate from `unlock`. When the
|
||||
/// matching unlocker serves a VID, we use it and SKIP the cert handshake
|
||||
/// Before running the cert mutual-auth, this checks for an OEM Volume ID a
|
||||
/// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks
|
||||
/// *drive functionality*, not just the disc: VID retrieval via the drive's
|
||||
/// OEM CDB is a capability separate from `unlock`. When one served a VID,
|
||||
/// we use it and SKIP the cert handshake
|
||||
/// entirely — the OEM path gets the VID *without* the host certificate +
|
||||
/// HRL, decoupling VID from the cert chain. The OEM path yields no
|
||||
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
|
||||
@@ -239,24 +230,23 @@ 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> {
|
||||
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
||||
if let Some(c) = &opts.credentials {
|
||||
host_certs.extend(c.host_certs.iter().cloned());
|
||||
}
|
||||
for src in &opts.key_sources {
|
||||
host_certs.extend(src.host_certs(mkb));
|
||||
}
|
||||
host_certs
|
||||
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.
|
||||
crate::aacs::host_certs::collect_host_certs(opts, mkb)
|
||||
}
|
||||
|
||||
fn do_handshake_cert(
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
|
||||
// Volume ID at drive `init()` (the new `unlock()` folds in the old
|
||||
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
|
||||
// OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
|
||||
// drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
|
||||
// Use it and SKIP the cert handshake — the OEM path
|
||||
// decouples the VID from the host cert + HRL. It yields no
|
||||
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
||||
// by the bus-key gate in `resolve_vid_only`.
|
||||
@@ -270,6 +260,13 @@ impl Disc {
|
||||
Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key: None,
|
||||
// OEM/VID-only path never attempts the bus-key read — None here
|
||||
// is "not attempted", not "failed".
|
||||
read_data_key_err: None,
|
||||
// The unlocker stashed this VID at init, which means it
|
||||
// matched and unlocked the drive — it now serves clear content,
|
||||
// so bus encryption is removed at the drive. Credit it.
|
||||
drive_unlocked: true,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
@@ -296,7 +293,7 @@ impl Disc {
|
||||
outcome = ?cert_unlock_outcome(&e),
|
||||
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
|
||||
);
|
||||
(None, Some(unlock_error_to_error(e)))
|
||||
(None, Some(unlock_error_to_error(&e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,69 +312,97 @@ impl Disc {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.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, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.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);
|
||||
let version = match cc.as_ref().map(|c| c.version) {
|
||||
Some(aacs::AacsVersion::V10) => 1,
|
||||
Some(_) => 2,
|
||||
None if bus_encryption => 2,
|
||||
None => 1,
|
||||
};
|
||||
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
|
||||
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
|
||||
// stride on the main resolve path fails loudly (sample validation) rather
|
||||
// than silently, so the conservative V20 default is safe here too.
|
||||
let version = cc
|
||||
.as_ref()
|
||||
.map(|c| c.version.major())
|
||||
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
|
||||
|
||||
// OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content
|
||||
// Certificate bus-encryption bit set) still carries bus encryption on
|
||||
// its sectors; descrambling needs the `read_data_key` (bus key), which
|
||||
// ONLY the AACS host-certificate cert-auth handshake produces. A
|
||||
// VID-only OEM unlock path returns `read_data_key: None`, and a VID
|
||||
// alone does NOT remove bus encryption — so if a handshake ran (live
|
||||
// drive) and yielded a VID but no bus key on a bus-encrypted disc, the
|
||||
// bytes would decrypt to garbage. Fail loudly here instead.
|
||||
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
|
||||
// Certificate bus-encryption bit set) carries bus encryption on its
|
||||
// sectors, which MUST be removed before any AACS key can decrypt them.
|
||||
// There are TWO ways it gets removed, and bus encryption is unremovable
|
||||
// only when NEITHER succeeded:
|
||||
// 1. An unlocker unlocked the drive → it serves CLEAR content
|
||||
// (`drive_unlocked`). This is the common live-drive case and yields
|
||||
// no `read_data_key` — it doesn't need one.
|
||||
// 2. The AACS host-certificate cert-auth handshake produced the bus key
|
||||
// (`read_data_key`).
|
||||
// The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID
|
||||
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
|
||||
// blocked ALL key resolution — including the online source — even though
|
||||
// the drive was serving clear content. That was the bug.
|
||||
//
|
||||
// Gated on `handshake.is_some()` so the two preserved cases never
|
||||
// regress: (1) file-backed/ISO scans reach here with `handshake = None`
|
||||
// and have already had bus encryption removed at read time; (2) AACS 1.0
|
||||
// BD is not bus-encrypted, so `bus_encryption` is false and the gate is
|
||||
// skipped (its `read_data_key` is legitimately absent).
|
||||
if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) {
|
||||
// Also skipped when `handshake = None` (file-backed/ISO scans — bus
|
||||
// encryption already removed at read time) and when `bus_encryption` is
|
||||
// false (AACS 1.0 BD is not bus-encrypted).
|
||||
// ONE question — "is AACS bus encryption gone?" — asked of the single
|
||||
// `bus_encryption_removed` predicate, which OWNS every case (never had it,
|
||||
// file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
|
||||
if !bus_encryption_removed(bus_encryption, handshake) {
|
||||
let (rdk_err, has_vid) = handshake
|
||||
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
|
||||
.unwrap_or((None, false));
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "bus_key_unavailable",
|
||||
"Disc declares bus encryption but the handshake produced no read_data_key; a VID-only/OEM unlock cannot remove bus encryption. Refusing to proceed with a key that would decrypt to garbage."
|
||||
read_data_key_err = ?rdk_err,
|
||||
has_volume_id = has_vid,
|
||||
"Disc declares bus encryption but it could not be removed: no unlocker \
|
||||
unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
|
||||
emit a key that would decrypt to garbage."
|
||||
);
|
||||
return Err(Error::AacsBusKeyUnavailable);
|
||||
}
|
||||
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
|
||||
// the real record length (same as `read_aacs_inputs`). Without this the
|
||||
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
||||
// processing-key `decrypt_with` derivation consume, and which a key
|
||||
// source ships to an online service — is the full 128 MiB pad, not the
|
||||
// ~few-MB record stream.
|
||||
let mkb_bytes = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
// Trim to the real record length. Use trim_mkb rather than a raw
|
||||
// truncate: trim_mkb only truncates when content_len > 0 and strictly
|
||||
// inside the buffer, so a malformed/unrecognised MKB is preserved
|
||||
// intact instead of being zeroed by truncate(0).
|
||||
let mkb_bytes = aacs::trim_mkb(mkb_bytes);
|
||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
||||
// Read the MKB record stream via the SAME bounded reader the
|
||||
// out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow
|
||||
// read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a
|
||||
// fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on
|
||||
// file-backed / large readers — which left `a.mkb` empty here, silently
|
||||
// breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to
|
||||
// the decode service and it 404'd, while autorip's separate
|
||||
// `read_aacs_inputs` path (this same helper) worked. One reader now, so
|
||||
// `Disc::inputs()` is the single complete source of AACS inputs.
|
||||
// A read ERROR is surfaced (logged), not silently emptied: an empty MKB
|
||||
// here is invisible until an online key service rejects the request, so
|
||||
// a transient I/O hiccup must not masquerade as "no MKB". We still
|
||||
// continue with an empty MKB (disc-hash-keyed keydb lookups don't need
|
||||
// it), but the cause is now on the log.
|
||||
let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_mkb",
|
||||
error = %e,
|
||||
"MKB read failed at scan; AACS inputs will carry an empty MKB \
|
||||
(online key resolution cannot proceed without it). Continuing \
|
||||
— disc-hash-keyed lookups are unaffected."
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
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(),
|
||||
@@ -388,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![],
|
||||
@@ -547,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];
|
||||
@@ -559,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).
|
||||
@@ -646,10 +671,12 @@ mod tests {
|
||||
assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
|
||||
}
|
||||
|
||||
/// No content cert at all but bus_encryption can't be read → version
|
||||
/// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false.
|
||||
/// No content cert at all → version defaults to UHD (major 2), matching
|
||||
/// `read_aacs_version` so the scanned `AacsState.version` and the out-of-band
|
||||
/// fetch agree on the Unit_Key_RO stride (audit #4: a wrong BD-vs-UHD guess
|
||||
/// mis-parses unit keys). bus_encryption false (unreadable → off).
|
||||
#[test]
|
||||
fn resolve_vid_only_no_cert_defaults_version_1() {
|
||||
fn resolve_vid_only_no_cert_defaults_version_uhd() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
@@ -661,12 +688,16 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(st.version, 1, "no cert → default version 1");
|
||||
assert_eq!(
|
||||
st.version,
|
||||
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() {
|
||||
@@ -682,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.
|
||||
@@ -718,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);
|
||||
@@ -765,6 +796,8 @@ mod tests {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: vid,
|
||||
read_data_key: Some(rdk),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
|
||||
assert_eq!(st.volume_id, vid);
|
||||
@@ -809,6 +842,8 @@ mod tests {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
|
||||
.expect_err("bus-encrypted disc with no bus key must hard-error");
|
||||
@@ -823,6 +858,8 @@ mod tests {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: Some([0x22u8; 16]),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
|
||||
assert!(st.bus_encryption);
|
||||
@@ -849,6 +886,8 @@ mod tests {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
|
||||
assert!(!st.bus_encryption);
|
||||
@@ -907,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);
|
||||
@@ -929,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,
|
||||
@@ -939,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()
|
||||
}
|
||||
}
|
||||
@@ -1013,45 +1052,52 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unlock_error_maps_to_legacy_error_variants() {
|
||||
use crate::unlock::UnlockError;
|
||||
// No host cert keeps the AacsNoHostCert sentinel path.
|
||||
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
|
||||
use freemkv_unlock::UnlockError;
|
||||
// No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert
|
||||
// sentinel path — as does the unlocker's own NoUsableHostCert.
|
||||
match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) {
|
||||
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
||||
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||
}
|
||||
match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) {
|
||||
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
||||
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::VidUnavailable),
|
||||
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||
Error::AacsVidUnavailable
|
||||
));
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::HandshakeRejected),
|
||||
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
|
||||
Error::AacsHostCertRejected
|
||||
));
|
||||
// A transport fault folds to the rejected surface too.
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
|
||||
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||
Error::AacsHostCertRejected
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
// The libfreemkv-side no-cert case carries the MKB generation.
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
|
||||
cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }),
|
||||
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
|
||||
);
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::VidUnavailable),
|
||||
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||
UnlockOutcome::VidUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::HandshakeRejected),
|
||||
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
|
||||
UnlockOutcome::HandshakeRejected
|
||||
);
|
||||
// A SCSI/transport error folds to HandshakeRejected at the trace layer.
|
||||
// A transport fault folds to HandshakeRejected at the trace layer.
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::Scsi(4000)),
|
||||
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||
UnlockOutcome::HandshakeRejected
|
||||
);
|
||||
}
|
||||
|
||||
+86
-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,
|
||||
};
|
||||
|
||||
@@ -686,6 +667,7 @@ fn report(opts: &ExtractOptions, done: u64, total: u64) -> bool {
|
||||
bytes_bad_in_main_title: 0,
|
||||
main_title_duration_secs: None,
|
||||
main_title_size_bytes: None,
|
||||
located: Default::default(),
|
||||
};
|
||||
p.report(&pp)
|
||||
}
|
||||
@@ -850,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 {
|
||||
@@ -859,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]) {
|
||||
@@ -890,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;
|
||||
@@ -1082,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
|
||||
@@ -1100,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 {
|
||||
@@ -1123,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;
|
||||
@@ -1411,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() {
|
||||
@@ -1584,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"
|
||||
@@ -1599,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)
|
||||
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
//! HD-DVD title scanning — `HVDVD_TS/` Enhanced-VOB (`.evo`) enumeration.
|
||||
//!
|
||||
//! HD-DVD is a **tree-level peer** of DVD and Blu-ray (not a stream variant like
|
||||
//! FMTS): its content lives in `HVDVD_TS/` as `.evo` clips — Enhanced VOB, an
|
||||
//! MPEG **program** stream — each with a small `.map` timemap sidecar, navigated
|
||||
//! by `.xpl`/`.ifo` playlists in `HVDVD_TS/` and `ADV_OBJ/`. Because it is a
|
||||
//! different tree with a different playlist format, it gets its OWN scanner
|
||||
//! (this file), a peer to [`Disc::scan_bluray_titles`] — the two-format design
|
||||
//! rule: a genuinely different format is a new enumerator, not an extension
|
||||
//! bolted into the BD path.
|
||||
//!
|
||||
//! Title composition: the `HVA*.VTI` navigation file (the DVD-IFO analogue) names
|
||||
//! every clip in authored order. Standard Content splits the main feature across
|
||||
//! clips at the layer break (`FEATURE_1`/`FEATURE_2`, or `feature`/`feature_Divide`);
|
||||
//! the scanner parses the VTI clip table and concatenates those parts into ONE
|
||||
//! [`DiscTitle`] (so the largest-title pick is the whole movie, not just part 1),
|
||||
//! emitting every other clip as its own title. Container is
|
||||
//! [`ContentFormat::MpegPs`], so the existing PS mux path handles it. Per-clip
|
||||
//! streams ARE enumerated: the clip head is demuxed through the PS demuxer and one
|
||||
//! [`Stream`] is built per distinct elementary stream (video + DD+ audio
|
||||
//! sub-streams), with the codec sniffed from the ES bytes.
|
||||
//!
|
||||
//! What is NOT parsed yet — and is honestly stubbed, not faked:
|
||||
//! * full VTI program-chain parsing (chapters, non-feature title grouping) —
|
||||
//! the feature join uses the clip table + the `feature*` naming convention,
|
||||
//! not the authoritative PGC,
|
||||
//! * `.map` timemap → real durations,
|
||||
//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
|
||||
//!
|
||||
//! Extents and size ARE real (the ripper needs those to image a clip); durations
|
||||
//! and chapters are left empty rather than guessed.
|
||||
|
||||
use super::*;
|
||||
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Clip stream-file extension in the HD-DVD `HVDVD_TS/` tree. HD-DVD is a
|
||||
/// separate tree from BD, so this is a separate constant — deliberately NOT an
|
||||
/// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`.
|
||||
const HDDVD_CLIP_EXT: &str = ".evo";
|
||||
|
||||
/// Sectors of an `.evo` clip head to demux when probing its elementary streams
|
||||
/// (~16 MiB). Enough to see the opening video access unit (SPS) plus every
|
||||
/// interleaved audio sub-stream, without imaging the whole multi-GiB clip.
|
||||
const EVO_PROBE_SECTORS: u32 = 8192;
|
||||
|
||||
/// Cap on the elementary-stream sample retained per stream while probing — a
|
||||
/// video SPS / audio syncword lands well inside the first few KiB, so 128 KiB
|
||||
/// is generous while bounding probe memory.
|
||||
const EVO_ES_SAMPLE_CAP: usize = 128 * 1024;
|
||||
|
||||
/// HD-DVD Standard Content navigation file magic (`HVDVD_TS/HVA*.VTI`). The VTI
|
||||
/// is the DVD-IFO analogue: it holds a fixed-stride clip table naming every
|
||||
/// `.evo` in authored order.
|
||||
const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS";
|
||||
|
||||
/// Byte stride between clip-table entries in the VTI. Each entry holds a
|
||||
/// NUL-terminated `<name>.EVO` at a constant sub-offset, so every clip name
|
||||
/// shares one residue modulo this stride — the signal used to isolate the table.
|
||||
const VTI_CLIP_ENTRY_STRIDE: usize = 0x140;
|
||||
|
||||
/// Cap on clip-name hits collected from a VTI. A real clip table holds a few
|
||||
/// dozen entries; this bounds the scan so a crafted VTI packed with millions of
|
||||
/// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory.
|
||||
const MAX_VTI_HITS: usize = 8192;
|
||||
|
||||
/// Parse the clip-name table from an `ADVANCED-VTS` VTI, returning clip
|
||||
/// filenames in authored (table) order.
|
||||
///
|
||||
/// The table is a run of `VTI_CLIP_ENTRY_STRIDE`-spaced records, each carrying a
|
||||
/// NUL-terminated `<name>.EVO`. Rather than trust the (imprecise) header pointer,
|
||||
/// this collects every NUL-terminated `*.EVO` name and keeps the largest group
|
||||
/// sharing one residue modulo the stride — the clip table — in offset order.
|
||||
/// Returns empty for a non-VTI blob or one with no recognizable table.
|
||||
fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
|
||||
if !vti.starts_with(HDDVD_VTI_MAGIC) {
|
||||
return Vec::new();
|
||||
}
|
||||
let is_name_byte = |b: u8| b.is_ascii_graphic();
|
||||
// Bucket hits by residue-mod-stride in a SINGLE pass — the clip table shares
|
||||
// one residue, so the largest bucket is it (avoids an O(stride*hits) rescan).
|
||||
let mut buckets: std::collections::HashMap<usize, Vec<(usize, String)>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut count = 0usize;
|
||||
let mut i = 0usize;
|
||||
while i < vti.len() && count < MAX_VTI_HITS {
|
||||
if !is_name_byte(vti[i]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < vti.len() && is_name_byte(vti[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let name = &vti[start..i];
|
||||
let nul_terminated = i < vti.len() && vti[i] == 0;
|
||||
if nul_terminated && name.len() >= 5 && name[name.len() - 4..].eq_ignore_ascii_case(b".EVO")
|
||||
{
|
||||
buckets
|
||||
.entry(start % VTI_CLIP_ENTRY_STRIDE)
|
||||
.or_default()
|
||||
.push((start, String::from_utf8_lossy(name).into_owned()));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
// Pick the largest residue bucket (the clip table). On a size tie, break
|
||||
// deterministically by the bucket's smallest offset — `HashMap` iteration
|
||||
// order is randomized, so `max_by_key` alone could pick a different bucket
|
||||
// run-to-run on identical bytes.
|
||||
let Some(mut best) = buckets
|
||||
.into_values()
|
||||
.max_by_key(|g| (g.len(), std::cmp::Reverse(g.iter().map(|(o, _)| *o).min())))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
best.sort_by_key(|(o, _)| *o);
|
||||
best.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
/// Whether a clip belongs to the main feature. HD-DVD Standard Content authors
|
||||
/// the feature as one or more clips whose name begins `feature` (case-insensitive)
|
||||
/// — `FEATURE_1`/`FEATURE_2` (a layer-break split) or `feature`/`feature_Divide`.
|
||||
/// The feature is imaged as ONE title by concatenating these in authored order.
|
||||
fn is_feature_clip(name: &str) -> bool {
|
||||
let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(name);
|
||||
base.to_ascii_lowercase().starts_with("feature")
|
||||
}
|
||||
|
||||
/// Sniff a video codec from a program-stream video elementary-stream sample by
|
||||
/// its MPEG / Annex-B start codes:
|
||||
/// * `00 00 01 B3` → MPEG-2 (sequence_header)
|
||||
/// * `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU)
|
||||
/// * `00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264
|
||||
///
|
||||
/// Returns `None` when no recognizable start code is present. The scan prefers
|
||||
/// the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS
|
||||
/// NAL so a stray slice/picture code can't be mistaken for a different codec.
|
||||
fn sniff_video_codec(es: &[u8]) -> Option<Codec> {
|
||||
let mut saw_h264_sps = false;
|
||||
let mut i = 0usize;
|
||||
while i + 4 <= es.len() {
|
||||
if es[i] == 0x00 && es[i + 1] == 0x00 && es[i + 2] == 0x01 {
|
||||
let code = es[i + 3];
|
||||
match code {
|
||||
0xB3 => return Some(Codec::Mpeg2),
|
||||
0x0F => return Some(Codec::Vc1),
|
||||
// H.264 SPS: mask off nal_ref_idc (bits 6-5); keep the
|
||||
// forbidden_zero_bit (must be 0) + nal_unit_type (low 5 bits).
|
||||
// 0x07/0x27/0x47/0x67 all decode to a type-7 SPS.
|
||||
_ if (code & 0x9F) == 0x07 => saw_h264_sps = true,
|
||||
_ => {}
|
||||
}
|
||||
// Skip the whole consumed `00 00 01 <code>` marker (4 bytes) so the
|
||||
// code byte isn't re-read as the start of an overlapping start code.
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
saw_h264_sps.then_some(Codec::H264)
|
||||
}
|
||||
|
||||
/// Sniff an audio codec from a `private_stream_1` sub-stream sample. Today only
|
||||
/// Dolby Digital Plus (E-AC-3) is recognized — its `0x0B77` syncword — which is
|
||||
/// what ANCHORMAN / SHAUN carry on sub-ids `0xC0..=0xC7`. Returns `None` for an
|
||||
/// unrecognized sample so the caller drops the stream rather than mislabeling it.
|
||||
fn sniff_audio_codec(es: &[u8]) -> Option<Codec> {
|
||||
let has_sync = es.windows(2).any(|w| w[0] == 0x0B && w[1] == 0x77);
|
||||
has_sync.then_some(Codec::Ac3Plus)
|
||||
}
|
||||
|
||||
/// Demux the head of an `.evo` clip (through the disc's [`SectorSource`]) and
|
||||
/// build one [`Stream`] per distinct elementary stream found: the video track
|
||||
/// (mapped to the canonical [`DVD_VIDEO_PID`]) and every DD+ audio sub-stream
|
||||
/// (mapped via [`dvd_audio_pid`]). Codec is sniffed from the demuxed ES bytes.
|
||||
///
|
||||
/// Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution /
|
||||
/// language / channels use sane HD-DVD defaults (the muxer reads the true pixel
|
||||
/// dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded
|
||||
/// here). Returns an empty vec when the clip cannot be read or carries no
|
||||
/// recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext).
|
||||
fn probe_evo_streams(reader: &mut dyn SectorSource, extents: &[Extent]) -> Vec<Stream> {
|
||||
let mut demux = PsDemuxer::new();
|
||||
let mut video: Vec<u8> = Vec::new();
|
||||
// Routing PID of the video track, captured from the first video PES seen:
|
||||
// `DVD_VIDEO_PID` for a plain 0xE0-0xEF stream (Anchorman's H.264 on 0xE2),
|
||||
// or `0xFD00 | stream_id_extension` for an HD-DVD extended-stream-id video
|
||||
// (Shaun's VC-1 on 0xFD ext 0x55). Kept in lockstep with `PsPacket::dvd_pid`
|
||||
// so the emitted `Stream` PID matches what the demuxer routes at mux time.
|
||||
let mut video_pid: Option<u16> = None;
|
||||
// sub_id -> ES sample, ordered so audio tracks surface in sub-id order.
|
||||
let mut audio: BTreeMap<u8, Vec<u8>> = BTreeMap::new();
|
||||
|
||||
let mut remaining = EVO_PROBE_SECTORS;
|
||||
'outer: for ext in extents {
|
||||
let mut lba = ext.start_lba;
|
||||
let mut left = ext.sector_count;
|
||||
while left > 0 && remaining > 0 {
|
||||
// 1 MiB read chunks (512 sectors) keep buffers small.
|
||||
let n = left.min(remaining).min(512) as u16;
|
||||
let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES];
|
||||
if reader.read_sectors(lba, n, &mut buf, false).is_err() {
|
||||
break 'outer;
|
||||
}
|
||||
for pkt in demux.feed(&buf) {
|
||||
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
|
||||
}
|
||||
lba += n as u32;
|
||||
left -= n as u32;
|
||||
remaining -= n as u32;
|
||||
}
|
||||
}
|
||||
for pkt in demux.flush() {
|
||||
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
|
||||
}
|
||||
|
||||
let mut streams = Vec::new();
|
||||
// Emit the video stream only when the codec was actually identified from the
|
||||
// sampled head. Guessing (e.g. defaulting to H.264) would tag a VC-1 — or a
|
||||
// still-encrypted — clip with the wrong codec, so the mux applies the wrong
|
||||
// parser and produces a corrupt track; dropping it is the honest outcome
|
||||
// (matches the audio path below), and a real clear clip always carries its
|
||||
// sequence header / SPS at the head, so this never fires on a normal disc.
|
||||
if let (Some(pid), Some(codec)) = (video_pid, sniff_video_codec(&video)) {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid,
|
||||
codec,
|
||||
// HD-DVD is HD (1080). The muxer reads the true coded dimensions
|
||||
// from the H.264/VC-1 bitstream; this is a coarse default only.
|
||||
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,
|
||||
}));
|
||||
}
|
||||
for (sub, sample) in &audio {
|
||||
let Some(codec) = sniff_audio_codec(sample) else {
|
||||
continue;
|
||||
};
|
||||
let Some(pid) = dvd_audio_pid(*sub) else {
|
||||
continue;
|
||||
};
|
||||
streams.push(Stream::Audio(AudioStream {
|
||||
pid,
|
||||
codec,
|
||||
// DD+ main tracks are 5.1; E-AC-3 channel counts are not decoded at
|
||||
// scan time, so this is a default (a 2.0 track is over-stated as
|
||||
// 5.1 in the header — the compressed audio itself is unaffected).
|
||||
channels: AudioChannels::Surround51,
|
||||
language: String::new(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
}));
|
||||
}
|
||||
streams
|
||||
}
|
||||
|
||||
/// Accumulate a demuxed PES packet's elementary-stream bytes into the video /
|
||||
/// per-audio-sub-id sample buffers (bounded by [`EVO_ES_SAMPLE_CAP`]).
|
||||
fn collect_es(
|
||||
pkt: &crate::mux::ps::PsPacket,
|
||||
video: &mut Vec<u8>,
|
||||
video_pid: &mut Option<u16>,
|
||||
audio: &mut BTreeMap<u8, Vec<u8>>,
|
||||
) {
|
||||
use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
|
||||
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
||||
/// VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension`
|
||||
/// `0x55`. HD audio (MLP/TrueHD) can also use `0xFD` with other extensions —
|
||||
/// routing those to their own audio tracks is deferred (see the HD-DVD
|
||||
/// program-chain follow-up); until then only the VC-1 extension is treated as
|
||||
/// video, so an audio `0xFD` sub-stream can never mis-stamp the video PID.
|
||||
const VC1_STREAM_ID_EXT: u8 = 0x55;
|
||||
// Whether this packet is the VC-1 video sub-stream of the 0xFD extended id.
|
||||
let is_vc1_ext =
|
||||
pkt.stream_id == EXTENDED_STREAM_ID && pkt.sub_stream_id == Some(VC1_STREAM_ID_EXT);
|
||||
match pkt.stream_id {
|
||||
// Plain MPEG video (0xE0-0xEF), or the VC-1 sub-stream of the HD-DVD
|
||||
// extended-stream-id (0xFD). Both feed the single video ES sample; the
|
||||
// routing PID comes from `PsPacket::dvd_pid` so it matches the demuxer.
|
||||
VIDEO..=VIDEO_MAX => {
|
||||
if video_pid.is_none() {
|
||||
*video_pid = pkt.dvd_pid();
|
||||
}
|
||||
if video.len() < EVO_ES_SAMPLE_CAP {
|
||||
video.extend_from_slice(&pkt.data);
|
||||
}
|
||||
}
|
||||
EXTENDED_STREAM_ID if is_vc1_ext => {
|
||||
if video_pid.is_none() {
|
||||
*video_pid = pkt.dvd_pid();
|
||||
}
|
||||
if video.len() < EVO_ES_SAMPLE_CAP {
|
||||
video.extend_from_slice(&pkt.data);
|
||||
}
|
||||
}
|
||||
PRIVATE_STREAM_1 => {
|
||||
if let Some(sub) = pkt.sub_stream_id {
|
||||
if (0xC0..=0xC7).contains(&sub) {
|
||||
let slot = audio.entry(sub).or_default();
|
||||
if slot.len() < EVO_ES_SAMPLE_CAP {
|
||||
slot.extend_from_slice(&pkt.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
|
||||
///
|
||||
/// The main feature is authored as one or more `.evo` clips (a layer-break
|
||||
/// split — `FEATURE_1`/`FEATURE_2` or `feature`/`feature_Divide`). The
|
||||
/// `HVA*.VTI` navigation file names every clip in authored order; this parses
|
||||
/// it to concatenate the feature clips into ONE title (so the largest-title
|
||||
/// pick gets the whole movie, not just part 1), emitting every other clip as
|
||||
/// its own title. Falls back to one title per clip when the VTI is absent or
|
||||
/// unparseable, so a disc with no readable navigation still enumerates.
|
||||
/// `chapters`/duration are left empty pending deeper VTI parsing.
|
||||
pub(super) fn scan_hddvd_titles(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
let Some(ts_dir) = udf_fs.find_dir("/HVDVD_TS") else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Snapshot clips (name, size) and the VTI navigation file. The `ts_dir`
|
||||
// borrow must end before the `udf_fs` reads below re-borrow it.
|
||||
let mut clips: Vec<(String, u64)> = Vec::new();
|
||||
let mut vti_name: Option<String> = None;
|
||||
for e in &ts_dir.entries {
|
||||
if e.is_dir {
|
||||
continue;
|
||||
}
|
||||
let lower = e.name.to_ascii_lowercase();
|
||||
if lower.ends_with(HDDVD_CLIP_EXT) {
|
||||
clips.push((e.name.clone(), e.size));
|
||||
} else if lower.ends_with(".vti") && vti_name.is_none() {
|
||||
vti_name = Some(e.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Authored clip order from the VTI clip table (empty if no VTI).
|
||||
let order: Vec<String> = vti_name
|
||||
.and_then(|n| udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")).ok())
|
||||
.map(|b| parse_vti_clip_order(&b))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Resolve each clip's physical extents once, keyed by lower-case name.
|
||||
let mut clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = BTreeMap::new();
|
||||
for (name, size) in &clips {
|
||||
let mut extents = Vec::new();
|
||||
if let Ok(file_exts) = udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) {
|
||||
for (lba, sectors) in file_exts {
|
||||
if sectors > 0 && lba > 0 {
|
||||
extents.push(Extent {
|
||||
start_lba: lba,
|
||||
sector_count: sectors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if !extents.is_empty() {
|
||||
clip_extents.insert(name.to_ascii_lowercase(), (name.clone(), *size, extents));
|
||||
}
|
||||
}
|
||||
|
||||
// Feature clips, in authored order, that actually resolved to extents.
|
||||
let feature: Vec<String> = order
|
||||
.iter()
|
||||
.filter(|n| is_feature_clip(n))
|
||||
.filter(|n| clip_extents.contains_key(&n.to_ascii_lowercase()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let feature_set: std::collections::HashSet<String> =
|
||||
feature.iter().map(|n| n.to_ascii_lowercase()).collect();
|
||||
|
||||
let mut titles = Vec::new();
|
||||
let mut next_id = 0u16;
|
||||
|
||||
// The composed feature title: concatenate its parts' extents in authored
|
||||
// order. Streams are probed from the head (the first part). One `Clip` per
|
||||
// part records the composition.
|
||||
if !feature.is_empty() {
|
||||
let mut extents = Vec::new();
|
||||
let mut size_bytes = 0u64;
|
||||
let mut parts = Vec::new();
|
||||
for n in &feature {
|
||||
if let Some((orig, size, exts)) = clip_extents.get(&n.to_ascii_lowercase()) {
|
||||
extents.extend_from_slice(exts);
|
||||
size_bytes += *size;
|
||||
parts.push(Clip {
|
||||
clip_id: orig
|
||||
.rsplit_once('.')
|
||||
.map(|(b, _)| b)
|
||||
.unwrap_or(orig)
|
||||
.to_string(),
|
||||
in_time: 0,
|
||||
out_time: 0,
|
||||
duration_secs: 0.0,
|
||||
source_packets: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
let streams = probe_evo_streams(reader, &extents);
|
||||
titles.push(DiscTitle {
|
||||
playlist: "FEATURE".to_string(),
|
||||
playlist_id: next_id,
|
||||
duration_secs: 0.0,
|
||||
size_bytes,
|
||||
clips: parts,
|
||||
streams,
|
||||
chapters: Vec::new(),
|
||||
extents,
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: Vec::new(),
|
||||
});
|
||||
next_id = next_id.saturating_add(1);
|
||||
}
|
||||
|
||||
// Every remaining clip is its own title (unchanged behaviour). Iterated in
|
||||
// directory order; when there is no VTI/feature this emits ALL clips.
|
||||
for (name, _size) in &clips {
|
||||
let key = name.to_ascii_lowercase();
|
||||
if feature_set.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
let Some((orig, size, extents)) = clip_extents.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
// Probe the clip head for its elementary streams so the mux path
|
||||
// builds a non-empty `pid_to_track` and actually routes packets.
|
||||
let streams = probe_evo_streams(reader, extents);
|
||||
let clip_id = orig
|
||||
.rsplit_once('.')
|
||||
.map(|(base, _)| base.to_string())
|
||||
.unwrap_or_else(|| orig.clone());
|
||||
titles.push(DiscTitle {
|
||||
playlist: orig.clone(),
|
||||
playlist_id: next_id,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: *size,
|
||||
clips: vec![Clip {
|
||||
clip_id,
|
||||
in_time: 0,
|
||||
out_time: 0,
|
||||
duration_secs: 0.0,
|
||||
source_packets: 0,
|
||||
}],
|
||||
streams,
|
||||
chapters: Vec::new(),
|
||||
extents: extents.clone(),
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: Vec::new(),
|
||||
});
|
||||
next_id = next_id.saturating_add(1);
|
||||
}
|
||||
titles
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::udf::fixture::*;
|
||||
|
||||
/// Build a UDF with an `HVDVD_TS/` tree holding the listed `.evo` clips
|
||||
/// (name, sector count, data LBA).
|
||||
fn make_hddvd_fs(disc: &mut MemDisc, evos: &[(&str, u32, u32)]) -> crate::udf::UdfFs {
|
||||
let mut files = Vec::new();
|
||||
let mut icb = 100u32;
|
||||
for (name, sectors, data_lba) in evos {
|
||||
files.push(file(name, icb, *data_lba, sectors * 2048, true));
|
||||
icb += 1;
|
||||
}
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "HVDVD_TS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files,
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(disc, 10);
|
||||
lay_dir(disc, &root);
|
||||
crate::udf::read_filesystem(disc).expect("fs")
|
||||
}
|
||||
|
||||
/// HD-DVD's own enumerator yields one title per `.evo`, MpegPs container,
|
||||
/// with real physical extents (mirrors the BD `.m2ts` extent path).
|
||||
#[test]
|
||||
fn scan_hddvd_titles_enumerates_evo_extents() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_hddvd_fs(
|
||||
&mut disc,
|
||||
&[("FEATURE.EVO", 2000, 5000), ("BLOOP.EVO", 300, 9000)],
|
||||
);
|
||||
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||
assert_eq!(titles.len(), 2, "one title per .evo clip");
|
||||
for t in &titles {
|
||||
assert_eq!(
|
||||
t.content_format,
|
||||
ContentFormat::MpegPs,
|
||||
"EVO is a program stream"
|
||||
);
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
}
|
||||
let feature = titles.iter().find(|t| t.playlist == "FEATURE.EVO").unwrap();
|
||||
assert_eq!(feature.extents[0].start_lba, PART_START + 5000);
|
||||
assert_eq!(feature.extents[0].sector_count, 2000);
|
||||
assert_eq!(
|
||||
feature.clips[0].clip_id, "FEATURE",
|
||||
"clip_id drops the extension"
|
||||
);
|
||||
}
|
||||
|
||||
// ── VTI playlist parsing + feature composition ────────────────────────
|
||||
|
||||
/// Build a synthetic `ADVANCED-VTS` VTI whose clip table lists `clips` in
|
||||
/// order — one fixed-stride entry each, NUL-terminated name at `entry+0x42`.
|
||||
fn synthetic_vti(clips: &[&str]) -> Vec<u8> {
|
||||
let table_start = 0x200usize;
|
||||
let mut v = vec![0u8; table_start + clips.len() * VTI_CLIP_ENTRY_STRIDE];
|
||||
v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
|
||||
for (i, name) in clips.iter().enumerate() {
|
||||
let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42;
|
||||
v[off..off + name.len()].copy_from_slice(name.as_bytes());
|
||||
// The byte after the name stays 0 (NUL terminator).
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vti_clip_order_reads_table_in_authored_order() {
|
||||
let vti = synthetic_vti(&[
|
||||
"DELOGO.EVO",
|
||||
"FEATURE_1.EVO",
|
||||
"FEATURE_2.EVO",
|
||||
"TRAILER.EVO",
|
||||
]);
|
||||
let order = parse_vti_clip_order(&vti);
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![
|
||||
"DELOGO.EVO".to_string(),
|
||||
"FEATURE_1.EVO".to_string(),
|
||||
"FEATURE_2.EVO".to_string(),
|
||||
"TRAILER.EVO".to_string(),
|
||||
]
|
||||
);
|
||||
// A non-VTI blob yields nothing.
|
||||
assert!(parse_vti_clip_order(b"not a vti").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vti_clip_order_caps_hits_on_a_crafted_vti() {
|
||||
// A crafted VTI packed with far more than MAX_VTI_HITS `.EVO` tokens must
|
||||
// not scan/collect them all (a CPU/memory amplification on a routine
|
||||
// scan). The result is capped, and parsing stays fast.
|
||||
let mut vti = Vec::with_capacity(1_000_000);
|
||||
vti.extend_from_slice(HDDVD_VTI_MAGIC);
|
||||
// ~160k tokens of the form "X.EVO\0" — well over the 8192 cap.
|
||||
for _ in 0..(MAX_VTI_HITS * 20) {
|
||||
vti.extend_from_slice(b"X.EVO\0");
|
||||
}
|
||||
let out = parse_vti_clip_order(&vti);
|
||||
assert!(
|
||||
out.len() <= MAX_VTI_HITS,
|
||||
"collected hits capped at MAX_VTI_HITS, got {}",
|
||||
out.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() {
|
||||
// Two residue buckets of EQUAL size must resolve to the SAME winner every
|
||||
// call — `HashMap` iteration is randomized, so a `max_by_key` without a
|
||||
// deterministic tie-break could pick a different bucket run-to-run on
|
||||
// identical bytes. Build a VTI whose stray `.EVO` names tie the real
|
||||
// table's bucket count, then assert the result is stable across calls.
|
||||
let mut vti = vec![0u8; 0x600];
|
||||
vti[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
|
||||
let put = |v: &mut Vec<u8>, off: usize, name: &str| {
|
||||
v[off..off + name.len()].copy_from_slice(name.as_bytes());
|
||||
};
|
||||
// Bucket A (residue 0x42): two names at stride 0x140.
|
||||
put(&mut vti, 0x142, "A1.EVO");
|
||||
put(&mut vti, 0x282, "A2.EVO");
|
||||
// Bucket B (residue 0x50): two names — same count, different residue.
|
||||
put(&mut vti, 0x150, "B1.EVO");
|
||||
put(&mut vti, 0x290, "B2.EVO");
|
||||
|
||||
let first = parse_vti_clip_order(&vti);
|
||||
for _ in 0..20 {
|
||||
assert_eq!(
|
||||
parse_vti_clip_order(&vti),
|
||||
first,
|
||||
"tie-break must be deterministic across repeated calls"
|
||||
);
|
||||
}
|
||||
assert!(!first.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_feature_clip_matches_the_feature_naming_variants() {
|
||||
// Layer-break split (Shaun / Anchorman) and the divide form (Harry Potter).
|
||||
assert!(is_feature_clip("FEATURE_1.EVO"));
|
||||
assert!(is_feature_clip("FEATURE_2.EVO"));
|
||||
assert!(is_feature_clip("feature.EVO"));
|
||||
assert!(is_feature_clip("feature_Divide.EVO"));
|
||||
// Extras are not the feature.
|
||||
assert!(!is_feature_clip("TRAILER.EVO"));
|
||||
assert!(!is_feature_clip("DLS_01.EVO"));
|
||||
assert!(!is_feature_clip("EPK.EVO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_hddvd_composes_split_feature_into_one_title() {
|
||||
// A disc whose feature is FEATURE_1 + FEATURE_2 (a layer-break split), plus
|
||||
// a TRAILER extra. The VTI names them in authored order; the scan must
|
||||
// JOIN the two feature parts into one title (so the largest-title pick is
|
||||
// the whole movie) and keep the trailer as its own title.
|
||||
let mut disc = MemDisc::new();
|
||||
let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]);
|
||||
let files = vec![
|
||||
file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
|
||||
file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true),
|
||||
file("TRAILER.EVO", 102, 12000, 2 * 2048, true),
|
||||
file_with("HVA00001.VTI", 103, 15000, vti, true),
|
||||
];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "HVDVD_TS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files,
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||
// One composed FEATURE title + the trailer.
|
||||
assert_eq!(titles.len(), 2, "feature parts merged, trailer separate");
|
||||
let feat = titles
|
||||
.iter()
|
||||
.find(|t| t.playlist == "FEATURE")
|
||||
.expect("composed feature title");
|
||||
assert_eq!(feat.clips.len(), 2, "both feature parts recorded");
|
||||
assert_eq!(feat.size_bytes, (10 + 6) * 2048, "part sizes summed");
|
||||
// Extents concatenated in authored order: FEATURE_1 (lba 5000) then
|
||||
// FEATURE_2 (lba 8000) — the movie plays through in order.
|
||||
assert_eq!(feat.extents.len(), 2);
|
||||
assert_eq!(feat.extents[0].start_lba, PART_START + 5000);
|
||||
assert_eq!(feat.extents[0].sector_count, 10);
|
||||
assert_eq!(feat.extents[1].start_lba, PART_START + 8000);
|
||||
assert_eq!(feat.extents[1].sector_count, 6);
|
||||
// The largest title is the whole feature, not just part 1.
|
||||
let largest = titles.iter().max_by_key(|t| t.size_bytes).unwrap();
|
||||
assert_eq!(largest.playlist, "FEATURE");
|
||||
assert!(titles.iter().any(|t| t.playlist == "TRAILER.EVO"));
|
||||
}
|
||||
|
||||
// ── codec sniffing ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sniff_video_codec_recognizes_h264_vc1_mpeg2() {
|
||||
// H.264 SPS NAL (type 7). 0x67/0x27/0x47 all decode to type 7.
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E]),
|
||||
Some(Codec::H264)
|
||||
);
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x11, 0x00, 0x00, 0x01, 0x27, 0x64]),
|
||||
Some(Codec::H264)
|
||||
);
|
||||
// VC-1 sequence-header BDU (0x0F).
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x00, 0x00, 0x01, 0x0F, 0xC0]),
|
||||
Some(Codec::Vc1)
|
||||
);
|
||||
// MPEG-2 sequence_header (0xB3).
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x00, 0x00, 0x01, 0xB3, 0x2D]),
|
||||
Some(Codec::Mpeg2)
|
||||
);
|
||||
// A slice/picture-only sample (no SPS/sequence) is indeterminate.
|
||||
assert_eq!(sniff_video_codec(&[0x00, 0x00, 0x01, 0x61, 0x9A]), None);
|
||||
assert_eq!(sniff_video_codec(&[0xDE, 0xAD, 0xBE, 0xEF]), None);
|
||||
|
||||
// Overlap regression: a picture_start_code (0x00) whose payload begins
|
||||
// with 00 00 must advance a full 4 bytes so the code byte isn't re-read
|
||||
// as the start of a new marker. Here the picture is followed by a real
|
||||
// MPEG-2 sequence header — the scan must reach it cleanly and return
|
||||
// Mpeg2 (and, critically, not be confused by the 1-byte overlap).
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xB3, 0x2D]),
|
||||
Some(Codec::Mpeg2)
|
||||
);
|
||||
// A lone picture_start_code with a 00-heavy payload and no following real
|
||||
// start code stays indeterminate (the overlap must not fabricate one).
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_audio_codec_recognizes_eac3_syncword() {
|
||||
assert_eq!(
|
||||
sniff_audio_codec(&[0x00, 0x0B, 0x77, 0x12, 0x34]),
|
||||
Some(Codec::Ac3Plus)
|
||||
);
|
||||
assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None);
|
||||
}
|
||||
|
||||
// ── EVO head probe → streams ──────────────────────────────────────────
|
||||
|
||||
/// A minimal bounded PES: `00 00 01 [id] [len:2] 80 00 00 [payload]`.
|
||||
fn pes(stream_id: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x01, stream_id];
|
||||
let len = (3 + payload.len()) as u16; // flags1+flags2+hdl + payload
|
||||
v.extend_from_slice(&len.to_be_bytes());
|
||||
v.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
/// Synthetic EVO program-stream: pack header, a video PES (H.264 SPS+IDR on
|
||||
/// stream_id 0xE2, exactly as ANCHORMAN carries it), two DD+ audio PES
|
||||
/// (sub-ids 0xC0/0xC1, each with the 4-byte sub-header + E-AC-3 syncword),
|
||||
/// then program-end.
|
||||
fn synthetic_evo() -> Vec<u8> {
|
||||
let mut d = Vec::new();
|
||||
// MPEG-2 pack header (14 bytes, stuffing 0).
|
||||
d.extend_from_slice(&[
|
||||
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
|
||||
]);
|
||||
// Video PES on stream_id 0xE2 (Anchorman's H.264 sub-id in the 0xE0-0xEF
|
||||
// range): SPS (type 7) + IDR (type 5) Annex-B.
|
||||
let video_es = [
|
||||
0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, 0xAB, 0xCD, // SPS
|
||||
0x00, 0x00, 0x01, 0x65, 0x88, 0x00, // IDR slice
|
||||
];
|
||||
d.extend_from_slice(&pes(0xE2, &video_es));
|
||||
// DD+ audio PES: sub-id + 4-byte sub-header (num_frames + ptr) folded in
|
||||
// — the demuxer strips 4 bytes, leaving the E-AC-3 syncword.
|
||||
for sub in [0xC0u8, 0xC1] {
|
||||
let audio_payload = [
|
||||
sub, 0x01, 0x00, 0x00, // sub-id + num_frames(1) + ptr(2)
|
||||
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + body
|
||||
];
|
||||
d.extend_from_slice(&pes(0xBD, &audio_payload));
|
||||
}
|
||||
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // program end
|
||||
d
|
||||
}
|
||||
|
||||
/// Build a UDF whose `HVDVD_TS/FEATURE.EVO` holds the given raw bytes.
|
||||
fn make_hddvd_fs_with_evo(disc: &mut MemDisc, evo: &[u8]) -> crate::udf::UdfFs {
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "HVDVD_TS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file_with("FEATURE.EVO", 100, 5000, evo.to_vec(), true)],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(disc, 10);
|
||||
lay_dir(disc, &root);
|
||||
crate::udf::read_filesystem(disc).expect("fs")
|
||||
}
|
||||
|
||||
/// End-to-end: scanning an `.evo` whose head carries an H.264 video PES and
|
||||
/// two DD+ audio PES yields a title with the video track (canonical
|
||||
/// DVD_VIDEO_PID) and both DD+ tracks (0xBDC0 / 0xBDC1) — the non-empty
|
||||
/// `streams` the mux path needs to route packets (the historical blocker).
|
||||
#[test]
|
||||
fn scan_hddvd_titles_probes_streams_from_evo_head() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo());
|
||||
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
|
||||
let video: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Video(v) => Some(v),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(video.len(), 1, "one video track probed");
|
||||
assert_eq!(video[0].codec, Codec::H264, "SPS sniffed as H.264");
|
||||
assert_eq!(
|
||||
video[0].pid,
|
||||
crate::mux::ps::DVD_VIDEO_PID,
|
||||
"video routes to canonical PID"
|
||||
);
|
||||
|
||||
let audio: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(audio.len(), 2, "both DD+ sub-streams probed");
|
||||
assert!(audio.iter().all(|a| a.codec == Codec::Ac3Plus));
|
||||
let pids: Vec<u16> = audio.iter().map(|a| a.pid).collect();
|
||||
assert_eq!(pids, vec![0xBDC0, 0xBDC1], "DD+ PIDs 0xBDC0/0xBDC1");
|
||||
}
|
||||
|
||||
/// A clip whose head carries no recognizable stream (unreadable /
|
||||
/// ciphertext) leaves `streams` empty rather than fabricating one — the
|
||||
/// title still enumerates (extents are real).
|
||||
#[test]
|
||||
fn scan_hddvd_titles_empty_streams_when_head_unrecognized() {
|
||||
let mut disc = MemDisc::new();
|
||||
// 4 KiB of junk with no PS start codes.
|
||||
let junk = vec![0x55u8; 4096];
|
||||
let udf = make_hddvd_fs_with_evo(&mut disc, &junk);
|
||||
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||
assert_eq!(titles.len(), 1);
|
||||
assert!(
|
||||
titles[0].streams.is_empty(),
|
||||
"no recognizable stream → empty, not fabricated"
|
||||
);
|
||||
}
|
||||
|
||||
/// A PES on the HD-DVD extended-stream-id (0xFD) carrying the given
|
||||
/// `stream_id_extension` in a minimal PES extension: flags1=0x80, flags2=0x01
|
||||
/// (PES_extension only), header_data_length=3, optional bytes
|
||||
/// `[ext_flags=0x01][field_len=0x81][ext]` — exactly the shape SHAUN's VC-1
|
||||
/// video PES uses (ext 0x55).
|
||||
fn pes_extended(stream_id_extension: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x01, 0xFD];
|
||||
let opt = [0x01u8, 0x81, stream_id_extension];
|
||||
let len = (3 + opt.len() + payload.len()) as u16; // flags1+flags2+hdl + opt + payload
|
||||
v.extend_from_slice(&len.to_be_bytes());
|
||||
v.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
|
||||
v.extend_from_slice(&opt);
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
/// Synthetic EVO carrying VC-1 video on the extended-stream-id 0xFD (ext
|
||||
/// 0x55), as SHAUN OF THE DEAD does, plus one DD+ audio PES.
|
||||
fn synthetic_evo_vc1() -> Vec<u8> {
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&[
|
||||
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
|
||||
]);
|
||||
// VC-1 sequence header (00 00 01 0F) + a frame BDU (00 00 01 0D).
|
||||
let video_es = [
|
||||
0x00, 0x00, 0x01, 0x0F, 0xC5, 0x00, 0x00, // sequence header BDU
|
||||
0x00, 0x00, 0x01, 0x0D, 0x12, 0x34, // frame BDU
|
||||
];
|
||||
d.extend_from_slice(&pes_extended(0x55, &video_es));
|
||||
let audio_payload = [0xC0u8, 0x01, 0x00, 0x00, 0x0B, 0x77, 0xDE, 0xAD];
|
||||
d.extend_from_slice(&pes(0xBD, &audio_payload));
|
||||
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
||||
d
|
||||
}
|
||||
|
||||
/// Build a bare PsPacket for the collect_es routing test.
|
||||
fn ps_pkt(stream_id: u8, sub: Option<u8>, data: Vec<u8>) -> crate::mux::ps::PsPacket {
|
||||
crate::mux::ps::PsPacket {
|
||||
stream_id,
|
||||
sub_stream_id: sub,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_es_routes_only_vc1_0xfd_to_video() {
|
||||
use crate::mux::ps::hddvd_extended_pid;
|
||||
// The 0xFD guard: only the VC-1 extension (0x55) is video. An HD-audio
|
||||
// 0xFD sub-stream (e.g. 0x72) that arrives FIRST must NOT stamp video_pid
|
||||
// with its PID or pollute the video sample — else the real video track is
|
||||
// lost. (Routing 0xFD audio to its own track is deferred.)
|
||||
let mut video = Vec::new();
|
||||
let mut video_pid: Option<u16> = None;
|
||||
let mut audio = BTreeMap::new();
|
||||
// Audio-on-0xFD (ext 0x72) first — must be ignored by the video path.
|
||||
collect_es(
|
||||
&ps_pkt(0xFD, Some(0x72), vec![0xAA; 32]),
|
||||
&mut video,
|
||||
&mut video_pid,
|
||||
&mut audio,
|
||||
);
|
||||
assert!(
|
||||
video.is_empty(),
|
||||
"0xFD audio sub-stream not routed to video"
|
||||
);
|
||||
assert_eq!(video_pid, None, "0xFD audio did not stamp the video PID");
|
||||
// Then the real VC-1 video (ext 0x55).
|
||||
collect_es(
|
||||
&ps_pkt(0xFD, Some(0x55), vec![0xBB; 32]),
|
||||
&mut video,
|
||||
&mut video_pid,
|
||||
&mut audio,
|
||||
);
|
||||
assert_eq!(
|
||||
video_pid,
|
||||
Some(hddvd_extended_pid(0x55)),
|
||||
"video PID stamped from the VC-1 0xFD sub-stream (0xFD55)"
|
||||
);
|
||||
assert_eq!(video.len(), 32, "VC-1 0xFD payload accumulated as video");
|
||||
}
|
||||
|
||||
/// End-to-end: an `.evo` whose video rides the extended-stream-id 0xFD yields
|
||||
/// a VC-1 video track routed to `0xFD00 | ext` (0xFD55) — the PID the demuxer
|
||||
/// derives from the same stream_id_extension, so mux-time routing lines up.
|
||||
#[test]
|
||||
fn scan_hddvd_titles_probes_vc1_on_extended_stream_id() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo_vc1());
|
||||
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||
assert_eq!(titles.len(), 1);
|
||||
|
||||
let video: Vec<_> = titles[0]
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Video(v) => Some(v),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(video.len(), 1, "one video track probed");
|
||||
assert_eq!(video[0].codec, Codec::Vc1, "VC-1 sequence header sniffed");
|
||||
assert_eq!(
|
||||
video[0].pid,
|
||||
crate::mux::ps::hddvd_extended_pid(0x55),
|
||||
"VC-1 routes to the extended-stream-id PID 0xFD55"
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-29
@@ -651,35 +651,9 @@ impl Drop for Mapfile {
|
||||
/// caller treats a bad VID comment as simply absent rather than an
|
||||
/// error, so a corrupt header never fails a mapfile load.
|
||||
fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
// Parse on bytes, not on the &str: slicing a &str by byte index
|
||||
// (`&s[i*2..i*2+2]`) panics when the cut lands inside a multi-byte
|
||||
// UTF-8 char. A hand-edited/corrupt `# freemkv-vid:` comment of
|
||||
// exactly 32 bytes containing a multi-byte char would otherwise
|
||||
// kill the whole load. ASCII hex is one byte per char, so anything
|
||||
// non-ASCII is simply rejected here as malformed.
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
let hi = hex_nibble(bytes[i * 2])?;
|
||||
let lo = hex_nibble(bytes[i * 2 + 1])?;
|
||||
*b = (hi << 4) | lo;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Map a single ASCII hex digit byte to its 0-15 value. Returns `None`
|
||||
/// for any non-hex byte (including any non-ASCII / multi-byte lead byte).
|
||||
fn hex_nibble(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Some(c - b'0'),
|
||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
// The one workspace hex parser (accepts an optional `0x`/`0X` prefix,
|
||||
// byte-based so a multi-byte `# freemkv-vid:` comment rejects, never panics).
|
||||
crate::hex::parse_hex_fixed::<16>(s)
|
||||
}
|
||||
|
||||
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
|
||||
|
||||
+665
-108
File diff suppressed because it is too large
Load Diff
+831
-2262
File diff suppressed because it is too large
Load Diff
+15
-20
@@ -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%
|
||||
@@ -531,13 +519,20 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
);
|
||||
|
||||
if is_wedge_transition {
|
||||
// NOTE: this is the FIRST escalation into the hardware/illegal-request
|
||||
// sense family — NOT a confirmed wedge. Drives frequently recover and keep
|
||||
// reading after one such error (a single bad spot), so calling it a "wedge"
|
||||
// here over-claims (it sent past investigations chasing a drive ghost). A
|
||||
// genuine wedge is PERSISTENT — see the `wedge_skip` / WEDGE_ABORT_THRESHOLD
|
||||
// path below, which only fires after repeated fast-fails with no recovery.
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "wedge_transition",
|
||||
phase = "fastfail_escalation",
|
||||
errors_in_zone = ctx.total_errors,
|
||||
ms_since_last_success,
|
||||
new_family = ?current_family,
|
||||
"drive entered wedge / fast-fail family (was returning recoverable medium errors before this)"
|
||||
"drive escalated into the fast-fail sense family (was returning recoverable medium \
|
||||
errors before this) — often transient; only a PERSISTENT run is a real wedge"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -190,11 +190,17 @@ impl Sink<WorkItem> for SweepSink {
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = self.map.stats();
|
||||
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
||||
// ahead of the sweep head, not damage; including it made the live
|
||||
// located drilldown (at-risk movie time + range count) treat the
|
||||
// whole unread disc as confirmed damage, so at sweep start it
|
||||
// showed ~full-movie at-risk and melted to 0 as the sweep
|
||||
// progressed. Matches the one-shot progress path, which already
|
||||
// excludes NonTried.
|
||||
let bad_ranges = self.map.ranges_with(&[
|
||||
SectorStatus::NonTrimmed,
|
||||
SectorStatus::Unreadable,
|
||||
SectorStatus::NonScraped,
|
||||
SectorStatus::NonTried,
|
||||
]);
|
||||
// Best-effort: drop on backpressure; producer's cache
|
||||
// stays current enough.
|
||||
|
||||
+15
-12
@@ -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 (libaacs `_verify_ts`, all-32 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;
|
||||
}
|
||||
}
|
||||
@@ -477,7 +478,9 @@ fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) {
|
||||
present.sort_unstable();
|
||||
for lba in present {
|
||||
if let Some(last) = out.last_mut() {
|
||||
if last.0 + last.1 == lba {
|
||||
// Saturating: LBAs come from disc-controlled ICB extents, so a
|
||||
// corrupt disc must not panic here (matches `udf::merge_ranges`).
|
||||
if last.0.saturating_add(last.1) == lba {
|
||||
last.1 += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -521,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 {
|
||||
|
||||
+183
-79
@@ -1,8 +1,8 @@
|
||||
//! Drive session — open, identify, and read from optical drives.
|
||||
//!
|
||||
//! A `Drive` is opened from a device path, identifies itself via INQUIRY,
|
||||
//! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`],
|
||||
//! and reads sectors.
|
||||
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
|
||||
//! (through [`crate::unlock_bridge`]), and reads sectors.
|
||||
|
||||
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||||
match e {
|
||||
@@ -51,6 +51,13 @@ pub enum DriveStatus {
|
||||
// SCSI opcodes used in drive control
|
||||
const SCSI_TEST_UNIT_READY: u8 = 0x00;
|
||||
const SCSI_START_STOP_UNIT: u8 = 0x1B;
|
||||
/// Idle time the disc sits spun-down during [`Drive::spin_cycle`] before it's
|
||||
/// spun back up — long enough for the mechanism's fast-fail wedge state to
|
||||
/// clear. Validated at 5–6 s live.
|
||||
const SPIN_DOWN_IDLE_SECS: u64 = 5;
|
||||
/// Settle time after spin-up in [`Drive::spin_cycle`] before the caller reads
|
||||
/// again, so the first post-cycle read doesn't hit a transient NOT_READY.
|
||||
const SPIN_UP_SETTLE_SECS: u64 = 10;
|
||||
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
||||
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||||
@@ -59,9 +66,9 @@ const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
/// Name of the [`crate::unlock::Unlocker`] that handled this drive at
|
||||
/// `init()`, if any matched. `None` means no unlocker matched and the
|
||||
/// drive runs in stock mode (host-cert AACS handshake carries discs).
|
||||
/// Name of the unlocker that handled this drive at `init()`, if any matched.
|
||||
/// `None` means no unlocker matched and the drive runs in stock mode
|
||||
/// (host-cert AACS handshake carries discs).
|
||||
unlocker_name: Option<String>,
|
||||
/// The OEM Volume ID the matching unlocker returned from `unlock()` at
|
||||
/// `init()`, stashed for the AACS handshake phase (which reads it via
|
||||
@@ -212,11 +219,20 @@ impl Drive {
|
||||
self.unlock_tray();
|
||||
}
|
||||
|
||||
/// Whether a registered unlocker matches this drive (i.e. it can be
|
||||
/// firmware-unlocked). Queried against the unlock registry by identity;
|
||||
/// does not require `init()` to have run.
|
||||
/// Whether an unlocker claims this drive by identity (i.e. it can be
|
||||
/// unlocked at drive-prep). Queried via `freemkv-unlock`; does not require
|
||||
/// `init()` to have run.
|
||||
pub fn has_profile(&self) -> bool {
|
||||
crate::unlock::matching_name(&self.drive_id).is_some()
|
||||
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
|
||||
}
|
||||
|
||||
/// The name of the drive unlocker that ACTUALLY unlocked this drive at
|
||||
/// `init()`, or `None` if none applied (unsupported drive, or the unlock
|
||||
/// failed). Distinct from [`has_profile`](Self::has_profile), which reports
|
||||
/// only an identity match: this is the runtime outcome. Apps render it in the
|
||||
/// user-facing unlocker report.
|
||||
pub fn unlocker_name(&self) -> Option<&str> {
|
||||
self.unlocker_name.as_deref()
|
||||
}
|
||||
|
||||
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
||||
@@ -224,10 +240,10 @@ impl Drive {
|
||||
self.scsi.as_mut()
|
||||
}
|
||||
|
||||
/// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at
|
||||
/// [`Drive::init`], if any. The AACS handshake uses this to skip the cert
|
||||
/// handshake when an unlocker already supplied the VID. `None` when no
|
||||
/// unlocker matched or it produced no VID.
|
||||
/// The OEM Volume ID a matching unlocker returned at [`Drive::init`], if any.
|
||||
/// The AACS handshake uses this to skip the cert handshake when an unlocker
|
||||
/// already supplied the VID. `None` when no unlocker matched or it produced
|
||||
/// no VID.
|
||||
pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> {
|
||||
self.oem_vid
|
||||
}
|
||||
@@ -334,15 +350,17 @@ impl Drive {
|
||||
}
|
||||
|
||||
/// Name of the unlocker handling this drive. After `init()` this is the
|
||||
/// unlocker that ran; before `init()` it reflects the registry match by
|
||||
/// unlocker that ran; before `init()` it reflects the unlocker match by
|
||||
/// identity. `"Unknown"` when no unlocker matches.
|
||||
pub fn platform_name(&self) -> &str {
|
||||
if let Some(ref n) = self.unlocker_name {
|
||||
return n;
|
||||
}
|
||||
// Cache the registry match so we can hand out a `&str` borrow.
|
||||
// Cache the unlocker match so we can hand out a `&str` borrow.
|
||||
self.matched_name_cache.get_or_init(|| {
|
||||
crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string())
|
||||
crate::unlock_bridge::unlocker_name(&self.drive_id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -353,7 +371,7 @@ impl Drive {
|
||||
/// Current mounted-disc profile from the GET CONFIGURATION header
|
||||
/// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
|
||||
/// family `0x0040..=0x0043`. This is a stock MMC command — it works
|
||||
/// before (and without) any firmware unlock. `None` if unreadable.
|
||||
/// before (and without) any drive unlock. `None` if unreadable.
|
||||
fn current_profile(&mut self) -> Option<u16> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_GET_CONFIGURATION,
|
||||
@@ -390,10 +408,10 @@ impl Drive {
|
||||
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
|
||||
}
|
||||
|
||||
/// Initialize drive — unlock + firmware upload.
|
||||
/// Initialize drive — drive-prep unlock + init.
|
||||
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
||||
///
|
||||
/// The LibreDrive/OEM firmware unlock is required for BD/UHD (AACS) reads,
|
||||
/// The drive-prep (OEM) unlock is required for BD/UHD (AACS) reads,
|
||||
/// but it puts the drive in an extended-access state where stock CSS
|
||||
/// authentication no longer works — so a CSS-protected DVD can't be read.
|
||||
/// For a DVD we therefore SKIP the unlock and run the drive in its normal
|
||||
@@ -402,45 +420,51 @@ impl Drive {
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
let t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::drive", phase = "init", "begin");
|
||||
if self.disc_is_dvd() {
|
||||
tracing::info!(target: "freemkv::drive", phase = "init", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no unlock)");
|
||||
self.init_ran = true;
|
||||
return Ok(());
|
||||
}
|
||||
// Walk the unlock registry: the first unlocker whose identity
|
||||
// matches runs; none matching leaves the drive in stock mode so the
|
||||
// host-cert AACS handshake (the OEM route) carries the disc.
|
||||
let r = crate::unlock::route_unlock(self.scsi.as_mut(), &self.drive_id);
|
||||
// Drive-prep runs for EVERY disc, DVD INCLUDED. The drive-level firmware
|
||||
// unlock lifts riplock and readies max read speed regardless of disc type
|
||||
// — drive features are disc-independent. At init the disc kind is not yet
|
||||
// probed (Unknown), so only the identity-keyed DRIVE unlocker matches
|
||||
// here; the AACS host-cert handshake and the CSS bus-auth handshake run
|
||||
// LATER, each gated on the actual disc kind, on TOP of the already-
|
||||
// unlocked drive. A genuine transport fault means the bus is dead — abort
|
||||
// init (the v1.1.0 invariant; `if let Ok` was silently swallowing it).
|
||||
// Every other error (NotApplicable / no match) is "nothing applied" —
|
||||
// fall through to stock mode. NOTE: the `if disc_is_dvd() { return }` skip
|
||||
// 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 = match r {
|
||||
Ok(Some((name, vid))) => {
|
||||
self.unlocker_name = Some(name);
|
||||
let (matched, unlock_res) =
|
||||
crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id);
|
||||
let r: Result<()> = match unlock_res {
|
||||
Ok(unlocked) => {
|
||||
// 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()`).
|
||||
self.oem_vid = Some(vid.0);
|
||||
// The matched unlocker may also be able to raise the drive to
|
||||
// its maximum read speed. Best-effort: a failure here must NOT
|
||||
// fail the rip — a slow drive still rips. Log and continue.
|
||||
if let Err(e) =
|
||||
crate::unlock::unlocker_set_max_read_speed(self.scsi.as_mut(), &self.drive_id)
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "freemkv::drive",
|
||||
phase = "init",
|
||||
error = ?e,
|
||||
"unlocker set_max_read_speed failed; continuing at current speed"
|
||||
);
|
||||
// handshake phase (do_handshake reads it via `oem_vid()`). A
|
||||
// drive-prep unlocker always carries a VID; guard anyway.
|
||||
if let Some(vid) = unlocked.vid {
|
||||
self.oem_vid = Some(vid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// No unlocker matched, or one matched but only hit a capability
|
||||
// failure (not firmware-unlockable / no OEM VID): not an error —
|
||||
// fall through to the OEM host-cert route.
|
||||
Ok(None) => Ok(()),
|
||||
// A genuine transport fault during unlock (UnlockError::Scsi)
|
||||
// propagates here and aborts init — the bus is dead.
|
||||
Err(e) => Err(e),
|
||||
Err(freemkv_unlock::UnlockError::Transport) => Err(Error::ScsiError {
|
||||
opcode: 0,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}),
|
||||
Err(_) => Ok(()),
|
||||
};
|
||||
// Raise the drive to its maximum read speed — UNCONDITIONALLY whenever
|
||||
// the bus is alive (init didn't transport-fault), whether or not an
|
||||
// unlocker matched, and for ANY disc type (DVD now flows through here too,
|
||||
// so it gets max speed on the freshly firmware-unlocked drive instead of
|
||||
// the stock riplock). A stock-mode drive with no firmware unlocker still
|
||||
// wants max speed. Best-effort: a failure here must NOT fail the rip.
|
||||
if r.is_ok() {
|
||||
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
||||
}
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
phase = "init",
|
||||
@@ -458,14 +482,9 @@ impl Drive {
|
||||
pub fn probe_disc(&mut self) -> Result<()> {
|
||||
let t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
|
||||
// A DVD runs in stock mode (see `init`); skip the OEM/firmware-path
|
||||
// disc calibration, which only applies to the unlocked BD/UHD drive.
|
||||
if self.disc_is_dvd() {
|
||||
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
|
||||
return Ok(());
|
||||
}
|
||||
// Disc-speed calibration is firmware-specific and now lives inside
|
||||
// the unlocker's `unlock()` (run at `init()`). Nothing to do here.
|
||||
// Disc-speed calibration is unlocker-specific and now lives inside
|
||||
// the unlocker's `unlock()` (run at `init()`, for every disc including
|
||||
// DVD). Nothing to do here — no disc-type branch.
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
phase = "probe_disc",
|
||||
@@ -609,16 +628,14 @@ impl Drive {
|
||||
|
||||
/// Whether libfreemkv should take the OEM extended-access read path.
|
||||
///
|
||||
/// Whether a registered [`crate::unlock::Unlocker`] matches this drive.
|
||||
///
|
||||
/// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID
|
||||
/// retrieval, and other vendor capabilities. When one matches, libfreemkv
|
||||
/// routes both `unlock` and OEM VID through it (VID via the OEM path is
|
||||
/// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`]
|
||||
/// — the honest signal is "a registered unlocker claims this drive" —
|
||||
/// rather than the old const `false`.
|
||||
/// True when an unlocker claims this drive by identity. Such an unlocker
|
||||
/// unlocks *drive functionality* — drive unlock, OEM VID retrieval, and other
|
||||
/// vendor capabilities. When one matches, libfreemkv routes both `unlock` and
|
||||
/// OEM VID through it (VID via the OEM path is decoupled from the host cert +
|
||||
/// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an
|
||||
/// unlocker claims this drive" — rather than the old const `false`.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
crate::unlock::matching_name(&self.drive_id).is_some()
|
||||
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
|
||||
}
|
||||
|
||||
/// Read sectors from the disc. Single-shot — no inline retries, no
|
||||
@@ -640,6 +657,25 @@ impl Drive {
|
||||
/// recovery layers (Disc::patch multi-pass, DiscStream batch halving)
|
||||
/// do not touch the wedge-prone reset path.
|
||||
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
|
||||
// Bulk path: FUA off (the drive cache IS the streaming throughput).
|
||||
self.read_fua(lba, count, buf, recovery, false)
|
||||
}
|
||||
|
||||
/// [`read`], but with an explicit Force Unit Access request: `fua = true`
|
||||
/// sets the READ(10) FUA bit so the drive re-fetches the medium instead of
|
||||
/// returning a cached copy — the Pass-N marginal-sector lever (see
|
||||
/// [`crate::sector::SectorSource::read_sectors_fua`]). The bulk sweep always
|
||||
/// passes `false`; only a per-sector recovery handler asks for FUA.
|
||||
///
|
||||
/// [`read`]: Drive::read
|
||||
pub fn read_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
let timeout_ms = if recovery {
|
||||
crate::scsi::READ_RECOVERY_TIMEOUT_MS
|
||||
} else {
|
||||
@@ -665,7 +701,7 @@ impl Drive {
|
||||
// read_one call with no behavior change.
|
||||
let max_sectors = (self.scsi.max_transfer_bytes() / 2048).max(1) as u32;
|
||||
if count as u32 <= max_sectors {
|
||||
return self.read_one(lba, count, buf, timeout_ms, recovery);
|
||||
return self.read_one(lba, count, buf, timeout_ms, recovery, fua);
|
||||
}
|
||||
|
||||
// Large read: split into chunks of at most `max_sectors` sectors,
|
||||
@@ -681,7 +717,7 @@ impl Drive {
|
||||
let byte_off = done as usize * 2048;
|
||||
let byte_len = chunk as usize * 2048;
|
||||
let slice = &mut buf[byte_off..byte_off + byte_len];
|
||||
let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery)?;
|
||||
let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery, fua)?;
|
||||
total += n;
|
||||
done += chunk;
|
||||
}
|
||||
@@ -701,13 +737,23 @@ impl Drive {
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
// `recovery` only gates the Linux /dev/sr0 pread fallback below; on
|
||||
// `recovery` gates only the Linux /dev/sr0 pread fallback below; on
|
||||
// other platforms it is intentionally unused.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] recovery: bool,
|
||||
// FUA (Force Unit Access): when set, byte-1 bit 0x08 forces the drive to
|
||||
// re-fetch the medium past its cache.
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
// FUA is OFF on the bulk path — unconditionally forcing every READ(10)
|
||||
// past the cache disabled the drive's readahead/streaming cache on the
|
||||
// sequential sweep and collapsed throughput ~10x (UHD 15-25 → ~2 MB/s,
|
||||
// DVD → ~0.5 MB/s), disc-type-agnostic — the cache IS the streaming
|
||||
// throughput. It is set ONLY when a Pass-N recovery handler (FuaRetry)
|
||||
// asks for it per marginal-sector re-read, where cache-masking of a
|
||||
// stochastic sector actually matters (#55).
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10,
|
||||
0x00,
|
||||
if fua { 0x08 } else { 0x00 },
|
||||
(lba >> 24) as u8,
|
||||
(lba >> 16) as u8,
|
||||
(lba >> 8) as u8,
|
||||
@@ -886,6 +932,29 @@ impl Drive {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft power-cycle the drive mechanism WITHOUT ejecting: spin the disc
|
||||
/// down (`START STOP UNIT`, START=0, **LOEJ=0**) then back up (START=1).
|
||||
/// This clears the BU40N/Initio fast-fail *wedge* state that a run of
|
||||
/// `HARDWARE_ERROR` reads leaves the drive in — the non-eject equivalent of
|
||||
/// the power-cycle our notes say the wedge needs. The disc stays loaded (the
|
||||
/// BU40N is slot-loading; we NEVER eject to recover — a hands-on eject is a
|
||||
/// failure for an unattended service). Validated live 2026-07-01: took the
|
||||
/// drive from failing-every-read back to reading at MB/s.
|
||||
pub fn spin_cycle(&mut self) -> Result<()> {
|
||||
let stop = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x00, 0]; // START=0, LOEJ=0 → spin down
|
||||
let start = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x01, 0]; // START=1, LOEJ=0 → spin up
|
||||
let mut buf = [0u8; 0];
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
|
||||
std::thread::sleep(std::time::Duration::from_secs(SPIN_DOWN_IDLE_SECS));
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
|
||||
std::thread::sleep(std::time::Duration::from_secs(SPIN_UP_SETTLE_SECS));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scsi_execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
@@ -970,6 +1039,17 @@ impl SectorSource for Drive {
|
||||
self.read(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
self.read_fua(lba, count, buf, recovery, fua)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
Drive::set_speed(self, kbs);
|
||||
}
|
||||
@@ -1245,7 +1325,7 @@ mod command_tests {
|
||||
|
||||
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
|
||||
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
|
||||
/// would skip the LibreDrive firmware unlock that UHD reads require; a
|
||||
/// would skip the drive unlock that UHD reads require; a
|
||||
/// false negative on a DVD would re-introduce the CSS read failure. The
|
||||
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
|
||||
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
|
||||
@@ -1259,7 +1339,7 @@ mod command_tests {
|
||||
hdr[7] = profile as u8;
|
||||
drive_with(hdr).disc_is_dvd()
|
||||
};
|
||||
// DVD family → DVD (skip firmware unlock, run stock for CSS).
|
||||
// DVD family → DVD (skip drive unlock, run stock for CSS).
|
||||
assert!(probe(0x0010), "DVD-ROM");
|
||||
assert!(probe(0x0011), "DVD-R");
|
||||
assert!(probe(0x001B), "DVD+R DL");
|
||||
@@ -1269,7 +1349,7 @@ mod command_tests {
|
||||
assert!(!probe(0x0008), "CD-ROM");
|
||||
assert!(!probe(0x0000), "no/unknown profile");
|
||||
// Short / failed GET CONFIGURATION → no Current Profile → NOT DVD,
|
||||
// so the firmware unlock still runs (safe default).
|
||||
// so the drive unlock still runs (safe default).
|
||||
assert!(
|
||||
!drive_with(vec![0u8; 4]).disc_is_dvd(),
|
||||
"short GET CONFIGURATION must default to not-DVD (unlock still runs)"
|
||||
@@ -1364,8 +1444,10 @@ mod command_tests {
|
||||
#[test]
|
||||
fn read_builds_read10_cdb_with_be_lba_and_count() {
|
||||
// Drive::read issues READ(10) (0x28). LBA bytes 2..5 big-endian,
|
||||
// transfer length bytes 7..8 big-endian (MMC-6). No FUA on this
|
||||
// path (byte 1 == 0). Distinct nibbles catch a swapped shift.
|
||||
// transfer length bytes 7..8 big-endian (MMC-6). FUA is DISABLED (byte 1
|
||||
// == 0x00) so the drive cache/readahead is allowed — forcing FUA on the
|
||||
// bulk sweep collapsed throughput ~10x. Distinct nibbles catch a swapped
|
||||
// shift.
|
||||
let RecordingHarness {
|
||||
drive: mut d,
|
||||
cdb,
|
||||
@@ -1376,11 +1458,33 @@ mod command_tests {
|
||||
assert_eq!(n, 4096, "returns transport bytes_transferred");
|
||||
let c = cdb.lock().unwrap();
|
||||
assert_eq!(c[0], crate::scsi::SCSI_READ_10);
|
||||
assert_eq!(c[1], 0x00, "Drive::read path sets no FUA");
|
||||
assert_eq!(
|
||||
c[1], 0x00,
|
||||
"FUA disabled — cache/readahead allowed on the bulk read path"
|
||||
);
|
||||
assert_eq!(&c[2..6], &[0x00, 0xAB, 0xCD, 0xEF], "LBA big-endian");
|
||||
assert_eq!(&c[7..9], &[0x00, 0x02], "transfer length big-endian");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_fua_sets_the_force_unit_access_bit() {
|
||||
// The Pass-N FuaRetry lever: read_fua(.., fua=true) sets READ(10) byte-1
|
||||
// bit 0x08 so the drive re-fetches the medium past its cache; fua=false
|
||||
// leaves it clear (the bulk path).
|
||||
let RecordingHarness {
|
||||
drive: mut d,
|
||||
cdb,
|
||||
timeouts: _to,
|
||||
} = recording(TransportOutcome::Ok(2048));
|
||||
let mut buf = vec![0u8; 2048];
|
||||
d.read_fua(0, 1, &mut buf, false, true).unwrap();
|
||||
assert_eq!(
|
||||
cdb.lock().unwrap()[1],
|
||||
0x08,
|
||||
"FUA requested — byte-1 bit 0x08 set so the drive bypasses its cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recovery_flag_selects_60s_timeout() {
|
||||
// recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false
|
||||
|
||||
+43
-5
@@ -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,
|
||||
@@ -879,6 +890,15 @@ impl Error {
|
||||
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
|
||||
..
|
||||
}
|
||||
) || matches!(
|
||||
// A failed `ioctl(SG_IO)` (Error::IoError, e.g. ENODEV/EIO on an
|
||||
// unplugged USB bridge) and a vanished device (Error::DeviceNotFound,
|
||||
// fd gone) are dead-bus / transport-layer faults too — NOT recoverable
|
||||
// bad sectors. Treat them as transport failures so sweep / patch /
|
||||
// fill_extents abort the pass and re-enumerate the bridge instead of
|
||||
// zero-filling every read against a wedged device.
|
||||
self,
|
||||
Error::IoError { .. } | Error::DeviceNotFound { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1200,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,
|
||||
@@ -1375,11 +1396,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// is_scsi_transport_failure returns true only for SCSI_STATUS_TRANSPORT_FAILURE (0xFF).
|
||||
/// Spec: comment on SCSI_STATUS_TRANSPORT_FAILURE says "synthesised sentinel: the
|
||||
/// transport never delivered a SCSI status byte".
|
||||
/// is_scsi_transport_failure is true for the 0xFF SCSI sentinel AND for the
|
||||
/// non-SCSI dead-bus faults (Error::IoError from a failed ioctl(SG_IO),
|
||||
/// Error::DeviceNotFound from a vanished fd) — but NEVER for a real SCSI
|
||||
/// reply (CHECK CONDITION) or unrelated errors.
|
||||
/// Mutation: testing against 0x02 (CHECK CONDITION) would wrongly mark CHECK
|
||||
/// CONDITION replies as transport failures.
|
||||
/// CONDITION replies as transport failures; dropping the IoError/
|
||||
/// DeviceNotFound arm would let a dead bus zero-fill the disc.
|
||||
#[test]
|
||||
fn is_scsi_transport_failure_only_for_0xff() {
|
||||
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
|
||||
@@ -1403,7 +1426,22 @@ mod tests {
|
||||
};
|
||||
assert!(!cc.is_scsi_transport_failure());
|
||||
|
||||
// False for non-SCSI errors.
|
||||
// True: non-SCSI dead-bus faults — a failed ioctl(SG_IO) and a vanished
|
||||
// device are transport-layer failures, not recoverable bad sectors.
|
||||
assert!(
|
||||
Error::IoError {
|
||||
source: std::io::Error::from(std::io::ErrorKind::NotConnected)
|
||||
}
|
||||
.is_scsi_transport_failure()
|
||||
);
|
||||
assert!(
|
||||
Error::DeviceNotFound {
|
||||
path: "/dev/sg9".into()
|
||||
}
|
||||
.is_scsi_transport_failure()
|
||||
);
|
||||
|
||||
// False for unrelated errors.
|
||||
assert!(!Error::Halted.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
//! The single hex → bytes parser for the whole workspace.
|
||||
//!
|
||||
//! Key material arrives as hex from three third-party sources — the keydb, an
|
||||
//! online key service, and the mapfile's `# freemkv-vid:` comment — and each
|
||||
//! used to parse it slightly differently (one stripped `0x`/`0X`, one stripped
|
||||
//! nothing, one stripped `0x` only). A key written with a prefix one parser
|
||||
//! didn't expect was silently dropped → "can't decrypt" with no error. This is
|
||||
//! the one parser they all call, so the prefix/case/validation rules live in
|
||||
//! exactly one place.
|
||||
//!
|
||||
//! Operates on BYTES, not `&str` char indices: the inputs are untrusted, so a
|
||||
//! multi-byte UTF-8 scalar must reject as malformed, never panic on a
|
||||
//! mid-codepoint slice.
|
||||
|
||||
/// Parse a hex string into bytes. Accepts an optional `0x`/`0X` prefix
|
||||
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
||||
/// non-hex byte, or an odd length, yields `None`.
|
||||
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
||||
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
|
||||
if bytes.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
for pair in bytes.chunks_exact(2) {
|
||||
out.push(byte(pair[0], pair[1])?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse a hex string into a fixed `[u8; N]`. Accepts an optional `0x`/`0X`
|
||||
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
|
||||
/// non-hex byte or a length mismatch.
|
||||
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
if bytes.len() != 2 * N {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
*slot = byte(bytes[2 * i], bytes[2 * i + 1])?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
|
||||
fn strip_prefix(s: &str) -> &str {
|
||||
s.strip_prefix("0x")
|
||||
.or_else(|| s.strip_prefix("0X"))
|
||||
.unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional:
|
||||
/// for a non-ASCII byte it produces a Latin-1 scalar that `to_digit(16)` then
|
||||
/// rejects — so non-hex (incl. `+`/`-` sign chars) and multi-byte input fail
|
||||
/// cleanly rather than slipping through `from_str_radix`'s sign handling.
|
||||
fn byte(hi: u8, lo: u8) -> Option<u8> {
|
||||
let hi = (hi as char).to_digit(16)?;
|
||||
let lo = (lo as char).to_digit(16)?;
|
||||
Some((hi * 16 + lo) as u8)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_accepts_0x_0x_and_bare_same_result() {
|
||||
let want = [0x00, 0x11, 0xab, 0xCD, 0xef, 0x42, 0x99, 0x00];
|
||||
let bare = "0011abcdef429900";
|
||||
assert_eq!(parse_hex_fixed::<8>(bare), Some(want));
|
||||
assert_eq!(parse_hex_fixed::<8>(&format!("0x{bare}")), Some(want));
|
||||
// The case that used to be dropped by one parser but not another.
|
||||
assert_eq!(parse_hex_fixed::<8>(&format!("0X{bare}")), Some(want));
|
||||
assert_eq!(parse_hex_fixed::<8>(&format!(" 0X{bare} ")), Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_rejects_wrong_length_and_non_hex_and_signs() {
|
||||
assert_eq!(parse_hex_fixed::<16>("00"), None); // too short
|
||||
assert_eq!(parse_hex_fixed::<2>("00112233"), None); // too long
|
||||
assert_eq!(parse_hex_fixed::<2>("zz11"), None); // non-hex
|
||||
assert_eq!(parse_hex_fixed::<2>("+5-A"), None); // sign chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_panic_on_multibyte_of_exact_byte_length() {
|
||||
// "中" is 3 bytes; + 29 'a' = 32 bytes → would mis-slice a &str-indexed
|
||||
// parser. Must reject, not panic.
|
||||
let s = "中".to_string() + &"a".repeat(29);
|
||||
assert_eq!(s.len(), 32);
|
||||
assert_eq!(parse_hex_fixed::<16>(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_variable_length_and_odd_rejected() {
|
||||
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
|
||||
assert_eq!(parse_hex_bytes("AABBC"), None); // odd
|
||||
// Empty (or prefix-only) → empty Vec: a legitimately-empty field.
|
||||
assert_eq!(parse_hex_bytes(""), Some(vec![]));
|
||||
assert_eq!(parse_hex_bytes("0x"), Some(vec![]));
|
||||
}
|
||||
}
|
||||
+69
-35
@@ -621,37 +621,34 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
|
||||
}
|
||||
|
||||
/// Assign the on-wire `private_stream_1` sub-stream id to each audio
|
||||
/// stream by per-codec ordinal, matching DVD authoring convention and the
|
||||
/// muxer's `dvd_pid()` routing:
|
||||
/// - AC-3 → `0x80 + n` (n = 0-based index among AC-3 streams)
|
||||
/// - DTS → `0x88 + n`
|
||||
/// - LPCM → `0xA0 + n`
|
||||
/// stream. On DVD-Video the sub-id's **low nibble is the audio-stream
|
||||
/// *number* (0-7), shared across all codecs** — the single stream index the
|
||||
/// PGC `audio_control` table / navigation registers select — and the high
|
||||
/// nibble is the codec base. So the sub-id is `codec_base | position`, where
|
||||
/// `position` is the stream's index in the IFO audio-attribute table (NOT a
|
||||
/// per-codec running count):
|
||||
/// - AC-3 → `0x80 | i`
|
||||
/// - DTS → `0x88 | i`
|
||||
/// - LPCM → `0xA0 | i`
|
||||
/// - MP1/MP2 and anything else → `None` (regular MPEG-audio PES, not a
|
||||
/// private-stream-1 sub-id).
|
||||
///
|
||||
/// Indices saturate at the codec range ceiling (8 AC-3/DTS, 8 LPCM) so a
|
||||
/// malformed over-count never produces an out-of-range sub-id.
|
||||
/// A per-codec ordinal was wrong: it only coincides with the wire id when a
|
||||
/// codec's first stream is also the disc's audio stream #0. Any codec that is
|
||||
/// not the first audio stream (e.g. a DTS track after an AC-3 track) then got
|
||||
/// a sub-id one-too-low, so the demux routing key (`0xBD00 | sub_id`) never
|
||||
/// matched and the track muxed silent. The positional index is the real wire
|
||||
/// number, so distinct positions still give distinct sub-ids (no collision).
|
||||
///
|
||||
/// Position saturates at 7 so a malformed over-count never produces an
|
||||
/// out-of-range sub-id.
|
||||
fn assign_audio_sub_stream_ids(streams: &mut [DvdAudioAttr]) {
|
||||
let mut n_ac3 = 0u8;
|
||||
let mut n_dts = 0u8;
|
||||
let mut n_lpcm = 0u8;
|
||||
for s in streams.iter_mut() {
|
||||
for (i, s) in streams.iter_mut().enumerate() {
|
||||
let n = (i as u8).min(7);
|
||||
s.sub_stream_id = match s.codec {
|
||||
Codec::Ac3 => {
|
||||
let id = 0x80 + n_ac3.min(7);
|
||||
n_ac3 = n_ac3.saturating_add(1);
|
||||
Some(id)
|
||||
}
|
||||
Codec::Dts => {
|
||||
let id = 0x88 + n_dts.min(7);
|
||||
n_dts = n_dts.saturating_add(1);
|
||||
Some(id)
|
||||
}
|
||||
Codec::Lpcm => {
|
||||
let id = 0xA0 + n_lpcm.min(7);
|
||||
n_lpcm = n_lpcm.saturating_add(1);
|
||||
Some(id)
|
||||
}
|
||||
Codec::Ac3 => Some(0x80 | n),
|
||||
Codec::Dts => Some(0x88 | n),
|
||||
Codec::Lpcm => Some(0xA0 | n),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
@@ -1138,9 +1135,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mixed_codec_sub_stream_ids_are_distinct() {
|
||||
// A title mixing AC-3, DTS and LPCM must get per-codec ordinal
|
||||
// sub-ids (0x80, 0x88, 0xA0...), all distinct — this is the
|
||||
// routing key that keeps mixed-codec audio from colliding.
|
||||
// A title mixing AC-3, DTS and LPCM: the sub-id low nibble is the
|
||||
// POSITIONAL audio-stream number (shared across codecs), OR'd with the
|
||||
// codec base. So idx 1 (DTS) → 0x89, idx 3 (AC-3) → 0x83 — the real
|
||||
// wire ids the demux routes on. All distinct (positions are unique).
|
||||
let mut streams = vec![
|
||||
DvdAudioAttr {
|
||||
codec: Codec::Ac3,
|
||||
@@ -1172,10 +1170,10 @@ mod tests {
|
||||
},
|
||||
];
|
||||
assign_audio_sub_stream_ids(&mut streams);
|
||||
assert_eq!(streams[0].sub_stream_id, Some(0x80)); // AC-3 #0
|
||||
assert_eq!(streams[1].sub_stream_id, Some(0x88)); // DTS #0
|
||||
assert_eq!(streams[2].sub_stream_id, Some(0xA0)); // LPCM #0
|
||||
assert_eq!(streams[3].sub_stream_id, Some(0x81)); // AC-3 #1
|
||||
assert_eq!(streams[0].sub_stream_id, Some(0x80)); // AC-3 @ pos 0
|
||||
assert_eq!(streams[1].sub_stream_id, Some(0x89)); // DTS @ pos 1
|
||||
assert_eq!(streams[2].sub_stream_id, Some(0xA2)); // LPCM @ pos 2
|
||||
assert_eq!(streams[3].sub_stream_id, Some(0x83)); // AC-3 @ pos 3
|
||||
// All sub-ids unique.
|
||||
let ids: Vec<u8> = streams.iter().filter_map(|s| s.sub_stream_id).collect();
|
||||
let mut sorted = ids.clone();
|
||||
@@ -1184,6 +1182,42 @@ mod tests {
|
||||
assert_eq!(ids.len(), sorted.len(), "sub-stream ids must be unique");
|
||||
}
|
||||
|
||||
/// Regression (The Punisher 2004): audio[0]=AC-3 5.1, audio[1]=DTS 5.0.
|
||||
/// The DTS track sits at audio position 1, so its wire sub-id is 0x89
|
||||
/// (0x88 | 1), NOT the per-codec 0x88. With the old per-codec ordinal it
|
||||
/// got 0x88 → demux routing key 0xBD88 had no match → every DTS packet
|
||||
/// (which carries 0x89) was dropped → the track muxed present-but-silent
|
||||
/// while the AC-3 (at position 0, where ordinal and position coincide)
|
||||
/// played fine. Positional numbering fixes it end-to-end.
|
||||
#[test]
|
||||
fn dts_after_ac3_uses_positional_substream_id() {
|
||||
let mut streams = vec![
|
||||
DvdAudioAttr {
|
||||
codec: Codec::Ac3,
|
||||
channels: 6,
|
||||
sample_rate: 48000,
|
||||
language: "en".into(),
|
||||
sub_stream_id: None,
|
||||
},
|
||||
DvdAudioAttr {
|
||||
codec: Codec::Dts,
|
||||
channels: 5,
|
||||
sample_rate: 48000,
|
||||
language: "en".into(),
|
||||
sub_stream_id: None,
|
||||
},
|
||||
];
|
||||
assign_audio_sub_stream_ids(&mut streams);
|
||||
assert_eq!(streams[0].sub_stream_id, Some(0x80));
|
||||
assert_eq!(
|
||||
streams[1].sub_stream_id,
|
||||
Some(0x89),
|
||||
"DTS at audio position 1 routes to 0x89 on the wire, not 0x88"
|
||||
);
|
||||
// The routing key the muxer actually uses must resolve for 0x89.
|
||||
assert_eq!(crate::mux::ps::dvd_audio_pid(0x89), Some(0xBD89));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_attr_dts() {
|
||||
let mut data = vec![0u8; 16];
|
||||
@@ -1371,10 +1405,10 @@ mod tests {
|
||||
];
|
||||
assign_audio_sub_stream_ids(&mut streams);
|
||||
assert_eq!(streams[0].sub_stream_id, None); // MP2 → no sub-id
|
||||
assert_eq!(streams[1].sub_stream_id, Some(0x80)); // AC3 #0
|
||||
assert_eq!(streams[1].sub_stream_id, Some(0x81)); // AC3 @ pos 1
|
||||
}
|
||||
|
||||
/// assign_audio_sub_stream_ids saturates the per-codec ordinal at the
|
||||
/// assign_audio_sub_stream_ids saturates the positional index at the
|
||||
/// range ceiling (min(7)) so a malformed over-count never produces an
|
||||
/// out-of-range sub-id. 9 AC-3 streams: the 9th still ≤ 0x87.
|
||||
#[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)
|
||||
|
||||
+294
-51
@@ -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;
|
||||
|
||||
@@ -32,6 +34,10 @@ pub struct DiscInputs {
|
||||
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
|
||||
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
|
||||
pub volume_id: [u8; 16],
|
||||
/// AACS major version (1 = V10 / BD AACS 1.0, 2 = V20+ / UHD). Drives the
|
||||
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21) when a
|
||||
/// source returns a VUK to derive unit keys from. Defaults to 2.
|
||||
pub version: u8,
|
||||
/// Raw MKB bytes. Empty when not captured.
|
||||
pub mkb: Vec<u8>,
|
||||
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
|
||||
@@ -70,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.
|
||||
@@ -99,24 +105,22 @@ pub struct DiscInputsCtx<'a> {
|
||||
|
||||
impl<'a> DiscInputsCtx<'a> {
|
||||
/// Build a context over `inputs`, parsing the encrypted title keys at the
|
||||
/// stride for AACS major `version_u8` (1 = V10, else V20/V21).
|
||||
/// stride for the disc's own AACS major (`inputs.version`: 1 → 48-byte V10
|
||||
/// stride, else 64-byte V20/V21) — the single source of truth, no separate
|
||||
/// version argument to drift from it.
|
||||
///
|
||||
/// A present-but-malformed `unit_key_ro` (truncated / wrong magic / wrong
|
||||
/// stride) parses to an empty key set, so a later [`Self::enc_title_keys`]
|
||||
/// returns `Ok(&[])` indistinguishably from a disc that legitimately has no
|
||||
/// title keys — the parse failure is swallowed here, not surfaced as an
|
||||
/// error.
|
||||
pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self {
|
||||
use crate::aacs::{AacsVersion, parse_unit_key_ro};
|
||||
pub fn new(inputs: &'a DiscInputs) -> Self {
|
||||
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 {
|
||||
let version = if version_u8 == 1 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
parse_unit_key_ro(&inputs.unit_key_ro, version)
|
||||
parse_unit_key_ro(&inputs.unit_key_ro, AacsVersion::from_major(inputs.version))
|
||||
.map(|f| f.encrypted_keys.into_iter().map(|(_, k)| k).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
@@ -163,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
|
||||
@@ -208,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
|
||||
@@ -218,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.
|
||||
@@ -227,16 +231,14 @@ 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();
|
||||
|
||||
// AACS major drives the Unit_Key_RO.inf stride the ctx parses at. Default to
|
||||
// the V20/V21 stride when there is no AACS state (it is the common live case;
|
||||
// a non-AACS disc has nothing to resolve and the loop simply finds nothing).
|
||||
let version_u8 = disc.aacs.as_ref().map(|a| a.version).unwrap_or(2);
|
||||
let ctx = DiscInputsCtx::new(inputs, version_u8);
|
||||
// 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.
|
||||
let ctx = DiscInputsCtx::new(inputs);
|
||||
|
||||
for source in sources {
|
||||
// `who` is the source's own stable identifier — no enum to map back to.
|
||||
@@ -320,9 +322,11 @@ pub fn key_fetch(
|
||||
let sources = make_sources();
|
||||
let mut di = inputs.clone();
|
||||
di.samples = samples.to_vec();
|
||||
// V20/V21 stride (BD/UHD AACS 2.x); the online /decode UK path forwards
|
||||
// raw inf + samples and doesn't depend on the parsed title-key stride.
|
||||
let ctx = DiscInputsCtx::new(&di, 2);
|
||||
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
|
||||
// an online /decode reply that returns a VUK (not a terminal UK) then
|
||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
fetch_unit_keys(&sources, &ctx)
|
||||
.into_iter()
|
||||
.map(|u| u.key)
|
||||
@@ -340,19 +344,31 @@ 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, sampling the
|
||||
/// largest extent at its midpoint forward.
|
||||
/// "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 (libaacs' `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
|
||||
const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint
|
||||
// Probe several evenly-spaced points across EACH extent rather than only the
|
||||
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
||||
// midpoint lands in a clear nav stretch, must STILL yield scrambled samples.
|
||||
// Empty samples make `Disc::decrypt_with` skip wrong-key validation, so a
|
||||
// real encrypted title returning nothing here is a silent wrong-key hazard.
|
||||
const PROBES_PER_EXTENT: u32 = 8;
|
||||
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
for ext in &title.extents {
|
||||
@@ -360,25 +376,30 @@ pub fn read_encrypted_units(
|
||||
if total_units == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut unit = total_units / 2; // midpoint (past the clear nav at the head)
|
||||
for _ in 0..MAX_CHUNKS_PER_EXTENT {
|
||||
for p in 1..=PROBES_PER_EXTENT {
|
||||
// Probe at p/(P+1) of the extent — spreads P points across it while
|
||||
// skipping the clear nav at the very head.
|
||||
let unit = ((total_units as u64 * p as u64) / (PROBES_PER_EXTENT as u64 + 1)) as u32;
|
||||
if unit >= total_units {
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
let units_this = CHUNK_UNITS.min(total_units - unit);
|
||||
// Saturate: start_lba comes from attacker-controlled UDF/MPLS
|
||||
// extents; a malformed extent near u32::MAX would otherwise panic
|
||||
// (debug) or wrap to a wrong LBA (release). An over-capacity LBA then
|
||||
// fails cleanly via the read_sectors().is_err() break below.
|
||||
// fails cleanly via the read_sectors().is_err() skip below.
|
||||
let lba = ext
|
||||
.start_lba
|
||||
.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
|
||||
let count = (units_this * ALIGNED_UNIT_SECTORS) as u16;
|
||||
let mut buf = vec![0u8; count as usize * 2048];
|
||||
// `false` = no recovery retries; the reader is the raw drive/file
|
||||
// (no decrypt decorator), so these are the on-disc encrypted bytes.
|
||||
// (no decrypt decorator), so these are the on-disc encrypted bytes. A
|
||||
// read error at one probe skips THAT probe only — it must not abandon
|
||||
// the rest of the extent (the old `break` blinded the sampler on a
|
||||
// single transient miss).
|
||||
if reader.read_sectors(lba, count, &mut buf, false).is_err() {
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
for i in 0..units_this as usize {
|
||||
let o = i * ALIGNED_UNIT_LEN;
|
||||
@@ -386,14 +407,13 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
unit += units_this;
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -402,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 ────────────────────────────────────
|
||||
@@ -443,6 +463,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0xABC".into(),
|
||||
volume_id: [0u8; 16],
|
||||
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]],
|
||||
@@ -450,7 +471,7 @@ mod tests {
|
||||
};
|
||||
|
||||
// Zero VID → None.
|
||||
let ctx = DiscInputsCtx::new(&inputs, 1);
|
||||
let ctx = DiscInputsCtx::new(&inputs);
|
||||
assert_eq!(ctx.disc_hash(), "0xABC");
|
||||
assert_eq!(ctx.title(), Some("TITLE_X"));
|
||||
assert!(ctx.vid().is_none(), "all-zero VID is the no-VID sentinel");
|
||||
@@ -461,7 +482,7 @@ mod tests {
|
||||
// Non-zero VID → Some(vid).
|
||||
let mut inputs2 = inputs.clone();
|
||||
inputs2.volume_id = [0x42u8; 16];
|
||||
let ctx2 = DiscInputsCtx::new(&inputs2, 1);
|
||||
let ctx2 = DiscInputsCtx::new(&inputs2);
|
||||
assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16])));
|
||||
}
|
||||
|
||||
@@ -498,6 +519,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0x00".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -518,6 +540,7 @@ mod tests {
|
||||
DiscInputs {
|
||||
disc_hash: String::new(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -552,7 +575,7 @@ mod tests {
|
||||
#[test]
|
||||
fn fetch_unit_keys_first_nonempty_skips_empty_and_errors() {
|
||||
let inputs = empty_inputs();
|
||||
let ctx = DiscInputsCtx::new(&inputs, 2);
|
||||
let ctx = DiscInputsCtx::new(&inputs);
|
||||
let key = [0xABu8; 16];
|
||||
|
||||
let sources: Vec<Box<dyn KeySource>> = vec![
|
||||
@@ -607,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,
|
||||
@@ -621,4 +644,224 @@ mod tests {
|
||||
);
|
||||
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
||||
}
|
||||
|
||||
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
|
||||
/// starting feature, or a midpoint landing in clear nav) must still be
|
||||
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
|
||||
/// The old midpoint-and-forward sampler returned empty; the probe-spread
|
||||
/// finds the early scrambled band.
|
||||
#[test]
|
||||
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||
use crate::error::Result;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
// Units in the FIRST SIXTH of the extent are scrambled (0xFF → no TS
|
||||
// sync); everything else (incl. the midpoint) is clear (0x47 syncs).
|
||||
struct BandSource {
|
||||
ext_start: u32,
|
||||
total_units: u32,
|
||||
}
|
||||
impl SectorSource for BandSource {
|
||||
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_unit = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32;
|
||||
if abs_unit < self.total_units / 6 {
|
||||
chunk.fill(0xFF); // scrambled: no TS sync
|
||||
} else {
|
||||
chunk.fill(0);
|
||||
let mut o = 4;
|
||||
while o < ALIGNED_UNIT_LEN {
|
||||
chunk[o] = 0x47; // clear TS syncs
|
||||
o += 192;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
let total_units = 600u32;
|
||||
let ext_start = 1000u32;
|
||||
let mut src = BandSource {
|
||||
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, 4);
|
||||
assert!(
|
||||
!samples.is_empty(),
|
||||
"the probe-spread must sample the early scrambled band the midpoint misses"
|
||||
);
|
||||
for s in &samples {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit #5 — a DISCRIMINATING test for the version→stride fix. A 2-key
|
||||
/// `Unit_Key_RO.inf` whose SECOND key sits at the V20 (64-byte) offset; a V10
|
||||
/// (48-byte) parse reads a DIFFERENT region. Confirms `DiscInputsCtx` parses
|
||||
/// at the stride for `inputs.version` — a swapped `from_major` branch or a
|
||||
/// hardcoded stride (the exact bug 1.2.0 fixes) would fail this, where the
|
||||
/// prior single-key fixtures passed regardless of stride.
|
||||
#[test]
|
||||
fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() {
|
||||
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
|
||||
inf[UK_POS..UK_POS + 2].copy_from_slice(&2u16.to_be_bytes()); // num_uk = 2
|
||||
let key0_at = UK_POS + 48; // first key — same for both strides
|
||||
let key1_v10_at = key0_at + 48; // second key if parsed at V10 stride
|
||||
let key1_v20_at = key0_at + 64; // second key if parsed at V20 stride
|
||||
inf[key0_at..key0_at + 16].fill(0xA0);
|
||||
inf[key1_v10_at..key1_v10_at + 16].fill(0x10);
|
||||
inf[key1_v20_at..key1_v20_at + 16].fill(0x20);
|
||||
|
||||
let base = DiscInputs {
|
||||
disc_hash: String::new(),
|
||||
volume_id: [0u8; 16],
|
||||
version: AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: inf,
|
||||
samples: Vec::new(),
|
||||
volume_label: None,
|
||||
};
|
||||
let k20 = DiscInputsCtx::new(&base).enc_title_keys().unwrap().to_vec();
|
||||
let v10_inputs = DiscInputs {
|
||||
version: AACS_MAJOR_BD,
|
||||
..base.clone()
|
||||
};
|
||||
let k10 = DiscInputsCtx::new(&v10_inputs)
|
||||
.enc_title_keys()
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
|
||||
assert_eq!(k20.len(), 2);
|
||||
assert_eq!(k10.len(), 2);
|
||||
assert_eq!(k20[0], [0xA0; 16], "first key is at +48 for both strides");
|
||||
assert_eq!(k10[0], [0xA0; 16]);
|
||||
assert_eq!(k20[1], [0x20; 16], "V20 reads the 2nd key at +64");
|
||||
assert_eq!(k10[1], [0x10; 16], "V10 reads the 2nd key at +48");
|
||||
assert_ne!(k20[1], k10[1], "the parse stride follows inputs.version");
|
||||
}
|
||||
}
|
||||
|
||||
+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`].
|
||||
|
||||
+15
-18
@@ -1,9 +1,10 @@
|
||||
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//!
|
||||
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
||||
//! sector reading. Drive unlocking is pluggable: libfreemkv owns only the
|
||||
//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in
|
||||
//! an external crate (e.g. `freemkv-unlock-ld`).
|
||||
//! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS
|
||||
//! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock`
|
||||
//! crate; libfreemkv consumes it privately and exposes none of it, so clients
|
||||
//! are oblivious to unlockers (just as they are to the SCSI layer).
|
||||
//!
|
||||
//! # Quick Start
|
||||
//!
|
||||
@@ -47,8 +48,8 @@
|
||||
//! Drive -- open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
||||
//! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld);
|
||||
//! libfreemkv owns only the trait + registry
|
||||
//! └── unlock_bridge -- private seam to the `freemkv-unlock` crate
|
||||
//! (firmware / AACS cert / CSS bus-auth unlockers)
|
||||
//!
|
||||
//! Disc -- scan titles, streams, AACS state
|
||||
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
|
||||
@@ -57,7 +58,7 @@
|
||||
//! ├── JAR parser -- BD-J audio track labels
|
||||
//! └── AACS -- encryption: key resolution + content decrypt
|
||||
//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt
|
||||
//! └── handshake -- SCSI auth, ECDH, bus key
|
||||
//! └── host_certs -- collect host certs (cert handshake lives in freemkv-unlock)
|
||||
//! ```
|
||||
//!
|
||||
//! # AACS Encryption
|
||||
@@ -111,6 +112,7 @@ pub mod dvdnav;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod halt;
|
||||
pub mod hex;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub mod io;
|
||||
@@ -125,11 +127,7 @@ pub mod scsi;
|
||||
pub mod sector;
|
||||
pub(crate) mod speed;
|
||||
pub(crate) mod udf;
|
||||
pub mod unlock;
|
||||
pub mod verify;
|
||||
|
||||
// Re-export verify types at the crate root for ergonomic imports.
|
||||
pub use verify::{SectorRange, SectorStatus, VerifyResult, verify_title};
|
||||
pub(crate) mod unlock_bridge;
|
||||
|
||||
// ─── Drive lifecycle ────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -175,14 +173,13 @@ pub use io::pipeline::{
|
||||
pub use event::{BatchSizeReason, Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
|
||||
// ─── Pluggable unlock seam ──────────────────────────────────────────────────
|
||||
// ─── Unlock seam ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An
|
||||
// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker`
|
||||
// and registers it once at process start via `register_unlocker`. At
|
||||
// drive-prep the registry is walked in order; the first matching unlocker
|
||||
// runs, else the drive falls through to the host-cert AACS handshake.
|
||||
pub use unlock::{UnlockError, Unlocker, register_unlocker};
|
||||
// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS
|
||||
// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes
|
||||
// it through the private `unlock_bridge` and exposes nothing of it: clients are
|
||||
// oblivious to unlockers, exactly as they are to the SCSI layer. There is no
|
||||
// public unlock surface to import.
|
||||
|
||||
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+127
-1
@@ -60,6 +60,20 @@ impl Ac3Parser {
|
||||
|
||||
impl CodecParser for Ac3Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
// B1: a concealed/lost gap means the bytes held in `buf` are a TRUNCATED
|
||||
// frame. Appending the post-gap bytes would splice them into one corrupt
|
||||
// frame (wrong frame_size, bad CRC → "exponent out of range" / garbage).
|
||||
// Drop the partial and resync on the next syncword — a clean single-frame
|
||||
// gap instead of a frankenstein frame. (The video parsers carry this via
|
||||
// the ResyncGate; audio has no inter-frame refs, so dropping the spliced
|
||||
// partial is the whole fix.)
|
||||
//
|
||||
// Handle the discontinuity BEFORE the empty-data guard so the signal can
|
||||
// never be stranded by an empty post-gap PES (the demuxer only emits
|
||||
// non-empty PES today; this is defensive for any future caller).
|
||||
if pes.discontinuity {
|
||||
self.buf.clear();
|
||||
}
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -69,7 +83,13 @@ impl CodecParser for Ac3Parser {
|
||||
// PES that carries several AC-3 frames stamps a monotonically increasing
|
||||
// PTS per frame instead of the same PES timestamp on all of them (which
|
||||
// collapses their timecodes and drifts A/V).
|
||||
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
//
|
||||
// A PES with no PTS (rare for audio, but legal — and the case OSS demuxers
|
||||
// guard at a post-gap continuation) must NOT reset the timeline to 0;
|
||||
// carry the running cadence (`flush_pts_ns` tracks the retained partial /
|
||||
// last frame's PTS). The discontinuity-carrying PES is a PUSI with a PTS
|
||||
// in practice, so this is defense-in-depth.
|
||||
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
|
||||
|
||||
// Prepend leftover from previous PES
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
@@ -115,6 +135,7 @@ impl CodecParser for Ac3Parser {
|
||||
|
||||
let duration_ns = frame_duration_ns(remaining, bsid);
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: frame_pts_ns,
|
||||
@@ -201,6 +222,7 @@ impl CodecParser for Ac3Parser {
|
||||
}
|
||||
let duration_ns = frame_duration_ns(frame, bsid);
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: self.flush_pts_ns,
|
||||
@@ -450,6 +472,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: vec![],
|
||||
discontinuity: false,
|
||||
};
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
@@ -464,6 +487,7 @@ mod tests {
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: frame_data.clone(),
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
@@ -483,6 +507,7 @@ mod tests {
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: frame_data[..mid].to_vec(),
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames1 = parser.parse(&pes1);
|
||||
assert!(frames1.is_empty(), "partial frame should not emit");
|
||||
@@ -494,12 +519,104 @@ mod tests {
|
||||
pts: Some(93000),
|
||||
dts: None,
|
||||
data: frame_data[mid..].to_vec(),
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames2 = parser.parse(&pes2);
|
||||
assert_eq!(frames2.len(), 1);
|
||||
assert_eq!(frames2[0].data.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discontinuity_drops_truncated_partial() {
|
||||
// B1: a partial AC-3 frame is buffered, then a concealed gap arrives
|
||||
// (PES marked discontinuity) carrying a fresh complete frame. The
|
||||
// truncated partial must be DROPPED, not spliced — otherwise the parser
|
||||
// emits one corrupt frame built from [stale partial | head of fresh] and
|
||||
// strands the tail (FFmpeg: "incomplete frame" / wrong sync).
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
|
||||
|
||||
// First PES: only the first half of a frame (no boundary marker).
|
||||
let pes1 = PesPacket {
|
||||
source: None,
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: frame_data[..80].to_vec(),
|
||||
discontinuity: false,
|
||||
};
|
||||
assert!(
|
||||
parser.parse(&pes1).is_empty(),
|
||||
"partial frame should not emit"
|
||||
);
|
||||
|
||||
// Concealed gap: a fresh whole frame, marked discontinuity.
|
||||
let fresh = make_ac3_frame(0, 2);
|
||||
let pes2 = PesPacket {
|
||||
source: None,
|
||||
pid: 0,
|
||||
pts: Some(99000),
|
||||
dts: None,
|
||||
data: fresh.clone(),
|
||||
discontinuity: true,
|
||||
};
|
||||
let frames = parser.parse(&pes2);
|
||||
assert_eq!(frames.len(), 1, "exactly one clean frame across the gap");
|
||||
assert_eq!(
|
||||
frames[0].data, fresh,
|
||||
"emitted frame is the fresh post-gap frame, not a spliced partial"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_discontinuity_pes_still_drops_partial() {
|
||||
// Defensive ordering: the discontinuity clear runs BEFORE the empty-data
|
||||
// guard, so even an empty-payload discontinuity PES drops the stranded
|
||||
// partial instead of leaking the signal and splicing on the next PES.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2); // 160 bytes
|
||||
|
||||
// Partial first half buffered.
|
||||
let pes1 = PesPacket {
|
||||
source: None,
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: frame_data[..80].to_vec(),
|
||||
discontinuity: false,
|
||||
};
|
||||
assert!(parser.parse(&pes1).is_empty());
|
||||
|
||||
// Empty-payload discontinuity PES: must still clear the partial.
|
||||
let gap = PesPacket {
|
||||
source: None,
|
||||
pid: 0,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: vec![],
|
||||
discontinuity: true,
|
||||
};
|
||||
assert!(parser.parse(&gap).is_empty(), "empty PES emits nothing");
|
||||
|
||||
// A fresh whole frame (no discontinuity now): if the partial had leaked,
|
||||
// this would splice into a frankenstein; instead it emits cleanly.
|
||||
let fresh = make_ac3_frame(0, 2);
|
||||
let pes2 = PesPacket {
|
||||
source: None,
|
||||
pid: 0,
|
||||
pts: Some(99000),
|
||||
dts: None,
|
||||
data: fresh.clone(),
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes2);
|
||||
assert_eq!(frames.len(), 1, "one clean frame, partial was dropped");
|
||||
assert_eq!(
|
||||
frames[0].data, fresh,
|
||||
"no splice — partial did not leak past the empty gap PES"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_garbage_before_sync() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
@@ -512,6 +629,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
@@ -535,6 +653,7 @@ mod tests {
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: pes1_data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames1 = parser.parse(&pes1);
|
||||
assert_eq!(frames1.len(), 1, "first complete frame emitted");
|
||||
@@ -548,6 +667,7 @@ mod tests {
|
||||
pts: Some(93000),
|
||||
dts: None,
|
||||
data: pes2_data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames2 = parser.parse(&pes2);
|
||||
assert_eq!(frames2.len(), 1, "split-sync frame must be recovered");
|
||||
@@ -574,6 +694,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
@@ -602,6 +723,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
|
||||
@@ -639,6 +761,7 @@ mod tests {
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f.len(), 1, "frame 1 emitted in parse");
|
||||
@@ -671,6 +794,7 @@ mod tests {
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f.len(), 2);
|
||||
@@ -714,6 +838,7 @@ mod tests {
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1, "only the real AC-3 frame is emitted");
|
||||
@@ -1179,6 +1304,7 @@ mod tests {
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+357
-34
@@ -37,7 +37,18 @@ 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
|
||||
/// duration. This per-PES re-base (rather than a global running clock) is
|
||||
/// what keeps a feature-long DVD DTS track from drifting past its real
|
||||
/// length. `PTS_UNSET` = no AU emitted yet.
|
||||
last_front_pts: i64,
|
||||
/// The PTS for the NEXT AU *if it shares the current PES* (the within-PES
|
||||
/// running cursor: previous emit + its duration). Only consulted when
|
||||
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
|
||||
next_pts_ns: i64,
|
||||
}
|
||||
|
||||
impl Default for DtsParser {
|
||||
@@ -51,10 +62,42 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp an access unit's PTS. `front` is the AU's own core-PES PTS (from
|
||||
/// [`front_pts`]); `dur_ns` its decoded duration.
|
||||
///
|
||||
/// The model matches the (correct) AC-3 path: **re-base to each PES's own
|
||||
/// container timestamp, and advance by one frame duration ONLY within a run
|
||||
/// of AUs that share the same PES.** A new PES (its `front` differs from the
|
||||
/// previous AU's) trusts its own timestamp — so the emitted timeline tracks
|
||||
/// the container and never drifts. Advancing within a PES is what fixes the
|
||||
/// DVD case (several DTS core frames packed in one PES, which otherwise all
|
||||
/// collided on that single PES timestamp → "non monotonically increasing
|
||||
/// dts"). A global running clock was WRONG here: once accumulated frame
|
||||
/// durations exceeded the PES spacing it never re-based, drifting the track
|
||||
/// minutes past the real length over a feature-long title.
|
||||
fn stamp_pts(&mut self, front: i64, dur_ns: i64) -> i64 {
|
||||
let base = if front != PTS_UNSET && front != self.last_front_pts {
|
||||
// New PES (or the first AU): trust its own timestamp — no drift.
|
||||
front
|
||||
} else if self.next_pts_ns != PTS_UNSET {
|
||||
// Same PES as the previous AU (front unchanged) → advance one frame.
|
||||
self.next_pts_ns
|
||||
} else if front != PTS_UNSET {
|
||||
front
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.last_front_pts = front;
|
||||
self.next_pts_ns = base + dur_ns;
|
||||
base
|
||||
}
|
||||
|
||||
/// Drop `n` bytes from the front of `buf` and rebase the PTS markers so
|
||||
/// their offsets stay relative to the new buffer start. A marker that now
|
||||
/// sits at or before offset 0 is clamped to 0 (it still covers the front).
|
||||
@@ -123,10 +166,43 @@ const PTS_UNSET: i64 = -1;
|
||||
|
||||
impl CodecParser for DtsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
// B1: a concealed/lost gap means the buffered DTS access unit is
|
||||
// TRUNCATED. Splicing post-gap bytes onto it corrupts the core/extension
|
||||
// framing (→ "Failed to decode block code(s)" / "Invalid data found").
|
||||
// Drop the partial AU and its PTS marks; the next PES re-bases a fresh
|
||||
// unit. (Audio has no inter-frame refs — dropping the spliced partial is
|
||||
// the whole fix; the video ResyncGate handles video.)
|
||||
//
|
||||
// Handle the discontinuity BEFORE the empty-data guard so the signal can
|
||||
// never be stranded by an empty post-gap PES (defensive; the demuxer only
|
||||
// emits non-empty PES today).
|
||||
if pes.discontinuity {
|
||||
self.buf.clear();
|
||||
self.pts_marks.clear();
|
||||
self.pending_pts = PTS_UNSET;
|
||||
// A concealed gap is a timeline discontinuity: let the post-gap AU
|
||||
// re-base to its own PES PTS rather than the pre-gap cursor.
|
||||
self.next_pts_ns = PTS_UNSET;
|
||||
self.last_front_pts = PTS_UNSET;
|
||||
}
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
// A PES with no PTS (rare for audio, but legal — the case OSS demuxers
|
||||
// guard at a post-gap continuation) must NOT reset the timeline to 0;
|
||||
// continue from the most recent known base. Defense-in-depth: the
|
||||
// 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
|
||||
.back()
|
||||
.map(|&(_, p)| p)
|
||||
.filter(|&p| p >= 0)
|
||||
.unwrap_or(if self.pending_pts >= 0 {
|
||||
self.pending_pts
|
||||
} else {
|
||||
0
|
||||
})
|
||||
});
|
||||
|
||||
// On Blu-ray, a DTS-HD MA/HRA access unit is a DTS core frame
|
||||
// (sync 0x7FFE8001) followed by one or more DTS extension substreams
|
||||
@@ -154,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();
|
||||
@@ -238,17 +317,21 @@ impl CodecParser for DtsParser {
|
||||
};
|
||||
|
||||
let au: Vec<u8> = self.buf[..au_end].to_vec();
|
||||
// The AU takes the PTS of the PES covering its first byte — its own
|
||||
// core's PES, even if that PES preceded the one(s) carrying its
|
||||
// extensions or the next core.
|
||||
let au_pts = self.front_pts();
|
||||
// The AU's own core PES PTS (the PES covering its first byte, even if
|
||||
// that PES preceded the one(s) carrying its extensions or the next
|
||||
// core), stamped monotonically: honored when it advances past the
|
||||
// running clock (UHD one-AU-per-PES), but never allowed to collide
|
||||
// with the previous AU when several cores share ONE PES (DVD).
|
||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: au_pts,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
duration_ns: Some(dur_ns as u64),
|
||||
});
|
||||
self.drain_front(au_end);
|
||||
// After draining, the marker covering the new front (if any) carries
|
||||
@@ -294,17 +377,18 @@ impl CodecParser for DtsParser {
|
||||
}
|
||||
// The final AU's PTS is the PES covering the buffer front (its core's
|
||||
// PES). Fall back to pending_pts, clamping the sentinel to 0.
|
||||
let front = self.front_pts();
|
||||
let pts_ns = if front == PTS_UNSET { 0 } else { front };
|
||||
let au = std::mem::take(&mut self.buf);
|
||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
|
||||
self.pts_marks.clear();
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
duration_ns: Some(dur_ns as u64),
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -470,6 +554,49 @@ fn dts_core_frame_size(data: &[u8]) -> usize {
|
||||
fsize + 1
|
||||
}
|
||||
|
||||
/// DTS core `SFREQ` → sample rate (Hz). 4-bit index; reserved/invalid entries
|
||||
/// fall back to 48 kHz (the DVD/UHD norm) so a bogus value never yields a zero
|
||||
/// rate (division) or a wildly wrong frame duration.
|
||||
const DTS_CORE_SAMPLE_RATES: [u32; 16] = [
|
||||
48_000, // 0: invalid → fallback
|
||||
8_000, 16_000, 32_000, 48_000, // 4: invalid → fallback
|
||||
48_000, // 5: invalid → fallback
|
||||
11_025, 22_050, 44_100, 48_000, // 9: invalid → fallback
|
||||
48_000, // 10: invalid → fallback
|
||||
12_000, 24_000, 48_000, 96_000, 192_000,
|
||||
];
|
||||
|
||||
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
|
||||
/// core-header PCM-sample-block count — the same field ffmpeg's `dca` decoder
|
||||
/// uses to timestamp frames. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
||||
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
|
||||
fn dts_core_samples(data: &[u8]) -> u32 {
|
||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||
return 512; // typical; only reached on a truncated header
|
||||
}
|
||||
let nblks = ((data[4] as u32 & 0x01) << 6) | (data[5] as u32 >> 2);
|
||||
(nblks + 1) * 32
|
||||
}
|
||||
|
||||
/// DTS core sample rate (Hz) from `SFREQ` (4 bits: byte8 bits5-2), with a 48 kHz
|
||||
/// fallback for reserved indices.
|
||||
fn dts_core_sample_rate(data: &[u8]) -> u32 {
|
||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||
return 48_000;
|
||||
}
|
||||
let sfreq = (data[8] as usize >> 2) & 0x0F;
|
||||
DTS_CORE_SAMPLE_RATES[sfreq]
|
||||
}
|
||||
|
||||
/// Duration of one DTS core access unit in nanoseconds: `samples / rate`,
|
||||
/// rounded to nearest. This is what lets consecutive core frames packed in a
|
||||
/// single DVD PES advance monotonically instead of colliding on one PES PTS.
|
||||
fn dts_core_duration_ns(data: &[u8]) -> u64 {
|
||||
let samples = dts_core_samples(data) as u64;
|
||||
let rate = dts_core_sample_rate(data) as u64;
|
||||
(samples * 1_000_000_000 + rate / 2) / rate
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -482,6 +609,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,12 +617,22 @@ mod tests {
|
||||
let fsize = size - 1;
|
||||
let mut data = vec![0u8; size];
|
||||
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
||||
data[5] = (data[5] & 0xFC) | ((fsize >> 12) & 0x03) as u8;
|
||||
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
|
||||
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
|
||||
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
|
||||
data[6] = ((fsize >> 4) & 0xFF) as u8;
|
||||
data[7] = (data[7] & 0x0F) | (((fsize & 0x0F) << 4) as u8);
|
||||
// SFREQ = 13 → 48 kHz (byte8 bits5-2). Only when the header byte exists.
|
||||
if size > 8 {
|
||||
data[8] = 13u8 << 2;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
// 512 samples @ 48 kHz, rounded to nearest ns — the duration make_dts_core
|
||||
// frames advance by. (512 * 1e9 + 24000) / 48000 = 10_666_667 ns.
|
||||
const DTS_CORE_DUR_NS: i64 = (512 * 1_000_000_000 + 48_000 / 2) / 48_000;
|
||||
|
||||
/// A real DTS-HD EXSS substream of `total` bytes (short header form), with an
|
||||
/// optional false DTS core syncword embedded in its payload (decoding to a
|
||||
/// plausible core size) — to prove precise sizing, not a payload scan, bounds
|
||||
@@ -582,10 +720,54 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_cores_back_to_back_emit_first_on_boundary() {
|
||||
// The first complete unit is emitted as soon as the next core sync is
|
||||
// seen; the second is held until flush. Both cores arrived in ONE PES,
|
||||
// so both legitimately carry that PES's PTS.
|
||||
fn discontinuity_drops_truncated_partial() {
|
||||
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
|
||||
// discontinuity) carries a fresh core. The truncated partial must be
|
||||
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
|
||||
// AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and
|
||||
// strands the rest. With the fix the post-gap core is the only AU, and it
|
||||
// carries the post-gap PTS (not the stale pre-gap one).
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// PES 1: first half of a 512-byte core (no boundary marker).
|
||||
let core = make_dts_core(512);
|
||||
let pes1 = make_pes(core[..256].to_vec(), Some(90000));
|
||||
assert!(parser.parse(&pes1).is_empty(), "partial core held");
|
||||
|
||||
// Concealed gap: a fresh whole core, marked discontinuity.
|
||||
let fresh = make_dts_core(512);
|
||||
let pes2 = PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: Some(99000),
|
||||
dts: None,
|
||||
data: fresh.clone(),
|
||||
discontinuity: true,
|
||||
};
|
||||
assert!(
|
||||
parser.parse(&pes2).is_empty(),
|
||||
"post-gap core held awaiting next core — NO corrupt partial emitted"
|
||||
);
|
||||
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1, "exactly one clean AU across the gap");
|
||||
assert_eq!(
|
||||
tail[0].data, fresh,
|
||||
"AU is the fresh post-gap core, not a splice"
|
||||
);
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(99000),
|
||||
"post-gap AU re-bases to the post-gap PTS, not the stranded pre-gap one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_cores_back_to_back_advance_within_one_pes() {
|
||||
// Both cores arrive in ONE PES — the DVD layout. AU1 keeps the PES PTS;
|
||||
// AU2 must ADVANCE by one frame duration to stay monotonic. Reusing the
|
||||
// single PES PTS for both was the "non monotonically increasing dts to
|
||||
// muxer" bug (fixed for 1.2.1).
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = make_dts_core(512);
|
||||
stream.extend_from_slice(&make_dts_core(640));
|
||||
@@ -593,13 +775,18 @@ mod tests {
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].data.len(), 512);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 keeps its PES PTS");
|
||||
assert_eq!(
|
||||
f[0].duration_ns,
|
||||
Some(DTS_CORE_DUR_NS as u64),
|
||||
"AU1 carries a real frame duration (was None)"
|
||||
);
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 640);
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(90000),
|
||||
"AU2 came in the same PES → same PTS"
|
||||
pts_to_ns(90000) + DTS_CORE_DUR_NS,
|
||||
"AU2 in the same PES advances one frame duration (monotonic)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -614,31 +801,33 @@ mod tests {
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// PES A: just core1 (held — no following core yet).
|
||||
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(100)));
|
||||
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(90000)));
|
||||
assert!(f0.is_empty(), "core1 held awaiting next core");
|
||||
|
||||
// PES B: core2 + core3. Closes AU1 (core1) and AU2 (core2).
|
||||
// PES B (realistically LATER — far past one frame): core2 + core3.
|
||||
// Closes AU1 (core1) and AU2 (core2).
|
||||
let mut pes_b = make_dts_core(600);
|
||||
pes_b.extend_from_slice(&make_dts_core(640));
|
||||
let f = parser.parse(&make_pes(pes_b, Some(200)));
|
||||
let f = parser.parse(&make_pes(pes_b, Some(190000)));
|
||||
assert_eq!(f.len(), 2, "AU1 and AU2 both close in this call");
|
||||
assert_eq!(f[0].data.len(), 512, "AU1 = core1");
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(100),
|
||||
pts_to_ns(90000),
|
||||
"AU1 keeps PES A's PTS, not the later PES B PTS"
|
||||
);
|
||||
assert_eq!(f[1].data.len(), 600, "AU2 = core2");
|
||||
assert_eq!(
|
||||
f[1].pts_ns,
|
||||
pts_to_ns(200),
|
||||
"AU2's core arrived in PES B → PES B PTS"
|
||||
pts_to_ns(190000),
|
||||
"AU2's core is in PES B → attributes to PES B PTS (ahead of the clock, so it wins)"
|
||||
);
|
||||
|
||||
// AU3 (core3) drains on flush, also PES B PTS.
|
||||
// AU3 (core3) drains on flush — 2nd core in PES B, so it advances one
|
||||
// frame duration from AU2 to stay monotonic.
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(200));
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(190000) + DTS_CORE_DUR_NS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -653,23 +842,157 @@ mod tests {
|
||||
// AU2 (core2) held awaiting a third core.
|
||||
let mut pes_a = make_dts_core(512);
|
||||
pes_a.extend_from_slice(&make_dts_core(600));
|
||||
let f = parser.parse(&make_pes(pes_a, Some(100)));
|
||||
let f = parser.parse(&make_pes(pes_a, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(100), "AU1 PES A PTS");
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 PES A PTS");
|
||||
|
||||
// PES B: core3 — closes AU2 (core2). AU2's core was in PES A.
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(200)));
|
||||
// PES B (realistically later): core3 — closes AU2 (core2). AU2's core
|
||||
// was in PES A, so it is PES A's 2nd frame: it advances one frame
|
||||
// duration from AU1 (still on PES A's timeline, and monotonic) — it does
|
||||
// NOT inherit the closing PES B PTS.
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(190000)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
assert_eq!(f2[0].data.len(), 600, "AU2 = core2");
|
||||
assert_eq!(
|
||||
f2[0].pts_ns,
|
||||
pts_to_ns(100),
|
||||
"AU2's core arrived in PES A → must keep PES A's PTS, not PES B's"
|
||||
pts_to_ns(90000) + DTS_CORE_DUR_NS,
|
||||
"AU2 = 2nd frame of PES A → PES A base + one frame, not the closing PES B PTS"
|
||||
);
|
||||
|
||||
// AU3 = core3, whose own core is in PES B → jumps to PES B's PTS.
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(200), "AU3 = core3, PES B PTS");
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(190000),
|
||||
"AU3 = core3 in PES B → PES B PTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
|
||||
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
|
||||
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
|
||||
// code stamped every AU with the one PES PTS, which ffmpeg rejected as
|
||||
// "non monotonically increasing dts to muxer: X >= X".
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = Vec::new();
|
||||
for _ in 0..6 {
|
||||
stream.extend_from_slice(&make_dts_core(512));
|
||||
}
|
||||
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 6, "all six cores emitted");
|
||||
for w in frames.windows(2) {
|
||||
assert!(
|
||||
w[1].pts_ns > w[0].pts_ns,
|
||||
"consecutive DTS AUs must STRICTLY increase: {} !> {}",
|
||||
w[1].pts_ns,
|
||||
w[0].pts_ns
|
||||
);
|
||||
}
|
||||
// Each advances by exactly one frame duration, and carries that duration.
|
||||
assert_eq!(frames[0].pts_ns, pts_to_ns(90000));
|
||||
assert_eq!(frames[1].pts_ns, pts_to_ns(90000) + DTS_CORE_DUR_NS);
|
||||
assert_eq!(frames[5].pts_ns, pts_to_ns(90000) + 5 * DTS_CORE_DUR_NS);
|
||||
for f in &frames {
|
||||
assert_eq!(f.duration_ns, Some(DTS_CORE_DUR_NS as u64));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_core_duration_512_samples_48khz() {
|
||||
// NBLKS=15 → (15+1)*32 = 512 samples; SFREQ=13 → 48 kHz.
|
||||
let core = make_dts_core(512);
|
||||
assert_eq!(dts_core_samples(&core), 512);
|
||||
assert_eq!(dts_core_sample_rate(&core), 48_000);
|
||||
assert_eq!(dts_core_duration_ns(&core), DTS_CORE_DUR_NS as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_core_sfreq_reserved_falls_back_to_48k() {
|
||||
// A bogus SFREQ index must never yield a zero rate (division) — fall
|
||||
// back to 48 kHz.
|
||||
let mut core = make_dts_core(512);
|
||||
core[8] = 0; // SFREQ = 0 (reserved)
|
||||
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
|
||||
// feature-long DTS track by minutes (2h44 for a 2h03 film). When a NEW
|
||||
// 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);
|
||||
pes_a.extend_from_slice(&make_dts_core(600));
|
||||
let f = parser.parse(&make_pes(pes_a, Some(90000)));
|
||||
assert_eq!(f.len(), 1, "AU1 (core1) emits on the core2 boundary");
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 = PES A base");
|
||||
// PES B: core3, pts only 500 ticks after PES A — LESS than one frame
|
||||
// (960 ticks @ 48 kHz). AU2 (core2, still PES A) advances within PES A;
|
||||
// AU3 (core3, PES B) must RE-BASE to PES B's own PTS.
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(90500)));
|
||||
assert_eq!(f2.len(), 1, "AU2 (core2) closes on core3");
|
||||
assert_eq!(
|
||||
f2[0].pts_ns,
|
||||
pts_to_ns(90000) + DTS_CORE_DUR_NS,
|
||||
"AU2 = 2nd frame of PES A → advances one frame within PES A"
|
||||
);
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(90500),
|
||||
"AU3 = core3 in PES B → re-bases to PES B's PTS"
|
||||
);
|
||||
assert_ne!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(90000) + 2 * DTS_CORE_DUR_NS,
|
||||
"must NOT carry the accumulated running clock across a PES (drift)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a minimal DTS-HD extension substream of `size` bytes (just the
|
||||
|
||||
@@ -43,6 +43,7 @@ impl DvdSubParser {
|
||||
if force || buf.len() >= *size {
|
||||
let (pts_ns, _, data) = self.pending.take().unwrap();
|
||||
return Some(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
@@ -103,6 +104,7 @@ impl CodecParser for DvdSubParser {
|
||||
let d = ((pes.data[0] as usize) << 8) | pes.data[1] as usize;
|
||||
if d < 2 {
|
||||
out.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
@@ -116,6 +118,7 @@ impl CodecParser for DvdSubParser {
|
||||
} else {
|
||||
// Too short to carry SPU_size — pass through as a lone frame.
|
||||
out.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
@@ -233,6 +236,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-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,16 +260,27 @@ 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),
|
||||
source: pes.source,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
|
||||
// signal maps straight onto this frame.
|
||||
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>> {
|
||||
@@ -537,6 +572,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,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
|
||||
@@ -923,6 +1024,7 @@ mod tests {
|
||||
pts: Some(180000), // 2 seconds (presentation)
|
||||
dts: Some(90000), // 1 second (decode)
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
@@ -1232,6 +1334,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: Some(90000),
|
||||
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
|
||||
discontinuity: false,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f.len(), 1);
|
||||
@@ -1247,6 +1350,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
|
||||
discontinuity: false,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f.len(), 1);
|
||||
|
||||
+41
-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.
|
||||
@@ -661,9 +685,20 @@ impl CodecParser for HevcParser {
|
||||
source: pes.source,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
|
||||
// signal maps straight onto this frame.
|
||||
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>> {
|
||||
@@ -973,6 +1008,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2243,6 +2279,7 @@ mod tests {
|
||||
pts: Some(180000), // 2 s (presentation)
|
||||
dts: Some(90000), // 1 s (decode)
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
@@ -2850,6 +2887,7 @@ mod tests {
|
||||
d.extend_from_slice(&[0x10, 0x20]);
|
||||
d
|
||||
},
|
||||
discontinuity: false,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f.len(), 1);
|
||||
|
||||
@@ -72,6 +72,7 @@ impl CodecParser for LpcmParser {
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
@@ -98,6 +99,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-3
@@ -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.
|
||||
@@ -44,6 +46,16 @@ pub struct Frame {
|
||||
pub pts_ns: i64,
|
||||
/// Whether this is a keyframe (used for cue points).
|
||||
pub keyframe: bool,
|
||||
/// This frame is the FIRST coded picture after a concealed/lost gap (P3/B1):
|
||||
/// its data begins after packets the demuxer never received (an undecryptable
|
||||
/// unit concealed as NULL-TS upstream, or a continuity break in a damaged
|
||||
/// source). Inter-coded video frames carrying this flag reference data that is
|
||||
/// gone, so the consumer's `ResyncGate` arms here and drops forward to the next
|
||||
/// keyframe. Carried per-FRAME (not per-PES) because buffering parsers — MPEG-2
|
||||
/// emits whole GOPs, H.264/HEVC lag one access unit — decouple the frame from
|
||||
/// the PES that carried the gap signal. Default `false`; only ever set on the
|
||||
/// degraded/conceal path, so a clean rip leaves every frame `false`.
|
||||
pub discontinuity: bool,
|
||||
/// Frame data (elementary stream bytes).
|
||||
pub data: Vec<u8>,
|
||||
/// Optional duration in nanoseconds — only set by parsers that
|
||||
@@ -123,11 +135,15 @@ impl PassthroughParser {
|
||||
impl CodecParser for PassthroughParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
// Passthrough emits exactly one frame per PES with no cross-PES buffering,
|
||||
// so the PES's discontinuity maps directly onto this frame. (Buffering
|
||||
// parsers must instead defer the flag to the next emitted frame.)
|
||||
vec![Frame {
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
keyframe: self.keyframe,
|
||||
discontinuity: pes.discontinuity,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
@@ -152,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()),
|
||||
@@ -198,6 +218,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+178
-231
@@ -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,
|
||||
@@ -156,13 +148,11 @@ 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,
|
||||
}
|
||||
@@ -189,175 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
self.gop_buf.push(BufferedPicture {
|
||||
tr,
|
||||
info,
|
||||
explicit_pts: explicit,
|
||||
frame: Frame {
|
||||
pts_ns: 0,
|
||||
keyframe,
|
||||
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;
|
||||
/// 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
|
||||
@@ -373,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
|
||||
@@ -418,25 +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));
|
||||
}
|
||||
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>> {
|
||||
@@ -482,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)> {
|
||||
@@ -707,6 +609,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let mut p = Mpeg2Parser::new();
|
||||
let mut frames = Vec::new();
|
||||
@@ -822,6 +725,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,6 +899,48 @@ mod tests {
|
||||
assert!(!frames[1].keyframe);
|
||||
}
|
||||
|
||||
/// B1 hole-2 regression: MPEG-2 buffers a GOP and emits asynchronously, so a
|
||||
/// concealed gap must be associated by OFFSET (like PTS), landing on the
|
||||
/// picture whose own bytes begin after the gap — NOT the previous picture
|
||||
/// that completes when the discontinuity PES arrives. pic1 (I) is pre-gap;
|
||||
/// pic2 (P), carried by a `discontinuity` PES, is the first post-gap AU.
|
||||
#[test]
|
||||
fn discontinuity_offset_mark_stamps_post_gap_picture_not_previous() {
|
||||
let mut parser = Mpeg2Parser::new();
|
||||
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
|
||||
pic1.extend_from_slice(&[0x11; 100]);
|
||||
let mut pic2 = make_picture_header(2); // P
|
||||
pic2.extend_from_slice(&[0x22; 100]);
|
||||
|
||||
// pic1 on a clean PES; nothing emits (same GOP, buffered).
|
||||
assert!(parser.parse(&make_pes(pic1.clone(), Some(0))).is_empty());
|
||||
// pic2 on a PES flagged discontinuity (a concealed gap preceded it).
|
||||
// parse() of this PES completes pic1's AU (the PREVIOUS picture) — which
|
||||
// must stay clean — while pic2 keeps buffering.
|
||||
let pes2 = PesPacket {
|
||||
source: None,
|
||||
pid: 0x1011,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: pic2.clone(),
|
||||
discontinuity: true,
|
||||
};
|
||||
assert!(parser.parse(&pes2).is_empty(), "same GOP — still buffered");
|
||||
|
||||
let frames = parser.flush();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0].data, pic1);
|
||||
assert!(
|
||||
!frames[0].discontinuity,
|
||||
"the previous (pre-gap) I picture must NOT be flagged"
|
||||
);
|
||||
assert_eq!(frames[1].data, pic2);
|
||||
assert!(
|
||||
frames[1].discontinuity,
|
||||
"the post-gap P picture (the discontinuity PES's own AU) IS flagged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_coding_extension_stays_with_its_picture() {
|
||||
// Regression for `ignoring pic cod ext after 0`: the picture coding
|
||||
@@ -1415,6 +1361,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: Some(90000),
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let f = parse_then_flush(&mut parser, &pes);
|
||||
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
|
||||
@@ -1428,6 +1375,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: data2,
|
||||
discontinuity: false,
|
||||
};
|
||||
let f2 = parse_then_flush(&mut parser2, &pes2);
|
||||
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
|
||||
@@ -1464,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ impl PgsParser {
|
||||
let (start_pts, data) = self.pending.take()?;
|
||||
let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
|
||||
Some(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -92,6 +93,7 @@ impl CodecParser for PgsParser {
|
||||
.take()
|
||||
.map(|(start_pts, data)| {
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -119,6 +121,7 @@ impl CodecParser for PgsParser {
|
||||
let frame = match pts {
|
||||
Some(end) => self.emit_pending(end),
|
||||
None => self.pending.take().map(|(start_pts, data)| Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -142,6 +145,7 @@ impl CodecParser for PgsParser {
|
||||
// Flush any prior pending undurated and skip storing this one.
|
||||
None => {
|
||||
out.extend(self.pending.take().map(|(start_pts, data)| Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -167,6 +171,7 @@ impl CodecParser for PgsParser {
|
||||
// (A missing PTS falls through to the drop path below: a
|
||||
// bitmap with no timing reference would land at 00:00:00.)
|
||||
out.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: pts.unwrap_or(0),
|
||||
@@ -195,6 +200,7 @@ impl CodecParser for PgsParser {
|
||||
// the final on-screen subtitle (see the module doc).
|
||||
match self.pending.take() {
|
||||
Some((start_pts, data)) => vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -223,6 +229,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,19 @@ enum Ac3Size {
|
||||
|
||||
impl CodecParser for TrueHdParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
|
||||
// Splicing post-gap bytes onto it corrupts the AU framing (→ "Invalid
|
||||
// data found") and strands the PTS cadence (the non-monotonic audio-DTS
|
||||
// band at gaps). Drop the partial; with `buf` now empty the PTS-base block
|
||||
// below re-seeds the cadence from the post-gap PES, monotonic across the
|
||||
// gap. (Audio has no inter-frame refs — this is the whole audio fix.)
|
||||
//
|
||||
// Handle the discontinuity BEFORE the empty-data guard so the signal can
|
||||
// never be stranded by an empty post-gap PES (defensive; the demuxer only
|
||||
// emits non-empty PES today).
|
||||
if pes.discontinuity {
|
||||
self.buf.clear();
|
||||
}
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -255,6 +268,7 @@ impl CodecParser for TrueHdParser {
|
||||
}
|
||||
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: self.next_pts_ns,
|
||||
@@ -425,6 +439,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +492,50 @@ mod tests {
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discontinuity_drops_truncated_partial() {
|
||||
// B1: a partial TrueHD unit is buffered, then a concealed gap (PES marked
|
||||
// discontinuity) carries a fresh unit. The truncated partial must be
|
||||
// DROPPED — splicing it makes the length-prefixed framer emit a
|
||||
// wrong-size unit (corrupt AU framing) and re-seeds the PTS cadence from
|
||||
// the post-gap PES rather than stranding it (the non-monotonic audio-DTS
|
||||
// band at gaps).
|
||||
let mut parser = TrueHdParser::new();
|
||||
|
||||
// PES 1: first 150 bytes of a 300-byte unit (length prefix says 300, only
|
||||
// 150 present) → held, nothing emitted.
|
||||
let partial = make_truehd_unit(300);
|
||||
let pes1 = make_pes(partial[..150].to_vec(), Some(90000));
|
||||
assert!(parser.parse(&pes1).is_empty(), "partial unit held");
|
||||
|
||||
// Concealed gap: a fresh 200-byte unit at a forward PTS jump.
|
||||
let fresh = make_truehd_unit(200);
|
||||
let pes2 = PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: Some(180000),
|
||||
dts: None,
|
||||
data: fresh.clone(),
|
||||
discontinuity: true,
|
||||
};
|
||||
let frames = parser.parse(&pes2);
|
||||
assert_eq!(frames.len(), 1, "exactly one clean unit across the gap");
|
||||
assert_eq!(
|
||||
frames[0].data.len(),
|
||||
200,
|
||||
"emitted unit is the fresh 200-byte one, not a 300-byte splice"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[0].data, fresh,
|
||||
"unit bytes are the fresh post-gap unit"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[0].pts_ns,
|
||||
pts_to_ns(180000),
|
||||
"cadence re-bases to the post-gap PTS across the cleared buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_units_incrementing_pts() {
|
||||
let mut parser = TrueHdParser::new();
|
||||
|
||||
+42
-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,16 +345,27 @@ 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),
|
||||
source: pes.source,
|
||||
pts_ns: ts_ns,
|
||||
keyframe,
|
||||
// One frame per PES (BD-TS aligns frames to PES), so the gap signal
|
||||
// maps straight onto this frame.
|
||||
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>> {
|
||||
@@ -451,6 +486,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,6 +793,7 @@ mod tests {
|
||||
pts: Some(180000), // presentation
|
||||
dts: Some(90000), // decode
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
@@ -1007,6 +1044,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: Some(90000),
|
||||
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
|
||||
discontinuity: false,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
|
||||
@@ -1018,6 +1056,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
|
||||
discontinuity: false,
|
||||
};
|
||||
let f2 = parser2.parse(&pes2);
|
||||
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
|
||||
|
||||
+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]
|
||||
|
||||
+113
-13
@@ -195,6 +195,16 @@ pub struct DiscStream {
|
||||
/// timestamp cost or `prof_active()` env-var lookup (which takes a
|
||||
/// process-wide lock).
|
||||
profiling: bool,
|
||||
/// B1 drop-to-keyframe resync gate, one per stream — mirrors
|
||||
/// `PipelinedPesStream`. After an AACS-concealed (undecryptable) gap stamps
|
||||
/// a frame `discontinuity`, the gate drops forward inter-coded video frames
|
||||
/// until the next keyframe so no dangling-reference frame reaches the muxer.
|
||||
/// Without this the live-drive single-pass path emitted decode-broken output
|
||||
/// on an undecryptable unit (the file-backed highway path is already gated).
|
||||
resync: Vec<super::resync::ResyncGate>,
|
||||
/// Whether each stream (by track index) is video — audio/subtitle frames are
|
||||
/// independent and always admit.
|
||||
is_video: Vec<bool>,
|
||||
}
|
||||
|
||||
impl DiscStream {
|
||||
@@ -227,7 +237,12 @@ impl DiscStream {
|
||||
|
||||
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
||||
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
||||
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
|
||||
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
|
||||
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
|
||||
// decode/mux stream (live-drive single-pass / direct), never the
|
||||
// ciphertext-preserving sweep, so concealment is always correct here.
|
||||
let mut reader =
|
||||
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
|
||||
|
||||
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
||||
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
|
||||
@@ -282,6 +297,18 @@ impl DiscStream {
|
||||
// clones an Arc per frame on the mux hot path.
|
||||
let decrypt_loss = reader.decrypt_loss();
|
||||
|
||||
// B1 resync gates: one per stream, video flagged so the gate only
|
||||
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
||||
// `title` is moved into the struct below.
|
||||
let is_video: Vec<bool> = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
|
||||
.collect();
|
||||
let resync = (0..title.streams.len())
|
||||
.map(|_| super::resync::ResyncGate::new())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
reader,
|
||||
decrypt_loss,
|
||||
@@ -310,6 +337,8 @@ impl DiscStream {
|
||||
pid_to_track,
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
||||
resync,
|
||||
is_video,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,10 +697,25 @@ impl crate::pes::Stream for DiscStream {
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
let resync = &mut self.resync;
|
||||
let is_video = &self.is_video;
|
||||
let pending = &mut self.pending_frames;
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
);
|
||||
// Same B1 gate — a concealed gap can leave a
|
||||
// post-gap frame in the demuxer's final flush.
|
||||
let emit = match resync.get_mut(*track) {
|
||||
Some(gate) => gate.admit(
|
||||
is_video.get(*track).copied().unwrap_or(false),
|
||||
frame.discontinuity,
|
||||
frame.keyframe,
|
||||
),
|
||||
None => true,
|
||||
};
|
||||
if emit {
|
||||
pending.push_back(crate::pes::PesFrame::from_codec_frame(
|
||||
*track, frame,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,6 +762,8 @@ impl crate::pes::Stream for DiscStream {
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data.clone(),
|
||||
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
|
||||
discontinuity: false,
|
||||
};
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
|
||||
{
|
||||
@@ -734,12 +780,42 @@ impl crate::pes::Stream for DiscStream {
|
||||
// PES boundaries).
|
||||
let pid_to_track = &self.pid_to_track;
|
||||
let pending = &mut self.pending_frames;
|
||||
let resync = &mut self.resync;
|
||||
let is_video = &self.is_video;
|
||||
for (pid, parser) in self.parsers.iter_mut() {
|
||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||
continue;
|
||||
};
|
||||
// Flush frames carry their own per-frame `discontinuity` (a
|
||||
// post-gap picture buffered at EOF was stamped by the parser),
|
||||
// so route them through the SAME B1 gate the in-stream path
|
||||
// uses — otherwise a trailing dangling-reference frame would
|
||||
// bypass the resync.
|
||||
for frame in parser.flush() {
|
||||
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
|
||||
let emit = match resync.get_mut(track) {
|
||||
Some(gate) => gate.admit(
|
||||
is_video.get(track).copied().unwrap_or(false),
|
||||
frame.discontinuity,
|
||||
frame.keyframe,
|
||||
),
|
||||
None => true,
|
||||
};
|
||||
if emit {
|
||||
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A gate still armed at EOF dropped post-gap frames that never
|
||||
// reached a keyframe (a concealed gap in the final GOP). Surface
|
||||
// it once so the loss is visible, not silent.
|
||||
for (track, gate) in self.resync.iter().enumerate() {
|
||||
if gate.is_armed() {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
track,
|
||||
dropped = gate.dropped_in_run(),
|
||||
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
@@ -787,10 +863,28 @@ impl crate::pes::Stream for DiscStream {
|
||||
} else if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
// Disjoint field borrows: the resync gate + is_video
|
||||
// reads coexist with the mutable `self.parsers` drain.
|
||||
let resync = &mut self.resync;
|
||||
let is_video = &self.is_video;
|
||||
let pending = &mut self.pending_frames;
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(track, frame),
|
||||
);
|
||||
// B1: after a concealed/lost gap, drop forward to
|
||||
// the next keyframe on a video track so no frame
|
||||
// with a dangling reference is emitted.
|
||||
let emit = match resync.get_mut(track) {
|
||||
Some(gate) => gate.admit(
|
||||
is_video.get(track).copied().unwrap_or(false),
|
||||
frame.discontinuity,
|
||||
frame.keyframe,
|
||||
),
|
||||
None => true,
|
||||
};
|
||||
if emit {
|
||||
pending.push_back(crate::pes::PesFrame::from_codec_frame(
|
||||
track, frame,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -841,6 +935,8 @@ impl crate::pes::Stream for DiscStream {
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data.clone(),
|
||||
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
|
||||
discontinuity: false,
|
||||
};
|
||||
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
@@ -1424,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
|
||||
|
||||
+105
-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)
|
||||
@@ -2665,6 +2724,7 @@ mod tests {
|
||||
pts: Some(gop_pts),
|
||||
dts: None,
|
||||
data: es,
|
||||
discontinuity: false,
|
||||
}));
|
||||
}
|
||||
frames.extend(parser.flush());
|
||||
@@ -2851,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;
|
||||
@@ -91,6 +92,7 @@ pub(crate) mod mkvstream;
|
||||
pub(crate) mod network;
|
||||
pub(crate) mod null;
|
||||
pub(crate) mod ps;
|
||||
pub(crate) mod resync;
|
||||
pub(crate) mod stdio;
|
||||
/// Shared clip-boundary timeline-continuity corrector (used by the MKV muxer
|
||||
/// and the `demux://` sink).
|
||||
|
||||
+397
-19
@@ -68,6 +68,31 @@ pub struct PipelinedPesStream {
|
||||
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||
/// and summarised once at EOF.
|
||||
dropped_nav_packets: u64,
|
||||
/// Per-track (by stream index) B1 drop-to-keyframe gate. After a TS gap on a
|
||||
/// video track, drop inter-coded frames until the next IRAP so the muxed
|
||||
/// stream stays decode-clean across an upstream concealed loss (P3/B1).
|
||||
resync: Vec<super::resync::ResyncGate>,
|
||||
/// 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 {
|
||||
@@ -87,6 +112,19 @@ impl PipelinedPesStream {
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
) -> Self {
|
||||
let is_video: Vec<bool> = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
|
||||
.collect();
|
||||
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,
|
||||
@@ -98,6 +136,9 @@ impl PipelinedPesStream {
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
decrypt_loss: None,
|
||||
dropped_nav_packets: 0,
|
||||
resync,
|
||||
is_video,
|
||||
au_asm,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +205,39 @@ impl PipelinedPesStream {
|
||||
} else if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
let is_video = self.is_video.get(track).copied().unwrap_or(false);
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
// B1: after a concealed/lost gap, drop forward to the next
|
||||
// keyframe on a video track so no frame with a dangling
|
||||
// reference is emitted. The signal is read PER-FRAME
|
||||
// (`frame.discontinuity`), not per-PES: buffering parsers
|
||||
// (MPEG-2 GOPs, H.264/HEVC AU lag) stamp the exact post-gap
|
||||
// picture, so only it arms the gate — not a whole PES of
|
||||
// frames. Audio/subtitle always admit (independent frames);
|
||||
// a track with no gate (out-of-range index) emits as-is.
|
||||
let emit = match self.resync.get_mut(track) {
|
||||
Some(gate) => {
|
||||
let was_armed = gate.is_armed();
|
||||
let dropped = gate.dropped_in_run();
|
||||
let admit =
|
||||
gate.admit(is_video, frame.discontinuity, frame.keyframe);
|
||||
if admit && was_armed {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
track,
|
||||
pid = pes.pid,
|
||||
dropped,
|
||||
"B1: resynced at keyframe after concealed gap"
|
||||
);
|
||||
}
|
||||
admit
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if emit {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,21 +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,
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,15 +354,66 @@ impl Stream for PipelinedPesStream {
|
||||
);
|
||||
}
|
||||
// Drain any access unit a parser buffered past the last
|
||||
// PES (e.g. DTS-HD's final core+extension unit).
|
||||
// PES (e.g. DTS-HD's final core+extension unit, or MPEG-2's
|
||||
// final GOP). These flush frames carry their own per-frame
|
||||
// `discontinuity` (a post-gap picture buffered at EOF was
|
||||
// stamped by the parser), so route them through the SAME B1
|
||||
// gate the in-stream path uses — otherwise a trailing
|
||||
// dangling-reference frame (MPEG-2 final-GOP corner) would
|
||||
// bypass the resync. Disjoint field borrows so the gate +
|
||||
// is_video reads coexist with the mutable parser drain.
|
||||
let pid_to_track = &self.pid_to_track;
|
||||
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() {
|
||||
pending.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
// 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),
|
||||
frame.discontinuity,
|
||||
frame.keyframe,
|
||||
),
|
||||
None => true,
|
||||
};
|
||||
if emit {
|
||||
pending.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A gate still armed at EOF dropped post-gap frames that never
|
||||
// reached a keyframe (e.g. a concealed gap in the final GOP).
|
||||
// Surface it once so the loss is visible, not silent.
|
||||
for (track, gate) in self.resync.iter().enumerate() {
|
||||
if gate.is_armed() {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
track,
|
||||
dropped = gate.dropped_in_run(),
|
||||
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
@@ -382,6 +533,7 @@ mod tests {
|
||||
source: None,
|
||||
pts_ns: pes.pts.unwrap_or(0) + i as i64,
|
||||
keyframe: i == 0,
|
||||
discontinuity: false,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
})
|
||||
@@ -394,6 +546,7 @@ mod tests {
|
||||
source: None,
|
||||
pts_ns: 0,
|
||||
keyframe: false,
|
||||
discontinuity: false,
|
||||
data: vec![0xEE],
|
||||
duration_ns: None,
|
||||
})
|
||||
@@ -411,6 +564,7 @@ mod tests {
|
||||
pts: Some(90_000),
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +652,127 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A parser that emits exactly one frame per PES, marking it a keyframe iff
|
||||
/// the PES payload's first byte is `b'K'`. Lets a test script a precise
|
||||
/// keyframe/inter-frame sequence to exercise the B1 resync gate.
|
||||
struct KeyframeParser;
|
||||
impl CodecParser for KeyframeParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<super::super::codec::Frame> {
|
||||
vec![super::super::codec::Frame {
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: pes.pts.unwrap_or(0),
|
||||
keyframe: pes.data.first() == Some(&b'K'),
|
||||
// Propagate so the B1 gate can be driven end-to-end in tests.
|
||||
discontinuity: pes.discontinuity,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
fn flush(&mut self) -> Vec<super::super::codec::Frame> {
|
||||
vec![]
|
||||
}
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_pes_disc(pid: u16, data: Vec<u8>, discontinuity: bool) -> PesPacket {
|
||||
PesPacket {
|
||||
source: None,
|
||||
pid,
|
||||
pts: Some(90_000),
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity,
|
||||
}
|
||||
}
|
||||
|
||||
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
|
||||
/// must DROP every inter-coded frame until the next keyframe, so no frame
|
||||
/// with a dangling reference reaches the muxer (an ffmpeg deep-scan would
|
||||
/// otherwise report a missing reference). The frame carrying the
|
||||
/// discontinuity and the inter frames behind it are dropped; the stream
|
||||
/// resumes cleanly at the next keyframe.
|
||||
#[test]
|
||||
fn b1_video_drops_to_keyframe_after_discontinuity() {
|
||||
let title = video_title(false); // one HEVC video stream, PID 0x1011
|
||||
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1011, Box::new(KeyframeParser))];
|
||||
let pid_to_track = vec![(0x1011u16, 0usize)];
|
||||
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||
|
||||
// K0,P1 clean → emit. P2 carries the gap (inter frame referencing the
|
||||
// lost data) → arms the gate; P2,P3 drop. K4 is the next keyframe →
|
||||
// resync + emit. P5 then admits cleanly.
|
||||
tx.send(DemuxBatch::Ts(vec![
|
||||
ts_pes_disc(0x1011, b"K0".to_vec(), false),
|
||||
ts_pes_disc(0x1011, b"P1".to_vec(), false),
|
||||
ts_pes_disc(0x1011, b"P2".to_vec(), true),
|
||||
ts_pes_disc(0x1011, b"P3".to_vec(), false),
|
||||
ts_pes_disc(0x1011, b"K4".to_vec(), false),
|
||||
ts_pes_disc(0x1011, b"P5".to_vec(), false),
|
||||
]))
|
||||
.unwrap();
|
||||
tx.send(DemuxBatch::Eof).unwrap();
|
||||
|
||||
let mut emitted = Vec::new();
|
||||
while let Some(f) = stream.read().unwrap() {
|
||||
emitted.push(f.data);
|
||||
}
|
||||
// P2 (gap) and P3 (still no keyframe) are dropped; the rest survive in
|
||||
// order. Crucially the FIRST frame after the gap that we emit is the
|
||||
// keyframe K4 — never a dangling-reference inter frame.
|
||||
assert_eq!(
|
||||
emitted,
|
||||
vec![
|
||||
b"K0".to_vec(),
|
||||
b"P1".to_vec(),
|
||||
b"K4".to_vec(),
|
||||
b"P5".to_vec()
|
||||
],
|
||||
"post-gap inter frames dropped, stream resumes at the keyframe"
|
||||
);
|
||||
}
|
||||
|
||||
/// Counterpart to B1: a discontinuity on a NON-video track must NOT drop
|
||||
/// frames — audio/subtitle access units are independent, so the gate admits
|
||||
/// every frame the parser still produces.
|
||||
#[test]
|
||||
fn b1_audio_does_not_drop_on_discontinuity() {
|
||||
let mut title = DiscTitle::empty();
|
||||
title.streams.push(crate::disc::Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Ac3,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
}));
|
||||
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1100, Box::new(KeyframeParser))];
|
||||
let pid_to_track = vec![(0x1100u16, 0usize)];
|
||||
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||
|
||||
tx.send(DemuxBatch::Ts(vec![
|
||||
ts_pes_disc(0x1100, b"a0".to_vec(), false),
|
||||
ts_pes_disc(0x1100, b"a1".to_vec(), true), // gap — but audio is independent
|
||||
ts_pes_disc(0x1100, b"a2".to_vec(), false),
|
||||
]))
|
||||
.unwrap();
|
||||
tx.send(DemuxBatch::Eof).unwrap();
|
||||
|
||||
let mut emitted = Vec::new();
|
||||
while let Some(f) = stream.read().unwrap() {
|
||||
emitted.push(f.data);
|
||||
}
|
||||
assert_eq!(
|
||||
emitted,
|
||||
vec![b"a0".to_vec(), b"a1".to_vec(), b"a2".to_vec()],
|
||||
"audio frames are never dropped on a discontinuity"
|
||||
);
|
||||
}
|
||||
|
||||
/// At EOF the consumer must call `flush()` on every parser and emit the
|
||||
/// buffered tail frames — a parser that holds the final access unit (e.g.
|
||||
/// DTS-HD) must NOT have it dropped. Without the flush the last frame is
|
||||
@@ -596,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.
|
||||
|
||||
+20
-7
@@ -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
|
||||
@@ -629,19 +635,26 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||
_ => 1,
|
||||
};
|
||||
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
|
||||
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
|
||||
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
|
||||
// decorator; only this mux pipeline opts in.
|
||||
let mut decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
|
||||
.tolerate_decrypt_loss();
|
||||
// Install the fresh-key-on-failure callback (if 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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! B1 drop-to-IRAP gate — keep a muxed elementary stream decode-clean across a
|
||||
//! mid-stream gap (e.g. an undecryptable unit the mux concealed as NULL TS,
|
||||
//! P3/A2).
|
||||
//!
|
||||
//! When packets are lost, the affected access unit is already dropped at the TS
|
||||
//! layer (the assembler drops the partial PES on the continuity gap). But for
|
||||
//! INTER-CODED video the frames that follow reference the lost frame (and each
|
||||
//! other) until the next IRAP/IDR keyframe — emitting them yields an ffmpeg
|
||||
//! "missing reference / non-existing PPS" deep-scan error and visibly broken
|
||||
//! decode. So after a gap on a video track we DROP FORWARD to the next keyframe
|
||||
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
|
||||
//! of never emitting a dangling reference; it is logged.
|
||||
//!
|
||||
//! Audio and subtitle frames are independent (no inter-frame references), so a
|
||||
//! gap there costs only the single already-dropped frame; the gate is a no-op
|
||||
//! for non-video tracks (it always admits).
|
||||
|
||||
/// Per-track keyframe-resync state. One gate per elementary stream; a video
|
||||
/// track's gate stays "armed" from a discontinuity until the next keyframe.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ResyncGate {
|
||||
/// True while dropping post-gap inter-coded frames until the next keyframe.
|
||||
armed: bool,
|
||||
/// Count of frames dropped while armed (for a single summary log on resync).
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
impl ResyncGate {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
armed: false,
|
||||
dropped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether a parsed frame should be EMITTED (`true`) or DROPPED
|
||||
/// (`false`).
|
||||
///
|
||||
/// * `is_video` — inter-coded video track (the only kind with cross-frame
|
||||
/// references); `false` for audio/subtitle, which always admit.
|
||||
/// * `discontinuity` — this frame's source PES followed a TS continuity gap.
|
||||
/// * `keyframe` — this frame is a self-contained IRAP/IDR.
|
||||
///
|
||||
/// A non-video track always admits. A video track arms on a discontinuity
|
||||
/// and then drops every non-keyframe until (and excluding the drop of) the
|
||||
/// next keyframe, which disarms and is emitted.
|
||||
pub(crate) fn admit(&mut self, is_video: bool, discontinuity: bool, keyframe: bool) -> bool {
|
||||
if !is_video {
|
||||
return true;
|
||||
}
|
||||
if discontinuity {
|
||||
self.armed = true;
|
||||
}
|
||||
if self.armed {
|
||||
if keyframe {
|
||||
self.armed = false;
|
||||
self.dropped = 0;
|
||||
true
|
||||
} else {
|
||||
self.dropped += 1;
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames dropped so far in the CURRENT armed run (0 when not armed / just
|
||||
/// resynced). Lets the consumer log the resync cost once at the keyframe.
|
||||
pub(crate) fn dropped_in_run(&self) -> u64 {
|
||||
self.dropped
|
||||
}
|
||||
|
||||
/// Whether the gate is currently dropping frames (armed, awaiting keyframe).
|
||||
pub(crate) fn is_armed(&self) -> bool {
|
||||
self.armed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_video_always_admits_even_on_discontinuity() {
|
||||
let mut g = ResyncGate::new();
|
||||
// Audio/subtitle: a gap drops only the (already TS-dropped) frame; every
|
||||
// frame the parser still emits is independent and must pass.
|
||||
assert!(g.admit(false, true, false));
|
||||
assert!(g.admit(false, true, false));
|
||||
assert!(!g.is_armed(), "non-video never arms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_drops_inter_frames_until_next_keyframe() {
|
||||
let mut g = ResyncGate::new();
|
||||
// Clean run: everything admits.
|
||||
assert!(g.admit(true, false, true)); // IDR
|
||||
assert!(g.admit(true, false, false)); // P
|
||||
// Gap arrives on the next frame (a P referencing lost data) → drop it
|
||||
// and every inter frame until the next keyframe.
|
||||
assert!(!g.admit(true, true, false), "post-gap P dropped");
|
||||
assert!(!g.admit(true, false, false), "still dropping (no key yet)");
|
||||
assert!(!g.admit(true, false, false));
|
||||
assert_eq!(g.dropped_in_run(), 3);
|
||||
// Next keyframe resyncs and is emitted.
|
||||
assert!(g.admit(true, false, true), "keyframe resumes the stream");
|
||||
assert!(!g.is_armed());
|
||||
assert_eq!(g.dropped_in_run(), 0);
|
||||
// Back to a clean run.
|
||||
assert!(g.admit(true, false, false), "post-resync P admits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discontinuity_landing_on_a_keyframe_emits_immediately() {
|
||||
let mut g = ResyncGate::new();
|
||||
// If the first surviving frame after the gap is itself an IRAP, there is
|
||||
// nothing to drop — it is self-contained.
|
||||
assert!(g.admit(true, true, true), "gap+keyframe emits, no drop");
|
||||
assert!(!g.is_armed());
|
||||
assert_eq!(g.dropped_in_run(), 0);
|
||||
}
|
||||
}
|
||||
+237
-22
@@ -12,6 +12,10 @@ use crate::consts::TS_PACKET_BYTES;
|
||||
|
||||
/// TS sync byte.
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
|
||||
/// concealment fill emits null packets on this PID, tagged with an
|
||||
/// adaptation-field discontinuity_indicator to signal a concealed gap.
|
||||
const NULL_PID: u16 = 0x1FFF;
|
||||
|
||||
/// A reassembled PES packet with timestamp info.
|
||||
#[derive(Debug)]
|
||||
@@ -28,6 +32,18 @@ pub struct PesPacket {
|
||||
/// from the producer's known stream offset. `None` when the demuxer was fed
|
||||
/// without a base offset (callers that don't need provenance).
|
||||
pub source: Option<crate::pes::SourcePos>,
|
||||
/// True when one or more packets for this stream were lost before this PES —
|
||||
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
|
||||
/// a tracked PID, or the CC-independent concealment marker the mux emits when
|
||||
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
|
||||
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
|
||||
/// truncated partial and flags the next complete PES; a loss landing on a PES
|
||||
/// boundary flags the PES STARTING after it (never the one just flushed). So
|
||||
/// for inter-coded video this PES — and every later frame up to the next
|
||||
/// IRAP/IDR — may reference data that is now gone. The codec-parse consumer
|
||||
/// (via the per-frame `Frame::discontinuity` its parser propagates) drops
|
||||
/// forward to the next keyframe (B1) instead of emitting dangling references.
|
||||
pub discontinuity: bool,
|
||||
}
|
||||
|
||||
/// Per-PID PES reassembly state.
|
||||
@@ -58,6 +74,12 @@ struct PesAssembler {
|
||||
/// Stamped at PES start, emitted on the completed packet — provenance is
|
||||
/// carried, never reconstructed downstream.
|
||||
pes_source: Option<crate::pes::SourcePos>,
|
||||
/// Sticky "a gap occurred on this PID" flag. Set by a CC gap, an explicit
|
||||
/// discontinuity_indicator, or the concealment marker; consumed by the NEXT
|
||||
/// PES this assembler completes — the first whose data is entirely post-gap —
|
||||
/// then cleared. A gap detected on a PUSI sets it AFTER `start()` so it rides
|
||||
/// the new PES, not the one just flushed. Drives B1 drop-to-keyframe.
|
||||
pending_discontinuity: bool,
|
||||
}
|
||||
|
||||
/// Initial capacity for a fresh PES buffer. Sized to cover the
|
||||
@@ -92,6 +114,7 @@ impl PesAssembler {
|
||||
header_remaining: 0,
|
||||
last_cc: None,
|
||||
pes_source: None,
|
||||
pending_discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,12 +128,15 @@ impl PesAssembler {
|
||||
source: Option<crate::pes::SourcePos>,
|
||||
) -> Option<PesPacket> {
|
||||
let completed = if self.active && !self.buffer.is_empty() {
|
||||
let discontinuity = self.pending_discontinuity;
|
||||
self.pending_discontinuity = false;
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(PES_BUFFER_INIT_CAP)),
|
||||
source: self.pes_source,
|
||||
discontinuity,
|
||||
})
|
||||
} else {
|
||||
self.buffer.clear();
|
||||
@@ -141,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);
|
||||
@@ -151,12 +181,15 @@ impl PesAssembler {
|
||||
fn flush(&mut self) -> Option<PesPacket> {
|
||||
if self.active && !self.buffer.is_empty() {
|
||||
self.active = false;
|
||||
let discontinuity = self.pending_discontinuity;
|
||||
self.pending_discontinuity = false;
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::take(&mut self.buffer),
|
||||
source: self.pes_source,
|
||||
discontinuity,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -335,6 +368,39 @@ impl TsDemuxer {
|
||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
|
||||
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
|
||||
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
|
||||
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
|
||||
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
|
||||
// an exact multiple of 16 packets and a loss at the very start of a PID
|
||||
// (no prior CC to diff against). The decrypt layer cannot know which
|
||||
// elementary PID(s) the lost unit carried (the data was undecryptable),
|
||||
// so force a pending discontinuity on EVERY tracked assembler: the next
|
||||
// completed PES of each resyncs. Harmless for audio/subtitle (the codec
|
||||
// gate is a no-op there) and at most one extra GOP on a video track that
|
||||
// did not actually lose packets — bounded, and only on a degraded disc.
|
||||
if pid == NULL_PID
|
||||
&& (adaptation == 0x02 || adaptation == 0x03)
|
||||
&& (ts[4] as usize) > 0
|
||||
&& (ts[5] & 0x80) != 0
|
||||
{
|
||||
for a in &mut self.assemblers {
|
||||
// A concealed unit may have dropped packets belonging to a PES
|
||||
// currently open on any PID — so that partial is potentially
|
||||
// TRUNCATED (a hole in the middle of its access unit). Drop it
|
||||
// like a mid-PES continuity break, and flag pending so the NEXT
|
||||
// completed PES (the first frame whose data is entirely post-gap)
|
||||
// resyncs. Mirrors the non-PUSI cc_gap path, applied to every PID
|
||||
// because the lost unit's PID(s) are unknowable (undecryptable).
|
||||
a.buffer.clear();
|
||||
a.active = false;
|
||||
a.header_remaining = 0;
|
||||
a.pending_discontinuity = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = if (pid as usize) < self.pid_index.len() {
|
||||
self.pid_index[pid as usize]
|
||||
} else {
|
||||
@@ -380,8 +446,9 @@ impl TsDemuxer {
|
||||
// — splicing the new payload would corrupt the elementary stream — so
|
||||
// drop the partial and resync on the next PUSI.
|
||||
let cc = ts[3] & 0x0f;
|
||||
let discontinuity_flag =
|
||||
(adaptation == 0x03 || adaptation == 0x02) && ts[4] > 0 && (ts[5] & 0x80) != 0;
|
||||
// adaptation == 0x02 (AF only) already returned above, so only 0x03
|
||||
// (AF + payload) can carry an adaptation field here.
|
||||
let discontinuity_flag = adaptation == 0x03 && ts[4] > 0 && (ts[5] & 0x80) != 0;
|
||||
// A gap is a CC that is neither the expected `(prev + 1) & 0xf` nor a
|
||||
// duplicate `prev` (ISO 13818-1 permits a packet to repeat its CC; a
|
||||
// duplicate is not a loss). Anything else means one or more packets for
|
||||
@@ -391,25 +458,30 @@ impl TsDemuxer {
|
||||
None => false,
|
||||
};
|
||||
asm.last_cc = Some(cc);
|
||||
if !pusi && (discontinuity_flag || cc_gap) && asm.active {
|
||||
tracing::trace!(
|
||||
target: "mux",
|
||||
pid = asm.pid,
|
||||
"TS continuity break on non-PUSI continuation; dropping partial PES",
|
||||
);
|
||||
asm.buffer.clear();
|
||||
asm.active = false;
|
||||
asm.header_remaining = 0;
|
||||
return;
|
||||
}
|
||||
// A continuity gap means packets for THIS PID were lost (a damaged source,
|
||||
// or — for the conceal path — a loss that the CC-independent NULL-TS marker
|
||||
// above did not already flag). The flag is sticky and rides to the FIRST
|
||||
// post-gap PES so the codec consumer drops forward to the next keyframe
|
||||
// (B1). Attribution differs by where the gap lands (see below).
|
||||
let gap = discontinuity_flag || cc_gap;
|
||||
|
||||
if pusi {
|
||||
// `header_len` is the FULL (uncapped) PES-header length:
|
||||
// 0 = malformed (payload is not a PES start), else 6/9+N.
|
||||
let (pts, dts, header_len) = parse_pes_header(payload);
|
||||
// Flush the previous PES FIRST — a gap detected on this PUSI packet
|
||||
// belongs to the PES STARTING now (its data begins after the lost
|
||||
// packets), NOT the one just completing. So set `pending_discontinuity`
|
||||
// AFTER start(): it rides the new PES to its own completion. (Setting
|
||||
// it before would stamp the pre-gap frame; if that frame were a
|
||||
// keyframe the gate would arm-then-disarm on it and admit the real
|
||||
// post-gap inter frame with a dangling reference.)
|
||||
if let Some(prev) = asm.start(pts, dts, source) {
|
||||
completed.push(prev);
|
||||
}
|
||||
if gap {
|
||||
asm.pending_discontinuity = true;
|
||||
}
|
||||
if header_len == 0 {
|
||||
// PUSI packet whose payload is not a valid PES start. Do
|
||||
// NOT push it — those bytes are not elementary-stream data
|
||||
@@ -426,16 +498,37 @@ impl TsDemuxer {
|
||||
// the following continuation packet(s).
|
||||
asm.header_remaining = header_len - payload.len();
|
||||
}
|
||||
} else if asm.header_remaining > 0 {
|
||||
// Continuation packet still inside a PES header that spanned
|
||||
// the boundary — consume header bytes before any ES data.
|
||||
let skip = asm.header_remaining.min(payload.len());
|
||||
asm.header_remaining -= skip;
|
||||
if skip < payload.len() {
|
||||
asm.push(&payload[skip..]);
|
||||
}
|
||||
} else {
|
||||
asm.push(payload);
|
||||
// Non-PUSI continuation.
|
||||
if gap {
|
||||
// Mid-PES hole: the open partial has a gap, so splicing this
|
||||
// payload would corrupt the ES. Flag pending (consumed at the
|
||||
// NEXT completed PES — the first post-gap frame) and drop the
|
||||
// open partial; resync on the next PUSI.
|
||||
asm.pending_discontinuity = true;
|
||||
if asm.active {
|
||||
tracing::trace!(
|
||||
target: "mux",
|
||||
pid = asm.pid,
|
||||
"TS continuity break on non-PUSI continuation; dropping partial PES",
|
||||
);
|
||||
asm.buffer.clear();
|
||||
asm.active = false;
|
||||
asm.header_remaining = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if asm.header_remaining > 0 {
|
||||
// Continuation packet still inside a PES header that spanned
|
||||
// the boundary — consume header bytes before any ES data.
|
||||
let skip = asm.header_remaining.min(payload.len());
|
||||
asm.header_remaining -= skip;
|
||||
if skip < payload.len() {
|
||||
asm.push(&payload[skip..]);
|
||||
}
|
||||
} else {
|
||||
asm.push(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,6 +1009,128 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// B1 plumbing: a continuity gap detected on a PUSI must stamp
|
||||
/// `discontinuity = true` on the PES STARTING after the gap, NOT the one
|
||||
/// flushed at the boundary — the post-gap PES is the one whose data begins
|
||||
/// after the lost packets and references them. (Attribution fix: stamping the
|
||||
/// pre-gap PES would, if it were a keyframe, arm-then-disarm the gate and let
|
||||
/// the real post-gap inter frame through with a dangling reference.) A clean
|
||||
/// in-sequence PES carries `discontinuity = false`.
|
||||
#[test]
|
||||
fn continuity_gap_stamps_discontinuity_on_next_pes() {
|
||||
let pid = 0x1011;
|
||||
let mut demux = TsDemuxer::new(&[pid]);
|
||||
|
||||
// Open A (cc=0): nothing completed yet.
|
||||
let out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
|
||||
assert!(out.is_empty());
|
||||
|
||||
// B's PUSI (cc=1, in sequence) flushes A. A saw no gap → clean.
|
||||
let out = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
|
||||
assert_eq!(out.len(), 1, "A completes");
|
||||
assert_eq!(&out[0].data[..4], b"AAAA");
|
||||
assert!(
|
||||
!out[0].discontinuity,
|
||||
"in-sequence PES is not a discontinuity"
|
||||
);
|
||||
|
||||
// C's PUSI jumps cc 1 -> 5: packets were lost between B and C. B (flushed
|
||||
// here) is PRE-gap and stays clean — the gap belongs to C, which starts
|
||||
// after the lost packets.
|
||||
let out = demux.feed(&ts_payload_packet(pid, true, 5, &pes_start(b"CCCC")));
|
||||
assert_eq!(out.len(), 1, "B completes");
|
||||
assert_eq!(&out[0].data[..4], b"BBBB");
|
||||
assert!(
|
||||
!out[0].discontinuity,
|
||||
"the pre-gap PES flushed at the boundary must NOT be flagged"
|
||||
);
|
||||
|
||||
// C carries the discontinuity — it is the first post-gap PES.
|
||||
let out = demux.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(&out[0].data[..4], b"CCCC");
|
||||
assert!(
|
||||
out[0].discontinuity,
|
||||
"the post-gap PES must be flagged so B1 resyncs at/after it"
|
||||
);
|
||||
}
|
||||
|
||||
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
|
||||
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
|
||||
/// shape `fill_null_ts_unit` writes for every packet of a concealed unit).
|
||||
fn null_marker_packet() -> Vec<u8> {
|
||||
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
||||
pkt[4] = SYNC_BYTE; // 0x47
|
||||
pkt[5] = 0x1F; // PID 0x1FFF
|
||||
pkt[6] = 0xFF;
|
||||
pkt[7] = 0x20; // adaptation-field only
|
||||
pkt[8] = 0xB7; // af_len 183
|
||||
pkt[9] = 0x80; // discontinuity_indicator
|
||||
for b in &mut pkt[10..] {
|
||||
*b = 0xFF;
|
||||
}
|
||||
pkt
|
||||
}
|
||||
|
||||
/// HOLE 3 (16-multiple CC blind spot) + the truncated-partial drop. A concealed
|
||||
/// unit can drop an exact multiple of 16 packets on a PID, leaving its 4-bit
|
||||
/// continuity_counter looking IN-SEQUENCE — so CC-based detection is blind. The
|
||||
/// CC-INDEPENDENT marker flags the loss anyway, drops the (potentially
|
||||
/// truncated) open PES, and stamps the first post-gap PES.
|
||||
#[test]
|
||||
fn conceal_marker_forces_discontinuity_with_in_sequence_cc() {
|
||||
let pid = 0x1011;
|
||||
let mut demux = TsDemuxer::new(&[pid]);
|
||||
// A (cc=0) opens; B's PUSI (cc=1) flushes A clean.
|
||||
demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
|
||||
let out = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
|
||||
assert_eq!(&out[0].data[..4], b"AAAA");
|
||||
assert!(!out[0].discontinuity);
|
||||
|
||||
// Concealment marker: a unit was dropped. B is open → potentially truncated
|
||||
// → dropped. CC is NOT consulted.
|
||||
assert!(
|
||||
demux.feed(&null_marker_packet()).is_empty(),
|
||||
"marker emits nothing itself"
|
||||
);
|
||||
|
||||
// C (cc=2) is EXACTLY in-sequence after B's cc=1 — as if a multiple of 16
|
||||
// packets were lost, so `cc_gap` is false. The marker is the only signal.
|
||||
let out = demux.feed(&ts_payload_packet(pid, true, 2, &pes_start(b"CCCC")));
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"the truncated open PES (B) is dropped, not emitted"
|
||||
);
|
||||
let out = demux.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(&out[0].data[..4], b"CCCC");
|
||||
assert!(
|
||||
out[0].discontinuity,
|
||||
"marker flags the post-gap PES despite in-sequence CC (16-aligned blind spot)"
|
||||
);
|
||||
}
|
||||
|
||||
/// HOLE 4 (leading loss). If the disc's very first unit is undecryptable the
|
||||
/// first surviving packet has no predecessor CC (`last_cc == None`), so CC
|
||||
/// detection is blind. The marker still flags the first PES.
|
||||
#[test]
|
||||
fn conceal_marker_at_stream_start_flags_first_pes() {
|
||||
let pid = 0x1011;
|
||||
let mut demux = TsDemuxer::new(&[pid]);
|
||||
// Marker FIRST — no prior CC exists for this PID.
|
||||
assert!(demux.feed(&null_marker_packet()).is_empty());
|
||||
// The first real PES of the PID.
|
||||
let out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
|
||||
assert!(out.is_empty());
|
||||
let out = demux.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(&out[0].data[..4], b"AAAA");
|
||||
assert!(
|
||||
out[0].discontinuity,
|
||||
"leading concealed loss flags the first PES (last_cc == None case)"
|
||||
);
|
||||
}
|
||||
|
||||
/// In-sequence continuation (cc 0 -> 1) must still splice normally — the
|
||||
/// continuity check must not break the happy path.
|
||||
#[test]
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
//! Platform-specific filesystem / IO helpers.
|
||||
//!
|
||||
//! Drive unlock no longer lives here — it moved out behind the pluggable
|
||||
//! [`crate::unlock::Unlocker`] seam. This module now carries only the
|
||||
//! filesystem-type detection used by the writeback / sink paths.
|
||||
//! Drive unlock no longer lives here — it moved out to the `freemkv-unlock`
|
||||
//! crate (consumed via [`crate::unlock_bridge`]). This module now carries only
|
||||
//! the filesystem-type detection used by the writeback / sink paths.
|
||||
|
||||
pub mod fs_type;
|
||||
|
||||
+56
-1
@@ -35,6 +35,51 @@ pub enum PassKind {
|
||||
Verify,
|
||||
}
|
||||
|
||||
/// One located bad/not-yet-good range, annotated with the chapter and movie
|
||||
/// time it falls in. This is the *rendered* drilldown a client draws: the LBA
|
||||
/// and sector count place it on the disc map, while `chapter` and
|
||||
/// `time_offset_secs` tell the user *what* is affected. Computed by the library
|
||||
/// (which owns the mapfile and title) so no client ever re-derives it. If the
|
||||
/// mapfile becomes a mapdb, this type and its producer change; clients don't.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocatedRange {
|
||||
/// First sector (LBA) of the range.
|
||||
pub lba: u64,
|
||||
/// Length of the range in sectors.
|
||||
pub count: u32,
|
||||
/// Movie time this range spans, in milliseconds (range bytes ÷ title
|
||||
/// bytes/sec). Used to sort the drilldown and size the "largest gap".
|
||||
pub duration_ms: f64,
|
||||
/// 1-based chapter the range falls in, if it lands inside the title.
|
||||
pub chapter: Option<u32>,
|
||||
/// Movie time offset (seconds) where the range begins, if in-title.
|
||||
pub time_offset_secs: Option<f64>,
|
||||
}
|
||||
|
||||
/// The fully-rendered "where is the damage" view for one progress sample: the
|
||||
/// located drilldown plus the derived movie-time figures. A client maps this
|
||||
/// straight to its UI — it never touches the mapfile. `Default` is the empty
|
||||
/// (no-damage / not-applicable) view, used by phases that don't locate ranges
|
||||
/// (verify, extract, mux-label).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LocatedProgress {
|
||||
/// Located not-yet-good ranges, largest-movie-time first, capped (see
|
||||
/// `truncated`).
|
||||
pub ranges: Vec<LocatedRange>,
|
||||
/// Total number of located ranges before the cap (so a client can say
|
||||
/// "N sections").
|
||||
pub num_ranges: u32,
|
||||
/// How many ranges were dropped by the display cap (`ranges.len()` is the
|
||||
/// kept count; this is the "+X more").
|
||||
pub truncated: u32,
|
||||
/// Main-feature movie time still at risk: duration of the not-yet-good
|
||||
/// ranges that intersect the title extents, in milliseconds. `0` when all
|
||||
/// damage is out-of-feature (menus/extras).
|
||||
pub main_at_risk_ms: f64,
|
||||
/// Movie time of the single largest range, in milliseconds.
|
||||
pub largest_gap_ms: f64,
|
||||
}
|
||||
|
||||
/// One progress sample from a pipeline phase.
|
||||
///
|
||||
/// `work_done / work_total` is the per-pass percentage — always 0..=100%
|
||||
@@ -48,7 +93,12 @@ pub enum PassKind {
|
||||
/// - `bytes_good_total` = good + slow + recovered sectors × 2048
|
||||
/// - `bytes_unreadable_total` = bad sectors × 2048
|
||||
/// - `bytes_pending_total` = 0 (verify processes sequentially, nothing pending)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
///
|
||||
/// NOT `Copy`: `located` carries a `Vec`. Constructed once per (throttled)
|
||||
/// emission and passed by reference to `Progress::report`, so this costs one
|
||||
/// small heap alloc per UI tick — cheap, and it makes `PassProgress` the single
|
||||
/// complete contract a client renders from.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PassProgress {
|
||||
pub kind: PassKind,
|
||||
pub work_done: u64,
|
||||
@@ -72,6 +122,11 @@ pub struct PassProgress {
|
||||
pub main_title_duration_secs: Option<f64>,
|
||||
/// Main title size in bytes (sum of extent sizes).
|
||||
pub main_title_size_bytes: Option<u64>,
|
||||
/// The fully-rendered "where is the damage" drilldown for this sample:
|
||||
/// located ranges + at-risk movie time. Empty (`Default`) for phases that
|
||||
/// don't locate ranges. A client renders the disc map + section list from
|
||||
/// this and NEVER reads the mapfile itself.
|
||||
pub located: LocatedProgress,
|
||||
}
|
||||
|
||||
impl PassProgress {
|
||||
|
||||
+511
-124
@@ -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
|
||||
@@ -99,27 +89,22 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
unit_base: u32,
|
||||
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
|
||||
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
|
||||
/// clear nav-file is never corrupted), but for genuine encrypted content the
|
||||
/// still-encrypted bytes are silently dropped by the downstream TS assembler
|
||||
/// — real, unaccounted loss. Mux read paths share this counter into their
|
||||
/// loss accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
|
||||
/// clear nav-file is never corrupted). On the mux read path
|
||||
/// (`tolerate_decrypt_loss`) such content is concealed as NULL-TS and tallied
|
||||
/// here (not silently dropped); on the rip path the read fails loud for
|
||||
/// re-read. Either way this counter is the loss signal: mux read paths fold it
|
||||
/// into their accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
|
||||
/// failure can't be reported as a perfect rip. Shared `Arc` so the highway's
|
||||
/// producer thread and the consuming `Stream` see the same tally.
|
||||
///
|
||||
/// [`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>,
|
||||
/// Latched once a fetch call returns no NEW key — further failures on this
|
||||
/// decorator then skip the callback (the source has nothing more to offer, so
|
||||
/// re-asking would only burn key-server requests).
|
||||
fetch_spent: bool,
|
||||
/// 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
|
||||
@@ -140,6 +125,20 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
/// Reused scratch buffer for verify-only decrypt checks — avoids a per-read
|
||||
/// allocation on the sweep's hot path. Grown on demand, never shrunk.
|
||||
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::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
|
||||
/// a MUX gate": the rip path leaves this `false` (default) and fails loud via
|
||||
/// [`DECRYPT_VERIFY_READ`] so its read-error recovery re-reads the disc; only
|
||||
/// the mux read path opts in. Ciphertext is NEVER passed downstream either
|
||||
/// way — fail-loud re-reads it, conceal replaces it with null packets.
|
||||
///
|
||||
/// Mutually meaningful only with `!verify_only` (the in-place decrypt path
|
||||
/// the mux uses); a verify-only sweep keeps the rip's fail-loud contract.
|
||||
tolerate_decrypt_loss: bool,
|
||||
}
|
||||
|
||||
impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
@@ -155,20 +154,32 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
unit_key_idx: 0,
|
||||
unit_base: 0,
|
||||
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
||||
fetch: None,
|
||||
fetch_spent: false,
|
||||
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(),
|
||||
tolerate_decrypt_loss: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt into MUX loss-concealment: an undecryptable content unit is concealed
|
||||
/// (filled with NULL TS packets), tallied, logged loud, and the read still
|
||||
/// succeeds — the mux never aborts over it. See
|
||||
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss). The rip path must
|
||||
/// NOT set this (it relies on fail-loud read-error recovery).
|
||||
pub fn tolerate_decrypt_loss(mut self) -> Self {
|
||||
self.tolerate_decrypt_loss = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Restrict decrypt/verify to the disc's encrypted-content extents
|
||||
/// (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 {
|
||||
@@ -209,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
|
||||
}
|
||||
|
||||
@@ -257,66 +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;
|
||||
}
|
||||
// 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 — stop asking for the rest of this decorator's life.
|
||||
self.fetch_spent = true;
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -342,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.
|
||||
@@ -366,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;
|
||||
@@ -382,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;
|
||||
}
|
||||
@@ -421,6 +372,20 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
// Bulk path: no Force Unit Access (the cache IS the streaming
|
||||
// throughput). FUA is a Pass-N recovery lever threaded through
|
||||
// `read_sectors_fua`.
|
||||
self.read_sectors_fua(lba, count, buf, recovery, false)
|
||||
}
|
||||
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
// Defense-in-depth: AACS aligned units are 3 sectors (6144 bytes) and
|
||||
// `decrypt_sectors` anchors units at buffer offset 0. A read that does
|
||||
@@ -434,12 +399,14 @@ 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);
|
||||
}
|
||||
let read_t0 = std::time::Instant::now();
|
||||
let n = self.inner.read_sectors(lba, count, buf, recovery)?;
|
||||
let n = self
|
||||
.inner
|
||||
.read_sectors_fua(lba, count, buf, recovery, fua)?;
|
||||
let read_ms = read_t0.elapsed().as_millis() as u64;
|
||||
// Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*`
|
||||
// dispatches on the keys (None / CSS / AACS) and returns the count of
|
||||
@@ -456,9 +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_spent && 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`
|
||||
@@ -473,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,
|
||||
@@ -490,27 +459,122 @@ 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);
|
||||
// MUX CONCEALMENT (P3 / Edit-2): on the mux read path an undecryptable
|
||||
// content unit is NOT a read failure — never abort the mux over it.
|
||||
// Overwrite each still-scrambled in-content unit with valid NULL TS
|
||||
// packets (A2: keeps the demuxer byte-synced; the lost video/audio PID
|
||||
// packets surface as a CC gap the TS assembler already drops a partial
|
||||
// PES on), tally it (done above), log it LOUD with the LBA, and return
|
||||
// Ok so the stream keeps flowing. Verify-only (sweep) is excluded — the
|
||||
// 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::content::ALIGNED_UNIT_LEN;
|
||||
let mut concealed = 0usize;
|
||||
let mut first_lba = lba;
|
||||
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
|
||||
if chunk.len() < unit_len {
|
||||
continue; // trailing partial can't be a whole scrambled unit
|
||||
}
|
||||
// Conceal ONLY a unit that is GENUINELY still ciphertext, using
|
||||
// the PADDING-AWARE test that matches `decrypt_unit`'s success
|
||||
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
|
||||
// A successfully padding-aware-decrypted content-fragment TAIL
|
||||
// (the 1.2.0 fix: a few real packets + source-zero padding) has
|
||||
// <16 TS syncs, so the majority vote would mis-flag it as
|
||||
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
|
||||
// padding-aware predicate excludes zero-payload (padding) packets
|
||||
// and flags the unit only when a real content packet is still
|
||||
// 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::content::aacs_unit_still_ciphertext(chunk) {
|
||||
if concealed == 0 {
|
||||
first_lba =
|
||||
lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS;
|
||||
}
|
||||
crate::aacs::content::fill_null_ts_unit(chunk);
|
||||
concealed += 1;
|
||||
}
|
||||
}
|
||||
if concealed > 0 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::decrypt",
|
||||
lba = first_lba,
|
||||
units = concealed,
|
||||
bytes = dropped,
|
||||
"mux: undecryptable content concealed as NULL TS (loss tallied)"
|
||||
);
|
||||
} else {
|
||||
// dropped > 0 (decrypt reported undecryptable bytes) yet the
|
||||
// padding-aware predicate matched NOTHING to conceal — a
|
||||
// contradiction: the only way a genuinely-ciphertext unit passes
|
||||
// `aacs_unit_still_ciphertext` is if every one of its non-zero
|
||||
// packets coincidentally carried a 0x47 (~256^-31). Belt-and-
|
||||
// suspenders so ciphertext can NEVER reach the mux: fall back to
|
||||
// the strict majority-vote predicate and conceal whatever it
|
||||
// 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::content::aacs_unit_needs_decrypt(chunk)
|
||||
{
|
||||
crate::aacs::content::fill_null_ts_unit(chunk);
|
||||
forced += 1;
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::decrypt",
|
||||
lba,
|
||||
bytes = dropped,
|
||||
forced,
|
||||
"mux: decrypt reported loss but padding-aware conceal matched nothing; forced strict conceal (unexpected)"
|
||||
);
|
||||
}
|
||||
return Ok(n);
|
||||
}
|
||||
// DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't
|
||||
// means this read did NOT truly succeed — it returned ciphertext the
|
||||
// TS assembler would silently drop. Fail the read loud so the caller's
|
||||
@@ -518,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?
|
||||
@@ -1118,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;
|
||||
@@ -1132,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 {
|
||||
@@ -1212,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"
|
||||
);
|
||||
|
||||
@@ -1223,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"
|
||||
);
|
||||
|
||||
@@ -1244,6 +1313,243 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// MUX CONCEALMENT (P3): with `tolerate_decrypt_loss()` an undecryptable AACS
|
||||
/// content unit must NOT fail the read. Instead the decorator (a) tallies the
|
||||
/// loss, (b) overwrites the unit with valid NULL TS packets (PID 0x1FFF, sync
|
||||
/// 0x47 at the BD-TS stride), and (c) returns `Ok` so the mux keeps going.
|
||||
/// This is the inverse of the fail-loud rip path proven directly above.
|
||||
#[test]
|
||||
fn tolerate_decrypt_loss_conceals_undecryptable_unit_as_null_ts() {
|
||||
let real_key = [0x33u8; 16];
|
||||
let wrong_key = [0x44u8; 16];
|
||||
|
||||
// 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::content::ALIGNED_UNIT_LEN];
|
||||
let mut o = 4;
|
||||
while o < clear.len() {
|
||||
clear[o] = 0x47;
|
||||
o += 192;
|
||||
}
|
||||
let mut two_units = enc;
|
||||
two_units.extend_from_slice(&clear);
|
||||
|
||||
struct TwoUnitSource {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
impl SectorSource for TwoUnitSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
(self.data.len() / 2048) as u32
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].copy_from_slice(&self.data[..bytes]);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
TwoUnitSource { data: two_units },
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, wrong_key)], // can't open the encrypted unit
|
||||
read_data_key: None,
|
||||
},
|
||||
)
|
||||
.tolerate_decrypt_loss();
|
||||
let loss = wrapped.decrypt_loss();
|
||||
|
||||
let mut buf = vec![0u8; 6 * 2048];
|
||||
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
|
||||
let n = wrapped
|
||||
.read_sectors(0, 6, &mut buf, false)
|
||||
.expect("tolerate_decrypt_loss must conceal, not error");
|
||||
assert_eq!(n, 6 * 2048);
|
||||
|
||||
// The undecryptable unit is tallied as loss.
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
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::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 0;
|
||||
while off + 192 <= unit0.len() {
|
||||
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
|
||||
assert_eq!(unit0[off + 5] & 0x1F, 0x1F, "PID high bits 0x1FFF");
|
||||
assert_eq!(unit0[off + 6], 0xFF, "PID low byte 0xFF");
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!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::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
|
||||
}
|
||||
|
||||
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
|
||||
/// SUCCESSFULLY-decrypted content-fragment TAIL unit. Such a tail (a few real
|
||||
/// content packets + source-zero padding — the 1.2.0 shape) carries <16 TS
|
||||
/// syncs after decrypt, so the old majority-vote `aacs_unit_needs_decrypt`
|
||||
/// predicate mis-flagged it as "still needs decrypt" and, when it shared a read
|
||||
/// buffer with a genuinely-undecryptable unit (`dropped > 0`), NULL-TS-filled
|
||||
/// the GOOD decrypted video. The padding-aware `aacs_unit_still_ciphertext`
|
||||
/// predicate must conceal ONLY the genuinely-undecryptable unit and leave the
|
||||
/// good tail byte-for-byte intact.
|
||||
#[test]
|
||||
fn conceal_leaves_decrypted_padding_tail_unit_intact() {
|
||||
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
|
||||
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
|
||||
|
||||
// Unit A: a full content unit encrypted under `bad_key` — with only
|
||||
// `good_key` in the pool it cannot be decrypted → restored to ciphertext.
|
||||
let bad_unit = encrypt_aacs_unit(&bad_key);
|
||||
|
||||
// Unit B: a SHORT-PADDING-TAIL unit — encrypt a full clear unit under
|
||||
// `good_key`, then zero the trailing source packets (from packet 11 on) so
|
||||
// they decrypt back to clean zero padding. Only 11 of 32 packets are real
|
||||
// content → 11 TS syncs after decrypt (well under the majority-vote 16).
|
||||
const KEEP: usize = 11;
|
||||
let mut good_tail = encrypt_aacs_unit(&good_key);
|
||||
for b in good_tail[KEEP * 192..].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
|
||||
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
|
||||
let mut expected_tail = good_tail.clone();
|
||||
assert!(
|
||||
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key),
|
||||
"padding-tail must decrypt under good_key"
|
||||
);
|
||||
|
||||
let mut two_units = bad_unit;
|
||||
two_units.extend_from_slice(&good_tail);
|
||||
|
||||
struct TwoUnitSource {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
impl SectorSource for TwoUnitSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
(self.data.len() / 2048) as u32
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].copy_from_slice(&self.data[..bytes]);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
TwoUnitSource { data: two_units },
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A
|
||||
read_data_key: None,
|
||||
},
|
||||
)
|
||||
.tolerate_decrypt_loss();
|
||||
let loss = wrapped.decrypt_loss();
|
||||
|
||||
let mut buf = vec![0u8; 6 * 2048];
|
||||
let n = wrapped
|
||||
.read_sectors(0, 6, &mut buf, false)
|
||||
.expect("tolerate_decrypt_loss must conceal, not error");
|
||||
assert_eq!(n, 6 * 2048);
|
||||
|
||||
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
|
||||
assert_eq!(
|
||||
loss.load(Ordering::Relaxed),
|
||||
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::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}");
|
||||
assert_eq!(
|
||||
unit0[off + 6],
|
||||
0xFF,
|
||||
"unit A null packet PID low 0xFF at {off}"
|
||||
);
|
||||
off += 192;
|
||||
}
|
||||
|
||||
// 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::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
assert_eq!(
|
||||
unit1,
|
||||
&expected_tail[..],
|
||||
"the decrypted padding-tail unit must be left byte-for-byte intact"
|
||||
);
|
||||
// Sanity: its real content packets carry their TS sync; its padding is zero.
|
||||
for p in 0..KEEP {
|
||||
assert_eq!(unit1[p * 192 + 4], 0x47, "content pkt {p} sync preserved");
|
||||
}
|
||||
for p in KEEP..32 {
|
||||
let o = p * 192;
|
||||
assert!(
|
||||
unit1[o..o + 192].iter().all(|&b| b == 0),
|
||||
"padding pkt {p} stayed zero (not NULL-TS-filled)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `fill_null_ts_unit` round-trip: every BD source packet in the unit becomes
|
||||
/// a well-formed TS null packet (PID 0x1FFF, invisible to any real PID) that
|
||||
/// carries the B1 adaptation-field discontinuity_indicator — the marker
|
||||
/// `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::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;
|
||||
let mut pkts = 0;
|
||||
while off + 192 <= unit.len() {
|
||||
assert_eq!(unit[off + 4], 0x47, "sync");
|
||||
let pid = ((unit[off + 5] as u16 & 0x1F) << 8) | unit[off + 6] as u16;
|
||||
assert_eq!(pid, 0x1FFF, "null PID");
|
||||
assert_eq!(
|
||||
(unit[off + 7] >> 4) & 0x03,
|
||||
0x02,
|
||||
"adaptation_field_control = AF only (no payload)"
|
||||
);
|
||||
assert!(unit[off + 8] > 0, "adaptation_field_length > 0");
|
||||
assert_eq!(
|
||||
unit[off + 9] & 0x80,
|
||||
0x80,
|
||||
"adaptation-field discontinuity_indicator set (the B1 marker)"
|
||||
);
|
||||
off += 192;
|
||||
pkts += 1;
|
||||
}
|
||||
assert_eq!(pkts, 32, "32 source packets per aligned unit");
|
||||
}
|
||||
|
||||
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set
|
||||
/// would normally count as decrypt loss. With a [`with_key_fetch`] callback
|
||||
/// that returns that key, the decorator must hand the still-scrambled unit to
|
||||
@@ -1309,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!(
|
||||
@@ -1333,11 +1639,92 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
|
||||
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
|
||||
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
|
||||
/// be asked for after unit A came back dry.
|
||||
#[test]
|
||||
fn fetch_dry_does_not_block_a_distinct_later_unit() {
|
||||
let key_a = [0x5au8; 16];
|
||||
let key_b = [0x77u8; 16];
|
||||
let unit_a = encrypt_aacs_unit(&key_a);
|
||||
let unit_b = encrypt_aacs_unit(&key_b);
|
||||
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
|
||||
|
||||
struct AltSource {
|
||||
units: Vec<Vec<u8>>,
|
||||
idx: usize,
|
||||
}
|
||||
impl SectorSource for AltSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
6
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_r: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
let u = &self.units[self.idx.min(self.units.len() - 1)];
|
||||
buf[..bytes].copy_from_slice(u);
|
||||
self.idx += 1;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Callback serves key_b only when asked about unit B; nothing for A.
|
||||
let unit_b_cb = unit_b.clone();
|
||||
let calls = Arc::new(Mutex::new(0usize));
|
||||
let calls_cb = Arc::clone(&calls);
|
||||
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
|
||||
*calls_cb.lock().unwrap() += 1;
|
||||
if samples.iter().any(|s| *s == unit_b_cb) {
|
||||
vec![key_b]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
});
|
||||
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
AltSource {
|
||||
units: vec![unit_a, unit_b],
|
||||
idx: 0,
|
||||
},
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
|
||||
read_data_key: None,
|
||||
},
|
||||
)
|
||||
.with_key_fetch(fetch);
|
||||
|
||||
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
|
||||
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
|
||||
// recover key_b → B decrypts cleanly.
|
||||
let mut buf2 = vec![0u8; 3 * 2048];
|
||||
wrapped
|
||||
.read_sectors(3, 3, &mut buf2, false)
|
||||
.expect("unit B recovers via its own fetch");
|
||||
|
||||
assert_eq!(
|
||||
*calls.lock().unwrap(),
|
||||
2,
|
||||
"fetch fired for BOTH units — the dry result for A did not latch off B"
|
||||
);
|
||||
assert!(
|
||||
!crate::aacs::content::ts_sync_destroyed(&buf2),
|
||||
"unit B is decrypted after its on-demand fetch"
|
||||
);
|
||||
}
|
||||
|
||||
/// `into_inner` / `inner` / `inner_mut` must hand back the original
|
||||
/// source unchanged. Grounding: the accessor methods.
|
||||
#[test]
|
||||
@@ -1448,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)
|
||||
}
|
||||
@@ -1547,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;
|
||||
|
||||
@@ -53,6 +54,33 @@ pub trait SectorSource: Send {
|
||||
recovery: bool,
|
||||
) -> Result<usize>;
|
||||
|
||||
/// Like [`read_sectors`], but with an explicit Force Unit Access request.
|
||||
///
|
||||
/// `fua = true` asks the drive to bypass its readahead cache and physically
|
||||
/// re-fetch the medium — a Pass-N marginal-sector lever: a cached hit can
|
||||
/// mask a *stochastic* sector that would land differently off the platter on
|
||||
/// each physical read, so FuaRetry re-reads it FUA. It is never blanket-
|
||||
/// applied to the bulk path (forcing every sequential read past the cache
|
||||
/// collapses streaming throughput ~10×).
|
||||
///
|
||||
/// The default ignores `fua` and delegates to [`read_sectors`]: only a live
|
||||
/// [`Drive`] sets the CDB bit; file- / memory-backed sources have no drive
|
||||
/// cache to bypass, so FUA is meaningless to them.
|
||||
///
|
||||
/// [`read_sectors`]: SectorSource::read_sectors
|
||||
/// [`Drive`]: crate::drive::Drive
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
let _ = fua;
|
||||
self.read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
/// Optional speed control for sources that map to a physical
|
||||
/// drive. No-op for everything else.
|
||||
fn set_speed(&mut self, _kbs: u16) {}
|
||||
@@ -87,6 +115,17 @@ impl SectorSource for Box<dyn SectorSource> {
|
||||
(**self).read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
(**self).read_sectors_fua(lba, count, buf, recovery, fua)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
(**self).set_speed(kbs)
|
||||
}
|
||||
@@ -107,6 +146,17 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
|
||||
(**self).read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
(**self).read_sectors_fua(lba, count, buf, recovery, fua)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
(**self).set_speed(kbs)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+224
-49
@@ -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,
|
||||
@@ -1771,8 +1724,9 @@ mod tests {
|
||||
reader.put(50, [0xCC; 2048]);
|
||||
|
||||
let fs = fs_with(0, 0, root);
|
||||
let (inf, _mkb) = crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
|
||||
.expect("read_aacs_inputs must succeed for a Long-AD disc");
|
||||
let (inf, _mkb, _version) =
|
||||
crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
|
||||
.expect("read_aacs_inputs must succeed for a Long-AD disc");
|
||||
assert_eq!(
|
||||
inf.len(),
|
||||
4096,
|
||||
@@ -2390,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-532
@@ -1,532 +0,0 @@
|
||||
//! Pluggable drive-unlock seam.
|
||||
//!
|
||||
//! libfreemkv knows the *seam*, never the *mechanism*. An [`Unlocker`] is
|
||||
//! supplied by an external crate (e.g. `freemkv-unlock-ld`) and registered
|
||||
//! once at process start via [`register_unlocker`]. At drive-prep the
|
||||
//! registry is walked in registration order; the first unlocker whose
|
||||
//! [`Unlocker::matches`] returns true is asked to [`Unlocker::unlock_drive`]
|
||||
//! the drive by issuing its own CDBs through the raw [`ScsiTransport`].
|
||||
//!
|
||||
//! No firmware blobs, no unlock CDBs, no drive profiles live here — only
|
||||
//! the trait, the registry, and the routing. If no unlocker matches, the
|
||||
//! drive is left untouched and the caller falls back to the standard
|
||||
//! host-certificate AACS handshake (the "OEM route").
|
||||
|
||||
use crate::aacs::Vid;
|
||||
use crate::error::Result;
|
||||
use crate::identity::DriveId;
|
||||
use crate::scsi::ScsiTransport;
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// Why an [`Unlocker::unlock`] attempt produced no Volume ID. Structured and
|
||||
/// English-free — applications render it. `Scsi` wraps the numeric error code
|
||||
/// from [`crate::error::Error::code`] (the `Error` itself is not `Clone`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum UnlockError {
|
||||
/// This unlocker cannot put this drive's firmware into extended mode.
|
||||
FirmwareNotUnlockable,
|
||||
/// No usable (non-revoked) host certificate was available for the auth
|
||||
/// attempt. `mkb` is the disc MKB generation when known.
|
||||
NoUsableHostCert { mkb: Option<u32> },
|
||||
/// Every available host cert was revoked on this drive's HRL. `mkb` is the
|
||||
/// disc MKB generation when known.
|
||||
CertRevoked { mkb: Option<u32> },
|
||||
/// The drive rejected the auth handshake (non-revocation rejection / wedge).
|
||||
HandshakeRejected,
|
||||
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
|
||||
VidUnavailable,
|
||||
/// A SCSI/transport error; carries the numeric [`crate::error::Error`] code.
|
||||
Scsi(u16),
|
||||
}
|
||||
|
||||
impl From<crate::error::Error> for UnlockError {
|
||||
fn from(e: crate::error::Error) -> Self {
|
||||
UnlockError::Scsi(e.code())
|
||||
}
|
||||
}
|
||||
|
||||
/// A pluggable drive-capability provider.
|
||||
///
|
||||
/// Unlockers are optional drive-capability providers. libfreemkv's AACS
|
||||
/// layer is the always-present baseline; it uses an unlocker's capabilities
|
||||
/// when one matches, and does the in-tree cert handshake (the
|
||||
/// `AacsCertUnlocker` peer) when none do.
|
||||
///
|
||||
/// Implementors own everything about *how* a particular drive family is
|
||||
/// driven: firmware upload, vendor CDBs, variant logic. libfreemkv only
|
||||
/// hands over the raw SCSI transport and the drive identity.
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Stable, language-neutral identifier for this unlocker (logged).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// True if this unlocker handles the given drive.
|
||||
fn matches(&self, id: &DriveId) -> bool;
|
||||
|
||||
/// Put the drive into extended-access mode (firmware/bootloader/whatever
|
||||
/// THIS unlocker needs) AND return the disc's Volume ID — folding the old
|
||||
/// `unlock_drive()` + `read_volume_id()` into one step. A firmware unlocker
|
||||
/// that cannot unlock returns [`UnlockError::FirmwareNotUnlockable`]; one
|
||||
/// that unlocks but has no OEM VID path returns [`UnlockError::VidUnavailable`].
|
||||
/// Either error makes libfreemkv fall through to the in-tree cert handshake.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
id: &DriveId,
|
||||
) -> std::result::Result<Vid, UnlockError>;
|
||||
|
||||
/// Raise the drive to its maximum read speed. Default: no-op.
|
||||
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide ordered registry of unlockers.
|
||||
static REGISTRY: RwLock<Vec<Box<dyn Unlocker>>> = RwLock::new(Vec::new());
|
||||
|
||||
/// Register an unlocker. Order is preserved; [`route_unlock`] tries each in
|
||||
/// registration order and stops at the first whose `matches` is true.
|
||||
///
|
||||
/// Call once at process start (CLI / service `main`), before any rip. The
|
||||
/// single `register_unlocker(...)` line is the entire plug — remove it (and
|
||||
/// the unlocker crate) and libfreemkv still compiles and falls back to the
|
||||
/// host-cert handshake.
|
||||
pub fn register_unlocker(u: Box<dyn Unlocker>) {
|
||||
if let Ok(mut reg) = REGISTRY.write() {
|
||||
reg.push(u);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the registry in order and run the first matching unlocker, returning
|
||||
/// its name AND the Volume ID it produced.
|
||||
///
|
||||
/// Returns:
|
||||
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive
|
||||
/// into extended mode, and returned the OEM Volume ID. The caller stashes
|
||||
/// the VID for the handshake phase and need not run the cert handshake.
|
||||
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker reported a
|
||||
/// *capability* failure ([`UnlockError::FirmwareNotUnlockable`],
|
||||
/// [`UnlockError::VidUnavailable`], or a cert-auth outcome — all logged).
|
||||
/// Either way the drive is usable in stock mode and the caller falls
|
||||
/// through to the in-tree cert handshake. Folding a capability failure into
|
||||
/// `Ok(None)` keeps drive `init()` infallible — a drive that simply isn't
|
||||
/// firmware-unlockable must not fail init.
|
||||
/// * `Err(_)` — the matching unlocker hit a genuine SCSI/transport fault
|
||||
/// ([`UnlockError::Scsi`]). The bus is broken, not merely unsupported, so
|
||||
/// this propagates and aborts init rather than silently falling through to
|
||||
/// a cert handshake that would also fail.
|
||||
pub(crate) fn route_unlock(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
id: &DriveId,
|
||||
) -> Result<Option<(String, Vid)>> {
|
||||
let reg = match REGISTRY.read() {
|
||||
Ok(r) => r,
|
||||
// A poisoned lock means a prior unlocker panicked; treat as
|
||||
// "no unlocker available" so the cert fallback still runs.
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
for u in reg.iter() {
|
||||
if u.matches(id) {
|
||||
let name = u.name().to_string();
|
||||
match u.unlock(scsi, id) {
|
||||
Ok(vid) => return Ok(Some((name, vid))),
|
||||
// A genuine SCSI/transport fault is not "this drive can't be
|
||||
// unlocked" — the bus is broken. Propagate so init() aborts
|
||||
// instead of falling through to a cert handshake that will
|
||||
// also fail on the same dead transport. The numeric code from
|
||||
// the originating error is logged; the returned error is the
|
||||
// canonical transport-error variant.
|
||||
Err(UnlockError::Scsi(code)) => {
|
||||
tracing::error!(
|
||||
target: "freemkv::unlock",
|
||||
unlocker = %name,
|
||||
code,
|
||||
"unlocker hit a transport fault during unlock; aborting init"
|
||||
);
|
||||
return Err(crate::error::Error::ScsiError {
|
||||
opcode: 0,
|
||||
status: 0,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
// A firmware unlocker that can't unlock / has no OEM VID:
|
||||
// fall through to the cert handshake. Debug-only structured
|
||||
// log (variant identifiers, no English prose).
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::unlock",
|
||||
unlocker = %name,
|
||||
outcome = ?e,
|
||||
"unlocker matched but produced no VID; falling through to cert handshake"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Walk the registry in order and ask the first matching unlocker to raise
|
||||
/// the drive to its maximum read speed.
|
||||
///
|
||||
/// Mirrors [`route_unlock`]'s resolution so the SAME identified unlocker
|
||||
/// that unlocks the drive is the one asked to set speed. Returns:
|
||||
/// * `Ok(())` — the matching unlocker set max speed, or no unlocker
|
||||
/// matched (no-op), or the matching unlocker has no speed capability
|
||||
/// (its default no-op).
|
||||
/// * `Err(_)` — the matching unlocker's `set_max_read_speed` failed. The
|
||||
/// caller treats this as non-fatal (log and continue): a slow drive
|
||||
/// still rips.
|
||||
pub(crate) fn unlocker_set_max_read_speed(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
id: &DriveId,
|
||||
) -> Result<()> {
|
||||
let reg = match REGISTRY.read() {
|
||||
Ok(r) => r,
|
||||
// Poisoned lock ⇒ treat as "no unlocker available" (no-op).
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
for u in reg.iter() {
|
||||
if u.matches(id) {
|
||||
return u.set_max_read_speed(scsi, id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of registered unlockers — test/introspection helper.
|
||||
#[doc(hidden)]
|
||||
pub fn registered_count() -> usize {
|
||||
REGISTRY.read().map(|r| r.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Name of the first registered unlocker that matches `id`, without
|
||||
/// running it. Used for drive-info display ("is this drive supported?")
|
||||
/// before any unlock has been attempted.
|
||||
pub(crate) fn matching_name(id: &DriveId) -> Option<String> {
|
||||
let reg = REGISTRY.read().ok()?;
|
||||
reg.iter()
|
||||
.find(|u| u.matches(id))
|
||||
.map(|u| u.name().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
struct NoopTransport;
|
||||
impl ScsiTransport for NoopTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_id(vendor: &str) -> DriveId {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
let v = vendor.as_bytes();
|
||||
inquiry[8..8 + v.len().min(8)].copy_from_slice(&v[..v.len().min(8)]);
|
||||
DriveId::from_inquiry(&inquiry, "")
|
||||
}
|
||||
|
||||
/// Fake unlocker that records whether its `unlock` ran, matches on vendor
|
||||
/// id, and serves a Volume ID (`Some` → `Ok(Vid)`; `None` →
|
||||
/// `Err(VidUnavailable)`, i.e. matched-but-no-OEM-VID → cert fallback) or
|
||||
/// records a `set_max_read_speed` call.
|
||||
struct FakeUnlocker {
|
||||
want_vendor: String,
|
||||
ran: Arc<AtomicBool>,
|
||||
/// VID this unlocker returns: `Some(vid)` → `unlock` yields `Ok(Vid)`;
|
||||
/// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so
|
||||
/// `route_unlock` falls through to the cert handshake.
|
||||
vid: Option<[u8; 16]>,
|
||||
/// When `Some(code)`, `unlock` yields `Err(UnlockError::Scsi(code))`
|
||||
/// (a transport fault) instead of consulting `vid`, so `route_unlock`
|
||||
/// propagates an error and aborts init.
|
||||
scsi_err: Option<u16>,
|
||||
/// Records whether set_max_read_speed was invoked.
|
||||
speed_ran: Arc<AtomicBool>,
|
||||
}
|
||||
impl FakeUnlocker {
|
||||
fn new(vendor: &str, ran: Arc<AtomicBool>) -> Self {
|
||||
Self {
|
||||
want_vendor: vendor.into(),
|
||||
ran,
|
||||
// Default: a successful unlock returning an all-zero VID.
|
||||
vid: Some([0u8; 16]),
|
||||
scsi_err: None,
|
||||
speed_ran: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
fn with_vid(mut self, vid: Option<[u8; 16]>) -> Self {
|
||||
self.vid = vid;
|
||||
self
|
||||
}
|
||||
fn with_scsi_err(mut self, code: u16) -> Self {
|
||||
self.scsi_err = Some(code);
|
||||
self
|
||||
}
|
||||
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
|
||||
self.speed_ran = speed_ran;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Unlocker for FakeUnlocker {
|
||||
fn name(&self) -> &str {
|
||||
"fake"
|
||||
}
|
||||
fn matches(&self, id: &DriveId) -> bool {
|
||||
id.vendor_id.trim() == self.want_vendor
|
||||
}
|
||||
fn unlock(
|
||||
&self,
|
||||
_scsi: &mut dyn ScsiTransport,
|
||||
_id: &DriveId,
|
||||
) -> std::result::Result<Vid, UnlockError> {
|
||||
self.ran.store(true, Ordering::SeqCst);
|
||||
if let Some(code) = self.scsi_err {
|
||||
return Err(UnlockError::Scsi(code));
|
||||
}
|
||||
match self.vid {
|
||||
Some(v) => Ok(Vid(v)),
|
||||
None => Err(UnlockError::VidUnavailable),
|
||||
}
|
||||
}
|
||||
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
|
||||
self.speed_ran.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `UnlockError` is `PartialEq` and a crate `Error` folds into
|
||||
/// `Scsi(code)` — the conversion `?`-callers rely on, English-free.
|
||||
#[test]
|
||||
fn unlock_error_from_crate_error_carries_code() {
|
||||
let e: UnlockError = crate::error::Error::AacsVidUnavailable.into();
|
||||
assert_eq!(e, UnlockError::Scsi(crate::error::E_AACS_VID_UNAVAILABLE));
|
||||
assert_ne!(
|
||||
UnlockError::NoUsableHostCert { mkb: Some(1) },
|
||||
UnlockError::NoUsableHostCert { mkb: Some(2) }
|
||||
);
|
||||
}
|
||||
|
||||
/// A registered, matching unlocker runs and returns its name + VID; a
|
||||
/// non-matching identity leaves the registry untouched and routes to the
|
||||
/// cert fallback (`None`).
|
||||
///
|
||||
/// Both assertions live in one test because the registry is process-wide
|
||||
/// and tests share it — running them as one case keeps the ordering
|
||||
/// deterministic regardless of test-harness threading.
|
||||
#[test]
|
||||
fn registry_routes_match_else_oem() {
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone())));
|
||||
|
||||
// Matching identity → unlocker runs, returns its name + VID.
|
||||
let mut scsi = NoopTransport;
|
||||
let matched = route_unlock(&mut scsi, &fake_id("MATCHVND")).unwrap();
|
||||
assert_eq!(
|
||||
matched.as_ref().map(|(n, _)| n.as_str()),
|
||||
Some("fake"),
|
||||
"matching unlocker runs"
|
||||
);
|
||||
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
|
||||
|
||||
// Non-matching identity → no unlocker runs, cert path (None).
|
||||
ran.store(false, Ordering::SeqCst);
|
||||
let none = route_unlock(&mut scsi, &fake_id("OTHERVND")).unwrap();
|
||||
assert!(none.is_none(), "no match → cert fallback");
|
||||
assert!(
|
||||
!ran.load(Ordering::SeqCst),
|
||||
"unlock() not invoked on no-match"
|
||||
);
|
||||
}
|
||||
|
||||
/// `route_unlock` returns the FIRST matching unlocker's VID. A matching
|
||||
/// unlocker that yields `Ok(Vid)` returns that VID (OEM path — cert
|
||||
/// handshake skipped). A matching unlocker whose `unlock` errors (no OEM
|
||||
/// VID), or no match at all, yields `Ok(None)` (cert fallback).
|
||||
///
|
||||
/// Distinct vendor ids keep this independent of the other registry test
|
||||
/// despite the process-wide shared registry.
|
||||
#[test]
|
||||
fn route_unlock_returns_vid_else_cert() {
|
||||
let mut scsi = NoopTransport;
|
||||
|
||||
// Unlocker WITH an OEM VID. Vendor ids are exactly 8 chars: INQUIRY
|
||||
// field [8..16] has no null padding to trim, so `matches` is exact.
|
||||
let vid = [0x5Au8; 16];
|
||||
register_unlocker(Box::new(
|
||||
FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))).with_vid(Some(vid)),
|
||||
));
|
||||
|
||||
// Matching identity → its VID is returned.
|
||||
let got = route_unlock(&mut scsi, &fake_id("VIDVNDOR")).unwrap();
|
||||
assert_eq!(
|
||||
got.map(|(_, v)| v),
|
||||
Some(Vid(vid)),
|
||||
"matching unlocker's OEM VID is used"
|
||||
);
|
||||
|
||||
// Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → cert.
|
||||
register_unlocker(Box::new(
|
||||
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
|
||||
));
|
||||
let got = route_unlock(&mut scsi, &fake_id("NOVIDVND")).unwrap();
|
||||
assert!(
|
||||
got.is_none(),
|
||||
"unlocker without OEM VID falls through to cert"
|
||||
);
|
||||
|
||||
// No matching unlocker → Ok(None), cert fallback.
|
||||
let got = route_unlock(&mut scsi, &fake_id("UNKNWNVD")).unwrap();
|
||||
assert!(got.is_none(), "no match → cert fallback");
|
||||
}
|
||||
|
||||
/// A matching unlocker that hits a genuine transport fault
|
||||
/// (`UnlockError::Scsi`) makes `route_unlock` PROPAGATE an `Err` rather
|
||||
/// than fold to `Ok(None)`: a dead bus must abort init, not silently fall
|
||||
/// through to a cert handshake that would also fail. Capability failures
|
||||
/// (`VidUnavailable` etc.) still fold to `Ok(None)` — proven by the sibling
|
||||
/// routing tests; this one pins the transport-fault exception.
|
||||
#[test]
|
||||
fn route_unlock_propagates_scsi_transport_fault() {
|
||||
let mut scsi = NoopTransport;
|
||||
|
||||
register_unlocker(Box::new(
|
||||
FakeUnlocker::new("SCSIVNDR", Arc::new(AtomicBool::new(false)))
|
||||
.with_scsi_err(crate::error::E_SCSI_ERROR),
|
||||
));
|
||||
|
||||
let got = route_unlock(&mut scsi, &fake_id("SCSIVNDR"));
|
||||
assert!(
|
||||
got.is_err(),
|
||||
"a transport fault during unlock aborts init (propagates Err)"
|
||||
);
|
||||
assert_eq!(
|
||||
got.unwrap_err().code(),
|
||||
crate::error::E_SCSI_ERROR,
|
||||
"propagated error is the canonical transport-error code"
|
||||
);
|
||||
}
|
||||
|
||||
/// `unlocker_set_max_read_speed` consults the FIRST matching unlocker's
|
||||
/// `set_max_read_speed`. A matching unlocker is invoked; a non-match is a
|
||||
/// safe no-op (nothing invoked, `Ok(())`).
|
||||
///
|
||||
/// Distinct vendor ids keep this independent of the other registry tests
|
||||
/// despite the process-wide shared registry.
|
||||
#[test]
|
||||
fn unlocker_set_max_read_speed_routes_match_else_noop() {
|
||||
let mut scsi = NoopTransport;
|
||||
|
||||
let speed_ran = Arc::new(AtomicBool::new(false));
|
||||
register_unlocker(Box::new(
|
||||
FakeUnlocker::new("SPEEDVND", Arc::new(AtomicBool::new(false)))
|
||||
.with_speed(speed_ran.clone()),
|
||||
));
|
||||
|
||||
// Matching identity → set_max_read_speed invoked.
|
||||
unlocker_set_max_read_speed(&mut scsi, &fake_id("SPEEDVND")).unwrap();
|
||||
assert!(
|
||||
speed_ran.load(Ordering::SeqCst),
|
||||
"set_max_read_speed() invoked on match"
|
||||
);
|
||||
|
||||
// No matching unlocker → Ok(()), nothing invoked (safe no-op).
|
||||
speed_ran.store(false, Ordering::SeqCst);
|
||||
unlocker_set_max_read_speed(&mut scsi, &fake_id("NOSPEEDV")).unwrap();
|
||||
assert!(
|
||||
!speed_ran.load(Ordering::SeqCst),
|
||||
"no match → safe no-op, nothing invoked"
|
||||
);
|
||||
}
|
||||
|
||||
/// `matching_name` reports the FIRST matching unlocker's name without
|
||||
/// running it (drive-info "is this drive supported?" before any unlock),
|
||||
/// and returns `None` for an unknown drive. `registered_count` counts the
|
||||
/// registered unlockers — pinning the two introspection helpers the routing
|
||||
/// tests never touch.
|
||||
///
|
||||
/// The registry is process-wide and other unlock tests register into it
|
||||
/// concurrently, so the count is only asserted to be MONOTONIC across this
|
||||
/// test's own registration (never an exact delta) — registering an unlocker
|
||||
/// can only grow the count, never shrink it.
|
||||
#[test]
|
||||
fn matching_name_and_registered_count_introspection() {
|
||||
let before = registered_count();
|
||||
|
||||
register_unlocker(Box::new(FakeUnlocker::new(
|
||||
"NAMEVNDR",
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
)));
|
||||
|
||||
// Registering an unlocker can only grow the count (other tests may also
|
||||
// be registering concurrently, so this is a monotonic check, not a
|
||||
// delta-of-exactly-one).
|
||||
assert!(
|
||||
registered_count() > before,
|
||||
"registered_count grows after register_unlocker"
|
||||
);
|
||||
|
||||
// A matching identity reports the unlocker's name — and `matches`
|
||||
// is consulted WITHOUT running unlock_drive (introspection only).
|
||||
assert_eq!(
|
||||
matching_name(&fake_id("NAMEVNDR")).as_deref(),
|
||||
Some("fake"),
|
||||
"matching_name reports the supporting unlocker"
|
||||
);
|
||||
|
||||
// An identity no registered unlocker matches → None (unsupported).
|
||||
assert!(
|
||||
matching_name(&fake_id("ZZNOMTCH")).is_none(),
|
||||
"matching_name is None for an unsupported drive"
|
||||
);
|
||||
}
|
||||
|
||||
/// Registration order is preserved and the FIRST matching unlocker wins:
|
||||
/// when two unlockers both match the same identity, `route_unlock` runs the
|
||||
/// one registered earlier and never consults the later one. The routing
|
||||
/// docs promise "registration order; stops at the first whose `matches` is
|
||||
/// true" — this is the only test that registers two overlapping matchers to
|
||||
/// prove the ordering rather than a single-match no-op.
|
||||
#[test]
|
||||
fn route_unlock_first_registered_match_wins() {
|
||||
let mut scsi = NoopTransport;
|
||||
|
||||
// Two unlockers that BOTH match vendor "DUPEVNDR"; the first registered
|
||||
// must be the one that runs.
|
||||
let first_ran = Arc::new(AtomicBool::new(false));
|
||||
let second_ran = Arc::new(AtomicBool::new(false));
|
||||
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", first_ran.clone())));
|
||||
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone())));
|
||||
|
||||
let matched = route_unlock(&mut scsi, &fake_id("DUPEVNDR")).unwrap();
|
||||
assert_eq!(
|
||||
matched.as_ref().map(|(n, _)| n.as_str()),
|
||||
Some("fake"),
|
||||
"a match was routed"
|
||||
);
|
||||
assert!(
|
||||
first_ran.load(Ordering::SeqCst),
|
||||
"the FIRST-registered matching unlocker ran"
|
||||
);
|
||||
assert!(
|
||||
!second_ran.load(Ordering::SeqCst),
|
||||
"the later-registered unlocker was never consulted (first-match-wins)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Bridges libfreemkv's drive layer to the `freemkv-unlock` crate: one generic
|
||||
//! SCSI-transport adapter, identity/host-cert mapping, and the dispatch that
|
||||
//! news up `all_unlockers()` and runs the first matching one. libfreemkv names
|
||||
//! no individual unlocker — it only calls this bridge.
|
||||
|
||||
use freemkv_unlock as fu;
|
||||
|
||||
/// Map libfreemkv's drive identity to the unlock contract's `DriveId`.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Name of the unlocker that claims this drive by identity (drive-info "is this
|
||||
/// drive supported?" display), or `None`. A pure lookup — does NOT touch the
|
||||
/// drive or unlock anything.
|
||||
pub(crate) fn unlocker_name(drive_id: &crate::identity::DriveId) -> Option<&'static str> {
|
||||
fu::unlocker_name(&to_fu_drive_id(drive_id))
|
||||
}
|
||||
|
||||
/// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract.
|
||||
struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport);
|
||||
|
||||
impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: fu::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> fu::scsi::Result<fu::scsi::ScsiResult> {
|
||||
let d = match dir {
|
||||
fu::scsi::DataDirection::None => crate::scsi::DataDirection::None,
|
||||
fu::scsi::DataDirection::FromDevice => crate::scsi::DataDirection::FromDevice,
|
||||
fu::scsi::DataDirection::ToDevice => crate::scsi::DataDirection::ToDevice,
|
||||
};
|
||||
match self.0.execute(cdb, d, data, timeout_ms) {
|
||||
Ok(r) => Ok(fu::scsi::ScsiResult {
|
||||
status: r.status,
|
||||
bytes_transferred: r.bytes_transferred,
|
||||
sense: r.sense,
|
||||
}),
|
||||
// libfreemkv's transport returns Err for ANY non-zero SCSI status —
|
||||
// i.e. a normal drive CHECK CONDITION (ILLEGAL_REQUEST, etc.), NOT
|
||||
// only a transport-layer fault. Preserve the real status AND the
|
||||
// parsed sense across the seam: the AACS handshake's wedge guard
|
||||
// bails on an ILLEGAL_REQUEST sense (so it stops hammering the drive),
|
||||
// and its diagnosis distinguishes a cert rejection from a dead bus by
|
||||
// the same status/sense. Collapsing everything to 0xFF/None defeated
|
||||
// both. Reconstruct the 32-byte sense buffer at the offsets the
|
||||
// unlock crate reads (sense_key@2 low-nibble, asc@12, ascq@13); a
|
||||
// genuine transport fault (status 0xFF, no sense) maps through
|
||||
// unchanged.
|
||||
Err(e) => {
|
||||
// A SCSI status (CHECK CONDITION or a 0xFF transport fault the
|
||||
// drive layer already tagged) carries its real status + sense via
|
||||
// extract_scsi_context. Any OTHER error variant is a non-SCSI
|
||||
// transport/IO-layer fault — ioctl(SG_IO) == -1 (ENODEV/EIO on an
|
||||
// unplugged bridge) or the fd is gone — i.e. a DEAD BUS, not a
|
||||
// drive rejection; surface the transport-failure status so the
|
||||
// unlock crate bails instead of hammering a wedged device.
|
||||
// (Keying off `sense.is_none()` would be wrong: a CHECK CONDITION
|
||||
// whose sense didn't parse is a rejection, not a transport fault.)
|
||||
let (status, sense) = match &e {
|
||||
crate::error::Error::ScsiError { .. }
|
||||
| crate::error::Error::DiscRead { .. } => {
|
||||
crate::drive::extract_scsi_context(&e)
|
||||
}
|
||||
_ => (crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None),
|
||||
};
|
||||
let sense_buf = sense.map(|s| {
|
||||
let mut b = [0u8; 32];
|
||||
b[2] = s.sense_key & 0x0F;
|
||||
b[12] = s.asc;
|
||||
b[13] = s.ascq;
|
||||
b
|
||||
});
|
||||
Err(fu::scsi::ScsiError {
|
||||
status,
|
||||
sense: sense_buf,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map libfreemkv's host certs (keysource-collected) to the unlock contract's.
|
||||
pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu::HostCert> {
|
||||
certs
|
||||
.iter()
|
||||
.map(|c| fu::HostCert {
|
||||
private_key: c.private_key,
|
||||
certificate: c.certificate.clone(),
|
||||
private_key_v2: c.private_key_v2,
|
||||
certificate_v2: c.certificate_v2.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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],
|
||||
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() {
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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 —
|
||||
/// sourced from `all_unlockers()`, so adding/removing an unlocker updates every
|
||||
/// report with no other change (no hardcoded names). The per-unlocker "did it
|
||||
/// run this rip" outcome is computed by the caller, which has the disc + drive
|
||||
/// runtime state this crate cannot see.
|
||||
pub(crate) fn unlocker_names() -> Vec<&'static str> {
|
||||
fu::all_unlockers().iter().map(|u| u.name()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use freemkv_unlock::scsi::ScsiTransport as _; // brings `execute` into scope
|
||||
|
||||
/// A fake libfreemkv transport whose `execute` always fails with a
|
||||
/// freshly-built error (`crate::error::Error` isn't `Clone` — `io::Error`).
|
||||
struct ErrTransport<F>(F);
|
||||
impl<F: FnMut() -> crate::error::Error + Send> crate::scsi::ScsiTransport for ErrTransport<F> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> std::result::Result<crate::scsi::ScsiResult, crate::error::Error> {
|
||||
Err((self.0)())
|
||||
}
|
||||
}
|
||||
|
||||
fn adapt(e: impl FnMut() -> crate::error::Error + Send) -> fu::scsi::ScsiError {
|
||||
let mut t = ErrTransport(e);
|
||||
let mut adapter = ScsiAdapter(&mut t);
|
||||
let mut buf = [0u8; 0];
|
||||
adapter
|
||||
.execute(&[0u8; 12], fu::scsi::DataDirection::None, &mut buf, 1_000)
|
||||
.expect_err("error path")
|
||||
}
|
||||
|
||||
/// A CHECK CONDITION carrying sense crosses the seam with status + parsed
|
||||
/// sense intact, so the unlock crate's ILLEGAL_REQUEST wedge guard can fire.
|
||||
#[test]
|
||||
fn check_condition_preserves_status_and_sense() {
|
||||
let err = adapt(|| crate::error::Error::ScsiError {
|
||||
opcode: 0xA3,
|
||||
status: 0x02,
|
||||
sense: Some(crate::scsi::ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x24,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
});
|
||||
assert_eq!(err.status, 0x02);
|
||||
let sense = err.sense.expect("sense preserved");
|
||||
assert!(fu::scsi::ScsiSense::from_buf(&sense).is_illegal_request());
|
||||
}
|
||||
|
||||
/// A drive-tagged transport fault (status 0xFF) crosses unchanged.
|
||||
#[test]
|
||||
fn scsi_transport_fault_maps_unchanged() {
|
||||
let err = adapt(|| crate::error::Error::ScsiError {
|
||||
opcode: 0,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert_eq!(err.status, 0xFF);
|
||||
assert!(err.sense.is_none());
|
||||
}
|
||||
|
||||
/// A non-SCSI IO fault (ioctl SG_IO == -1: ENODEV/EIO) is a dead bus —
|
||||
/// surfaced as 0xFF so the unlock crate bails instead of hammering it.
|
||||
#[test]
|
||||
fn io_error_maps_to_transport_failure() {
|
||||
let err = adapt(|| crate::error::Error::IoError {
|
||||
source: std::io::Error::from(std::io::ErrorKind::NotConnected),
|
||||
});
|
||||
assert_eq!(err.status, crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(err.sense.is_none());
|
||||
}
|
||||
|
||||
/// Device-gone (fd closed) likewise maps to the transport-failure status.
|
||||
#[test]
|
||||
fn device_not_found_maps_to_transport_failure() {
|
||||
let err = adapt(|| crate::error::Error::DeviceNotFound {
|
||||
path: "/dev/sg9".into(),
|
||||
});
|
||||
assert_eq!(err.status, 0xFF);
|
||||
assert!(err.sense.is_none());
|
||||
}
|
||||
}
|
||||
-888
@@ -1,888 +0,0 @@
|
||||
//! Disc sector verification — read every sector and classify health.
|
||||
|
||||
use crate::disc::{Chapter, DiscTitle};
|
||||
use crate::progress::{PassProgress, Progress};
|
||||
use crate::sector::SectorSource;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Pause before the single retry attempt on a failed sector, letting a
|
||||
/// drive that briefly went NOT READY spin back up. Slept in
|
||||
/// [`RETRY_POLL_INTERVAL`] increments so a cancel request (the progress
|
||||
/// callback returning `false`) is observed within that interval rather
|
||||
/// than after the full pause.
|
||||
const RETRY_PAUSE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Polling cadence while sleeping out [`RETRY_PAUSE`].
|
||||
const RETRY_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Sleep up to `total`, returning early with `false` if `cancelled`
|
||||
/// observes a stop request mid-pause. Returns `true` if the full pause
|
||||
/// elapsed without cancellation.
|
||||
fn cancellable_pause(total: Duration, cancelled: &mut dyn FnMut() -> bool) -> bool {
|
||||
let deadline = Instant::now() + total;
|
||||
loop {
|
||||
if cancelled() {
|
||||
return false;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(RETRY_POLL_INTERVAL.min(deadline - now));
|
||||
}
|
||||
}
|
||||
|
||||
/// Health status of a single sector read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SectorStatus {
|
||||
/// Read succeeded on first attempt, under 500ms
|
||||
Good,
|
||||
/// Read succeeded but took longer than expected (>500ms per sector)
|
||||
Slow,
|
||||
/// Read failed initially but succeeded on retry
|
||||
Recovered,
|
||||
/// Read failed all attempts — unrecoverable
|
||||
Bad,
|
||||
}
|
||||
|
||||
/// A contiguous range of bad or slow sectors.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SectorRange {
|
||||
pub start_lba: u32,
|
||||
pub count: u32,
|
||||
pub status: SectorStatus,
|
||||
/// Byte offset from start of title
|
||||
pub byte_offset: u64,
|
||||
}
|
||||
|
||||
/// Result of a disc verification scan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifyResult {
|
||||
pub total_sectors: u64,
|
||||
pub good: u64,
|
||||
pub slow: u64,
|
||||
pub recovered: u64,
|
||||
pub bad: u64,
|
||||
/// Contiguous ranges of non-good sectors
|
||||
pub ranges: Vec<SectorRange>,
|
||||
pub elapsed_secs: f64,
|
||||
}
|
||||
|
||||
impl VerifyResult {
|
||||
/// Percentage of sectors that are fully readable (good + slow + recovered).
|
||||
///
|
||||
/// Returns `100.0` when there are no sectors. The subtraction is
|
||||
/// saturating so a caller-constructed `VerifyResult` with `bad` greater
|
||||
/// than `total_sectors` cannot wrap in release builds (it pins at 0%).
|
||||
pub fn readable_pct(&self) -> f64 {
|
||||
if self.total_sectors == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
self.total_sectors.saturating_sub(self.bad) as f64 / self.total_sectors as f64 * 100.0
|
||||
}
|
||||
|
||||
/// True if every sector read first-attempt clean — no bad, no
|
||||
/// recovered-on-retry, and no slow sectors. A `Recovered` or `Slow`
|
||||
/// sector means data was eventually returned but the surface is
|
||||
/// degraded, so `is_perfect()` is intentionally stricter than
|
||||
/// "fully readable" (which [`readable_pct`](Self::readable_pct)
|
||||
/// reports by counting recovered + slow as readable).
|
||||
pub fn is_perfect(&self) -> bool {
|
||||
self.bad == 0 && self.recovered == 0 && self.slow == 0
|
||||
}
|
||||
|
||||
/// Map a bad sector range to a chapter timestamp.
|
||||
pub fn chapter_at_offset(
|
||||
chapters: &[Chapter],
|
||||
byte_offset: u64,
|
||||
duration_secs: f64,
|
||||
total_bytes: u64,
|
||||
) -> Option<(usize, f64)> {
|
||||
if total_bytes == 0 || chapters.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
|
||||
let mut chapter_idx = 0;
|
||||
for (i, ch) in chapters.iter().enumerate() {
|
||||
if ch.time_secs <= time_secs {
|
||||
chapter_idx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some((chapter_idx + 1, time_secs))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify all sectors in a title's extents.
|
||||
///
|
||||
/// Reads in batches for speed, falling back to single-sector reads on a
|
||||
/// batch failure. Each sector that fails its single read gets one retry
|
||||
/// after a [`RETRY_PAUSE`] cool-down (cancellable — see below).
|
||||
///
|
||||
/// `batch_sectors` is the read granularity in 2048-byte sectors; a value
|
||||
/// of `0` is treated as `1` (a `0` batch would make no forward progress).
|
||||
///
|
||||
/// `on_progress` is both the progress sink and the cancellation channel:
|
||||
/// returning `false` from [`Progress::report`] requests an early stop. The
|
||||
/// stop is honoured between sectors *and* mid-retry-pause, so a verify of a
|
||||
/// disc with many bad sectors stays responsive instead of blocking the full
|
||||
/// pause per sector. Timing classification uses a 500 ms-per-sector
|
||||
/// threshold to mark a successful-but-slow read as [`SectorStatus::Slow`].
|
||||
pub fn verify_title(
|
||||
reader: &mut dyn SectorSource,
|
||||
title: &DiscTitle,
|
||||
batch_sectors: u16,
|
||||
on_progress: Option<&dyn Progress>,
|
||||
) -> VerifyResult {
|
||||
let start = Instant::now();
|
||||
let mut good: u64 = 0;
|
||||
let mut slow: u64 = 0;
|
||||
let mut recovered: u64 = 0;
|
||||
let mut bad: u64 = 0;
|
||||
let mut ranges: Vec<SectorRange> = Vec::new();
|
||||
let mut sectors_done: u64 = 0;
|
||||
let mut byte_offset: u64 = 0;
|
||||
|
||||
// A zero batch size would never advance `offset` -> infinite loop.
|
||||
// Clamp to at least one sector per read.
|
||||
let batch_sectors = batch_sectors.max(1);
|
||||
|
||||
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
|
||||
let mut buf = vec![0u8; batch_sectors as usize * 2048];
|
||||
|
||||
'outer: for ext in &title.extents {
|
||||
let mut offset: u32 = 0;
|
||||
while offset < ext.sector_count {
|
||||
let remaining = ext.sector_count - offset;
|
||||
let count = remaining.min(batch_sectors as u32) as u16;
|
||||
// `start_lba + offset` stays within the extent's own LBA span by
|
||||
// construction, but a crafted/corrupt extent can push the sum past
|
||||
// u32::MAX; saturate rather than wrap (release) or panic (debug).
|
||||
let lba = ext.start_lba.saturating_add(offset);
|
||||
let bytes = count as usize * 2048;
|
||||
|
||||
let batch_start = Instant::now();
|
||||
let batch_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes], false)
|
||||
.is_ok();
|
||||
let batch_ms = batch_start.elapsed().as_millis();
|
||||
|
||||
if batch_ok {
|
||||
// Batch succeeded — classify by speed
|
||||
let status = if batch_ms > count as u128 * 500 {
|
||||
SectorStatus::Slow
|
||||
} else {
|
||||
SectorStatus::Good
|
||||
};
|
||||
|
||||
match status {
|
||||
SectorStatus::Good => good += count as u64,
|
||||
SectorStatus::Slow => {
|
||||
slow += count as u64;
|
||||
// Merge with the previous range when contiguous and the
|
||||
// same status, mirroring the per-sector path so a run of
|
||||
// slow batches coalesces into one range instead of one
|
||||
// entry per batch.
|
||||
let merged = ranges.last_mut().is_some_and(|last| {
|
||||
if last.status == SectorStatus::Slow
|
||||
&& last.start_lba.saturating_add(last.count) == lba
|
||||
{
|
||||
last.count = last.count.saturating_add(count as u32);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if !merged {
|
||||
ranges.push(SectorRange {
|
||||
start_lba: lba,
|
||||
count: count as u32,
|
||||
status: SectorStatus::Slow,
|
||||
byte_offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
sectors_done += count as u64;
|
||||
if let Some(cb) = on_progress {
|
||||
let pp = crate::progress::PassProgress {
|
||||
kind: crate::progress::PassKind::Verify,
|
||||
work_done: sectors_done,
|
||||
work_total: total_sectors,
|
||||
bytes_good_total: (good + slow + recovered) * 2048,
|
||||
bytes_unreadable_total: bad * 2048,
|
||||
bytes_pending_total: 0,
|
||||
bytes_retryable_total: 0,
|
||||
bytes_total_disc: total_sectors * 2048,
|
||||
disc_duration_secs: Some(title.duration_secs),
|
||||
bytes_bad_in_main_title: 0,
|
||||
main_title_duration_secs: Some(title.duration_secs),
|
||||
main_title_size_bytes: Some(total_sectors * 2048),
|
||||
};
|
||||
if !cb.report(&pp) {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Batch failed — test each sector individually
|
||||
for i in 0..count {
|
||||
let sector_lba = lba.saturating_add(i as u32);
|
||||
let sector_offset = i as usize * 2048;
|
||||
let sector_byte_offset = byte_offset + i as u64 * 2048;
|
||||
|
||||
let s1 = Instant::now();
|
||||
let first_ok = reader
|
||||
.read_sectors(
|
||||
sector_lba,
|
||||
1,
|
||||
&mut buf[sector_offset..sector_offset + 2048],
|
||||
false,
|
||||
)
|
||||
.is_ok();
|
||||
let s1_ms = s1.elapsed().as_millis();
|
||||
|
||||
let status = if first_ok && s1_ms <= 500 {
|
||||
good += 1;
|
||||
SectorStatus::Good
|
||||
} else if first_ok {
|
||||
slow += 1;
|
||||
SectorStatus::Slow
|
||||
} else {
|
||||
// Retry once more after a brief cool-down. The pause is
|
||||
// cancellable via the progress callback so a long run of
|
||||
// bad sectors doesn't pin the thread for 2s each with no
|
||||
// way to stop. If cancelled mid-pause, skip the retry and
|
||||
// count the sector as bad, then bail out of the scan.
|
||||
let cancelled_during_pause = !cancellable_pause(RETRY_PAUSE, &mut || {
|
||||
on_progress.is_some_and(|cb| {
|
||||
!cb.report(&PassProgress {
|
||||
kind: crate::progress::PassKind::Verify,
|
||||
work_done: sectors_done,
|
||||
work_total: total_sectors,
|
||||
bytes_good_total: (good + slow + recovered) * 2048,
|
||||
bytes_unreadable_total: bad * 2048,
|
||||
bytes_pending_total: 0,
|
||||
bytes_retryable_total: 0,
|
||||
bytes_total_disc: total_sectors * 2048,
|
||||
disc_duration_secs: Some(title.duration_secs),
|
||||
bytes_bad_in_main_title: 0,
|
||||
main_title_duration_secs: Some(title.duration_secs),
|
||||
main_title_size_bytes: Some(total_sectors * 2048),
|
||||
})
|
||||
})
|
||||
});
|
||||
if cancelled_during_pause {
|
||||
bad += 1;
|
||||
break 'outer;
|
||||
}
|
||||
if reader
|
||||
.read_sectors(
|
||||
sector_lba,
|
||||
1,
|
||||
&mut buf[sector_offset..sector_offset + 2048],
|
||||
false,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
recovered += 1;
|
||||
SectorStatus::Recovered
|
||||
} else {
|
||||
bad += 1;
|
||||
SectorStatus::Bad
|
||||
}
|
||||
};
|
||||
|
||||
if status != SectorStatus::Good {
|
||||
// Merge with previous range if contiguous and same status
|
||||
if let Some(last) = ranges.last_mut() {
|
||||
if last.status == status
|
||||
&& last.start_lba.saturating_add(last.count) == sector_lba
|
||||
{
|
||||
last.count += 1;
|
||||
} else {
|
||||
ranges.push(SectorRange {
|
||||
start_lba: sector_lba,
|
||||
count: 1,
|
||||
status,
|
||||
byte_offset: sector_byte_offset,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ranges.push(SectorRange {
|
||||
start_lba: sector_lba,
|
||||
count: 1,
|
||||
status,
|
||||
byte_offset: sector_byte_offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sectors_done += 1;
|
||||
if let Some(cb) = on_progress {
|
||||
let pp = crate::progress::PassProgress {
|
||||
kind: crate::progress::PassKind::Verify,
|
||||
work_done: sectors_done,
|
||||
work_total: total_sectors,
|
||||
bytes_good_total: (good + slow + recovered) * 2048,
|
||||
bytes_unreadable_total: bad * 2048,
|
||||
bytes_pending_total: 0,
|
||||
bytes_retryable_total: 0,
|
||||
bytes_total_disc: total_sectors * 2048,
|
||||
disc_duration_secs: Some(title.duration_secs),
|
||||
bytes_bad_in_main_title: 0,
|
||||
main_title_duration_secs: Some(title.duration_secs),
|
||||
main_title_size_bytes: Some(total_sectors * 2048),
|
||||
};
|
||||
if !cb.report(&pp) {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += count as u32;
|
||||
byte_offset += bytes as u64;
|
||||
}
|
||||
}
|
||||
|
||||
VerifyResult {
|
||||
total_sectors,
|
||||
good,
|
||||
slow,
|
||||
recovered,
|
||||
bad,
|
||||
ranges,
|
||||
elapsed_secs: start.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{ContentFormat, DiscTitle, Extent};
|
||||
use crate::error::Error;
|
||||
|
||||
/// Synthetic source: every read succeeds, filling the buffer with zeros.
|
||||
struct AlwaysGood;
|
||||
impl SectorSource for AlwaysGood {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize, Error> {
|
||||
let n = count as usize * 2048;
|
||||
buf[..n].fill(0);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic source: every read fails.
|
||||
struct AlwaysBad;
|
||||
impl SectorSource for AlwaysBad {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize, Error> {
|
||||
Err(Error::ScsiError {
|
||||
opcode: 0x28,
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn title_with_extent(start_lba: u32, sector_count: u32) -> DiscTitle {
|
||||
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![Extent {
|
||||
start_lba,
|
||||
sector_count,
|
||||
}],
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_zero_does_not_hang() {
|
||||
// A zero batch size must be clamped to 1 internally; otherwise the
|
||||
// inner `while offset < sector_count` loop never advances. The
|
||||
// assertion only matters because the test returns at all.
|
||||
let title = title_with_extent(0, 4);
|
||||
let mut src = AlwaysGood;
|
||||
let r = verify_title(&mut src, &title, 0, None);
|
||||
assert_eq!(r.total_sectors, 4);
|
||||
assert_eq!(r.good, 4);
|
||||
assert!(r.is_perfect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_good_is_perfect() {
|
||||
let title = title_with_extent(100, 10);
|
||||
let mut src = AlwaysGood;
|
||||
let r = verify_title(&mut src, &title, 4, None);
|
||||
assert_eq!(r.good, 10);
|
||||
assert_eq!(r.bad, 0);
|
||||
assert!(r.ranges.is_empty());
|
||||
assert_eq!(r.readable_pct(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_during_retry_pause_stops_promptly() {
|
||||
// AlwaysBad forces the single-sector retry path, which pauses before
|
||||
// its retry. The progress callback requests stop on its first call,
|
||||
// so the pause must return early and the scan must bail out rather
|
||||
// than sleeping RETRY_PAUSE * sector_count. A generous wall-clock
|
||||
// bound (well under a single full pause) proves cancellation worked.
|
||||
let title = title_with_extent(0, 8);
|
||||
let mut src = AlwaysBad;
|
||||
let cb = |_p: &PassProgress| false; // always request stop
|
||||
let started = Instant::now();
|
||||
let r = verify_title(&mut src, &title, 4, Some(&cb));
|
||||
assert!(
|
||||
started.elapsed() < RETRY_PAUSE,
|
||||
"verify did not honour cancel during retry pause"
|
||||
);
|
||||
// Cancelled mid-first-bad-sector: exactly one sector counted bad.
|
||||
assert_eq!(r.bad, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readable_pct_saturates_on_inconsistent_counts() {
|
||||
let r = VerifyResult {
|
||||
total_sectors: 10,
|
||||
good: 0,
|
||||
slow: 0,
|
||||
recovered: 0,
|
||||
bad: 9999, // impossible in practice; must not wrap
|
||||
ranges: Vec::new(),
|
||||
elapsed_secs: 0.0,
|
||||
};
|
||||
assert_eq!(r.readable_pct(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_lba_extent_does_not_overflow() {
|
||||
// An extent whose start_lba + offset would exceed u32::MAX must
|
||||
// saturate, not panic in debug builds.
|
||||
let title = title_with_extent(u32::MAX - 1, 4);
|
||||
let mut src = AlwaysGood;
|
||||
let r = verify_title(&mut src, &title, 2, None);
|
||||
assert_eq!(r.total_sectors, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_progress_callback_still_completes_on_bad_sectors() {
|
||||
// Without a callback there is no cancel signal, so each bad sector
|
||||
// takes the full RETRY_PAUSE. Keep the extent tiny (1 sector) so the
|
||||
// test stays fast while still exercising the bad+retry path.
|
||||
let title = title_with_extent(0, 1);
|
||||
let mut src = AlwaysBad;
|
||||
let r = verify_title(&mut src, &title, 1, None);
|
||||
assert_eq!(r.bad, 1);
|
||||
assert_eq!(r.ranges.len(), 1);
|
||||
assert_eq!(r.ranges[0].status, SectorStatus::Bad);
|
||||
}
|
||||
|
||||
// ── New comprehensive tests ────────────────────────────────────────────────
|
||||
|
||||
/// A SectorSource that fails exactly `fail_count` calls, then succeeds.
|
||||
struct FailFirst {
|
||||
remaining_fails: usize,
|
||||
}
|
||||
impl SectorSource for FailFirst {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize, Error> {
|
||||
if self.remaining_fails > 0 {
|
||||
self.remaining_fails -= 1;
|
||||
Err(Error::ScsiError {
|
||||
opcode: 0x28,
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
} else {
|
||||
let n = count as usize * 2048;
|
||||
buf[..n].fill(0);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn title_with_two_extents(s1: u32, c1: u32, s2: u32, c2: u32) -> DiscTitle {
|
||||
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![
|
||||
Extent {
|
||||
start_lba: s1,
|
||||
sector_count: c1,
|
||||
},
|
||||
Extent {
|
||||
start_lba: s2,
|
||||
sector_count: c2,
|
||||
},
|
||||
],
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// readable_pct on a fully-good result is 100.0 (not NaN or < 100).
|
||||
/// Mutation: changing the formula to `(total - bad - 1) / total` makes this fail.
|
||||
#[test]
|
||||
fn readable_pct_all_good_is_exactly_100() {
|
||||
let r = VerifyResult {
|
||||
total_sectors: 100,
|
||||
good: 100,
|
||||
slow: 0,
|
||||
recovered: 0,
|
||||
bad: 0,
|
||||
ranges: Vec::new(),
|
||||
elapsed_secs: 0.0,
|
||||
};
|
||||
assert_eq!(r.readable_pct(), 100.0);
|
||||
}
|
||||
|
||||
/// readable_pct with zero total_sectors must return 100.0, not NaN/inf
|
||||
/// from a division by zero.
|
||||
/// Spec: the doc-comment explicitly states "Returns 100.0 when there are no sectors."
|
||||
/// Mutation: removing the `if self.total_sectors == 0` guard makes this panic or NaN.
|
||||
#[test]
|
||||
fn readable_pct_empty_disc_is_100() {
|
||||
let r = VerifyResult {
|
||||
total_sectors: 0,
|
||||
good: 0,
|
||||
slow: 0,
|
||||
recovered: 0,
|
||||
bad: 0,
|
||||
ranges: Vec::new(),
|
||||
elapsed_secs: 0.0,
|
||||
};
|
||||
assert_eq!(r.readable_pct(), 100.0);
|
||||
}
|
||||
|
||||
/// readable_pct counts slow and recovered as readable (not bad).
|
||||
/// Spec: doc says "good + slow + recovered" are readable.
|
||||
/// Mutation: omitting `slow` or `recovered` from the numerator reduces the result.
|
||||
#[test]
|
||||
fn readable_pct_counts_slow_and_recovered_as_readable() {
|
||||
// 6 of 10 sectors are "lost" (bad); 4 are readable (slow + recovered).
|
||||
let r = VerifyResult {
|
||||
total_sectors: 10,
|
||||
good: 0,
|
||||
slow: 2,
|
||||
recovered: 2,
|
||||
bad: 6,
|
||||
ranges: Vec::new(),
|
||||
elapsed_secs: 0.0,
|
||||
};
|
||||
// saturating_sub(6) = 4; 4/10 = 40.0
|
||||
let got = r.readable_pct();
|
||||
assert!((got - 40.0).abs() < 1e-9, "expected 40.0, got {got}");
|
||||
}
|
||||
|
||||
/// is_perfect() is false when there are slow sectors, even if bad == 0.
|
||||
/// Spec: doc says "no bad, no recovered-on-retry, and no slow sectors".
|
||||
/// Mutation: removing `self.slow == 0` from is_perfect() makes this pass when it should fail.
|
||||
#[test]
|
||||
fn is_perfect_false_when_slow_present() {
|
||||
let r = VerifyResult {
|
||||
total_sectors: 10,
|
||||
good: 9,
|
||||
slow: 1,
|
||||
recovered: 0,
|
||||
bad: 0,
|
||||
ranges: Vec::new(),
|
||||
elapsed_secs: 0.0,
|
||||
};
|
||||
assert!(!r.is_perfect());
|
||||
}
|
||||
|
||||
/// is_perfect() is false when there are recovered sectors.
|
||||
/// Mutation: removing `self.recovered == 0` from is_perfect() makes this fail.
|
||||
#[test]
|
||||
fn is_perfect_false_when_recovered_present() {
|
||||
let r = VerifyResult {
|
||||
total_sectors: 10,
|
||||
good: 9,
|
||||
slow: 0,
|
||||
recovered: 1,
|
||||
bad: 0,
|
||||
ranges: Vec::new(),
|
||||
elapsed_secs: 0.0,
|
||||
};
|
||||
assert!(!r.is_perfect());
|
||||
}
|
||||
|
||||
/// is_perfect() is false when there are bad sectors.
|
||||
/// Mutation: removing `self.bad == 0` from is_perfect() makes this fail.
|
||||
#[test]
|
||||
fn is_perfect_false_when_bad_present() {
|
||||
let r = VerifyResult {
|
||||
total_sectors: 5,
|
||||
good: 4,
|
||||
slow: 0,
|
||||
recovered: 0,
|
||||
bad: 1,
|
||||
ranges: Vec::new(),
|
||||
elapsed_secs: 0.0,
|
||||
};
|
||||
assert!(!r.is_perfect());
|
||||
}
|
||||
|
||||
/// chapter_at_offset returns None when chapters is empty.
|
||||
/// Mutation: removing the `chapters.is_empty()` check makes this return Some.
|
||||
#[test]
|
||||
fn chapter_at_offset_empty_chapters_returns_none() {
|
||||
let result = VerifyResult::chapter_at_offset(&[], 1024, 120.0, 100_000);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
/// chapter_at_offset returns None when total_bytes == 0 (division-by-zero guard).
|
||||
/// Mutation: removing the `total_bytes == 0` guard makes this panic or NaN.
|
||||
#[test]
|
||||
fn chapter_at_offset_zero_total_bytes_returns_none() {
|
||||
use crate::disc::Chapter;
|
||||
let chapters = vec![Chapter {
|
||||
time_secs: 0.0,
|
||||
name: String::new(),
|
||||
}];
|
||||
let result = VerifyResult::chapter_at_offset(&chapters, 1024, 120.0, 0);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
/// chapter_at_offset maps byte offset to the correct chapter index (1-based).
|
||||
/// Spec: time_secs = byte_offset / total_bytes * duration; chapter = last chapter
|
||||
/// whose time_secs <= computed time.
|
||||
/// Mutation: off-by-one on chapter_idx (using `i` instead of `chapter_idx`) changes result.
|
||||
#[test]
|
||||
fn chapter_at_offset_selects_correct_chapter() {
|
||||
use crate::disc::Chapter;
|
||||
// 3 chapters at 0s, 30s, 60s; disc is 120s, 120,000 bytes.
|
||||
let chapters = vec![
|
||||
Chapter {
|
||||
time_secs: 0.0,
|
||||
name: String::new(),
|
||||
},
|
||||
Chapter {
|
||||
time_secs: 30.0,
|
||||
name: String::new(),
|
||||
},
|
||||
Chapter {
|
||||
time_secs: 60.0,
|
||||
name: String::new(),
|
||||
},
|
||||
];
|
||||
let total_bytes: u64 = 120_000;
|
||||
let duration = 120.0_f64;
|
||||
|
||||
// Byte 90,000 → time 90.0s → chapter 3 (index 2, 1-based = 3).
|
||||
let (ch, t) =
|
||||
VerifyResult::chapter_at_offset(&chapters, 90_000, duration, total_bytes).unwrap();
|
||||
assert_eq!(ch, 3, "expected chapter 3, got {ch}");
|
||||
assert!((t - 90.0).abs() < 1e-6, "expected t≈90.0, got {t}");
|
||||
|
||||
// Byte 0 → time 0s → chapter 1 (first chapter).
|
||||
let (ch0, _) =
|
||||
VerifyResult::chapter_at_offset(&chapters, 0, duration, total_bytes).unwrap();
|
||||
assert_eq!(ch0, 1);
|
||||
}
|
||||
|
||||
/// A contiguous sequence of bad sectors in one extent must be coalesced
|
||||
/// into a single SectorRange (not one entry per sector).
|
||||
/// Mutation: removing the range-merge logic produces many small ranges.
|
||||
#[test]
|
||||
fn contiguous_bad_sectors_coalesce_into_one_range() {
|
||||
// 4-sector extent, all bad. Batch size 1 forces per-sector path.
|
||||
// With cancel-on-second-bad we'd stop early; we must let all 4 sectors
|
||||
// run to check coalescing. Use no callback so they all complete.
|
||||
// But AlwaysBad + no callback is slow (RETRY_PAUSE per sector × 4).
|
||||
// Limit to 2 sectors to keep wall-clock reasonable.
|
||||
let title = title_with_extent(100, 2);
|
||||
let mut src = AlwaysBad;
|
||||
// no callback → full 2 × RETRY_PAUSE, but only 2 sectors
|
||||
let r = verify_title(&mut src, &title, 1, None);
|
||||
// Both sectors are bad and contiguous; they must merge into one range.
|
||||
assert_eq!(r.bad, 2);
|
||||
assert_eq!(
|
||||
r.ranges.len(),
|
||||
1,
|
||||
"contiguous bad sectors must coalesce: got {} ranges",
|
||||
r.ranges.len()
|
||||
);
|
||||
assert_eq!(r.ranges[0].start_lba, 100);
|
||||
assert_eq!(r.ranges[0].count, 2);
|
||||
assert_eq!(r.ranges[0].status, SectorStatus::Bad);
|
||||
}
|
||||
|
||||
/// A batch that fails, then each individual sector succeeds on the first
|
||||
/// per-sector read, is counted as Good (not Recovered).
|
||||
/// Mutation: counting per-sector-first-ok as Recovered instead of Good flips this.
|
||||
#[test]
|
||||
fn batch_fail_then_per_sector_ok_counts_as_good() {
|
||||
// First read fails (batch), then all per-sector reads succeed.
|
||||
let mut src = FailFirst { remaining_fails: 1 };
|
||||
let title = title_with_extent(0, 2);
|
||||
let r = verify_title(&mut src, &title, 2, None);
|
||||
// Both sectors should be Good (or Slow if the read was slow — but
|
||||
// FailFirst returns instantly, so they should be Good).
|
||||
assert_eq!(r.bad, 0);
|
||||
assert_eq!(r.recovered, 0);
|
||||
// Good ≥ 1: at least one sector was read clean on the per-sector path.
|
||||
assert!(r.good >= 1, "expected some good sectors, got {}", r.good);
|
||||
}
|
||||
|
||||
/// A recovered sector (batch fails, per-sector fails, retry succeeds)
|
||||
/// appears in ranges with SectorStatus::Recovered.
|
||||
/// This is exercised by FailFirst with remaining_fails=2: batch fails (1),
|
||||
/// first per-sector attempt fails (2), retry succeeds (0 remaining).
|
||||
/// Mutation: mapping recovered to Good removes the Recovered range entry.
|
||||
#[test]
|
||||
fn recovered_sector_appears_in_ranges_as_recovered() {
|
||||
// Batch read of 1 sector: fail once (batch), fail once (per-sector),
|
||||
// then succeed (retry). That yields Recovered status.
|
||||
let mut src = FailFirst { remaining_fails: 2 };
|
||||
let title = title_with_extent(0, 1);
|
||||
// Provide a callback that never cancels so the retry pause can run.
|
||||
// But we want the test to be fast — FailFirst returns immediately,
|
||||
// so the cancel-during-pause path won't be hit. However, we still
|
||||
// sleep RETRY_PAUSE here (2s). Use no-cancel callback.
|
||||
let noop_cb = |_: &crate::progress::PassProgress| true;
|
||||
let r = verify_title(&mut src, &title, 1, Some(&noop_cb));
|
||||
assert_eq!(r.recovered, 1, "sector should be Recovered");
|
||||
assert_eq!(r.bad, 0);
|
||||
assert_eq!(r.ranges.len(), 1);
|
||||
assert_eq!(r.ranges[0].status, SectorStatus::Recovered);
|
||||
}
|
||||
|
||||
/// total_sectors sums across multiple extents correctly.
|
||||
/// Mutation: only summing the first extent makes total wrong.
|
||||
#[test]
|
||||
fn total_sectors_sums_multiple_extents() {
|
||||
let title = title_with_two_extents(0, 3, 100, 5);
|
||||
let mut src = AlwaysGood;
|
||||
let r = verify_title(&mut src, &title, 4, None);
|
||||
assert_eq!(r.total_sectors, 8, "expected 3+5=8 sectors");
|
||||
assert_eq!(r.good, 8);
|
||||
}
|
||||
|
||||
/// SectorRange byte_offset is zero for the very first sector of the first extent.
|
||||
/// Mutation: starting byte_offset at 2048 instead of 0 shifts all offsets.
|
||||
#[test]
|
||||
fn sector_range_byte_offset_starts_at_zero_for_first_sector() {
|
||||
// AlwaysBad with 1 sector: the range for LBA 0 must have byte_offset=0.
|
||||
let title = title_with_extent(0, 1);
|
||||
let mut src = AlwaysBad;
|
||||
let r = verify_title(&mut src, &title, 1, None);
|
||||
assert_eq!(r.ranges.len(), 1);
|
||||
assert_eq!(
|
||||
r.ranges[0].byte_offset, 0,
|
||||
"first sector byte_offset must be 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// SectorRange byte_offset for the second sector is 2048.
|
||||
/// Spec: sector size = 2048 bytes on Blu-ray (ISO 9660 / ECMA-119 §7).
|
||||
/// Mutation: computing offset as `i * 2049` changes the second sector offset.
|
||||
#[test]
|
||||
fn sector_range_byte_offset_increments_by_2048() {
|
||||
// Force the second sector to be bad: fail twice (first two reads
|
||||
// attempt LBA 0 in batch, then individually). That is complex.
|
||||
// Simpler: use a source that only fails on LBA 1.
|
||||
struct BadOnLba1;
|
||||
impl SectorSource for BadOnLba1 {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize, Error> {
|
||||
if lba == 1 && count == 1 {
|
||||
Err(Error::ScsiError {
|
||||
opcode: 0x28,
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
} else if lba == 0 && count == 2 {
|
||||
// Batch of 2 fails when it includes LBA 1 — simulate batch
|
||||
// failure so we fall to per-sector.
|
||||
Err(Error::ScsiError {
|
||||
opcode: 0x28,
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
} else {
|
||||
let n = count as usize * 2048;
|
||||
buf[..n].fill(0);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extent: LBA 0..1 (2 sectors), batch size 2 so we get a batch attempt first.
|
||||
let title = title_with_extent(0, 2);
|
||||
let mut src = BadOnLba1;
|
||||
let noop_cb = |_: &crate::progress::PassProgress| true;
|
||||
let r = verify_title(&mut src, &title, 2, Some(&noop_cb));
|
||||
// We expect exactly one bad sector at LBA 1.
|
||||
assert_eq!(r.bad, 1);
|
||||
let bad_range = r
|
||||
.ranges
|
||||
.iter()
|
||||
.find(|rng| rng.status == SectorStatus::Bad)
|
||||
.unwrap();
|
||||
// LBA 1 is the second sector → byte_offset = 1 * 2048 = 2048.
|
||||
assert_eq!(
|
||||
bad_range.byte_offset, 2048,
|
||||
"second sector byte_offset must be 2048, got {}",
|
||||
bad_range.byte_offset
|
||||
);
|
||||
}
|
||||
|
||||
/// cancellable_pause returns true when it completes the full duration
|
||||
/// without cancellation.
|
||||
/// Mutation: returning `false` unconditionally makes callers think they were cancelled.
|
||||
#[test]
|
||||
fn cancellable_pause_returns_true_when_not_cancelled() {
|
||||
// Use a very short duration so the test runs fast.
|
||||
let tiny = Duration::from_millis(10);
|
||||
let result = cancellable_pause(tiny, &mut || false);
|
||||
assert!(result, "not-cancelled pause must return true");
|
||||
}
|
||||
|
||||
/// cancellable_pause returns false immediately when the callback requests cancel.
|
||||
/// Mutation: ignoring the callback return and sleeping the full duration breaks this.
|
||||
#[test]
|
||||
fn cancellable_pause_returns_false_when_cancelled() {
|
||||
let long = Duration::from_secs(60);
|
||||
let started = Instant::now();
|
||||
let result = cancellable_pause(long, &mut || true);
|
||||
assert!(!result, "cancelled pause must return false");
|
||||
// Must return well within the first poll interval, not after 60s.
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
+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.
|
||||
|
||||
@@ -275,6 +275,7 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
|
||||
progress: None,
|
||||
halt: Some(halt.clone()),
|
||||
key_fetch: None,
|
||||
fast_capture: false,
|
||||
};
|
||||
|
||||
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
||||
|
||||
+116
-26
@@ -397,10 +397,14 @@ fn profile_02_all_medium_error() {
|
||||
);
|
||||
assert!(!pr.halted, "02_all_medium halted");
|
||||
// Upper bound: every sector probed individually + a few batch-drop
|
||||
// and skip-escalation attempts. 16 sectors × ~3 visits ≈ 50.
|
||||
// and skip-escalation attempts, PLUS scatter-recovery on each hard
|
||||
// single sector (up to SCATTER_MAX_ATTEMPTS fresh tries, each a
|
||||
// recalibration read + a re-read = +6 reads/sector). Still strictly
|
||||
// bounded — the guard exists to catch an UNBOUNDED retry loop, which
|
||||
// would be in the hundreds.
|
||||
assert!(
|
||||
trace_len <= 80,
|
||||
"02_all_medium trace_len={trace_len} exceeds 80"
|
||||
trace_len <= 200,
|
||||
"02_all_medium trace_len={trace_len} exceeds 200"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -463,9 +467,13 @@ fn profile_03_alternating_good_bad() {
|
||||
"03_alternating expected NonTrimmed remainder, got bytes_pending=0"
|
||||
);
|
||||
assert!(!pr.halted, "03_alternating halted");
|
||||
// Efficiency guard (catches runaway, not the tier-2 roster). The 8
|
||||
// permanently-bad sectors are now additionally probed by the tier-2
|
||||
// marginal specialists (SlowSpin/FuaRetry/SlowFua/CachePrime/Oscillate/
|
||||
// SpeedSweep) before being left NonTrimmed — bounded, finite extra reads.
|
||||
assert!(
|
||||
trace_len <= 120,
|
||||
"03_alternating trace_len={trace_len} exceeds 120"
|
||||
trace_len <= 220,
|
||||
"03_alternating trace_len={trace_len} exceeds 220"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,9 +644,13 @@ fn profile_06_deep_pit() {
|
||||
"06_deep_pit bytes_pending expected > 0"
|
||||
);
|
||||
assert!(!pr.halted, "06_deep_pit halted");
|
||||
// Bounded as in profile 02: the deep pit's hard single sectors each get
|
||||
// scatter-recovery (up to SCATTER_MAX_ATTEMPTS recalibrate + re-read
|
||||
// tries) on top of the baseline probe/skip walk. Still bounded — a
|
||||
// runaway loop would be in the hundreds.
|
||||
assert!(
|
||||
trace_len <= 120,
|
||||
"06_deep_pit trace_len={trace_len} exceeds 120"
|
||||
trace_len <= 180,
|
||||
"06_deep_pit trace_len={trace_len} exceeds 180"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -691,31 +703,28 @@ fn profile_07_medium_then_good() {
|
||||
trace,
|
||||
);
|
||||
|
||||
// GOLDEN: patch's cache-priming (`prime_cache`) issues 3 throwaway
|
||||
// single-sector reads at lba-3, lba-2, lba-1 (NOT lba itself) before
|
||||
// each count==1 recovery read. In the default REVERSE patch pass,
|
||||
// lba 108's prime reads lba 105 (consuming step 0 = fail) and lba 107's
|
||||
// prime reads lba 105 again (consuming step 1 = fail), so when the real
|
||||
// recovery read for lba 105 occurs its script step is 2 (= ok).
|
||||
// prime_cache(105) itself reads 102, 103, 104 — it does NOT advance
|
||||
// lba 105's own counter. Net effect: patch fully recovers the range in
|
||||
// one pass thanks to priming, even though the script said "fails on
|
||||
// first two attempts."
|
||||
//
|
||||
// This is the documented cache-prime behavior (`disc/patch.rs`
|
||||
// ~line 398, "Proven 2026-05-07 with dd-as-oracle: 8/8 sectors
|
||||
// recoverable when primed vs 6/8 cold"). The golden pins it.
|
||||
// GOLDEN (handler-chain engine): with the script "fail, fail, ok" per bad
|
||||
// sector, each bad sector needs three reads to recover. The chain re-reads a
|
||||
// sector across successive handlers — linear reverse/forward (each narrows a
|
||||
// failed batch to per-sector reads) then bisect — so every sector in the
|
||||
// cluster is read enough times to consume its two failing steps and reach
|
||||
// the Ok step WITHIN one pass. So all 256 sectors recover here; none defer.
|
||||
// (The old batch-halving loop reached only 254; the chain is strictly
|
||||
// better because more handlers re-touch each sector.)
|
||||
assert_eq!(
|
||||
stats.bytes_good,
|
||||
capacity_sectors as u64 * 2048,
|
||||
"07_medium_then_good bytes_good — cache-prime should consume \
|
||||
the failing script steps so the real read sees Ok"
|
||||
256 * 2048,
|
||||
"07_medium_then_good bytes_good (handler chain re-reads each sector \
|
||||
across handlers, consuming the fail,fail,ok script for the whole cluster)"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.bytes_unreadable, 0,
|
||||
"07_medium_then_good bytes_unreadable"
|
||||
"07_medium_then_good bytes_unreadable (NonTrimmed, never terminal in one pass)"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.bytes_pending, 0,
|
||||
"07_medium_then_good bytes_pending (whole cluster recovered in one pass)"
|
||||
);
|
||||
assert_eq!(stats.bytes_pending, 0, "07_medium_then_good bytes_pending");
|
||||
assert!(!pr.halted, "07_medium_then_good halted");
|
||||
assert!(
|
||||
trace_len <= 100,
|
||||
@@ -810,3 +819,84 @@ fn profile_08_batch_fail_singles_ok() {
|
||||
// If the unification ever proceeds, the next step is to add a clock
|
||||
// injection point in `handle_read_failure` and extend this fixture
|
||||
// with the wedge/NOT_READY profiles too.
|
||||
|
||||
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
|
||||
//
|
||||
// A bad range holds one genuinely-dead sector surrounded by readable ones. The
|
||||
// handler chain's linear pass narrows a failed batch to per-sector reads, so it
|
||||
// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed —
|
||||
// strictly better than the old fast-capture path, which left the whole failed
|
||||
// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The
|
||||
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
|
||||
// a scheduling concern for the handler scheduler, tracked separately.)
|
||||
//
|
||||
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
|
||||
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
|
||||
|
||||
#[test]
|
||||
fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() {
|
||||
let capacity_sectors: u32 = 256;
|
||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||
// One bad sector at LBA 130 — inside the LOW 32-sector block of the range.
|
||||
reader.always(
|
||||
130,
|
||||
ScriptStep::Err {
|
||||
sense_key: 3,
|
||||
asc: 0x11,
|
||||
ascq: 0x05,
|
||||
},
|
||||
);
|
||||
|
||||
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
// 64-sector NonTrimmed range [128,192); everything else already Finished.
|
||||
let nontrimmed = [(128 * 2048, 64 * 2048)];
|
||||
let finished = [
|
||||
(0, 128 * 2048),
|
||||
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
|
||||
];
|
||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
||||
|
||||
let opts = libfreemkv::disc::PatchOptions {
|
||||
decrypt: false,
|
||||
block_sectors: Some(32),
|
||||
full_recovery: true,
|
||||
reverse: true,
|
||||
wedged_threshold: 50,
|
||||
progress: None,
|
||||
halt: None,
|
||||
key_fetch: None,
|
||||
fast_capture: true,
|
||||
};
|
||||
disc.patch(&mut reader, &iso_path, &opts)
|
||||
.expect("fast-capture patch must not error");
|
||||
|
||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||
|
||||
// The clean sectors of [128,192) all recover; only the one always-dead
|
||||
// sector (LBA 130) stays NonTrimmed — NOT Unreadable. The chain narrows the
|
||||
// failed batch to per-sector reads, so 63 of the 64 range sectors come back.
|
||||
// Conservation: 255 good + 1 still-pending = the full 256, nothing lost.
|
||||
assert_eq!(
|
||||
stats.bytes_unreadable, 0,
|
||||
"recovery must never mark Unreadable in a pass"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.bytes_pending, 2048,
|
||||
"only the single always-dead sector (LBA 130) stays NonTrimmed"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.bytes_good,
|
||||
255 * 2048,
|
||||
"every sector except the one dead LBA is recovered"
|
||||
);
|
||||
|
||||
let _ = trace; // read trace retained by the fixture; no ordering assertion here
|
||||
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(&map_path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user