mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+196
-2
@@ -1,5 +1,199 @@
|
||||
# Changelog
|
||||
|
||||
## [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 +201,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.2.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.2.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
|
||||
|
||||
+302
-1
@@ -163,6 +163,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 +353,62 @@ 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 decrypt key (identical to `decrypt_unit_checked`).
|
||||
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];
|
||||
}
|
||||
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`
|
||||
@@ -703,6 +853,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 +1442,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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::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
|
||||
}
|
||||
+85
-105
@@ -24,6 +24,13 @@ pub enum AacsVersion {
|
||||
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`.
|
||||
@@ -33,6 +40,24 @@ impl AacsVersion {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── VUK derivation ──────────────────────────────────────────────────────────
|
||||
@@ -435,61 +460,38 @@ pub mod probe {
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
let mut pos = 0;
|
||||
let mut verify_rec_seen: Vec<(u8, usize, usize)> = Vec::new();
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x81 || rec_type == 0x86 {
|
||||
verify_rec_seen.push((rec_type, pos, rec_len));
|
||||
}
|
||||
|
||||
if (rec_type == 0x81 || rec_type == 0x86) && rec_len >= 20 {
|
||||
// mk_dv is at offset 4 of the record (after the 4-byte header)
|
||||
// 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 = crate::aacs::variants::mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| (rt == 0x81 || rt == 0x86) && len >= 20);
|
||||
match found {
|
||||
Some((o, rec_type, rec_len)) => {
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
|
||||
dv.copy_from_slice(&mkb[o + 4..o + 20]);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
rec_type,
|
||||
pos,
|
||||
pos = o,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
return Some(dv);
|
||||
Some(dv)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
verify_rec_seen = ?verify_rec_seen,
|
||||
scanned_bytes = pos,
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
/// Find Subset-Difference records (type 0x04) in MKB.
|
||||
fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x04 && rec_len > 4 {
|
||||
return Some(mkb[pos + 4..pos + rec_len].to_vec());
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
None
|
||||
find_record_body(mkb, 0x04)
|
||||
}
|
||||
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB.
|
||||
@@ -520,19 +522,9 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
/// record matching `rec_type`. Returns `None` if no such record exists or
|
||||
/// the record is empty.
|
||||
fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
if rec_type == rec_type_wanted && rec_len > 4 {
|
||||
return Some(mkb[pos + 4..pos + rec_len].to_vec());
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
None
|
||||
crate::aacs::variants::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
|
||||
@@ -543,17 +535,13 @@ fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
/// 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 {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
// A zero type, a zero/short length, or an overrun = records done, padding begun.
|
||||
if rec_type == 0x00 || rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
pos
|
||||
// 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".)
|
||||
crate::aacs::variants::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 —
|
||||
@@ -577,25 +565,11 @@ pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
|
||||
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
|
||||
/// (`pos + 8`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x10 && rec_len >= 12 {
|
||||
return Some(u32::from_be_bytes([
|
||||
mkb[pos + 8],
|
||||
mkb[pos + 9],
|
||||
mkb[pos + 10],
|
||||
mkb[pos + 11],
|
||||
]));
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
None
|
||||
// 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).
|
||||
crate::aacs::variants::mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == 0x10 && len >= 12)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
||||
}
|
||||
|
||||
// ── MKB Type field (Type-and-Version record 0x10, bytes 4-7) ────────────────
|
||||
@@ -662,24 +636,11 @@ impl MkbType {
|
||||
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
|
||||
/// 4-7. `None` if no 0x10 record is present.
|
||||
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
if rec_type == 0x10 && rec_len >= 8 {
|
||||
return Some(u32::from_be_bytes([
|
||||
mkb[pos + 4],
|
||||
mkb[pos + 5],
|
||||
mkb[pos + 6],
|
||||
mkb[pos + 7],
|
||||
]));
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
None
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
crate::aacs::variants::mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == 0x10 && 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.
|
||||
@@ -972,7 +933,9 @@ 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 crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
|
||||
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 = [
|
||||
@@ -990,7 +953,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 32772];
|
||||
session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||
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 {
|
||||
@@ -1028,7 +991,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
|
||||
|
||||
let mut buf = vec![0u8; 32772];
|
||||
if session
|
||||
.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||
.is_ok()
|
||||
{
|
||||
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
@@ -1546,6 +1509,23 @@ mod tests {
|
||||
use super::super::types::DiscEntry;
|
||||
use super::*;
|
||||
|
||||
/// Audit #5: the `major` / `from_major` mapping is load-bearing for the
|
||||
/// Unit_Key_RO stride, so pin it as a table. V10 ↔ BD; V20/V21 → UHD; any
|
||||
/// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte).
|
||||
#[test]
|
||||
fn aacs_major_round_trips_and_strides_differ() {
|
||||
assert_eq!(AacsVersion::V10.major(), AACS_MAJOR_BD);
|
||||
assert_eq!(AacsVersion::V20.major(), AACS_MAJOR_UHD);
|
||||
assert_eq!(AacsVersion::V21.major(), AACS_MAJOR_UHD);
|
||||
assert_eq!(AacsVersion::from_major(AACS_MAJOR_BD), AacsVersion::V10);
|
||||
assert_eq!(AacsVersion::from_major(AACS_MAJOR_UHD), AacsVersion::V20);
|
||||
assert_eq!(AacsVersion::from_major(99), AacsVersion::V20); // any non-BD → V20
|
||||
assert_ne!(
|
||||
AacsVersion::from_major(AACS_MAJOR_BD).unit_key_stride(),
|
||||
AacsVersion::from_major(AACS_MAJOR_UHD).unit_key_stride()
|
||||
);
|
||||
}
|
||||
|
||||
/// Finding #5 regression: parse_unit_key_ro must REJECT a Unit_Key_RO.inf
|
||||
/// whose declared `num_unit_keys` exceeds the keys actually present in the
|
||||
/// buffer, instead of silently returning a short list. A truncated list
|
||||
|
||||
+20
-7
@@ -16,13 +16,25 @@
|
||||
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod host_certs;
|
||||
pub mod keys;
|
||||
pub mod provider;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variants;
|
||||
|
||||
/// 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";
|
||||
|
||||
// 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.
|
||||
@@ -32,9 +44,10 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un
|
||||
// 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,
|
||||
aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
|
||||
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
|
||||
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps,
|
||||
unit_is_clean_ts, unit_key_validates,
|
||||
};
|
||||
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
|
||||
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||
@@ -42,9 +55,9 @@ pub use decrypt::{
|
||||
#[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,
|
||||
AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C,
|
||||
MKB_TYPE_3_RECORDABLE, MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext,
|
||||
ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
|
||||
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
|
||||
|
||||
+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,
|
||||
|
||||
+46
-14
@@ -88,29 +88,44 @@ pub struct MkbRecord {
|
||||
/// 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> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
mkb_records(mkb)
|
||||
.map(|(offset, rec_type, rec_len)| MkbRecord {
|
||||
offset,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body: mkb[offset + 4..offset + rec_len].to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
|
||||
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
|
||||
/// then the body — stopping at the `00 000000` end marker or a
|
||||
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||
/// MKB record walk in `aacs::keys` are built on this, so the framing rules — and
|
||||
/// any future fix to them — live in exactly one place (they had drifted across
|
||||
/// six hand-rolled copies).
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 4 > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = ((mkb[pos + 1] as usize) << 16)
|
||||
| ((mkb[pos + 2] as usize) << 8)
|
||||
| (mkb[pos + 3] as usize);
|
||||
if rec_type == 0 && rec_len == 0 {
|
||||
break;
|
||||
return None;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
return None;
|
||||
}
|
||||
let body = mkb[pos + 4..pos + rec_len].to_vec();
|
||||
out.push(MkbRecord {
|
||||
offset: pos,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body,
|
||||
});
|
||||
let here = pos;
|
||||
pos += rec_len;
|
||||
}
|
||||
out
|
||||
Some((here, rec_type, rec_len))
|
||||
})
|
||||
}
|
||||
|
||||
/// True iff `records` contains at least one Media Key Variant record
|
||||
@@ -773,6 +788,23 @@ mod tests {
|
||||
assert_eq!(recs[1].body, vec![1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_records_matches_walk_mkb_framing() {
|
||||
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
|
||||
// (offset, type, len) for every record — they share the one framing
|
||||
// walker, and every keys.rs MKB walk now relies on this equivalence.
|
||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
|
||||
let owned: Vec<(usize, u8, usize)> = walk_mkb(&mkb)
|
||||
.iter()
|
||||
.map(|r| (r.offset, r.rec_type, r.rec_len))
|
||||
.collect();
|
||||
let lazy: Vec<(usize, u8, usize)> = mkb_records(&mkb).collect();
|
||||
assert_eq!(lazy, owned);
|
||||
assert_eq!(lazy, vec![(0, 0x10, 6), (6, 0x05, 8)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_mkb_be24_high_byte_is_honored() {
|
||||
// A record longer than 255 bytes needs the high BE24 byte. Build a
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod lfsr;
|
||||
pub mod stevenson;
|
||||
pub(crate) mod tables;
|
||||
|
||||
+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(),
|
||||
|
||||
+243
-200
@@ -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::read_mkb_from_drive(session.scsi_mut())
|
||||
.ok()
|
||||
.and_then(|m| aacs::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."
|
||||
);
|
||||
Err(UnlockError::HandshakeRejected)
|
||||
// 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 unlocked = crate::unlock_bridge::run_unlockers(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Aacs,
|
||||
&fu_certs,
|
||||
)
|
||||
.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 {
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
|
||||
/// for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
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
|
||||
@@ -240,23 +231,19 @@ impl Disc {
|
||||
/// [`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
|
||||
// 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 +257,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 +290,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,63 +309,91 @@ 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 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);
|
||||
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::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);
|
||||
// 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_version(&mkb_bytes);
|
||||
|
||||
tracing::debug!(
|
||||
@@ -646,10 +668,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,7 +685,11 @@ 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::AACS_MAJOR_UHD,
|
||||
"no cert → default UHD (major 2)"
|
||||
);
|
||||
assert!(!st.bus_encryption);
|
||||
}
|
||||
|
||||
@@ -765,6 +793,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 +839,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 +855,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 +883,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);
|
||||
@@ -1013,22 +1049,28 @@ 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
|
||||
));
|
||||
}
|
||||
@@ -1036,22 +1078,23 @@ mod tests {
|
||||
#[test]
|
||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -686,6 +686,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)
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+336
-30
@@ -16,6 +16,7 @@ mod extract;
|
||||
pub mod mapfile;
|
||||
mod patch;
|
||||
pub mod read_error;
|
||||
mod section_recover;
|
||||
mod sweep;
|
||||
pub mod verify;
|
||||
|
||||
@@ -588,6 +589,167 @@ pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
|
||||
total
|
||||
}
|
||||
|
||||
/// Byte offset of `lba` within `title`'s extents (concatenated in order), or
|
||||
/// `None` if the LBA falls outside every extent. The title is a virtual
|
||||
/// contiguous stream; this maps a disc LBA into that stream so a chapter/time
|
||||
/// lookup can place it. (Moved from autorip — clients must not re-derive it.)
|
||||
fn byte_offset_in_title(lba: u32, title: &DiscTitle) -> Option<u64> {
|
||||
use crate::consts::SECTOR_BYTES_U64;
|
||||
let mut cumulative = 0u64;
|
||||
for ext in &title.extents {
|
||||
if lba >= ext.start_lba && lba < ext.start_lba + ext.sector_count {
|
||||
return Some(cumulative + (lba - ext.start_lba) as u64 * SECTOR_BYTES_U64);
|
||||
}
|
||||
cumulative += ext.sector_count as u64 * SECTOR_BYTES_U64;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The 1-based chapter index + movie-time offset a byte position within a title
|
||||
/// falls in, or `None` if the title has no size/chapters. Pure helper for the
|
||||
/// range→chapter/time annotation the progress drilldown ([`locate_ranges`])
|
||||
/// renders — also used by autorip's done-card range annotation. (Formerly lived
|
||||
/// in the removed standalone sector-verify module.)
|
||||
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))
|
||||
}
|
||||
|
||||
/// The 1-based chapter + movie-time offset an LBA falls in, or `(None, None)`
|
||||
/// if it isn't inside the title.
|
||||
fn range_chapter(lba: u32, title: &DiscTitle) -> (Option<u32>, Option<f64>) {
|
||||
if let Some(byte_offset) = byte_offset_in_title(lba, title) {
|
||||
if let Some((ch, t)) = chapter_at_offset(
|
||||
&title.chapters,
|
||||
byte_offset,
|
||||
title.duration_secs,
|
||||
title.size_bytes,
|
||||
) {
|
||||
return (Some(ch as u32), Some(t));
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Annotate raw bad byte-ranges with chapter + movie time, producing the
|
||||
/// rendered drilldown ([`crate::progress::LocatedProgress`]) a client draws.
|
||||
/// `raw` is the mapfile's `(byte_pos, byte_len)` set for whichever statuses the
|
||||
/// caller cares about (the live "Maybe" set during a patch, or terminal
|
||||
/// `Unreadable` for the verdict). The list is sorted largest-movie-time first
|
||||
/// and capped at 50; `truncated` reports the overflow. `bps` (title bytes/sec)
|
||||
/// is derived from the title so callers don't thread it.
|
||||
///
|
||||
/// This is the single place range→chapter/time annotation happens; autorip used
|
||||
/// to own it and read the mapfile to do so. Now the library computes it from
|
||||
/// its in-memory mapfile + title, and the client renders the result verbatim.
|
||||
pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress::LocatedProgress {
|
||||
use crate::consts::{MILLIS_PER_SEC, SECTOR_BYTES_U64};
|
||||
use crate::progress::{LocatedProgress, LocatedRange};
|
||||
const MAX_LOCATED: usize = 50;
|
||||
let bps = if title.duration_secs > 0.0 {
|
||||
title.size_bytes as f64 / title.duration_secs
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let num_ranges = raw.len() as u32;
|
||||
let mut ranges: Vec<LocatedRange> = raw
|
||||
.iter()
|
||||
.map(|(pos, size)| {
|
||||
let lba = pos / SECTOR_BYTES_U64;
|
||||
let count = (size / SECTOR_BYTES_U64) as u32;
|
||||
let duration_ms = if bps > 0.0 {
|
||||
(*size as f64) / bps * MILLIS_PER_SEC
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (chapter, time_offset_secs) = range_chapter(lba as u32, title);
|
||||
LocatedRange {
|
||||
lba,
|
||||
count,
|
||||
duration_ms,
|
||||
chapter,
|
||||
time_offset_secs,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ranges.sort_by(|a, b| {
|
||||
b.duration_ms
|
||||
.partial_cmp(&a.duration_ms)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let largest_gap_ms = ranges.first().map(|r| r.duration_ms).unwrap_or(0.0);
|
||||
let truncated = ranges.len().saturating_sub(MAX_LOCATED) as u32;
|
||||
ranges.truncate(MAX_LOCATED);
|
||||
// At-risk movie time = duration of the ranges that intersect the title
|
||||
// extents (the others are menus/extras → no movie impact).
|
||||
let main_at_risk_ms = if bps > 0.0 {
|
||||
bytes_bad_in_title(title, raw) as f64 * MILLIS_PER_SEC / bps
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
LocatedProgress {
|
||||
ranges,
|
||||
num_ranges,
|
||||
truncated,
|
||||
main_at_risk_ms,
|
||||
largest_gap_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot progress snapshot built from a mapfile on disk plus the title. The
|
||||
/// library reads + parses the mapfile HERE so a client (autorip) gets a
|
||||
/// fully-rendered [`crate::progress::PassProgress`] without ever touching
|
||||
/// mapfile internals — used for the pass-boundary paint (before the live
|
||||
/// callback stream begins) and the terminal done-card verdict. Returns `None`
|
||||
/// if the mapfile can't be read. `work_done`/`work_total` are `0`: this is a
|
||||
/// point-in-time snapshot, not a per-pass progress tick.
|
||||
pub fn progress_snapshot_from_mapfile(
|
||||
mapfile_path: &std::path::Path,
|
||||
title: Option<&DiscTitle>,
|
||||
kind: crate::progress::PassKind,
|
||||
bytes_total_disc: u64,
|
||||
) -> Option<crate::progress::PassProgress> {
|
||||
use mapfile::SectorStatus::{NonScraped, NonTrimmed, Unreadable};
|
||||
let map = mapfile::Mapfile::load(mapfile_path).ok()?;
|
||||
let stats = map.stats();
|
||||
// MAYBE set = not-yet-good (NonTrimmed/NonScraped/Unreadable), excluding
|
||||
// NonTried (the unread remainder) — same set the live patch emitter uses.
|
||||
let maybe = map.ranges_with(&[NonTrimmed, NonScraped, Unreadable]);
|
||||
let located = title.map(|t| locate_ranges(&maybe, t)).unwrap_or_default();
|
||||
let main_bad = title.map(|t| bytes_bad_in_title(t, &maybe)).unwrap_or(0);
|
||||
Some(crate::progress::PassProgress {
|
||||
kind,
|
||||
work_done: 0,
|
||||
work_total: 0,
|
||||
bytes_good_total: stats.bytes_good,
|
||||
bytes_unreadable_total: stats.bytes_unreadable,
|
||||
bytes_pending_total: stats.bytes_pending,
|
||||
bytes_retryable_total: stats.bytes_retryable,
|
||||
bytes_total_disc,
|
||||
disc_duration_secs: title.map(|t| t.duration_secs),
|
||||
bytes_bad_in_main_title: main_bad,
|
||||
main_title_duration_secs: title.map(|t| t.duration_secs),
|
||||
main_title_size_bytes: title.map(|t| t.size_bytes),
|
||||
located,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Display helpers ────────────────────────────────────────────────────────
|
||||
|
||||
impl Codec {
|
||||
@@ -1397,7 +1559,7 @@ impl Disc {
|
||||
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
|
||||
|
||||
// Request max read speed — removes riplock on DVD
|
||||
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
|
||||
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
|
||||
session.set_speed(0xFFFF);
|
||||
|
||||
// Read UDF filesystem with buffered sector reader
|
||||
@@ -1471,18 +1633,27 @@ impl Disc {
|
||||
tracing::info!(target: "freemkv::scan", extents = main_extents.len(), "phase: CSS — main feature located");
|
||||
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
|
||||
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
|
||||
// Unlock the drive's CSS read gating. A CSS-enforcing drive (the
|
||||
// BU40N) refuses to return scrambled sectors until a CSS bus-auth
|
||||
// handshake has run for the title; we run it here purely for that
|
||||
// unlock and IGNORE the key it derives (the disc-key crack is
|
||||
// unreliable). The real descramble key is recovered from the
|
||||
// scrambled movie data itself via the known-plaintext attack — no
|
||||
// player keys, no disc-key crack, no REPORT-KEY-derived title key.
|
||||
if let Err(e) = crate::css::auth::unlock_css_reads(session, unlock_lba) {
|
||||
// Unlock the drive's CSS read gating through the uniform
|
||||
// unlocker dispatch: the CSS unlocker matches DiscKind::Css and
|
||||
// runs the bus-auth handshake (self-guarding to DVD media). A
|
||||
// CSS-enforcing drive (the BU40N) refuses to return scrambled
|
||||
// sectors until that handshake has run; we run it purely for that
|
||||
// unlock and IGNORE any key (the descramble key is recovered
|
||||
// keylessly from the scrambled movie data via the known-plaintext
|
||||
// attack — no player keys, no disc-key crack, no REPORT-KEY title
|
||||
// key). Any failure is non-fatal: continue to the crack, which
|
||||
// simply finds nothing if the drive kept the sectors gated.
|
||||
let drive_id = session.drive_id.clone();
|
||||
if let Err(e) = crate::unlock_bridge::run_unlockers(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Css,
|
||||
&[],
|
||||
) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
error_code = e.code(),
|
||||
"CSS bus-auth unlock failed; scrambled sectors may be unavailable"
|
||||
outcome = ?e,
|
||||
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||
);
|
||||
}
|
||||
// Size the crack's batch reads to THIS drive's per-command max
|
||||
@@ -1600,13 +1771,46 @@ impl Disc {
|
||||
pub(crate) fn read_aacs_inputs_from_reader(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||||
let inf = 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 mkb = Self::read_mkb_content(reader, udf_fs)?;
|
||||
Ok((inf, mkb))
|
||||
let version = Self::read_aacs_version(reader, udf_fs);
|
||||
Ok((inf, mkb, version))
|
||||
}
|
||||
|
||||
/// AACS major version ([`crate::aacs::AACS_MAJOR_BD`] /
|
||||
/// [`crate::aacs::AACS_MAJOR_UHD`]) from the content certificate. Drives the
|
||||
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the
|
||||
/// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a
|
||||
/// server VUK then derives the correct unit keys).
|
||||
///
|
||||
/// When no content certificate is readable/parseable, defaults to **UHD
|
||||
/// (V20, 64-byte stride)** — the conservative choice the pre-1.2.0 fetch path
|
||||
/// hardcoded — and logs it: a wrong stride here folds a server VUK against
|
||||
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
|
||||
/// not quietly pick the V10 stride for a UHD disc.
|
||||
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
|
||||
match udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(crate::aacs::parse_content_cert)
|
||||
{
|
||||
Some(c) => c.version.major(),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_version",
|
||||
"no readable AACS content certificate; defaulting to the V20/UHD \
|
||||
Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)"
|
||||
);
|
||||
crate::aacs::AACS_MAJOR_UHD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the AACS MKB's real record stream — NOT its zero padding.
|
||||
@@ -1626,8 +1830,8 @@ impl Disc {
|
||||
let mut want = START_BYTES;
|
||||
loop {
|
||||
let buf = udf_fs
|
||||
.read_file_prefix(reader, "/AACS/MKB_RO.inf", want)
|
||||
.or_else(|_| udf_fs.read_file_prefix(reader, "/AACS/MKB_RW.inf", want))
|
||||
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
|
||||
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let n = crate::aacs::mkb_content_len(&buf);
|
||||
// `n` strictly inside `buf` => the record walk reached the padding
|
||||
@@ -1642,10 +1846,10 @@ impl Disc {
|
||||
}
|
||||
|
||||
/// Read a disc's AACS key-input files from an ISO image: returns
|
||||
/// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve a Unit Key
|
||||
/// out-of-band: obtain the key however you like, then apply it via
|
||||
/// `(Unit_Key_RO.inf, MKB, aacs_major_version)`. For callers that resolve a
|
||||
/// Unit Key out-of-band: obtain the key however you like, then apply it via
|
||||
/// [`Disc::decrypt_with`]. libfreemkv never makes a network call.
|
||||
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||||
// Preserve the underlying open error (`Error::IoError`, E5000, carrying
|
||||
// the OS errno) instead of collapsing ENOENT/EPERM/etc. into
|
||||
// `Error::AacsNoKeys` (E7000). A missing or unreadable ISO is an I/O
|
||||
@@ -1661,7 +1865,7 @@ impl Disc {
|
||||
/// resolves a key from them however it likes, then applies it via
|
||||
/// [`Disc::decrypt_with`]. These files are plaintext UDF metadata — no
|
||||
/// AACS handshake or keys are required to read them.
|
||||
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||||
let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
|
||||
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
|
||||
}
|
||||
@@ -2091,6 +2295,42 @@ impl Disc {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The unlocker matrix for this scanned disc on `drive`: each REGISTERED
|
||||
/// unlocker's name + whether it actually **did work this rip** — i.e. ran and
|
||||
/// accomplished its job, NOT merely "matched the disc kind". Registry-driven
|
||||
/// names (no hardcoding) so the CLI and autorip render an identical, always-
|
||||
/// current report; the per-unlocker runtime signal is computed here because
|
||||
/// the library owns unlock semantics AND the disc/drive state.
|
||||
///
|
||||
/// The distinction matters: on a LibreDrive drive, LibreDrive's firmware route
|
||||
/// removes the AACS bus and reads the VID, so the AACS host-cert unlocker
|
||||
/// never runs — it "matched" (AACS disc) but did nothing. `did-work` reports
|
||||
/// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
|
||||
/// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
|
||||
pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
|
||||
// LibreDrive firmware-unlocked the drive iff a drive unlocker actually
|
||||
// succeeded at init (name recorded only on success).
|
||||
let ld_worked = drive.unlocker_name().is_some();
|
||||
crate::unlock_bridge::unlocker_names()
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let did_work = match name {
|
||||
// The drive firmware unlock ran and succeeded.
|
||||
"LibreDrive" => ld_worked,
|
||||
// The AACS host-cert route removed the bus ONLY when LibreDrive
|
||||
// didn't (stock drive) AND AACS state was actually obtained.
|
||||
"AACS" => self.aacs.is_some() && !ld_worked,
|
||||
// The CSS handshake/crack succeeded → title keys recovered.
|
||||
"CSS" => self.css.is_some(),
|
||||
// A newly-registered unlocker with no runtime signal wired
|
||||
// here yet: report `no` rather than guess.
|
||||
_ => false,
|
||||
};
|
||||
(name, did_work)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The system-wide decrypt correctness gate.
|
||||
///
|
||||
/// Returns `Ok(())` when it is safe to proceed with a copy or mux, and a
|
||||
@@ -2326,7 +2566,11 @@ impl Disc {
|
||||
aacs.key_source = KeyOrigin::ExternalUk;
|
||||
} else if self.encrypted && self.css.is_none() {
|
||||
self.aacs = Some(AacsState {
|
||||
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
|
||||
version: if self.format == DiscFormat::Uhd {
|
||||
crate::aacs::AACS_MAJOR_UHD
|
||||
} else {
|
||||
crate::aacs::AACS_MAJOR_BD
|
||||
},
|
||||
bus_encryption: self.format == DiscFormat::Uhd,
|
||||
mkb_version: None,
|
||||
disc_hash: String::new(),
|
||||
@@ -2353,6 +2597,7 @@ impl Disc {
|
||||
self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
|
||||
disc_hash: a.disc_hash.clone(),
|
||||
volume_id: a.volume_id,
|
||||
version: a.version,
|
||||
mkb: a.mkb.clone(),
|
||||
unit_key_ro: a.uk_ro.clone(),
|
||||
// Content samples need the disc reader, which scan does not retain;
|
||||
@@ -2702,14 +2947,15 @@ impl Disc {
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
key_fetch: opts.key_fetch.clone(),
|
||||
// Disc::copy's internal patch grinds each range fully (it's a
|
||||
// single-call recovery); the breadth-first fast-capture ordering is
|
||||
// an autorip multi-pass concern.
|
||||
fast_capture: false,
|
||||
};
|
||||
let pr = self.patch(reader, path, &patch_opts)?;
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_done",
|
||||
blocks_attempted = pr.blocks_attempted,
|
||||
blocks_read_ok = pr.blocks_read_ok,
|
||||
blocks_read_failed = pr.blocks_read_failed,
|
||||
bytes_recovered = pr.bytes_recovered_this_pass,
|
||||
halted = pr.halted,
|
||||
wedged_exit = pr.wedged_exit,
|
||||
@@ -3021,7 +3267,7 @@ impl Disc {
|
||||
);
|
||||
|
||||
// Request the drive's max read speed for the whole sweep — removes
|
||||
// riplock. BD/UHD get their speed from the firmware unlock/init, but a
|
||||
// riplock. BD/UHD get their speed from the drive unlock/init, but a
|
||||
// DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so
|
||||
// without this explicit SET CD SPEED a DVD rip sweeps at the drive's
|
||||
// default (riplocked) speed. The damage-recovery branch below also
|
||||
@@ -3318,7 +3564,15 @@ impl Disc {
|
||||
);
|
||||
}
|
||||
|
||||
let jump_pos = (pos + block_bytes + sectors * 2048).min(region_end);
|
||||
// Saturating throughout — the read_error side
|
||||
// computes the sector count with saturating_mul as
|
||||
// "defence in depth"; honor the same guarantee at
|
||||
// the consuming multiply/add so a pathological jump
|
||||
// distance can't wrap.
|
||||
let jump_pos = pos
|
||||
.saturating_add(block_bytes)
|
||||
.saturating_add(sectors.saturating_mul(2048))
|
||||
.min(region_end);
|
||||
let gap_start = pos + block_bytes;
|
||||
let gap_bytes = jump_pos.saturating_sub(gap_start);
|
||||
if gap_bytes > 0 {
|
||||
@@ -3465,6 +3719,15 @@ impl Disc {
|
||||
bytes_bad_in_main_title: main_title_bad,
|
||||
main_title_duration_secs: main_title.map(|t| t.duration_secs),
|
||||
main_title_size_bytes: main_title.map(|t| t.size_bytes),
|
||||
// Rendered drilldown from the consumer's in-memory
|
||||
// snapshot (bad ranges) + title; empty until the first
|
||||
// snapshot arrives.
|
||||
located: match &cached_snapshot {
|
||||
Some(snap) => main_title
|
||||
.map(|t| locate_ranges(&snap.bad_ranges, t))
|
||||
.unwrap_or_default(),
|
||||
None => crate::progress::LocatedProgress::default(),
|
||||
},
|
||||
};
|
||||
if !reporter.report(&pp) {
|
||||
halt_requested = true;
|
||||
@@ -3608,6 +3871,18 @@ pub struct PatchOptions<'a> {
|
||||
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
|
||||
/// recover an orphan CPS unit's key when re-reading its bad range.
|
||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||
/// Fast-capture pass: read each bad range ONCE at the full batch and leave
|
||||
/// every failed block `NonTrimmed` for a later pass — WITHOUT bisecting,
|
||||
/// re-reading, or grinding it here. This lets a first retry pass grab the
|
||||
/// readable blocks (the sweep's good skip-ahead overshoot) of EVERY range
|
||||
/// quickly, before any single range's slow per-sector recovery — so
|
||||
/// recovered data surfaces across the whole disc first instead of grinding
|
||||
/// section 1 to exhaustion before even touching section 2. A later pass
|
||||
/// (`fast_capture = false`) does the granular bisect/retry on what's left.
|
||||
/// No data is dropped: a failed block stays `NonTrimmed` until a granular
|
||||
/// pass recovers it or finally gives up. A transport fault (bridge crash)
|
||||
/// still aborts — it isn't a recoverable bad sector.
|
||||
pub fast_capture: bool,
|
||||
}
|
||||
|
||||
/// Result returned by [`Disc::patch`].
|
||||
@@ -3618,9 +3893,6 @@ pub struct PatchOutcome {
|
||||
pub bytes_pending: u64,
|
||||
pub bytes_recovered_this_pass: u64,
|
||||
pub halted: bool,
|
||||
pub blocks_attempted: u64,
|
||||
pub blocks_read_ok: u64,
|
||||
pub blocks_read_failed: u64,
|
||||
pub wedged_exit: bool,
|
||||
pub wedged_threshold: u64,
|
||||
}
|
||||
@@ -4028,6 +4300,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_ranges_at_risk_in_vs_out_of_feature() {
|
||||
// The honest-"Maybe" behaviour the rendered drilldown must preserve:
|
||||
// in-feature damage counts as movie time at risk; out-of-feature damage
|
||||
// is still located but reads 0:00. (Ported from autorip's old
|
||||
// RipProgress::from_map tests when that logic moved into the library.)
|
||||
// bps = size_bytes / duration_secs = 4096 B/s → 4096 B == 1000 ms.
|
||||
let mut title = title_with_video(Codec::Hevc, Resolution::R2160p);
|
||||
title.duration_secs = 100.0;
|
||||
title.size_bytes = 409_600;
|
||||
// Feature extent = sectors [10,110) → bytes [20480, 225280).
|
||||
title.extents = vec![Extent {
|
||||
start_lba: 10,
|
||||
sector_count: 100,
|
||||
}];
|
||||
|
||||
// In-feature pending range: 4096 B == 1000 ms of movie at risk.
|
||||
let in_feat = locate_ranges(&[(40_960, 4096)], &title);
|
||||
assert_eq!(in_feat.num_ranges, 1);
|
||||
assert!(
|
||||
(in_feat.main_at_risk_ms - 1000.0).abs() < 1.0,
|
||||
"in-feature damage must count as at-risk movie time, got {}",
|
||||
in_feat.main_at_risk_ms
|
||||
);
|
||||
|
||||
// Out-of-feature range: located, but zero movie time at risk.
|
||||
let out_feat = locate_ranges(&[(2_000_000, 100_000)], &title);
|
||||
assert_eq!(out_feat.num_ranges, 1, "still a located range");
|
||||
assert_eq!(
|
||||
out_feat.main_at_risk_ms, 0.0,
|
||||
"out-of-feature damage must not read as movie loss"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a DiscTitle with full control over the fields the title
|
||||
/// sorter cares about. Used by the canonical-title-order tests.
|
||||
fn title_with(
|
||||
|
||||
+815
-2252
File diff suppressed because it is too large
Load Diff
@@ -531,13 +531,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.
|
||||
|
||||
+3
-1
@@ -477,7 +477,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;
|
||||
}
|
||||
|
||||
+184
-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,52 @@ 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 r: Result<()> = match crate::unlock_bridge::run_unlockers(
|
||||
self.scsi.as_mut(),
|
||||
&self.drive_id,
|
||||
freemkv_unlock::DiscKind::Unknown,
|
||||
&[],
|
||||
) {
|
||||
Ok(unlocked) => {
|
||||
self.unlocker_name =
|
||||
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::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 +483,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 +629,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 +658,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 +702,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 +718,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 +738,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 +933,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 +1040,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 +1326,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 +1340,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 +1350,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 +1445,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 +1459,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
|
||||
|
||||
+31
-5
@@ -879,6 +879,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 { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1375,11 +1384,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 +1414,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]
|
||||
|
||||
+172
-29
@@ -32,6 +32,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.
|
||||
@@ -99,24 +103,21 @@ 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 {
|
||||
pub fn new(inputs: &'a DiscInputs) -> Self {
|
||||
use crate::aacs::{AacsVersion, parse_unit_key_ro};
|
||||
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()
|
||||
};
|
||||
@@ -232,11 +233,9 @@ pub fn resolve_and_apply_traced(
|
||||
|
||||
let mut trace = crate::aacs::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 +319,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)
|
||||
@@ -343,8 +344,9 @@ pub fn key_fetch(
|
||||
/// "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.
|
||||
/// clear unit proves nothing, so this collects only scrambled ones — probing
|
||||
/// several points spread across EACH extent so a title whose encrypted body
|
||||
/// starts late (or whose midpoint lands in clear nav) still yields samples.
|
||||
pub fn read_encrypted_units(
|
||||
reader: &mut dyn crate::sector::SectorSource,
|
||||
title: &crate::disc::DiscTitle,
|
||||
@@ -352,7 +354,12 @@ pub fn read_encrypted_units(
|
||||
) -> Vec<Vec<u8>> {
|
||||
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
||||
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 +367,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;
|
||||
@@ -393,7 +405,6 @@ pub fn read_encrypted_units(
|
||||
}
|
||||
}
|
||||
}
|
||||
unit += units_this;
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -443,6 +454,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0xABC".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::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 +462,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 +473,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 +510,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0x00".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -518,6 +531,7 @@ mod tests {
|
||||
DiscInputs {
|
||||
disc_hash: String::new(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -552,7 +566,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![
|
||||
@@ -621,4 +635,133 @@ 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::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
||||
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!(ts_sync_destroyed(s), "every sample is a scrambled unit");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::{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");
|
||||
}
|
||||
}
|
||||
|
||||
+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) ────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
-1
@@ -123,10 +123,39 @@ 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;
|
||||
}
|
||||
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
|
||||
.last()
|
||||
.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
|
||||
@@ -243,6 +272,7 @@ impl CodecParser for DtsParser {
|
||||
// extensions or the next core.
|
||||
let au_pts = self.front_pts();
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: au_pts,
|
||||
@@ -299,6 +329,7 @@ impl CodecParser for DtsParser {
|
||||
let au = std::mem::take(&mut self.buf);
|
||||
self.pts_marks.clear();
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
@@ -482,6 +513,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,6 +613,49 @@ mod tests {
|
||||
assert_eq!(tail[0].data.len(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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_emit_first_on_boundary() {
|
||||
// The first complete unit is emitted as soon as the next core sync is
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,9 @@ impl CodecParser for H264Parser {
|
||||
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,
|
||||
}]
|
||||
@@ -537,6 +540,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -923,6 +927,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 +1237,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 +1253,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);
|
||||
|
||||
@@ -661,6 +661,9 @@ 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,
|
||||
}]
|
||||
@@ -973,6 +976,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2243,6 +2247,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 +2855,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,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 +133,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,
|
||||
}]
|
||||
@@ -198,6 +212,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,15 @@ pub struct Mpeg2Parser {
|
||||
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
|
||||
/// audio. None until the first PES timestamp is seen.
|
||||
origin_pts_ns: Option<i64>,
|
||||
/// B1: absolute ES offsets at which a concealed/lost-gap PES began, parallel
|
||||
/// to `pts_marks`/`source_marks` and drained by the SAME mark-drain invariant.
|
||||
/// MPEG-2 emits whole GOPs asynchronously, so a per-PES flag can't ride
|
||||
/// through to the right frame (the PES that carries the gap completes the
|
||||
/// PREVIOUS picture); associating by OFFSET instead stamps `discontinuity` on
|
||||
/// the access unit whose own bytes begin after the gap — the first post-gap
|
||||
/// picture — surviving GOP buffering + temporal reorder. The consumer's
|
||||
/// ResyncGate then arms at that exact picture, mid-GOP if need be.
|
||||
disc_marks: VecDeque<u64>,
|
||||
}
|
||||
|
||||
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
|
||||
@@ -165,6 +174,7 @@ impl Mpeg2Parser {
|
||||
gop_buf: Vec::new(),
|
||||
emitted_fields: 0,
|
||||
origin_pts_ns: None,
|
||||
disc_marks: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +233,13 @@ impl Mpeg2Parser {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&off) = self.disc_marks.front() {
|
||||
if off < cutoff {
|
||||
self.disc_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
};
|
||||
@@ -316,6 +333,11 @@ impl Mpeg2Parser {
|
||||
if gop_boundary && !self.gop_buf.is_empty() {
|
||||
self.flush_gop(&mut out);
|
||||
}
|
||||
// A concealed-gap mark inside this AU's range [start, end_abs) means
|
||||
// this picture's own bytes begin after the gap — the first post-gap
|
||||
// AU. Same front-mark invariant as PTS/source. Carries through GOP
|
||||
// buffering/reorder to the ResyncGate (which arms at this picture).
|
||||
let discontinuity = self.disc_marks.front().is_some_and(|&off| off < end_abs);
|
||||
self.gop_buf.push(BufferedPicture {
|
||||
tr,
|
||||
info,
|
||||
@@ -323,6 +345,7 @@ impl Mpeg2Parser {
|
||||
frame: Frame {
|
||||
pts_ns: 0,
|
||||
keyframe,
|
||||
discontinuity,
|
||||
data,
|
||||
duration_ns: None,
|
||||
coding: Some(info),
|
||||
@@ -352,6 +375,13 @@ impl Mpeg2Parser {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&off) = self.disc_marks.front() {
|
||||
if off < end_abs {
|
||||
self.disc_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
|
||||
if force {
|
||||
@@ -429,6 +459,12 @@ impl CodecParser for Mpeg2Parser {
|
||||
if let Some(src) = pes.source {
|
||||
self.source_marks.push_back((off, src));
|
||||
}
|
||||
// A concealed/lost gap on this PES marks the access unit its bytes begin —
|
||||
// associated by offset (like PTS/source) so it lands on the first post-gap
|
||||
// picture, not the previous one that completes when this PES arrives.
|
||||
if pes.discontinuity {
|
||||
self.disc_marks.push_back(off);
|
||||
}
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
self.drain_complete_aus(false)
|
||||
}
|
||||
@@ -707,6 +743,7 @@ mod tests {
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
};
|
||||
let mut p = Mpeg2Parser::new();
|
||||
let mut frames = Vec::new();
|
||||
@@ -822,6 +859,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,6 +1033,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 +1495,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 +1509,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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -328,6 +328,9 @@ impl CodecParser for Vc1Parser {
|
||||
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,
|
||||
}]
|
||||
@@ -451,6 +454,7 @@ mod tests {
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,6 +761,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 +1012,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 +1024,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");
|
||||
|
||||
+104
-8
@@ -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) {
|
||||
|
||||
@@ -2665,6 +2665,7 @@ mod tests {
|
||||
pts: Some(gop_pts),
|
||||
dts: None,
|
||||
data: es,
|
||||
discontinuity: false,
|
||||
}));
|
||||
}
|
||||
frames.extend(parser.flush());
|
||||
|
||||
@@ -91,6 +91,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).
|
||||
|
||||
+209
-4
@@ -68,6 +68,13 @@ 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>,
|
||||
}
|
||||
|
||||
impl PipelinedPesStream {
|
||||
@@ -87,6 +94,14 @@ 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();
|
||||
Self {
|
||||
title,
|
||||
parsers,
|
||||
@@ -98,6 +113,8 @@ impl PipelinedPesStream {
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
decrypt_loss: None,
|
||||
dropped_nav_packets: 0,
|
||||
resync,
|
||||
is_video,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +181,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +264,8 @@ impl PipelinedPesStream {
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data,
|
||||
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
|
||||
discontinuity: false,
|
||||
};
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(&pes) {
|
||||
@@ -254,15 +303,47 @@ 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;
|
||||
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));
|
||||
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 +463,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 +476,7 @@ mod tests {
|
||||
source: None,
|
||||
pts_ns: 0,
|
||||
keyframe: false,
|
||||
discontinuity: false,
|
||||
data: vec![0xEE],
|
||||
duration_ns: None,
|
||||
})
|
||||
@@ -411,6 +494,7 @@ mod tests {
|
||||
pts: Some(90_000),
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +582,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
|
||||
|
||||
+6
-1
@@ -629,8 +629,13 @@ 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+233
-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();
|
||||
@@ -151,12 +177,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 +364,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::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 +442,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 +454,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 +494,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 +1005,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 {
|
||||
|
||||
+465
-14
@@ -99,10 +99,11 @@ 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.
|
||||
///
|
||||
@@ -113,10 +114,13 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
/// [`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,
|
||||
/// Fingerprints (hash over the unit ciphertext) of failing units a fetch
|
||||
/// already returned NO new key for. A later failure re-asks the source only
|
||||
/// for units NOT in this set — so on a multi-CPS disc the source is still
|
||||
/// asked for the *second* CPS unit's key even after the first came back dry
|
||||
/// (the old global latch blocked that), while the *same* failing unit is
|
||||
/// never re-fetched (and the total is still bounded by `MAX_FETCH_CALLS`).
|
||||
fetch_dry: std::collections::HashSet<u64>,
|
||||
/// How many times the fetch closure has been invoked, capped at
|
||||
/// [`MAX_FETCH_CALLS`].
|
||||
fetch_calls: usize,
|
||||
@@ -140,6 +144,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::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> {
|
||||
@@ -156,14 +174,25 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
unit_base: 0,
|
||||
decrypt_dropped: Arc::new(AtomicU64::new(0)),
|
||||
fetch: None,
|
||||
fetch_spent: false,
|
||||
fetch_dry: std::collections::HashSet::new(),
|
||||
fetch_calls: 0,
|
||||
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)).
|
||||
@@ -289,6 +318,15 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
if samples.is_empty() {
|
||||
return prev_dropped;
|
||||
}
|
||||
// Skip the call when EVERY failing unit here is one a prior fetch already
|
||||
// came back empty for — re-asking the identical ciphertext only burns a
|
||||
// key-server request. A unit we have NOT asked about yet (e.g. a second
|
||||
// CPS unit on a multi-CPS disc) still gets its one chance, where the old
|
||||
// global `fetch_spent` latch wrongly blocked it.
|
||||
let fps: Vec<u64> = samples.iter().map(|s| Self::sample_fp(s)).collect();
|
||||
if fps.iter().all(|fp| self.fetch_dry.contains(fp)) {
|
||||
return prev_dropped;
|
||||
}
|
||||
// Ask the application's key source for keys that open this ciphertext.
|
||||
self.fetch_calls += 1;
|
||||
let fresh = match self.fetch.as_ref() {
|
||||
@@ -307,8 +345,9 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
}
|
||||
}
|
||||
if added == 0 {
|
||||
// Nothing new — stop asking for the rest of this decorator's life.
|
||||
self.fetch_spent = true;
|
||||
// Nothing new for THESE units — remember them so we don't re-ask the
|
||||
// same ciphertext, but leave the door open for other units.
|
||||
self.fetch_dry.extend(fps);
|
||||
return prev_dropped;
|
||||
}
|
||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
||||
@@ -317,6 +356,16 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
.unwrap_or(prev_dropped)
|
||||
}
|
||||
|
||||
/// Stable per-run fingerprint of a failing unit's ciphertext, for the
|
||||
/// `fetch_dry` set. `DefaultHasher` is fixed-seed, so equal samples map to
|
||||
/// equal fingerprints within a process — all the dedup needs.
|
||||
fn sample_fp(sample: &[u8]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
sample.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Emit a bounded, structured diagnostic for each undecryptable unit in a
|
||||
/// failed verify read. Called only on the failure (cold) path. On a fresh
|
||||
/// rip `buf` holds the post-decrypt bytes straight off the drive, so the
|
||||
@@ -421,6 +470,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
|
||||
@@ -439,7 +502,9 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
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
|
||||
@@ -457,8 +522,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
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;
|
||||
let fetch_viable = self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
|
||||
// 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`
|
||||
@@ -511,6 +575,77 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
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::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::aacs_unit_still_ciphertext(chunk) {
|
||||
if concealed == 0 {
|
||||
first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS;
|
||||
}
|
||||
crate::aacs::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::aacs_unit_needs_decrypt(chunk) {
|
||||
crate::aacs::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
|
||||
@@ -1244,6 +1379,241 @@ 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::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::ALIGNED_UNIT_LEN as u64,
|
||||
"the concealed unit is still counted as loss"
|
||||
);
|
||||
|
||||
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
|
||||
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
|
||||
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let 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::ts_sync_destroyed(unit0),
|
||||
"concealed unit reads as well-formed TS, not scrambled"
|
||||
);
|
||||
|
||||
// Unit 1 (clear) passed through untouched.
|
||||
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
|
||||
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::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::ALIGNED_UNIT_LEN as u64,
|
||||
"exactly one unit (the undecryptable one) is counted as loss"
|
||||
);
|
||||
|
||||
// Unit A → NULL TS (concealed).
|
||||
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let 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::ALIGNED_UNIT_LEN..2 * crate::aacs::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::ALIGNED_UNIT_LEN];
|
||||
crate::aacs::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
|
||||
@@ -1338,6 +1708,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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::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]
|
||||
|
||||
@@ -53,6 +53,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 +114,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 +145,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)
|
||||
}
|
||||
|
||||
+3
-2
@@ -1771,8 +1771,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,
|
||||
|
||||
-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,215 @@
|
||||
//! 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_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::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()
|
||||
}
|
||||
|
||||
/// News up the unlockers, build the context for `kind`, and run the FIRST
|
||||
/// matching one — returning its `Result` so the caller can both consume what it
|
||||
/// learned (vid / bus_key / drive_unlocked) AND render the specific failure
|
||||
/// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
|
||||
/// NotApplicable)` when nothing matched. `host_certs` are collected by the
|
||||
/// caller — lazily, only for AACS; pass `&[]` for the drive-prep / CSS kinds.
|
||||
pub(crate) fn run_unlockers(
|
||||
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
kind: fu::DiscKind,
|
||||
host_certs: &[fu::HostCert],
|
||||
) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
|
||||
let id = to_fu_drive_id(drive_id);
|
||||
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
|
||||
let mut adapter = ScsiAdapter(scsi);
|
||||
for u in fu::all_unlockers() {
|
||||
if u.matches(&ctx) {
|
||||
return u.unlock(&mut adapter, &ctx);
|
||||
}
|
||||
}
|
||||
Err(fu::UnlockError::NotApplicable)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -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