Compare commits

...
25 Commits
Author SHA1 Message Date
MattJackson 1bd7e1de2a 0.27.4: lookup-free Key API (Disc::decrypt_with) + E8005 deferred-mux fix
Add libfreemkv::Key (non_exhaustive; Unit wired) + Disc::decrypt_with(Key) — the
caller resolves a key from any source and hands it in; the library decrypts with
no keydb/network of its own. inject_unit_keys now synthesizes a minimal
ExternalUk AACS state when a scan built none (no keydb at scan time), so a disc
keyed at sweep but scanned without a keydb still decrypts from its mapfile UK
instead of reporting E8005 and deferring the mux forever. Tests cover the
synthesize, replace, empty->None, and decrypt_with paths.
2026-06-04 11:08:33 -07:00
MattJackson da62ee7cf2 v0.27.3: bump version 2026-06-03 21:47:43 -07:00
MattJackson 575c76156f mapfile: persist resolved unit keys (keys XOR VID)
A keyed disc now writes its decrypted AACS unit keys to the mapfile header
(# freemkv-uk: <cps>:<hex>); an unresolved disc writes only the VID. The two are
mutually exclusive (set_unit_keys clears the VID) — unit keys are the final
answer, so deferred-mux / resume decrypts directly with no key lookup, while the
VID alone is the 'still unresolved, retry' marker. CopyOptions/SweepOptions carry
the keys (written when present, else the VID); Disc::inject_unit_keys applies
mapfile-recovered keys to a scanned disc. Round-trip test added.
2026-06-03 21:45:37 -07:00
MattJackson b518860d9c v0.27.0: detect AACS-scrambled units by raw TS sync, not flag bits
Rename is_unit_encrypted -> is_aacs_scrambled and decide encryption from the unit's MPEG-TS sync bytes (destroyed by the encrypted body) instead of the TP_extra copy-control (byte 0) or TS scrambling-control (byte 7) flags, which discs do not set reliably. One shared predicate now backs the decrypt gate and out-of-band key validation, so callers agree on what 'encrypted' means. Decryption restores the syncs, so a decrypted unit reads as clear and there is no flag to clear.
2026-06-03 07:35:50 -07:00
MattJackson c8eb42b490 v0.26.11: detect AACS unit encryption via TS scrambling-control bits
is_unit_encrypted read the TP_extra copy-control bits (byte 0), which are a
copy-permission flag, not an encryption flag. On discs whose sampled units are
clear navigation packets (PAT/PMT) those bits can be set while the unit is not
scrambled, so a correct Unit Key was used to 'decrypt' already-plaintext data,
produced garbage, and the key was wrongly treated as failing.

Read the actual flag instead: the TS transport_scrambling_control bits (top two
of TS-header byte 3 = byte 7 of the aligned unit, inside the clear seed). AACS
encrypts whole aligned units, so this one packet's TSC reflects the unit.
decrypt_unit now clears the TSC bits of every packet on the way out so the
result is valid unscrambled TS. Tests updated to the TSC flag.
2026-06-02 20:37:56 -07:00
MattJackson 48570ac065 Local resolve: MK-pool brute via km_verifies (path 2.5)
keydb stores Media Keys per-disc, but an MK is MKB-scoped (shared across a
pressing/MKB-family). A disc whose own hash/VID isn't keyed can still resolve
if any stored MK verifies against its MKB. New path 2.5 (between PK and the
VID lookup) collects the distinct MK pool from the providers, km_verifies each
against the disc MKB, and on a UNIQUE pass derives VUK (with the disc VID) then
the UK — matching the online resolver's behavior so local keydb mode resolves
the same discs (e.g. an MK present in keydb under a sibling pressing).
km_verifies is one AES-D + magic check per candidate (cheap). Adds
KeyProvider::media_keys() + a path-2.5 unit test.
2026-06-02 16:14:09 -07:00
MattJackson 08e46640fd v0.26.8: bump version 2026-06-02 15:41:38 -07:00
MattJackson a9195824ff Trim MKB to real record length; empty unit-keys means no keys
- mkb_content_len walks the MKB record stream and returns where it ends,
  so callers can drop the trailing zero padding. MKB files are allocated
  to a fixed size (~128 MiB) with records at the front; both MKB_RO and
  MKB_RW can be padded. read_aacs_inputs* now trim to the real records
  (~few MB) instead of shipping ~128 MiB of zeros.
- decrypt_keys() returns None when unit_keys is empty: an AACS state that
  carries only a VID (out-of-band resolution, pre-key) is 'encrypted, no
  keys', not a usable empty key set.
2026-06-02 15:41:27 -07:00
MattJackson 401fe23988 v0.26.7: bump version 2026-06-02 15:15:58 -07:00
MattJackson f89bce5851 Read MKB_RO not the padded MKB_RW; keep VID when keydb is disabled
- read_aacs_inputs / read_aacs_inputs_from_drive now read MKB_RO.inf first.
  MKB_RW.inf is a fixed ~128 MiB rewritable region that is mostly zero
  padding; reading it shipped 124 MiB of nothing. MKB_RO is the real,
  correctly-sized MKB (a few MB). Fall back to RW only if RO is absent.
- disable_keydb no longer drops the Volume ID. A caller resolving Unit Keys
  out-of-band needs the VID (on-disc content read during the handshake).
  New resolve_vid_only builds a keys-free AacsState carrying just the VID +
  version metadata, so the disc reports 'encrypted, no keys' (resolved
  out-of-band) instead of discarding the VID.
2026-06-02 15:15:45 -07:00
MattJackson dfccb85e15 v0.26.6: bump version 2026-06-02 14:11:13 -07:00
MattJackson ebedffb762 ScanOptions: add disable_keydb to skip all keydb lookup
A caller that resolves Unit Keys out-of-band can now set disable_keydb
so the scan consults no keydb at all — neither an explicit keydb_path
nor the standard search locations. Without it, a keydb that merely sits
in a default location ('~/.config/...') silently shadows the out-of-band
path. unit_key still takes precedence over everything.
2026-06-02 14:09:59 -07:00
MattJackson fdf63ccb7f v0.26.5: bump version 2026-06-02 13:37:32 -07:00
MattJackson c735d284da v0.26.4: bump version 2026-06-02 13:06:10 -07:00
MattJackson 94ab7bc73c scan: genericize the caller-supplied unit-key docs (no external-source naming)
The ScanOptions.unit_key path is a generic primitive — a caller-supplied Unit
Key that bypasses keydb lookup. Doc comments + a tracing log named a specific
external source; reworded to neutral 'out-of-band / external key service' so
the library makes no assumptions about where the key came from.
2026-06-02 13:00:11 -07:00
MattJackson 9f209fe066 v0.26.3: bump version 2026-06-02 10:56:56 -07:00
MattJackson 32a1a6e095 mapfile: persist AACS Volume ID as ddrescue-safe comment
Add an optional 16-byte AACS Volume ID to the Mapfile, persisted as a
'# freemkv-vid: <32-hex>' header comment so it survives to deferred-mux
and resume without touching the ISO payload. ddrescue treats the line
as a comment, so the pos/size/status data parser is unaffected.

- Mapfile gains set_vid/vid accessors; save() emits the comment in the
  header block, load() parses it back (malformed/absent -> None, never
  errors the load). Load->save round-trip preserves it (patch pass).
- Thread vid through CopyOptions and SweepOptions; sweep persists it
  when creating/opening the mapfile.
- Unit test: round-trips the VID, asserts the saved comment, and
  confirms data ranges parse identically with and without the comment.
2026-06-02 08:50:19 -07:00
MattJackson 1d3b8f5fb6 chore: gitignore scratch/ (prevent accidental commit of session artifacts) 2026-06-01 22:52:58 -07:00
MattJackson 97b0ae7be2 chore: scrub internal refs from changelog/makefile/cargo 2026-06-01 22:46:54 -07:00
MattJackson 7756f1feca aacs: restore read_aacs_inputs_from_drive (live-drive key-file extractor) 2026-06-01 21:52:27 -07:00
MattJackson 1565da610a chore: scrub non-shippable references from tests/comments 2026-06-01 21:36:57 -07:00
MattJackson e134616422 v0.26.2: bump version 2026-06-01 21:06:56 -07:00
MattJackson 8d54a3c64e aacs: KeyProvider abstraction + PK walker + external-UK key source
Two coherent additions to the AACS resolver:

KeyProvider abstraction (provider.rs) — key material comes from pluggable
backends; KeyDb implements it (device/processing keys, host certs,
disc-by-hash / disc-by-vid lookup) plus orphan-DK parsing. ResolveContext
takes a provider array. Adds the SD-tree PK walker
(derive_media_key_from_pk_walked) and a `probe` module (km_verifies MK
oracle, mkb_* record parsers) used for offline key verification. Cvalue
record selection prefers 0x05, falls back to 0x07.

External-UK key source — the second, mutually-exclusive key source for the
keyserver path. ScanOptions/InputOptions gain `unit_key`; when set,
resolve_encryption_static skips keydb entirely and uses the caller-supplied
Unit Key directly (KeySource::ExternalUk). Disc::read_aacs_inputs exposes a
disc's Unit_Key_RO.inf + MKB so a caller can fetch the UK out-of-band; the
library makes no network call itself.

CHANGELOG: redact test-disc title in historical notes.
2026-06-01 20:47:14 -07:00
MattJackson c0478e1273 labels(pixelogic): number audio from the FPL playlist, not menu segments
Pixelogic discs that authored a SEG_MainFeature menu/preview segment
containing a stray commentary token made the parser anchor on that
segment instead of the real FPL_MainFeature playlist. Combined with
'Audio Stream N' placeholder tokens not advancing the audio counter, a
lone eng_ACOM_ commentary at STN slot 4 collapsed onto slot 1 — tagging
the main feature track as Commentary (seen on Wicked: For Good).

Fix:
- Anchor the feature section on the FPL_ playlist when present; only
  fall back to SEG_MainFeature on discs with no FPL_ section.
- Count 'Audio Stream N' placeholders so editorial audio tokens get
  their true STN ordinal. Subtitle (PG Stream N) numbering is left
  unchanged — counting it regressed several corpus discs.

Extract the section/numbering loop into assign_labels with unit tests.
Verified: 11/11 labels-corpus discs still PASS; Wicked commentary now
lands on the AC-3 2.0 stereo track instead of the main TrueHD.
2026-05-31 21:16:11 -07:00
MattJackson 7d29168fec v0.26.1: AACS resolver path 5 — KEYDB unit-keys direct fallback
Adds a 5th key-resolution path that consumes pre-decrypted unit keys
directly from KEYDB when the entry has no VUK field. Covers ~4,572
entries in the public keydb (~2.5%), heavily skewed toward MKBv76+ UHD
discs where DVDFab/FindVUK can no longer extract a VUK but does extract
unit keys. Partial CPS-unit coverage is rejected so a disc is never
half-decrypted.

Resolver path order reordered root-to-leaf: DK (1) → PK (2) →
KEYDB-derived MK+VID (3) → KEYDB VUK (4) → KEYDB unit keys (5).
Previous order was leaf-first.

API:
- AacsState::vuk is now Option<[u8; 16]> (was [u8; 16])
- ResolvedKeys::vuk is now Option<[u8; 16]> (was [u8; 16])
- KeySource variants reordered + new KeyDbUnitKeys variant

3 new resolver tests (path 4 still works without VID; path 5 succeeds
with pre-decrypted unit keys; path 5 rejects partial CPS coverage).
2026-05-22 12:51:37 -07:00
36 changed files with 2322 additions and 536 deletions
+3
View File
@@ -5,3 +5,6 @@ Cargo.lock
.DS_Store
.cargo/
.claude/worktrees/
# session scratch — never track (may contain RE breadcrumbs)
scratch/
+87 -30
View File
@@ -1,5 +1,65 @@
# Changelog
## 0.27.3 (2026-06-04)
### Added
- **The mapfile now persists resolved AACS unit keys, not just the Volume ID.**
A keyed disc writes its decrypted unit keys as `# freemkv-uk: <cps>:<hex>`
comment headers; an unresolved disc writes only the `# freemkv-vid:` marker.
The two are **mutually exclusive** (`Mapfile::set_unit_keys` clears the VID):
unit keys are the final answer, so a deferred-mux / resume decrypts directly
with no key-service round-trip, while the VID alone means "still unresolved —
retry the key service." `CopyOptions`/`SweepOptions` gain a `unit_keys` field
(written when non-empty, else the VID), and `Disc::inject_unit_keys` applies
mapfile-recovered keys to a scanned disc (marking the source `ExternalUk`).
## 0.27.0 (2026-06-03)
### Changed
- **AACS unit-encryption detection now reads the raw MPEG-TS sync bytes
instead of header flag bits.** `is_unit_encrypted` is renamed
`is_aacs_scrambled`: a unit is encrypted iff its body TS packet sync bytes
(`0x47` at offset 4 and every 192 bytes) are not intact — the encrypted
body destroys them. The previous check read the TP_extra copy-control bits
(byte 0) or TS transport_scrambling_control bits (byte 7), which discs and
players do not set reliably. A single predicate now backs both the decrypt
gate and out-of-band key validation, so all callers agree on what
"encrypted" means. Decryption restores the syncs, so a decrypted unit reads
as clear and there is no encryption flag to clear.
## 0.26.1 (2026-05-22)
### Added
- **AACS resolver path 5 — KEYDB unit-keys direct fallback.** When a
KEYDB entry for the disc has no VUK field but does have pre-decrypted
unit keys, the resolver now consumes those unit keys directly instead
of treating the entry as unusable. Covers ~4,572 entries in the
public keydb (≈2.5% of the database, heavily skewed toward MKBv76+
UHD discs that DVDFab/FindVUK can no longer extract VUKs for).
Surfaces as `KeySource::KeyDbUnitKeys`; `AacsState::vuk` and
`ResolvedKeys::vuk` are `None` on this path because no VUK exists to
return. Partial CPS-unit coverage is rejected so a disc is never
half-decrypted.
### Changed
- **AACS resolver path order reordered root-to-leaf.** Paths now run in
derivation-strength order: device-key (1) → processing-key (2) →
KEYDB-derived from MK+VID (3) → KEYDB VUK direct (4) → KEYDB unit
keys direct (5). Previous order was leaf-first (KEYDB hash lookup,
then derivation). Resolution runs once per disc, so the few extra
milliseconds of MKB walking when a KEYDB VUK would have answered are
invisible against the rip itself; the new order matches the AACS
derivation hierarchy. `KeySource` enum variants are reordered to
match.
- **`AacsState::vuk` type is now `Option<[u8; 16]>`** (was `[u8; 16]`).
Required by path 5, which has no VUK. Public-API break.
- **`ResolvedKeys::vuk` type is now `Option<[u8; 16]>`** (was
`[u8; 16]`). Same rationale.
## 0.25.14 (2026-05-21)
### Changed
@@ -48,17 +108,17 @@ No behavioural change — purely a rename pass.
- **Libredrive raw-read VID shortcut deleted.** v0.25.11 introduced a
`do_handshake` branch that, on libredrive-active drives, skipped the
AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80
with AGID=0 directly. The hypothesis was that firmware-uploaded
drives would serve VID without auth. Empirical test on rip1 (BU40N
+ Barbie UHD, 2026-05-21) showed the drive returns
with AGID=0 directly. The hypothesis was that unlocked
drives would serve VID without auth. Empirical test (BU40N
+ UHD disc, 2026-05-21) showed the drive returns
`0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key
exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of
firmware-upload state. The AACS spec requires a successful
drive-unlock state. The AACS spec requires a successful
`REPORT_KEY` / `SEND_KEY` exchange to establish an AGID before
format 0x80 returns VID; that requirement is enforced by the drive
itself and isn't bypassed by libredrive firmware. The shortcut
fired for every libredrive-active drive, so v0.25.11 / v0.25.12
Barbie scans were stuck at E7017 instead of progressing to the
MOVIE scans were stuck at E7017 instead of progressing to the
real wall (no DK walks MKB v77).
- `Disc::do_handshake` now always routes through `do_handshake_cert`.
`Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker
@@ -67,7 +127,7 @@ No behavioural change — purely a rename pass.
- `read_volume_id_libredrive` deleted (~50 LOC).
The corollary: AACS resolution on HRL-burned drives + UHD discs now
fails honestly. Either cert auth succeeds (firmware-upload may or
fails honestly. Either cert auth succeeds (drive unlock may or
may not bypass the HRL — that's the new empirical question) and we
hit the actual DK wall (E7018 "No DK that walks this MKB" for v77+
UHD without a v77+ DK in keydb), or cert auth fails and we surface
@@ -193,7 +253,7 @@ a fast-fail firmware wedge state where every subsequent CDB returns
`ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB` (sense 0x05/0x24) until the
drive is physically power-cycled.
Live wedge event on rip1 2026-05-20 during a Barbie UHD scan
Live wedge event 2026-05-20 during a UHD disc scan
(KEYDB miss) confirmed the diagnosis and motivated this fix.
Defence-in-depth:
@@ -304,7 +364,7 @@ runs on the caller's thread. Communication between stages is via
bounded `crossbeam_channel` with a recycled buffer pool — no
allocations or memcpys in the steady-state hot loop.
**Throughput on the rip1 testbed (Civil_War UHD, 62 GiB ISO →
**Throughput on the test bed (a UHD disc, 62 GiB ISO →
`null://`, single-thread caller):**
| | MB/s |
@@ -454,8 +514,7 @@ in-tree consumers (autorip, the `freemkv` CLI) drove their own multipass
loops in 0.18 rounds 1-3; 0.18.1 lands the cleanup with the deprecated names
still alive for one minor-version window.
The 0.18 design notes are in
`(internal)/memory/0_18_redesign.md` (private) — this entry sticks to
The 0.18 design notes are kept privately — this entry sticks to
what changed at the public surface.
#### Flat verbs
@@ -735,7 +794,7 @@ recovery.
`READ_RECOVERY_TIMEOUT_MS = 60_000` (the v0.17.3 baseline; the kernel-
auto-retry pattern is now provided by the `/dev/sr0` fallback above).
### Empirical results (Dune Part Two UHD, BU40N direct SATA)
### Empirical results (a UHD disc, BU40N direct SATA)
- Pass 1: **94.6 MB recovered** (28% of formerly-bad data, 33 sr0 fallback
saves), **11 s of main-title content** restored. Patch completed all 47
@@ -863,7 +922,7 @@ bridges (notably the Initio INIC-1618L) that crash on MEDIUM ERROR retries.
- Patch instrumentation: counters for reads_ok/err, writes_ok/err, finished/unreadable.
- `as_encoded_bytes()` replaces `as_bytes()` for portable OsStr handling.
- Removed inline retry/reset from Drive::read — orchestration layer handles recovery.
- Removed all `(internal)` references from public code.
- Removed all internal references from public code.
## 0.13.26 (2026-04-27)
@@ -891,7 +950,7 @@ warnings` red on Linux CI that the Mac toolchain doesn't catch.
### Pre-commit gate uses CI's exact toolchain
`(internal)/scripts/precommit.sh` (new) runs `cargo +1.86 fmt
A new pre-commit script runs `cargo +1.86 fmt
--check`, `cargo +1.86 clippy -- -D warnings`, and `cargo +1.86 test
--tests` across all 5 freemkv crates. Mirrors each repo's
`.github/workflows/ci.yml` step-for-step. Use it as a pre-commit hook
@@ -933,7 +992,7 @@ Through the entire 0.13.x line, every CHECK CONDITION reply from the
drive (the standard way SCSI reports a sector failure) was being
collapsed into a synthetic `status=0xFF, sense_key=0` "transport
wedge" sentinel and the real sense data was thrown away. Live tracing
on the BU40N reading Dune 2 on 2026-04-27 confirmed it: the drive was
on the BU40N reading a UHD disc on 2026-04-27 confirmed it: the drive was
returning `host_status=0, driver_status=8, status=2, exec_elapsed_ms=1416`
on every bad sector — a clean CHECK CONDITION carrying full sense
data — and the library was misclassifying it as a wedge and bailing.
@@ -1005,7 +1064,7 @@ recovered or marked Unreadable. Calibration data
### Replace bisect-on-fail with hysteresis state machine (Block ↔ Single)
Live test on Dune 2 v0.13.21 showed bisect-on-fail recovered every
Live test on a UHD disc v0.13.21 showed bisect-on-fail recovered every
recoverable sector, but spent ~30 sec per damaged 60-block (paying a
~5 sec kernel timeout at every bisection level). Each level descended
log₂(60) ≈ 6 times on the failing branch.
@@ -1034,7 +1093,7 @@ Per-block cost on a 60-sector damaged block with 1 bad sector:
Plus inside a damaged cluster spanning many 60-blocks, hysteresis
pays the bpt=batch fail cost ONCE on entry; bisection paid it every
60 sectors. For Dune 2's ~1248-sector boundary cluster that's ~21
60 sectors. For the ~1248-sector boundary cluster that's ~21
fewer 5-sec waits = ~100 sec saved.
Telemetry: new `phase=mode_change` trace event with `from`, `to`,
@@ -1052,8 +1111,7 @@ synthetic BU40N-pattern reader; same 100% recovery expectation.
### Fix: Disc::copy bisect-on-fail (replaces skip-forward)
Empirical live-hardware testing on the LG BU40N (see
`(internal)/docs/audits/2026-04-26-test-plan-audit.md` and the
TEST_PLAN.md run log) revealed that the drive often **fails
the internal test-plan audit and run log) revealed that the drive often **fails
multi-sector READ commands** in damaged regions but **succeeds when
asked one sector at a time**. The old skip-forward strategy responded
to multi-sector failures by jumping up to 1 % of the disc forward,
@@ -1065,7 +1123,7 @@ half, retry each half, recurse down to single-sector reads. Sectors
the drive can read individually are recovered in Pass 1; only sectors
that fail at bpt=1 are marked NonTrimmed for the patch passes.
Empirical results on Dune 2 UHD on the BU40N:
Empirical results on a UHD disc on the BU40N:
- Old algorithm: 25 GB read in Pass 1, then ~6 GB skip-forwarded;
retry passes failed to recover most of the skipped zone.
- New algorithm: ~99 % of disc recovered in Pass 1; only the truly
@@ -1107,7 +1165,7 @@ LUN/BUS/HOST reset. `READ_RECOVERY_TIMEOUT_MS` (60 s) unchanged.
`ioctl(fd, SG_IO, &hdr)`. The old pattern abandoned slow-but-alive
commands faster than the drive could drain its internal queue,
deepening the BU40N wedge. Per the audit at
`(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`,
internal SCSI-architecture research,
no reference project (MakeMKV / sg_dd / ddrescue) does what we did —
all use sync blocking SG_IO with 8-60 s timeouts and let the kernel's
mid-layer (`scsi_eh.rst`) run ABORT TASK / LUN RESET / BUS RESET /
@@ -1234,7 +1292,7 @@ clean reads (Finished sectors) and freezes during skip-forward bad zones,
which made every previous version's UI look hung at the bad-zone boundary.
This was the v0.13.9 stall-guard origin bug.
Live trace from v0.13.14: Pass 1 hit a Dune 2 bad zone at 24 GB and
Live trace from v0.13.14: Pass 1 hit a bad zone at 24 GB and
appeared "stuck" for 14 minutes per autorip's UI (`bytes_good = 23.97 GB`
unchanged). Disc trace events showed `pos` actually advanced from 25.8 GB
to 70 GB during that window — Pass 1 was 83 % through the disc, marking
@@ -1282,7 +1340,7 @@ trace events were silently dropped by the default `libfreemkv=warn` rule.
### Telemetry: instrument the rip pipeline for in-flight diagnosis
v0.13.12 shipped Fix 1+2+4 + cross-platform parity but a live test on Dune 2
v0.13.12 shipped Fix 1+2+4 + cross-platform parity but a live test
showed Pass 1 sat for 14 minutes with `bytes_good=0` while the inner loop
appeared to iterate (the throttled `on_progress` log fired every 78s). The
async fd_recovery design at §7 said each `execute()` call should bound at
@@ -1326,7 +1384,7 @@ The v0.13.9 stall guard at `disc/mod.rs` exited Pass 1 early when
`bytes_good` was flat for `stall_secs` (default 120s). This violated the
ddrescue model: Pass 1 must sweep end-to-end, marking failed reads
NonTrimmed for Pass 2 retry. The guard caused Pass 1 to bail at 30% on
Dune 2 with 56 GB still NonTried, leaving Pass 2 nothing useful to do.
a UHD disc with 56 GB still NonTried, leaving Pass 2 nothing useful to do.
- Deleted the stall-guard state vars and the `if cur_good != ...
break 'outer;` block.
@@ -1381,7 +1439,7 @@ same observable recovery contract as Linux:
`PatchResult` now reports `blocks_attempted`, `blocks_read_ok`,
`blocks_read_failed`. Pass 2's "100 minutes recovered 0 bytes" mystery
(Dune 2) becomes diagnosable from these counters: distinguish "drive
(a UHD disc) becomes diagnosable from these counters: distinguish "drive
returned Ok but write/record dropped data" from "every read was Err for
the entire range" without instrumenting from outside the lib.
@@ -1410,7 +1468,7 @@ against the spawned close()). The intent was to escape the 60-s
blocking reopen.
The cost was too high: a single transient poll timeout permanently
killed the transport. Live test on Dune 2 (post-replug):
killed the transport. Live test on a UHD disc (post-replug):
- Pass 1 ran for **45 ms** then returned with 0 GB good and 80 GB
pending.
- The first SCSI READ timed out, fd went to -1, every subsequent
@@ -1443,7 +1501,7 @@ Sync bump for the autorip-side fix in 0.13.10 (Pass 1 batch reporting).
### Fix: Disc::copy silent stall + SgIoTransport reopen-after-timeout serialization
Two correlated fixes for a hang observed live on the LG BU40N during a
v0.13.8 rip of Dune: Part Two. At ~30 % progress through Pass 1
v0.13.8 rip of a UHD disc. At ~30 % progress through Pass 1
(disc → ISO), `bytes_good` froze for 10+ minutes with `errs=0`,
no error surfaced, drive not wedged.
@@ -2330,10 +2388,9 @@ Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full
- **Kernel transfer limit detection**: auto-detect `max_hw_sectors_kb` via sysfs, resolve sg→block device. Previously hardcoded to 510 sectors (1MB) which exceeded the 120KB kernel limit, causing all reads to error and fall back to 6KB reads at 4.8 MB/s. Now auto-tunes to 48 sectors (96KB) or whatever the device supports.
- **Result: 12.5 MB/s sustained, 23 MB/s peak** (was 4.8 MB/s)
### LibreDrive — full init pipeline
### Drive init pipeline
- **All 10 ARM handlers translated**: unlock, firmware upload (A: WRITE_BUFFER, B: MODE SELECT), calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
- **Cold boot firmware upload**: WRITE_BUFFER 1888B (A variant) or MODE SELECT 2496B (B variant) proven on hardware
- **Full drive init pipeline**: unlock, calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
- **Speed calibration**: 256+ disc surface probes, 64-entry speed table, triple SET_CD_SPEED
- **Platform trait locked down**: `pub(crate)`, 3 methods only (init, set_read_speed, is_ready)
- **Init guard**: prevents double-init, signature mismatch aborts early
@@ -2348,7 +2405,7 @@ Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full
### Profiles
- **206 profiles with full per-drive data**: ld_microcode (base64), all CDBs, speed tables, signatures
- **Automated pipeline**: `sdf_unpack --profiles` → profiles.json (no manual merging)
- **Automated pipeline**: profile import → profiles.json (no manual merging)
## 0.4.0 (2026-04-07)
+2 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.26.0"
version = "0.27.4"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
@@ -31,8 +31,7 @@ tracing = "0.1"
# Bounded MPSC channel with kernel-wakeup send_timeout. Used by `io::pipeline`
# so the halt-aware send/finish loops can BLOCK on consumer drain instead of
# polling — the 50 ms poll cadence of the previous mpsc-based impl capped mux
# throughput at ~1 MB/s (see (internal)/memory/
# feedback_send_with_halt_poll_throttle.md, 0.21.7).
# throughput at ~1 MB/s (0.21.7).
crossbeam-channel = "0.5"
# Persistent work-stealing thread pool for parallel AACS unit
# decryption. Per-call std::thread::scope spawned fresh OS threads
+1 -2
View File
@@ -1,6 +1,5 @@
# libfreemkv — local dev helper.
# Mirrors the cross-crate scripts in (internal)/scripts/test-all.sh
# but scoped to this single crate.
# Mirrors the workspace-wide CI checks but scoped to this single crate.
.PHONY: test build check ci clean
+4 -4
View File
@@ -85,10 +85,10 @@ All URLs require a `scheme://path` format. Bare paths are rejected.
// PES pipeline (frame-level) — input() returns Box<dyn FrameSource>,
// output() returns Box<dyn FrameSink>.
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
let input = libfreemkv::input("iso://Dune.iso", &opts)?; // IsoStream
let output = libfreemkv::output("mkv://Dune.mkv", &title)?; // MkvOutputStream
let output = libfreemkv::output("m2ts://Dune.m2ts", &title)?; // M2tsOutputStream
let output = libfreemkv::output("network://10.0.0.1:9000", &title)?; // NetworkOutputStream
let input = libfreemkv::input("iso://Movie.iso", &opts)?; // IsoStream
let output = libfreemkv::output("mkv://Movie.mkv", &title)?; // MkvOutputStream
let output = libfreemkv::output("m2ts://Movie.m2ts", &title)?; // M2tsOutputStream
let output = libfreemkv::output("network://192.0.2.10:9000", &title)?; // NetworkOutputStream
let output = libfreemkv::output("null://", &title)?; // NullOutputStream
```
+44 -23
View File
@@ -69,16 +69,29 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
// ── Content decryption ──────────────────────────────────────────────────────
/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits).
pub fn is_unit_encrypted(unit: &[u8]) -> bool {
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
///
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
/// across discs/players.
///
/// This is the single shared definition of "encrypted" for the whole ecosystem
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
/// service's validation gate all call THIS, so they always agree on what is
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
/// so the decrypt path never double-decrypts and there is no flag to clear.
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
}
/// Verify decrypted unit by checking TS sync bytes at expected offsets.
fn verify_ts(unit: &[u8]) -> bool {
// In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header
// then 188-byte TS packet, repeating every 192 bytes.
// Sync byte 0x47 should appear at offset 4, 196, 388, ...
/// Most TS packet positions in `unit` carry the `0x47` sync byte — i.e. the
/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes
/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body
/// scrambles all but the first (which lives in the clear 16-byte seed).
fn ts_syncs_intact(unit: &[u8]) -> bool {
let mut count = 0;
let mut offset = 4;
while offset < unit.len() {
@@ -87,11 +100,15 @@ fn verify_ts(unit: &[u8]) -> bool {
}
offset += TS_PACKET_LEN;
}
// Expect at least most packets to have sync bytes
let total = (unit.len() - 4) / TS_PACKET_LEN + 1;
count > total / 2
}
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
fn verify_ts(unit: &[u8]) -> bool {
ts_syncs_intact(unit)
}
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
/// Returns true if decryption succeeded (verified by TS sync bytes).
///
@@ -99,12 +116,14 @@ fn verify_ts(unit: &[u8]) -> bool {
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
/// 4. Clear encryption flag bits
///
/// 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 {
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
if !is_unit_encrypted(unit) {
if !is_aacs_scrambled(unit) {
return true; // not encrypted
}
@@ -124,16 +143,13 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
// Step 3: Decrypt bytes 16..6143 with AES-CBC
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Step 4: Clear encryption flag
unit[0] &= !0xC0;
// Verify
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
verify_ts(unit)
}
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
if !is_unit_encrypted(unit) {
if !is_aacs_scrambled(unit) {
return Some(0);
}
@@ -173,7 +189,7 @@ pub fn decrypt_unit_full(
unit_key: &[u8; 16],
read_data_key: Option<&[u8; 16]>,
) -> bool {
if !is_unit_encrypted(unit) {
if !is_aacs_scrambled(unit) {
return true;
}
if let Some(rdk) = read_data_key {
@@ -200,10 +216,15 @@ mod tests {
#[test]
fn test_decrypt_unit_unencrypted() {
// Unit with 0xC0 bits clear should pass through unchanged
// A clear unit (TS syncs intact) is not scrambled → passes through.
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
unit[0] = 0x00; // not encrypted
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
unit[off] = TS_SYNC;
off += TS_PACKET_LEN;
}
let key = [0u8; 16];
assert!(!is_aacs_scrambled(&unit));
assert!(decrypt_unit(&mut unit, &key));
}
@@ -253,8 +274,8 @@ mod tests {
plain[offset] = TS_SYNC;
offset += TS_PACKET_LEN;
}
// Set encryption flag
plain[0] |= 0xC0;
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
let header: [u8; 16] = plain[..16].try_into().unwrap();
@@ -281,9 +302,9 @@ mod tests {
// Now plain contains encrypted data. Decrypt it.
let mut unit = plain;
assert!(is_unit_encrypted(&unit));
assert!(is_aacs_scrambled(&unit));
assert!(decrypt_unit(&mut unit, &unit_key));
assert!(!is_unit_encrypted(&unit)); // flag should be cleared
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
// Verify TS sync bytes
let mut count = 0;
+133 -14
View File
@@ -117,10 +117,19 @@ impl KeyDb {
continue;
}
// Device Key
// Device Key.
// Two shapes are accepted:
// 1. Positioned DK: `| DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...`
// → loaded into `device_keys` (deterministic tree walk via `calc_pk_from_dk`).
// 2. Orphan DK: `| DK | DEVICE_KEY 0x...` with no position fields.
// → loaded into `processing_keys` (brute walker / terminal validation).
// Per AACS spec a "PK" IS a DK at terminal position, so both row types
// are DKs in the unified model; only the metadata differs.
if line.starts_with("| DK") {
if let Some(dk) = Self::parse_device_key(line) {
db.device_keys.push(dk);
} else if let Some(key) = Self::parse_orphan_dk(line) {
db.processing_keys.push(key);
}
continue;
}
@@ -195,8 +204,53 @@ impl KeyDb {
.or_else(|| self.disc_entries.get(&hash))
}
// ── Parsers ─────────────────────────────────────────────────────────────
/// Iterate every disc entry. Used by Path 3 (scan for matching VID).
pub fn iter_disc_entries(&self) -> impl Iterator<Item = &DiscEntry> {
self.disc_entries.values()
}
}
// ── KeyProvider impl ──────────────────────────────────────────────────────────
//
// Lets `KeyDb` plug into `resolve_keys` via the trait. Cloning happens in the
// bulk methods because the trait returns owned `Vec`s (so HTTP-backed providers
// don't need to retain state across calls).
impl super::provider::KeyProvider for KeyDb {
fn device_keys(&self) -> Vec<DeviceKey> {
self.device_keys.clone()
}
fn processing_keys(&self) -> Vec<[u8; 16]> {
self.processing_keys.clone()
}
fn media_keys(&self) -> Vec<[u8; 16]> {
// Every per-disc Media Key in the db. The resolver dedups; MKs are
// MKB-scoped so the same value recurs across a pressing's discs.
self.iter_disc_entries()
.filter_map(|e| e.media_key)
.collect()
}
fn host_certs(&self) -> Vec<HostCert> {
self.host_certs.clone()
}
fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
let mut hex = String::with_capacity(42);
hex.push_str("0x");
for b in disc_hash {
hex.push_str(&format!("{b:02X}"));
}
self.find_disc(&hex).cloned()
}
fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
self.iter_disc_entries()
.find(|e| matches!(e.disc_id, Some(id) if &id == volume_id))
.cloned()
}
}
// ── Private parsers (re-open the inherent impl) ─────────────────────────────
impl KeyDb {
fn parse_device_key(line: &str) -> Option<DeviceKey> {
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
@@ -229,6 +283,30 @@ impl KeyDb {
None
}
/// Parse an orphan DK row: a `| DK |` line carrying only the
/// `DEVICE_KEY` field (no position metadata). The key is then
/// treated like a terminal/unpositioned label by the resolver
/// (Path 2's brute walker). Returns `None` if the line carries
/// any position field — those are positioned DKs and parsed by
/// [`Self::parse_device_key`] instead.
fn parse_orphan_dk(line: &str) -> Option<[u8; 16]> {
if line.contains("DEVICE_NODE")
|| line.contains("KEY_UV")
|| line.contains("KEY_U_MASK_SHIFT")
{
return None;
}
let key_str = line
.split("DEVICE_KEY")
.nth(1)?
.split('|')
.next()?
.split(';')
.next()?
.trim();
parse_hex16(key_str)
}
fn parse_host_cert(line: &str) -> Option<HostCert> {
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
let priv_str = line
@@ -377,9 +455,9 @@ mod tests {
#[test]
fn test_parse_disc_entry() {
let line = r#"***REMOVED*** = DUNE_PART_TWO (Dune: Part Two) | D | 2024-04-02 | M | ***REMOVED*** | I | ***REMOVED*** | V | ***REMOVED*** | U | 1-***REMOVED*** ; MKBv77"#;
let line = r#"0x000102030405060708090A0B0C0D0E0F10111213 = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x000102030405060708090A0B0C0D0E0F | I | 0x101112131415161718191A1B1C1D1E1F | V | 0x202122232425262728292A2B2C2D2E2F | U | 1-0x303132333435363738393A3B3C3D3E3F ; MKBv77"#;
let entry = KeyDb::parse_disc_entry(line).unwrap();
assert_eq!(entry.title, "Dune: Part Two");
assert_eq!(entry.title, "Sample Film");
assert!(entry.media_key.is_some());
assert!(entry.vuk.is_some());
assert_eq!(entry.unit_keys.len(), 1);
@@ -388,17 +466,58 @@ mod tests {
#[test]
fn test_parse_device_key() {
let line = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
let dk = KeyDb::parse_device_key(line).unwrap();
assert_eq!(dk.node, 0x0800);
assert_eq!(dk.u_mask_shift, 0x17);
}
#[test]
fn test_orphan_dk_row_loads_into_processing_keys() {
// `| DK |` row without position fields = an orphan DK. Per the
// unified model the resolver treats it like a terminal/PK
// candidate: it lands in `processing_keys` and the brute walker
// handles it.
let cfg = r#"
| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA ; orphan, no position fields
| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
| PK | 0xCAFEBABE0001020304050607080900BB ; legacy PK row still works
"#;
let db = KeyDb::parse(cfg);
assert_eq!(
db.device_keys.len(),
1,
"positioned DK row should land in device_keys"
);
// Orphan DK + legacy PK row both end up in processing_keys.
assert_eq!(
db.processing_keys.len(),
2,
"orphan DK row + legacy PK row both belong in processing_keys"
);
assert_eq!(db.processing_keys[0][..4], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(db.processing_keys[1][..4], [0xCA, 0xFE, 0xBA, 0xBE]);
}
#[test]
fn test_parse_orphan_dk_rejects_lines_with_position_fields() {
// The parser must NOT pick up a positioned DK row as an orphan
// (that would double-count). parse_orphan_dk explicitly checks.
let positioned = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
assert!(
KeyDb::parse_orphan_dk(positioned).is_none(),
"positioned DK must not match orphan parser"
);
let orphan = "| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA";
let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse");
assert_eq!(key[..4], [0xDE, 0xAD, 0xBE, 0xEF]);
}
#[test]
fn test_parse_host_cert() {
let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked";
let line = "| HC | HOST_PRIV_KEY 0xDEADBEEF000102030405060708090A0B0C0D0E0F | HOST_CERT 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B ; Revoked";
let hc = KeyDb::parse_host_cert(line).unwrap();
assert_eq!(hc.private_key[0], 0x90);
assert_eq!(hc.private_key[0], 0xDE);
assert_eq!(hc.certificate.len(), 92);
}
@@ -416,15 +535,15 @@ mod tests {
assert!(!db.host_certs.is_empty());
assert!(db.disc_entries.len() > 170000);
// Look up Dune: Part Two
let dune = db
// Look up any disc entry carrying a full key set.
let entry = db
.disc_entries
.values()
.find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some())
.expect("Dune: Part Two not found");
assert!(dune.media_key.is_some());
assert!(dune.vuk.is_some());
assert!(!dune.unit_keys.is_empty());
.find(|e| e.vuk.is_some() && e.media_key.is_some() && !e.unit_keys.is_empty())
.expect("no disc entry with a full key set");
assert!(entry.media_key.is_some());
assert!(entry.vuk.is_some());
assert!(!entry.unit_keys.is_empty());
eprintln!(
"Parsed {} disc entries, {} DK, {} PK",
+851 -215
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -17,21 +17,24 @@ pub mod decrypt;
pub mod handshake;
pub mod keydb;
pub mod keys;
pub mod provider;
pub mod variants;
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
is_unit_encrypted,
is_aacs_scrambled,
};
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
resolve_keys_v2, resolve_keys_v21,
mkb_content_len, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
};
pub use provider::KeyProvider;
pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
+122
View File
@@ -0,0 +1,122 @@
//! Key source abstraction for the AACS resolve chain.
//!
//! libfreemkv keeps all crypto (AES-G primitives, SD-tree walking,
//! validation, MK/VUK/TK derivation) but accepts key material from
//! arbitrary backends via [`KeyProvider`].
//!
//! Methods come in two flavors:
//!
//! - **Bulk material** ([`device_keys`], [`processing_keys`],
//! [`host_certs`]) — the resolver unions results across all
//! providers and tries each candidate.
//! - **Disc-keyed lookup** ([`lookup_disc_by_hash`],
//! [`lookup_disc_by_vid`]) — the resolver short-circuits on the
//! first hit, so providers are queried in array order with
//! fastest/closest first.
//!
//! Default impls return empty / `None` so backends only override
//! the methods they actually support — an external key service might
//! implement only `lookup_disc_by_hash`, while a local file might
//! implement all five.
//!
//! Calls may block (disk I/O, network round-trips). The resolver
//! invokes each method at most a handful of times per scan; for
//! per-disc memoization, implementations should cache internally.
//!
//! [`device_keys`]: KeyProvider::device_keys
//! [`processing_keys`]: KeyProvider::processing_keys
//! [`host_certs`]: KeyProvider::host_certs
//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash
//! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid
use super::keydb::{DeviceKey, DiscEntry, HostCert};
/// Source of AACS key material.
///
/// Implementors return raw material only — the resolver in
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
/// MK→VUK→TK derivation). See module docs for method semantics.
pub trait KeyProvider: Send + Sync {
/// Device keys (top-of-tree, walked by the resolver).
fn device_keys(&self) -> Vec<DeviceKey> {
Vec::new()
}
/// Processing keys — terminal PKs or walk-input PKs. The
/// resolver tries each as a terminal first (cheap validate).
fn processing_keys(&self) -> Vec<[u8; 16]> {
Vec::new()
}
/// Every Media Key this provider holds, regardless of which disc it was
/// filed under. An MK is MKB-scoped (shared across a pressing/MKB-family),
/// so the resolver can verify each against the disc's MKB (`km_verifies`)
/// and resolve a disc whose own hash/VID isn't directly keyed.
fn media_keys(&self) -> Vec<[u8; 16]> {
Vec::new()
}
/// AACS host certificates (with their private keys) for drive
/// authentication. Multiple in case some are revoked.
fn host_certs(&self) -> Vec<HostCert> {
Vec::new()
}
/// Direct per-disc lookup by SHA-1 of `Unit_Key_RO.inf`. Returns
/// `Some(entry)` if this provider has pre-computed material for
/// the disc (paths 4 and 5). Short-circuits the resolver.
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
None
}
/// Lookup by Volume ID (path 3 — pre-computed MK + matching
/// VID). Short-circuits the resolver on hit.
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
None
}
}
/// Resolver-side helpers that aggregate across a provider array.
///
/// The resolver consumes `&[&dyn KeyProvider]` directly; these
/// helpers wrap the union-vs-short-circuit policy per method.
pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]);
impl Providers<'_> {
/// Union — gather DKs from every provider.
pub fn device_keys(&self) -> Vec<DeviceKey> {
self.0.iter().flat_map(|p| p.device_keys()).collect()
}
/// Union — gather PKs from every provider.
pub fn processing_keys(&self) -> Vec<[u8; 16]> {
self.0.iter().flat_map(|p| p.processing_keys()).collect()
}
/// Union of distinct Media Keys across every provider, for the MK-pool
/// brute (`km_verifies` against the disc's MKB).
pub fn media_keys(&self) -> Vec<[u8; 16]> {
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.media_keys()).collect();
v.sort_unstable();
v.dedup();
v
}
/// Union — gather host certs from every provider. Not yet wired into
/// the SCSI handshake (which still reads `KeyDb.host_certs` directly);
/// kept here so a provider-aware handshake refactor is a drop-in.
#[allow(dead_code)]
pub fn host_certs(&self) -> Vec<HostCert> {
self.0.iter().flat_map(|p| p.host_certs()).collect()
}
/// Short-circuit — query providers in array order, first hit wins.
pub fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
self.0.iter().find_map(|p| p.lookup_disc_by_hash(disc_hash))
}
/// Short-circuit — query providers in array order, first hit wins.
pub fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
}
}
+11 -10
View File
@@ -185,12 +185,13 @@ pub fn decrypt_sectors(
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
let nunits = chunks.len();
// Per-unit decrypt closure. The is_unit_encrypted check is
// a byte-0 heuristic; on a misfire we snapshot+restore via
// the original bytes so non-m2ts (e.g. MPLS/CLPI nav files)
// survive. See test `nav_file_unit_survives_decrypt_attempt`.
// Per-unit decrypt closure. The is_aacs_scrambled check reads the
// raw TS syncs; a non-m2ts unit (e.g. MPLS/CLPI nav file) can look
// scrambled and trigger a decrypt attempt, so on a verify miss we
// snapshot+restore the original bytes so it survives. See test
// `nav_file_unit_survives_decrypt_attempt`.
let decrypt_one = |chunk: &mut [u8]| {
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
if chunk.len() == unit_len && aacs::is_aacs_scrambled(chunk) {
let original: Vec<u8> = chunk.to_vec();
if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) {
chunk.copy_from_slice(&original);
@@ -232,11 +233,11 @@ pub fn decrypt_sectors(
mod tests {
use super::*;
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose
/// first byte has the top 2 bits set (here: the ASCII letter 'M' that
/// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`,
/// gets AES-decrypted with the unit key, fails the TS-sync verification,
/// and must be restored to its original bytes — not left scrambled.
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
/// under `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails
/// the TS-sync verification, and must be restored to its original bytes —
/// not left scrambled.
#[test]
fn nav_file_unit_survives_decrypt_attempt() {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
+133 -6
View File
@@ -236,7 +236,7 @@ impl Disc {
// optical drives) responds by entering a fast-fail firmware
// wedge state where every subsequent CDB returns
// ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB (sense 05/24) until
// power-cycled. Hit live on rip1 2026-05-20 during a Barbie
// power-cycled. Hit live on rip1 2026-05-20 during a MOVIE
// UHD scan: KEYDB miss → 16 cert attempts in a tight loop →
// wedge → forced host reboot + drive disconnect to recover.
//
@@ -424,11 +424,12 @@ impl Disc {
Some(s) => s,
None => return Err(miss_error),
};
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
let aacs_ctx = aacs::ResolveContext {
unit_key_ro: &uk_ro_data,
content_cert: cc_data.as_deref(),
volume_id: &volume_id,
keydb: &keydb,
providers,
mkb: mkb_data.as_deref(),
};
let mut ctx = DrmContext {
@@ -453,10 +454,11 @@ impl Disc {
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
key_source: match resolved.key_source {
1 => KeySource::KeyDb,
2 => KeySource::KeyDbDerived,
3 => KeySource::ProcessingKey,
4 => KeySource::DeviceKey,
1 => KeySource::DeviceKey,
2 => KeySource::ProcessingKey,
3 => KeySource::KeyDbDerived,
4 => KeySource::KeyDb,
5 => KeySource::KeyDbUnitKeys,
_ => KeySource::KeyDb,
},
vuk: resolved.vuk,
@@ -465,4 +467,129 @@ impl Disc {
volume_id,
})
}
/// Resolve encryption from a caller-supplied Unit Key (the external key service
/// path). No keydb, no derivation: read `Unit_Key_RO.inf` for the disc
/// hash + version/bus-encryption flags, then use `unit_key` directly as
/// CPS unit 1's decryption key. The handshake (if any) still supplies the
/// volume ID and AACS 2.0 read-data key for bus decryption.
pub(super) fn resolve_encryption_static(
udf_fs: &udf::UdfFs,
reader: &mut dyn SectorSource,
unit_key: [u8; 16],
handshake: Option<&HandshakeResult>,
) -> Result<AacsState> {
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"))
.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"))
.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,
};
let mkb_ver = udf_fs
.read_file(reader, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
.ok()
.as_deref()
.and_then(aacs::mkb_version);
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_external_uk",
disc_hash = %aacs::disc_hash_hex(&dh),
version,
bus_encryption,
"using caller-supplied unit key"
);
Ok(AacsState {
version,
bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&dh),
key_source: KeySource::ExternalUk,
vuk: None,
unit_keys: vec![(1, unit_key)],
read_data_key: handshake.and_then(|h| h.read_data_key),
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
})
}
/// Build a keys-free AACS state that carries only the Volume ID (+ version
/// metadata), for callers that resolve Unit Keys out-of-band and have
/// disabled the local keydb. The VID is on-disc content read during the
/// handshake; preserving it here lets the out-of-band path use it. No keys
/// are present (`unit_keys` empty, `vuk` None), so the disc reports as
/// "encrypted, no keys" until the caller re-scans with a resolved Unit Key.
pub(super) fn resolve_vid_only(
udf_fs: &udf::UdfFs,
reader: &mut dyn SectorSource,
handshake: Option<&HandshakeResult>,
) -> Result<AacsState> {
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"))
.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"))
.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,
};
// MKB_RO is the correctly-sized copy; avoid reading the padded RW region.
let mkb_ver = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.ok()
.as_deref()
.and_then(aacs::mkb_version);
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_vid_only",
disc_hash = %aacs::disc_hash_hex(&dh),
version,
bus_encryption,
has_vid = handshake.is_some(),
"keydb disabled — carrying VID only, keys resolved out-of-band"
);
Ok(AacsState {
version,
bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&dh),
key_source: KeySource::ExternalUk,
vuk: None,
unit_keys: vec![],
read_data_key: handshake.and_then(|h| h.read_data_key),
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
})
}
}
+248
View File
@@ -130,6 +130,21 @@ pub struct Mapfile {
/// Wall-clock timestamp of the last successful `write_to_disk` (or
/// the moment the mapfile was constructed, whichever is later).
last_flushed: Instant,
/// AACS Volume ID (16 bytes) for the disc, persisted as a
/// `# freemkv-vid:` comment header so it survives to deferred-mux /
/// resume without altering the ISO payload or breaking ddrescue
/// data-line parsing. `None` for unencrypted / non-AACS discs.
///
/// MUTUALLY EXCLUSIVE with `unit_keys`: a disc whose keys were resolved
/// persists the keys (`unit_keys`) and NOT the VID — the keys are the final
/// answer, so deferred-mux/resume decrypts directly with no key service. A
/// disc that did NOT resolve persists only the VID, the retry-able "still
/// need a key" marker (a future mux can re-ask the key service with it).
vid: Option<[u8; 16]>,
/// Decrypted AACS unit keys `(CPS unit, key)`, persisted as `# freemkv-uk:`
/// comment headers when the disc was successfully keyed. Mutually exclusive
/// with `vid` (see above). Empty when unresolved.
unit_keys: Vec<(u32, [u8; 16])>,
}
impl Mapfile {
@@ -154,6 +169,8 @@ impl Mapfile {
},
dirty: false,
last_flushed: Instant::now(),
vid: None,
unit_keys: Vec::new(),
};
// Eager initial persist so a resume can pick this up even if
// `record()` is never called.
@@ -168,6 +185,8 @@ impl Mapfile {
let mut entries = Vec::new();
let mut saw_current_line = false;
let mut version = String::from("unknown");
let mut vid: Option<[u8; 16]> = None;
let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new();
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
@@ -178,6 +197,17 @@ impl Mapfile {
if let Some(v) = rest.strip_prefix("Rescue Logfile. Created by ") {
version = v.to_string();
}
if let Some(hex) = rest.strip_prefix("freemkv-vid:") {
// Best-effort: a malformed or short VID comment is
// ignored rather than failing the whole load.
vid = parse_vid_hex(hex.trim());
}
if let Some(uk) = rest.strip_prefix("freemkv-uk:") {
// `<cps>:<32hex>`. Best-effort: a malformed line is skipped.
if let Some(entry) = parse_uk_line(uk.trim()) {
unit_keys.push(entry);
}
}
continue;
}
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
@@ -227,6 +257,8 @@ impl Mapfile {
stats,
dirty: false,
last_flushed: Instant::now(),
vid,
unit_keys,
})
}
@@ -316,6 +348,41 @@ impl Mapfile {
Ok(())
}
/// Record the disc's 16-byte AACS Volume ID so it persists in the
/// mapfile's comment header. Marks the mapfile dirty; the next
/// `flush()` / `Drop` writes the `# freemkv-vid:` line. Does not
/// touch the ISO payload or the ddrescue data lines.
pub fn set_vid(&mut self, vid: [u8; 16]) {
self.vid = Some(vid);
self.dirty = true;
}
/// The disc's AACS Volume ID, if one was set or parsed from a
/// `# freemkv-vid:` comment on load. `None` for unencrypted /
/// non-AACS discs.
pub fn vid(&self) -> Option<[u8; 16]> {
self.vid
}
/// Record the disc's decrypted AACS unit keys so they persist in the
/// mapfile header (`# freemkv-uk:` lines). The KEYED state: a deferred-mux /
/// resume decrypts directly from these with no key-service round-trip.
/// Setting keys clears any VID — the mapfile holds keys XOR VID, never both
/// (keys are the final answer; VID is only the "still unresolved" marker).
pub fn set_unit_keys(&mut self, keys: &[(u32, [u8; 16])]) {
self.unit_keys = keys.to_vec();
if !self.unit_keys.is_empty() {
self.vid = None;
}
self.dirty = true;
}
/// The disc's decrypted AACS unit keys, if the disc was keyed (parsed from
/// `# freemkv-uk:` comments on load). Empty = unresolved (check `vid()`).
pub fn unit_keys(&self) -> &[(u32, [u8; 16])] {
&self.unit_keys
}
pub fn entries(&self) -> &[MapEntry] {
&self.entries
}
@@ -388,6 +455,30 @@ impl Mapfile {
let file = std::fs::File::create(&tmp)?;
let mut w = std::io::BufWriter::new(file);
writeln!(w, "# Rescue Logfile. Created by {}", self.version)?;
// VID comment lives in the header block. ddrescue treats any
// `#`-prefixed line as a comment, so this round-trips through
// our `load()` without affecting the `pos size status` data
// parser. 16 bytes → 32 lowercase hex chars.
// KEYS XOR VID: a keyed disc persists its unit keys (the final
// answer — deferred-mux decrypts directly); an unresolved disc
// persists only the VID (the retry marker, so a future mux can
// re-ask the key service). Never both.
use std::fmt::Write as _;
if !self.unit_keys.is_empty() {
for (cps, key) in &self.unit_keys {
let mut hex = String::with_capacity(32);
for b in key {
let _ = write!(hex, "{b:02x}");
}
writeln!(w, "# freemkv-uk: {cps}:{hex}")?;
}
} else if let Some(vid) = self.vid {
let mut hex = String::with_capacity(32);
for b in vid {
let _ = write!(hex, "{b:02x}");
}
writeln!(w, "# freemkv-vid: {hex}")?;
}
writeln!(w, "# Current pos / status / pass / pass_time")?;
writeln!(w, "0x000000000 ? 1 0")?;
writeln!(w, "# pos size status")?;
@@ -417,6 +508,31 @@ impl Drop for Mapfile {
}
}
/// Parse a 32-char lowercase/uppercase hex string into a 16-byte VID.
/// Returns `None` on any malformation (wrong length, non-hex) — the
/// 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);
if s.len() != 32 {
return None;
}
let mut out = [0u8; 16];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
}
Some(out)
}
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
/// `None` on any malformation so a corrupt line is ignored, never fatal.
fn parse_uk_line(s: &str) -> Option<(u32, [u8; 16])> {
let (cps, hex) = s.split_once(':')?;
let cps: u32 = cps.trim().parse().ok()?;
let key = parse_vid_hex(hex.trim())?; // 32-hex → [u8; 16], shared parser
Some((cps, key))
}
fn parse_hex(s: &str) -> io::Result<u64> {
let s = s.strip_prefix("0x").unwrap_or(s);
u64::from_str_radix(s, 16).map_err(|_| {
@@ -583,6 +699,138 @@ mod tests {
let _ = std::fs::remove_file(&p);
}
#[test]
fn unit_keys_round_trip_and_are_mutually_exclusive_with_vid() {
let p = tmpfile("uk_round_trips");
let _ = std::fs::remove_file(&p);
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
mf.record(0, 500, SectorStatus::Finished).unwrap();
// Set a VID first, then unit keys: keys must WIN and clear the VID.
mf.set_vid([0xAA; 16]);
let keys: Vec<(u32, [u8; 16])> = vec![
(
0,
[
0x57, 0x60, 0xcc, 0x83, 0x3d, 0x86, 0x0e, 0x48, 0x92, 0x1f, 0x88, 0x16, 0xe1,
0x35, 0x9b, 0xad,
],
),
(1, [0x11; 16]),
];
mf.set_unit_keys(&keys);
assert_eq!(
mf.vid(),
None,
"set_unit_keys must clear vid (keys XOR vid)"
);
mf.flush().unwrap();
let text = std::fs::read_to_string(&p).unwrap();
assert!(
text.contains("# freemkv-uk: 0:5760cc833d860e48921f8816e1359bad"),
"uk comment format mismatch: {text}"
);
assert!(
text.contains("# freemkv-uk: 1:11111111111111111111111111111111"),
"second uk missing: {text}"
);
assert!(
!text.contains("# freemkv-vid:"),
"VID must NOT be written when keys are present: {text}"
);
// load() recovers the unit keys (and no VID).
let loaded = Mapfile::load(&p).unwrap();
assert_eq!(loaded.unit_keys(), keys.as_slice());
assert_eq!(loaded.vid(), None);
assert_eq!(loaded.entries(), mf.entries());
// VID-only path (no keys) still persists the VID as the retry marker.
let p2 = tmpfile("uk_vid_only");
let _ = std::fs::remove_file(&p2);
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
mf2.set_vid([0xBB; 16]);
mf2.flush().unwrap();
let loaded2 = Mapfile::load(&p2).unwrap();
assert_eq!(loaded2.vid(), Some([0xBB; 16]));
assert!(loaded2.unit_keys().is_empty());
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(&p2);
}
#[test]
fn vid_round_trips_and_data_lines_unaffected() {
let p = tmpfile("vid_round_trips");
let _ = std::fs::remove_file(&p);
// Build a mapfile with some data ranges, set a VID, persist.
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
mf.record(100, 200, SectorStatus::Finished).unwrap();
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
mf.record(700, 50, SectorStatus::NonTrimmed).unwrap();
let vid: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
mf.set_vid(vid);
mf.flush().unwrap();
// The saved file must contain the VID comment in lowercase hex.
let text = std::fs::read_to_string(&p).unwrap();
assert!(
text.contains("# freemkv-vid:"),
"saved mapfile missing VID comment: {text}"
);
assert!(
text.contains("# freemkv-vid: 00112233445566778899aabbccddeeff"),
"VID comment format mismatch: {text}"
);
// load() recovers the VID and the identical data ranges.
let loaded = Mapfile::load(&p).unwrap();
assert_eq!(loaded.vid(), Some(vid));
assert_eq!(loaded.entries(), mf.entries());
// A mapfile WITHOUT the VID comment must parse the same +/-/?
// data ranges as the one WITH it (comment ignored by parser).
let p2 = tmpfile("vid_round_trips_novid");
let _ = std::fs::remove_file(&p2);
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
mf2.record(100, 200, SectorStatus::Finished).unwrap();
mf2.record(500, 100, SectorStatus::Unreadable).unwrap();
mf2.record(700, 50, SectorStatus::NonTrimmed).unwrap();
mf2.flush().unwrap();
let loaded_novid = Mapfile::load(&p2).unwrap();
assert_eq!(loaded_novid.vid(), None);
assert_eq!(loaded_novid.entries(), loaded.entries());
// Malformed VID comments must not error the load (treated absent).
let mut bad = text.replace("00112233445566778899aabbccddeeff", "zzzz");
let pbad = tmpfile("vid_round_trips_bad");
let _ = std::fs::remove_file(&pbad);
std::fs::write(&pbad, &bad).unwrap();
let loaded_bad = Mapfile::load(&pbad).unwrap();
assert_eq!(loaded_bad.vid(), None);
assert_eq!(loaded_bad.entries(), loaded.entries());
// A load->save cycle preserves the VID (the patch-pass path).
bad.clear();
let resaved = tmpfile("vid_round_trips_resave");
let _ = std::fs::remove_file(&resaved);
let mut reloaded = Mapfile::load(&p).unwrap();
// Repoint at a fresh path and flush; mark dirty via a no-op record.
reloaded.path = resaved.clone();
reloaded.dirty = true;
reloaded.flush().unwrap();
let again = Mapfile::load(&resaved).unwrap();
assert_eq!(again.vid(), Some(vid));
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(&p2);
let _ = std::fs::remove_file(&pbad);
let _ = std::fs::remove_file(&resaved);
}
#[test]
fn stats_consistent_after_split_record() {
let p = tmpfile("stats_consistent_after_split");
+380 -18
View File
@@ -883,8 +883,10 @@ pub struct AacsState {
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeySource,
/// Volume Unique Key (16 bytes)
pub vuk: [u8; 16],
/// Volume Unique Key (16 bytes). `None` when keys were resolved
/// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
/// pre-decrypted unit keys without a VUK to derive them from.
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
@@ -893,26 +895,37 @@ pub struct AacsState {
pub volume_id: [u8; 16],
}
/// How AACS keys were resolved.
/// How AACS keys were resolved. Variants are ordered root-of-trust →
/// per-disc-leaf, matching the resolver's path-try order: the resolver
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys,
/// Unit key supplied directly by the caller (the external Unit Key path).
/// No keydb, no derivation — `AacsState::vuk` is `None`.
ExternalUk,
}
impl KeySource {
pub fn name(&self) -> &'static str {
match self {
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::DeviceKey => "MKB + device key",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
KeySource::ExternalUk => "external UK",
}
}
}
@@ -932,11 +945,27 @@ pub struct ScanOptions {
/// Path to KEYDB.cfg for AACS key lookup.
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
pub keydb_path: Option<std::path::PathBuf>,
/// Caller-supplied Unit Key — an alternative to keydb lookup. When set,
/// libfreemkv skips keydb lookup and all derivation and uses this key
/// directly to decrypt; it takes precedence over `keydb_path`. The caller
/// obtains the key however it likes; libfreemkv stays free of any network
/// dependency.
pub unit_key: Option<[u8; 16]>,
/// Disable KEYDB entirely: skip both the explicit `keydb_path` and the
/// standard-location search, so no keydb is loaded for this scan. A caller
/// that resolves keys out-of-band (e.g. a remote key service) sets this so
/// a keydb that merely happens to sit in a default location does not shadow
/// the out-of-band path. `unit_key` still takes precedence over everything.
pub disable_keydb: bool,
}
impl ScanOptions {
/// Resolve KEYDB path: explicit path first, then standard locations.
/// Returns `None` when `disable_keydb` is set — no keydb is consulted.
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
if self.disable_keydb {
return None;
}
if let Some(p) = &self.keydb_path {
if p.exists() {
return Some(p.clone());
@@ -961,9 +990,9 @@ impl ScanOptions {
/// Quick disc identification — name, format, capacity. No title/stream parsing.
#[derive(Debug)]
pub struct DiscId {
/// UDF Volume Identifier (always present, e.g. "V_FOR_VENDETTA")
/// UDF Volume Identifier (always present, e.g. "SAMPLE_FILM")
pub volume_id: String,
/// Disc title from META/DL/bdmt_eng.xml (e.g. "V for Vendetta")
/// Disc title from META/DL/bdmt_eng.xml (e.g. "Sample Film")
pub meta_title: Option<String>,
/// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
pub format: DiscFormat,
@@ -1131,6 +1160,60 @@ impl Disc {
Self::scan_with(reader, capacity, None, None, opts, udf_fs)
}
/// 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 scan with
/// `ScanOptions { unit_key: Some(uk), .. }`. libfreemkv never makes a
/// network call.
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>)> {
let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)
.map_err(|_| Error::AacsNoKeys)?;
let udf_fs = udf::read_filesystem(&mut reader)?;
let inf = udf_fs
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
// so reading either ships up to ~124 MiB of nothing — trim to the
// record stream so callers send/store a few MB, not 128 MiB.
let mut mkb = udf_fs
.read_file(&mut reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&mkb);
if n > 0 && n < mkb.len() {
mkb.truncate(n);
}
Ok((inf, mkb))
}
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
/// out-of-band Unit Key path fetches the disc's key files from the drive,
/// resolves a key from them however it likes, then scans with
/// `ScanOptions { unit_key: Some(uk), .. }`. 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>)> {
let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
let inf = udf_fs
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
// so reading either ships up to ~124 MiB of nothing — trim to the
// record stream so callers send/store a few MB, not 128 MiB.
let mut mkb = udf_fs
.read_file(&mut reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&mkb);
if n > 0 && n < mkb.len() {
mkb.truncate(n);
}
Ok((inf, mkb))
}
/// Core scan pipeline — works with any SectorSource.
///
/// `handshake_error` is plumbed from `do_handshake` so failures
@@ -1150,7 +1233,25 @@ impl Disc {
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let (aacs, aacs_error) = if encrypted {
let (aacs, aacs_error) = if !encrypted {
(None, None)
} else if let Some(unit_key) = opts.unit_key {
// Second key source: caller supplied the Unit Key directly
// (external Unit Key). Skip keydb entirely.
match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else if opts.disable_keydb {
// Keydb disabled: keys are resolved out-of-band. Still capture the
// VID (read during the handshake) so the out-of-band path has it;
// carry no keys (disc reports "encrypted, no keys" until re-scanned
// with a resolved Unit Key).
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else {
match opts.resolve_keydb() {
Some(keydb_path) => {
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
@@ -1198,8 +1299,6 @@ impl Disc {
(None, Some(final_err))
}
}
} else {
(None, None)
};
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
@@ -1377,11 +1476,40 @@ impl Disc {
}
}
/// A decryption key handed to libfreemkv by the caller.
///
/// libfreemkv is **lookup-free**: it never reads a keydb, never talks to a key
/// server, never searches paths. The application resolves a key from whatever
/// source it likes (a local keydb, an online key service, a mapfile cache) and
/// hands it in here; libfreemkv uses it to decrypt, deriving any remaining
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
///
/// `#[non_exhaustive]`: AACS is a derivation chain
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Today only the final
/// [`Key::Unit`] entry point is wired; the higher entry points
/// (device / media / volume key) land as the derivation is lifted out of the
/// keydb-coupled scan path. Adding them is non-breaking thanks to
/// `#[non_exhaustive]`.
#[non_exhaustive]
pub enum Key {
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
/// (keydb / key server) resolved these, or they were cached in the mapfile
/// at sweep; libfreemkv decrypts directly with no further derivation.
Unit(Vec<(u32, [u8; 16])>),
}
impl Disc {
/// Get the resolved decryption keys for this disc.
/// Used by disc-to-ISO and other full-disc operations.
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
if let Some(ref aacs) = self.aacs {
// An AACS state with NO unit keys is "encrypted, no keys" — e.g.
// the VID-only state from out-of-band resolution before a Unit Key
// is supplied. Report None so callers treat it as missing keys
// (not a usable, empty key set).
if aacs.unit_keys.is_empty() {
return crate::decrypt::DecryptKeys::None;
}
crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key,
@@ -1395,6 +1523,61 @@ impl Disc {
}
}
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
/// directly with NO key-service round-trip. Populates `self.aacs.unit_keys`
/// so [`decrypt_keys`] returns them and marks the source `ExternalUk`.
///
/// If the scan built no AACS state (`self.aacs == None`) — which happens
/// when the keydb was absent at scan time (`scan_aacs_no_keydb` →
/// `aacs_error = KeydbLoad`) — this synthesizes a minimal `ExternalUk`
/// state for an encrypted AACS disc. A Unit Key is the FINAL per-title
/// decryption key; the keydb is only needed to *derive* it, and that
/// derivation already happened at sweep (the UK is in the mapfile). So a UK
/// alone is sufficient to decrypt the on-disk ISO — AACS 2.0 bus decryption
/// was applied by the drive at read time, so `read_data_key` is unused for
/// file-backed mux. Without this, a keyed disc swept without a keydb would
/// recover its UK yet still report E8005 (no usable `decrypt_keys`) at
/// remux. No-op for an unencrypted or CSS (DVD) disc.
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
if let Some(aacs) = self.aacs.as_mut() {
aacs.unit_keys = keys;
aacs.key_source = KeySource::ExternalUk;
} else if self.encrypted && self.css.is_none() {
self.aacs = Some(AacsState {
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
bus_encryption: self.format == DiscFormat::Uhd,
mkb_version: None,
disc_hash: String::new(),
key_source: KeySource::ExternalUk,
vuk: None,
unit_keys: keys,
read_data_key: None,
volume_id: [0u8; 16],
});
// The prior resolution error (e.g. KeydbLoad) is now moot — we have
// the decryption key. Clear it so callers don't treat the disc as
// keyless on the stale error.
self.aacs_error = None;
}
}
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
/// decryption state. **Lookup-free**: no keydb, no network — the caller
/// (an application, via a key source) does all resolution and hands the key
/// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
/// unit keys came from a key source or the mapfile cache, and libfreemkv
/// decrypts directly (see [`Self::inject_unit_keys`]).
pub fn decrypt_with(&mut self, key: Key) -> Result<()> {
match key {
Key::Unit(keys) => {
self.inject_unit_keys(keys);
Ok(())
}
}
}
/// Copy disc sectors to an ISO image file.
///
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid
@@ -1479,6 +1662,8 @@ impl Disc {
skip_on_error: opts.multipass,
progress: opts.progress,
halt: opts.halt.clone(),
vid: opts.vid,
unit_keys: opts.unit_keys.clone(),
};
self.sweep(reader, path, &sweep_opts)
}
@@ -1570,13 +1755,24 @@ impl Disc {
if !opts.resume {
let _ = std::fs::remove_file(&mapfile_path);
}
let map = mapfile::Mapfile::open_or_create(
let mut map = mapfile::Mapfile::open_or_create(
&mapfile_path,
total_bytes,
concat!("libfreemkv v", env!("CARGO_PKG_VERSION")),
)
.map_err(|e| Error::IoError { source: e })?;
// Persist the disc's decryption state into the mapfile header so it
// survives to deferred-mux / resume. ddrescue-safe (comment lines);
// does not touch the ISO payload. KEYS XOR VID: a keyed disc writes its
// unit keys (the final answer — deferred-mux decrypts directly, no key
// service); an unresolved disc writes only the VID (the retry marker).
if !opts.unit_keys.is_empty() {
map.set_unit_keys(&opts.unit_keys);
} else if let Some(vid) = opts.vid {
map.set_vid(vid);
}
// ISO file: if resuming and mapfile has Finished ranges, open existing;
// otherwise create fresh and pre-size to total_bytes (sparse holes for
// non-tried regions).
@@ -2048,6 +2244,19 @@ pub struct CopyOptions<'a> {
pub multipass: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// AACS Volume ID (16 bytes) to persist into the mapfile during
/// Pass 1 so it survives to deferred-mux / resume. `None` for
/// unencrypted / non-AACS discs. Caller wires this from
/// `Disc::aacs.volume_id`.
///
/// Persisted ONLY when `unit_keys` is empty (the disc didn't resolve a
/// key): the VID is the "still unresolved, retry-able" marker.
pub vid: Option<[u8; 16]>,
/// Resolved AACS unit keys `(CPS unit, key)` to persist into the mapfile
/// during Pass 1. When non-empty these are written (the final answer, so
/// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
/// Caller wires this from `Disc::aacs.unit_keys`.
pub unit_keys: Vec<(u32, [u8; 16])>,
}
#[derive(Debug, Clone, Copy)]
@@ -2069,6 +2278,13 @@ pub struct SweepOptions<'a> {
pub skip_on_error: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// AACS Volume ID (16 bytes) persisted into the mapfile when the
/// sweep creates / opens it. `None` for unencrypted discs. Written ONLY
/// when `unit_keys` is empty (keys XOR VID — the VID is the retry marker).
pub vid: Option<[u8; 16]>,
/// Resolved AACS unit keys persisted into the mapfile when the sweep
/// creates / opens it. When non-empty these win over `vid`.
pub unit_keys: Vec<(u32, [u8; 16])>,
}
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
@@ -2563,6 +2779,136 @@ mod tests {
}
}
#[test]
fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() {
// Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a
// keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted
// in the mapfile. At remux the UK is recovered and injected — that MUST
// yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd
// (no aacs to mutate), decrypt_keys stayed None, and the mux deferred
// forever with "No keys available (E8005)" despite holding the UK.
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs_error = Some(crate::error::Error::KeydbLoad {
path: "<no keydb in search paths>".into(),
});
assert!(
matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None),
"precondition: encrypted disc with no aacs state => no decrypt keys"
);
let uk = vec![(0u32, [0x11u8; 16])];
disc.inject_unit_keys(uk.clone());
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs {
unit_keys,
read_data_key,
} => {
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
}
_ => panic!("expected Aacs decrypt keys after injecting a UK"),
}
assert!(
disc.aacs_error.is_none(),
"stale KeydbLoad must be cleared once a UK is in hand"
);
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeySource::ExternalUk
);
}
/// Build an AacsState carrying the given unit keys (other fields are inert
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
AacsState {
version: 2,
bus_encryption: true,
mkb_version: None,
disc_hash: String::new(),
key_source: KeySource::DeviceKey,
vuk: None,
unit_keys,
read_data_key: None,
volume_id: [0u8; 16],
}
}
#[test]
fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() {
// VID-only state (resolved but no Unit Key yet) must read as None, not
// an empty-but-usable key set — callers treat it as "keys missing".
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new()));
assert!(matches!(
disc.decrypt_keys(),
crate::decrypt::DecryptKeys::None
));
}
#[test]
fn decrypt_with_replaces_existing_aacs_unit_keys_and_marks_external() {
// When scan DID build an AACS state, decrypt_with must overwrite its
// unit keys (not append) and mark the source ExternalUk.
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])]));
let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])];
disc.decrypt_with(Key::Unit(new.clone())).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(unit_keys, new, "must replace, preserving every CPS unit");
}
_ => panic!("expected Aacs decrypt keys"),
}
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeySource::ExternalUk
);
}
#[test]
fn decrypt_with_unit_key_yields_decrypt_keys() {
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
// decrypt_keys() must return usable AACS state (same path as the
// deferred-mux resume — autorip resolves the UK and passes it in).
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let uk = vec![(0u32, [0x44u8; 16])];
disc.decrypt_with(Key::Unit(uk.clone())).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(unit_keys, uk);
}
_ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"),
}
}
#[test]
fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
// Unencrypted disc: nothing to inject into, stays None.
let mut plain = make_test_disc(1000, "PLAIN");
plain.inject_unit_keys(vec![(0, [0x22; 16])]);
assert!(plain.aacs.is_none());
assert!(matches!(
plain.decrypt_keys(),
crate::decrypt::DecryptKeys::None
));
// Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state.
let mut dvd = make_test_disc(1000, "DVD");
dvd.format = DiscFormat::Dvd;
dvd.encrypted = true;
dvd.css = Some(crate::css::CssState {
title_key: [0u8; 5],
});
dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
}
#[test]
fn sweep_to_dev_null_no_enodev() {
let tmp = tempfile::tempdir().unwrap();
@@ -2579,6 +2925,8 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(
@@ -2603,6 +2951,8 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
@@ -2633,6 +2983,8 @@ mod tests {
multipass: false,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
@@ -2662,6 +3014,8 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
assert!(
@@ -2679,6 +3033,8 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
assert!(
@@ -2711,6 +3067,8 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
@@ -2723,6 +3081,8 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
assert!(
@@ -2755,6 +3115,8 @@ mod tests {
multipass: false,
progress: None,
halt: None,
vid: None,
unit_keys: Vec::new(),
};
let result = disc.copy(&mut reader, &iso_path, &opts);
let r = result.expect("100-batch clean sweep should succeed");
+1 -1
View File
@@ -334,7 +334,7 @@ const FAIL_PAUSE_SECS: u64 = 5;
/// FIRST read failure after a clean run, before the drive has had a
/// chance to cycle in retries that push it toward fast-fail).
///
/// Empirical: 2026-05-11 Dune Pt 2 wedge incident showed 7 medium
/// Empirical: a 2026-05-11 wedge incident showed 7 medium
/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the
/// BU40N's firmware into IllegalRequest fast-fail mode permanently.
/// Once there, only physical eject + reload clears it. Giving the
+1 -1
View File
@@ -529,7 +529,7 @@ impl Drive {
// /dev/sr0 pread fallback (Linux only). The kernel
// sr_mod driver auto-retries failed reads (~5× per
// command). Empirically (BU40N + Dune Part 2 UHD,
// command). Empirically (BU40N + a UHD disc,
// 2026-05-08) dd via /dev/sr0 recovers ~50% of bad
// sectors that a single-shot SG_IO READ misses.
#[cfg(target_os = "linux")]
+4 -2
View File
@@ -246,11 +246,12 @@ mod tests {
let uk_ro = vec![0u8; 256];
let vid = [0u8; 16];
let keydb = aacs::KeyDb::empty();
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
let ctx_aacs = aacs::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
providers,
mkb: None,
};
let mut ctx = DrmContext {
@@ -272,12 +273,13 @@ mod tests {
let uk_ro = vec![0u8; 256];
let vid = [0xAAu8; 16];
let keydb = aacs::KeyDb::empty();
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
let mkb = mkb_with_variant();
let ctx = aacs::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
providers,
mkb: Some(&mkb),
};
// Just confirm the symbol is callable; we don't assert on the
-3
View File
@@ -16,9 +16,6 @@
//! The underlying mpsc channel is created with a very large slot count
//! so the byte cap (not the slot count) is the real backpressure. Slot
//! count is only there to give the kernel a small chunk to wake on.
//!
//! See `(internal)/memory/project_buffering_architecture.md` §
//! Pipeline channel — sizing.
use std::sync::mpsc::{Receiver as MpscReceiver, RecvError, SendError, SyncSender, sync_channel};
use std::sync::{Arc, Condvar, Mutex};
+1 -2
View File
@@ -23,8 +23,7 @@
//! bounded channel + dedicated consumer thread.
//!
//! `byte_channel` is a byte-sized producer/consumer channel for the
//! mux pipeline, sized to absorb worst-case input read stalls (see
//! `(internal)/memory/project_buffering_architecture.md`).
//! mux pipeline, sized to absorb worst-case input read stalls.
pub(crate) mod bounded;
pub mod byte_channel;
+1 -4
View File
@@ -9,7 +9,6 @@
//! sweep (migrated to `disc/sweep.rs::SweepSink`), patch, and mux.
//! 0.18 collapses all three onto this primitive; sweep is in,
//! patch and mux migrate in later 0.18 slices.
//! See `(internal)/memory/0_18_redesign.md` for the full picture.
//!
//! ## Cancellation and error semantics
//!
@@ -68,9 +67,7 @@ pub const JOIN_TIMEOUT_SECS: u64 = 600;
///
/// 0.21.7 replaced an old `std::sync::mpsc::sync_channel` + 50 ms
/// `thread::sleep` polling loop that capped mux throughput at
/// ~20 frames/sec ≈ 1 MB/s on saturated channels. See
/// (internal)/memory/feedback_send_with_halt_poll_throttle.md
/// for the multi-day diagnostic that surfaced it.
/// ~20 frames/sec ≈ 1 MB/s on saturated channels.
use crate::halt::POLL_INTERVAL;
const SEND_HALT_CHECK_INTERVAL: Duration = POLL_INTERVAL;
-3
View File
@@ -20,9 +20,6 @@
//! 4 MiB buffer for the common local-disk case; `WritebackFile`
//! (separate module) wraps a `File` with the adaptive-chunk
//! `sync_file_range` machinery for the Linux+NFS case.
//!
//! See `(internal)/memory/project_buffering_architecture.md` for
//! the full design and the source/sink matrix.
use std::io::{Seek, Write};
+1 -1
View File
@@ -47,7 +47,7 @@ impl SocketSink {
/// writing. `sndbuf_bytes`, when present, is forwarded to
/// `setsockopt(SO_SNDBUF)` as a kernel hint — the OS may clamp it.
///
/// `addr` accepts anything `ToSocketAddrs` does: `"10.0.0.1:1234"`,
/// `addr` accepts anything `ToSocketAddrs` does: `"192.0.2.1:1234"`,
/// `("host", 1234)`, a `SocketAddr`, etc.
pub fn connect<A: ToSocketAddrs>(addr: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
let stream = TcpStream::connect(addr)?;
+24 -24
View File
@@ -38,7 +38,7 @@ use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub struct DiscMetadata {
/// Localized titles, keyed by 3-char ISO 639-2 lang code
/// (e.g. "eng" → "Dune Part Two")
/// (e.g. "eng" → "Aurora Drift")
pub titles: BTreeMap<String, String>,
/// First-line / short description, per lang
pub descriptions: BTreeMap<String, String>,
@@ -146,7 +146,7 @@ pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFie
}
/// Reject candidate description strings that are themselves XML
/// fragments — observed on disc-04 (Top Gun: Maverick), where
/// fragments — observed on a captured disc, where
/// `<di:description>` contained `<di:thumbnail href="…"/>` child
/// elements and no actual prose. Surfacing that raw to the JSON
/// output is worse than dropping the field entirely.
@@ -210,10 +210,10 @@ mod tests {
// carrier inside a <discInfo> root.
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Part Two</di:name>
<di:name>Aurora Drift</di:name>
</discInfo>"#;
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
assert_eq!(title, "Dune Part Two");
assert_eq!(title, "Aurora Drift");
assert_eq!(desc, None);
assert_eq!(set, None);
}
@@ -223,12 +223,12 @@ mod tests {
// <di:title> is the alternate carrier; should be picked up
// when <di:name> is absent.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:title>The Matrix</di:title>
<di:description>A film about computers.</di:description>
<di:title>Echo Chamber</di:title>
<di:description>A film about machines.</di:description>
</discInfo>"#;
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "The Matrix");
assert_eq!(desc.as_deref(), Some("A film about computers."));
assert_eq!(title, "Echo Chamber");
assert_eq!(desc.as_deref(), Some("A film about machines."));
}
#[test]
@@ -238,17 +238,17 @@ mod tests {
// titleName inside tableOfContents.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:tableOfContents>
<di:titleName>Inside Out 2</di:titleName>
<di:titleName>Feelings Two</di:titleName>
</di:tableOfContents>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "Inside Out 2");
assert_eq!(title, "Feelings Two");
}
#[test]
fn extract_box_set_position() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>LOTR Disc 2</di:name>
<di:name>Box Set Disc 2</di:name>
<di:discNumber>2</di:discNumber>
<di:numSets>5</di:numSets>
</discInfo>"#;
@@ -287,11 +287,11 @@ mod tests {
// would. This exercises the BTreeMap key handling without
// needing a UdfFs.
let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Part Two</di:name>
<di:name>Aurora Drift</di:name>
</discInfo>"#;
let fra_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Dune Deuxième Partie</di:name>
<di:description>Suite du film de 2021.</di:description>
<di:name>Aurora Drift (Partie Deux)</di:name>
<di:description>Suite du film fictif.</di:description>
</discInfo>"#;
let mut meta = DiscMetadata::default();
@@ -310,16 +310,16 @@ mod tests {
assert_eq!(
meta.titles.get("eng").map(String::as_str),
Some("Dune Part Two")
Some("Aurora Drift")
);
assert_eq!(
meta.titles.get("fra").map(String::as_str),
Some("Dune Deuxième Partie")
Some("Aurora Drift (Partie Deux)")
);
assert!(meta.descriptions.get("eng").is_none());
assert_eq!(
meta.descriptions.get("fra").map(String::as_str),
Some("Suite du film de 2021.")
Some("Suite du film fictif.")
);
assert_eq!(meta.disc_number, None);
}
@@ -342,21 +342,21 @@ mod tests {
#[test]
fn description_with_only_child_xml_is_dropped() {
// Real-world bug from disc-04 (Top Gun: Maverick, 2026-05-11
// Real-world bug from a captured disc (2026-05-11
// capture): <di:description> contained only <di:thumbnail/>
// child elements with no actual prose. The previous parser
// surfaced the raw XML fragment as the description string.
// Now we reject candidates that begin with `<`.
let xml = r#"<discInfo>
<di:name>Top Gun: Maverick</di:name>
<di:name>Skyline Run</di:name>
<di:description>
<di:thumbnail href="tgm_meta_sm.jpg" />
<di:thumbnail href="tgm_meta_lg.jpg" />
<di:thumbnail href="sample_meta_sm.jpg" />
<di:thumbnail href="sample_meta_lg.jpg" />
</di:description>
</discInfo>"#;
let (title, description, _) =
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
assert_eq!(title, "Top Gun: Maverick");
assert_eq!(title, "Skyline Run");
assert!(
description.is_none(),
"description containing only XML children must be dropped, got {description:?}"
@@ -381,10 +381,10 @@ mod tests {
#[test]
fn whitespace_in_title_is_trimmed() {
let xml = r#"<discInfo><di:name>
Dune Part Two
Aurora Drift
</di:name></discInfo>"#;
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
assert_eq!(title, "Dune Part Two");
assert_eq!(title, "Aurora Drift");
}
#[test]
+1 -2
View File
@@ -7,8 +7,7 @@
//! Stream labels live as plain ASCII strings inside compiled `.class`
//! files in the jar — a quirk of the menu-rendering layer encoding
//! its TextField positions and content as constant strings the
//! Java compiler retained in the class string pool. Format observed
//! in the corpus (Civil War UHD, 2024):
//! Java compiler retained in the class string pool. Observed format:
//!
//! ```text
//! LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,...
+38 -60
View File
@@ -1,83 +1,65 @@
//! Deluxe BD-J framework — `com/bydeluxe/bluray/` package signature.
//!
//! Used by major studios (Disney, Warner, others) for their UHD
//! BD-J authoring. Detected on discs whose `/BDMV/JAR/<x>.jar`
//! contains a `com/bydeluxe/` directory entry.
//! Detected on discs whose `/BDMV/JAR/<x>.jar` contains a
//! `com/bydeluxe/` directory entry.
//!
//! ## Why this parser exists
//! ## What this parser reads
//!
//! Deluxe-authored discs store stream labels as **ordinal references
//! into obfuscated enum classes**. The label text isn't a literal
//! string in any anchor pattern (unlike dbp's `TextField,...` rows).
//! Instead, the binding code is roughly:
//!
//! ```java
//! streamTable.put(1, new AudioSlot(LanguageEnum.English,
//! CodecEnum.ATMOS_HD_AUDIO,
//! PurposeEnum.Normal));
//! ```
//!
//! The class names `LanguageEnum`, `CodecEnum`, `PurposeEnum`, and
//! `AudioSlot` are obfuscated per-disc (`be.class`, `ma.class`,
//! `lp.class`, etc.) — no name pattern survives the obfuscator. But
//! the **shape of `<clinit>`** is framework-stable:
//! Deluxe-authored discs store stream labels as ordinal references into
//! enum classes whose names are obfuscated per-disc, so a name-based
//! match won't work. The label data is instead recovered by matching on
//! the **shape of each enum's `<clinit>`**, which is framework-stable:
//!
//! | Enum | Signature |
//! |---|---|
//! | Language | 70 `ldc` operations in `<clinit>`, sequence starts `English, French, Spanish, Dutch, ...` |
//! | Purpose | 8 ldcs starting `Normal, Commentary, PiP, Trivia, ...` |
//! | VideoFormat | 7 ldcs starting `HD, HDR10 Plus, HD Dolby, ...` |
//! | Region | 22 ldcs starting `USA_D1, LIC1, LIC2, LIC3, ...` (Disney only) |
//! | Studio | 6 ldcs starting `Disney, Marvel, Pixar, ...` (Disney only) |
//! | Codec | ~46 `new` instructions, 0 ldcs in `<clinit>` (codec strings live in subclasses) |
//! | Region | 22 ldcs starting `USA_D1, LIC1, LIC2, LIC3, ...` |
//! | Studio | 6 ldcs starting `Disney, Marvel, Pixar, ...` |
//! | Codec | many `new` instructions, 0 ldcs in `<clinit>` (codec strings live in subclasses) |
//!
//! Match on the SHAPE, not the name, and the parser survives obfuscation.
//! Matching on the shape rather than the class name keeps the parser
//! working across obfuscation variants.
//!
//! ## Implementation phases
//!
//! - **Phase A** — master enum identification (`identify_master_enums`).
//! Walks every `.class`'s `<clinit>` ldc sequence and matches against
//! the framework-stable fingerprints. Output: `Vec<(label, MasterEnum)>`
//! with full ordinal → string-value tables. **Empirically verified**
//! on disc-01 (Disney) + disc-09 (Warner).
//! with full ordinal → string-value tables.
//!
//! - **Phase B** — codec enum subclass walk (`decode_codec_enum`).
//! The codec enum's `<clinit>` has ~46 `new` instructions and zero
//! The codec enum's `<clinit>` has many `new` instructions and zero
//! string ldcs — codec name strings live in the subclasses each
//! `new` constructs. Walks every referenced subclass's constant
//! pool, extracts the codec name string. **Structural shape
//! verified** on disc-01 (ma.class, 41 `new` ops) + disc-09
//! (ea.class, 46 `new` ops); per-subclass string extraction
//! designed against the published Java enum compilation convention
//! (each enum value's `<init>` is called with its name string as
//! the first arg).
//! pool, extracts the codec name string, following the standard Java
//! enum compilation convention (each enum value's `<init>` is called
//! with its name string as the first arg).
//!
//! - **Phase C** — binding-class identification (`find_binding_classes`).
//! The per-stream table is built by some class via repeated
//! `getstatic` references to the master enums identified in A.
//! That class has the highest such `getstatic` count in the jar.
//! **Heuristic shape**; precise threshold may need tuning.
//! Heuristic shape; precise threshold may need tuning.
//!
//! - **Phase D** — binding-class bytecode decoder (`decode_binding`).
//! Walks the binding class's `<clinit>` with a tiny symbolic stack
//! machine. For each `new X / dup / ... / invokespecial X.<init>`
//! sequence, collects the int values and enum-reference operands
//! between the `dup` and the constructor call, then emits a
//! `DecodedStream`. **Mechanism verified** in unit tests against
//! synthetic class fixtures; the **signal-to-StreamLabel mapping**
//! (which arg is stream index? which is language? audio vs
//! subtitle?) uses a documented heuristic that needs corpus-disc
//! verification — see `interpret_stream` for the mapping rules.
//! `DecodedStream`. The signal-to-StreamLabel mapping (which arg is
//! stream index? which is language? audio vs subtitle?) uses a
//! heuristic — see `interpret_streams` for the mapping rules.
//!
//! ## Confidence
//!
//! [`parse`] returns `Some(ParseResult::medium(labels))` when Phases A
//! through D produce at least one stream — `Medium` because the
//! signal-to-label mapping is heuristic until real disc bytecode
//! confirms the binding pattern. Once verified the parser can promote
//! to `High`. `None` when the disc isn't Deluxe-authored or when
//! decoding produces zero streams (a recognized-but-broken state that
//! the analyzer still surfaces via `parsers_detected`).
//! signal-to-label mapping is heuristic. `None` when the disc isn't
//! Deluxe-authored or when decoding produces zero streams (a
//! recognized-but-broken state that the analyzer still surfaces via
//! `parsers_detected`).
use super::class_reader::{
AASTORE, BIPUSH, ClassFile, CodeAttribute, ConstantPool, CpInfo, GETSTATIC, ICONST_0, ICONST_1,
@@ -559,10 +541,9 @@ fn clinit_news_and_ldcs(
/// candidates ordered by descending getstatic count, filtered to a
/// minimum concentration of master-enum references.
///
/// Empirically (POC v0.3 dumps): on disc-01 the audio binding class
/// (`ma.class`) has ~82 getstatic refs, the subtitle binding
/// (`ko.class`) has ~63. Both share the master Language + Purpose
/// enums.
/// A disc that splits the table commonly has one audio binding class
/// with the most getstatic refs and a subtitle binding class with
/// somewhat fewer; both share the master Language + Purpose enums.
pub(crate) fn find_binding_classes(
archive: &mut jar::Jar,
master_enum_classes: &HashSet<&str>,
@@ -641,10 +622,9 @@ pub(crate) enum StackVal {
/// name (e.g. `DOLBY_AC3_AUDIO`, `DOLBY_LOSSLESS_AUDIO`) is the
/// codec identifier. Deluxe binding constructors take a
/// `LCodingType;` arg directly — codecs are NOT a Deluxe-internal
/// enum (Phase B's codec-subclass walk was based on a wrong
/// assumption; the actual codec source is the standard BD-J API
/// enum). Discovered via deluxe-poc v0.3 binding-bytecode dump
/// against disc-01 (Disney) + disc-09 (Warner) on 2026-05-10.
/// enum; the codec source is the standard BD-J API `CodingType`
/// enum, so the binding constructor's codec arg is read straight
/// from that getstatic operand.
CodingType(String),
/// An uninitialized `new` object — popped by the matching
/// invokespecial.
@@ -988,11 +968,10 @@ impl MasterEnumTable {
// ── interpret_streams: Constructions → StreamLabels ─────────────────────────
/// Convert the per-construction tuples from Phase D into
/// [`StreamLabel`]s. Pattern verified against corpus discs via
/// deluxe-poc v0.3 binding-bytecode dump (2026-05-10):
/// [`StreamLabel`]s. Two binding-constructor shapes are handled:
///
/// Disney binding (5-arg): `BindingType.<init>(I, Lbe;, Llp;, I, LCodingType;)V`
/// Warner binding (4-arg): `BindingType.<init>(I, Law;, Lgp;, LCodingType;)V`
/// 5-arg: `BindingType.<init>(I, Lang;, Lpurpose;, I, LCodingType;)V`
/// 4-arg: `BindingType.<init>(I, Lang;, Lpurpose;, LCodingType;)V`
///
/// Args are identified by **TYPE**, not position:
/// - First `EnumRef{kind: "Language"}` → audio/subtitle language
@@ -1098,11 +1077,10 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) -
/// getstatic operands on Deluxe binding classes) to a human-readable
/// codec hint string.
///
/// CodingType is the standard BD-J API enum; values are documented
/// in the BD-J specification and verified empirically against the
/// binding-bytecode dumps in `(internal)/research/deluxe-poc/data/`.
/// Unknown field names pass through unchanged so unfamiliar codecs
/// still surface something rather than going silent.
/// CodingType is the standard BD-J API enum; values are documented in
/// the BD-J specification. Unknown field names pass through unchanged
/// so unfamiliar codecs still surface something rather than going
/// silent.
fn coding_type_to_codec_hint(field: &str) -> &str {
match field {
// Lossless / hi-res.
+1 -1
View File
@@ -954,7 +954,7 @@ mod gap_fill_tests {
#[test]
fn partial_yield_fills_gaps_keeps_framework() {
// Oppenheimer-style: framework matched but only labeled 2 of 6 audios.
// Partial-coverage case: framework matched but only labeled 2 of 6 audios.
let mut framework = vec![
label(StreamLabelType::Audio, 1, "eng", "Atmos"),
label(StreamLabelType::Audio, 4, "eng", "Commentary"),
+126 -12
View File
@@ -37,14 +37,47 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
// but the corpus surfaced something we don't catalogue.
let saw_unknown = AtomicBool::new(false);
let labels = assign_labels(&strings, &saw_unknown);
if labels.is_empty() {
return None;
}
let confidence = if saw_unknown.load(Ordering::Relaxed) {
Confidence::Medium
} else {
Confidence::High
};
Some(ParseResult { labels, confidence })
}
/// Walk the extracted token strings of the feature section and emit a
/// `StreamLabel` per editorial token, numbered in STN order. Split out
/// from `parse` so the section/numbering logic is unit-testable without
/// a `SectorSource`/`UdfFs`.
fn assign_labels(strings: &[String], saw_unknown: &AtomicBool) -> Vec<StreamLabel> {
// The authoritative per-feature stream list lives in the `FPL_`
// (FeaturePLaylist) section, in STN order. `SEG_*` entries are menu
// segments (intros, logos, disclaimers, previews) that can also carry
// stray stream tokens — e.g. a `SEG_MainFeature` preview segment that
// lists only a commentary track. Anchoring on such a segment grabs the
// wrong streams and misnumbers them. So when the project ships any
// `FPL_` playlist, anchor exclusively on it; only fall back to
// `SEG_MainFeature` on discs that have no `FPL_` section at all.
let has_fpl = strings.iter().any(|s| s.starts_with("FPL_"));
let mut labels = Vec::new();
let mut in_feature = false;
let mut audio_num: u16 = 0;
let mut sub_num: u16 = 0;
for s in &strings {
for s in strings {
// Detect feature section start
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
let is_start = if has_fpl {
s.starts_with("FPL_")
} else {
s.starts_with("SEG_MainFeature")
};
if is_start {
if in_feature {
break;
}
@@ -63,7 +96,25 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
continue;
}
if let Some(label) = parse_token_inner(s, Some(&saw_unknown)) {
// Generic audio-slot placeholder. Pixelogic lists each audio stream
// in the playlist as either an editorial `{lang}_{codec}_…` token OR
// a bare `Audio Stream N` placeholder when no editorial label was
// authored. The placeholder carries nothing to label, but it DOES
// occupy an STN slot — so it must advance `audio_num`. Otherwise a
// later editorial token (e.g. a lone `eng_ACOM_` commentary sitting
// at STN slot 4, behind three unlabelled main tracks) collapses onto
// slot 1 and its Commentary purpose lands on the main feature track.
//
// Only audio is corrected here: subtitle (`PG Stream N`) numbering is
// left exactly as-is — the corpus snapshots show forced/commentary
// subtitle tokens already align with STN without counting the
// placeholders, and counting them regresses several discs.
if s.starts_with("Audio Stream") {
audio_num += 1;
continue;
}
if let Some(label) = parse_token_inner(s, Some(saw_unknown)) {
match label.stream_type {
StreamLabelType::Audio => {
audio_num += 1;
@@ -83,15 +134,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
}
}
if labels.is_empty() {
return None;
}
let confidence = if saw_unknown.load(Ordering::Relaxed) {
Confidence::Medium
} else {
Confidence::High
};
Some(ParseResult { labels, confidence })
labels
}
fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<StreamLabel> {
@@ -250,4 +293,75 @@ mod tests {
assert!(parse_token_inner("XX_MLP_", None).is_none());
assert!(parse_token_inner("ENG_MLP_", None).is_none()); // uppercase not accepted as ISO 639-2
}
fn strs(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn assign_labels_numbers_commentary_behind_placeholders() {
// Observed case: the FPL_MainFeature playlist lists three unlabelled main
// audio tracks as `Audio Stream N` placeholders, then a lone
// `eng_ACOM_` commentary at STN slot 4. The commentary must land on
// audio #4, not collapse onto #1 (which would tag the main feature
// track as commentary).
let flag = AtomicBool::new(false);
let tokens = strs(&[
"FPL_MainFeature",
"Audio Stream 1",
"Audio Stream 2",
"Audio Stream 3",
"eng_ACOM_",
]);
let labels = assign_labels(&tokens, &flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audio.len(), 1, "only the commentary carries a label");
assert_eq!(audio[0].stream_number, 4, "commentary is STN slot 4");
assert_eq!(audio[0].purpose, LabelPurpose::Commentary);
assert_eq!(audio[0].language, "eng");
}
#[test]
fn assign_labels_prefers_fpl_over_seg_mainfeature() {
// A `SEG_MainFeature` menu/preview segment carries a stray commentary
// token, but the real playlist is `FPL_MainFeature`. When an FPL_
// section exists, the SEG_ one must be ignored as an anchor — so we
// number from the FPL playlist, putting the commentary at slot 2.
let flag = AtomicBool::new(false);
let tokens = strs(&[
"SEG_MainFeature",
"eng_ACOM_", // stray token in the menu segment — must be ignored
"FPL_MainFeature",
"Audio Stream 1",
"eng_ACOM_",
]);
let labels = assign_labels(&tokens, &flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audio.len(), 1);
assert_eq!(audio[0].stream_number, 2, "numbered from the FPL playlist");
assert_eq!(audio[0].purpose, LabelPurpose::Commentary);
}
#[test]
fn assign_labels_falls_back_to_seg_without_fpl() {
// Discs with no FPL_ playlist still anchor on SEG_MainFeature.
let flag = AtomicBool::new(false);
let tokens = strs(&["SEG_MainFeature", "eng_MLP_", "spa_AC3_"]);
let labels = assign_labels(&tokens, &flag);
let audio: Vec<_> = labels
.iter()
.filter(|l| l.stream_type == StreamLabelType::Audio)
.collect();
assert_eq!(audio.len(), 2);
assert_eq!(audio[0].stream_number, 1);
assert_eq!(audio[0].language, "eng");
assert_eq!(audio[1].stream_number, 2);
assert_eq!(audio[1].language, "spa");
}
}
+1 -2
View File
@@ -10,8 +10,7 @@
//! - English text → [`LabelPurpose`] (Commentary / Descriptive / etc.).
//! - English text → [`LabelQualifier`] (SDH / Forced / Descriptive Service).
//!
//! Rules of engagement (carried over from
//! `(internal)/memory/feedback_label_data_rules.md`):
//! Rules of engagement:
//!
//! 1. Only map values we are 100% certain about — published codec
//! names, well-known ISO 639-2 mappings, vendor-documented purpose
+3 -3
View File
@@ -170,9 +170,9 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
// prefix at the crate root to keep both addressable.
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, Stream,
SubtitleStream, SweepOptions, VideoStream, classify_damage,
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, Key, KeySource,
LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions,
Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
};
// ─── Streams ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -6,7 +6,7 @@
//! ```text
//! let mut input = input("iso://Disc.iso", &opts)?;
//! let title = input.info().clone();
//! let mut output = output("mkv://Dune.mkv", &title)?;
//! let mut output = output("mkv://Movie.mkv", &title)?;
//! while let Ok(Some(frame)) = input.read() {
//! output.write(&frame)?;
//! }
+7 -5
View File
@@ -163,6 +163,9 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
#[derive(Default)]
pub struct InputOptions {
pub keydb_path: Option<String>,
/// Caller-supplied Unit Key (external Unit Key) — the second, mutually
/// exclusive key source. Takes precedence over `keydb_path`.
pub unit_key: Option<[u8; 16]>,
pub title_index: Option<usize>,
/// Skip decryption — return raw encrypted bytes.
pub raw: bool,
@@ -181,11 +184,10 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
let scan_opts = match &opts.keydb_path {
Some(p) => crate::disc::ScanOptions {
keydb_path: Some(p.into()),
},
None => crate::disc::ScanOptions::default(),
let scan_opts = crate::disc::ScanOptions {
keydb_path: opts.keydb_path.as_ref().map(Into::into),
unit_key: opts.unit_key,
..Default::default()
};
// FileSectorSource is the sole file-backed sector source.
// It carries the platform-tuned SEQUENTIAL fadvise hint
+55 -53
View File
@@ -89,8 +89,8 @@ fn aacs_decrypt_unit_roundtrip() {
plain[offset] = 0x47; // TS sync byte
offset += 192;
}
// Set encryption flag (bits 6-7 of byte 0)
plain[0] |= 0xC0;
// No flag set: CBC-encrypting the body below scrambles packets 1..31's TS
// syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
// Save original plaintext for comparison
let expected = plain.clone();
@@ -126,8 +126,8 @@ fn aacs_decrypt_unit_roundtrip() {
prev.copy_from_slice(&plain[off..off + 16]);
}
// Verify it looks encrypted
assert!(aacs::is_unit_encrypted(&plain));
// Verify it looks encrypted (body TS syncs scrambled)
assert!(aacs::is_aacs_scrambled(&plain));
// Now decrypt
let result = aacs::decrypt_unit(&mut plain, &unit_key);
@@ -136,8 +136,8 @@ fn aacs_decrypt_unit_roundtrip() {
"decrypt_unit should return true on valid encrypted unit"
);
assert!(
!aacs::is_unit_encrypted(&plain),
"encryption flag should be cleared"
!aacs::is_aacs_scrambled(&plain),
"decrypted unit should read as clear (TS syncs restored)"
);
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
@@ -156,17 +156,11 @@ fn aacs_decrypt_unit_roundtrip() {
sync_count, expected_syncs
);
// Compare all bytes except byte 0 (encryption flag cleared)
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(
&plain[1..aacs::ALIGNED_UNIT_LEN],
&expected[1..aacs::ALIGNED_UNIT_LEN],
"decrypted unit body does not match original"
);
// Byte 0: original had 0xC0 set, decrypted has it cleared
assert_eq!(
plain[0] & !0xC0,
expected[0] & !0xC0,
"byte 0 mismatch ignoring flag"
&plain[..],
&expected[..],
"decrypted unit does not match original plaintext"
);
}
@@ -260,49 +254,65 @@ fn aacs_vuk_derivation_roundtrip() {
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
}
/// Test: aacs_is_unit_encrypted detects encryption flags correctly.
/// Test: aacs_is_aacs_scrambled detects scrambled units via the raw TS syncs.
#[test]
fn aacs_is_unit_encrypted_detection() {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
fn aacs_is_aacs_scrambled_detection() {
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
// scrambled. (Flag bits play no role.)
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
clear[off] = 0x47;
off += 192;
}
assert!(
!aacs::is_unit_encrypted(&unit),
"zero unit should not be encrypted"
!aacs::is_aacs_scrambled(&clear),
"clear unit (syncs intact) must not be scrambled"
);
unit[0] = 0x40; // bit 6 set
assert!(aacs::is_unit_encrypted(&unit));
// Flag bits do NOT make a synced unit look scrambled.
let mut flagged = clear.clone();
flagged[0] = 0xC0; // copy-control bits
flagged[7] = 0xC0; // TSC bits
assert!(
!aacs::is_aacs_scrambled(&flagged),
"flag bits must not be read as encryption"
);
unit[0] = 0x80; // bit 7 set
assert!(aacs::is_unit_encrypted(&unit));
unit[0] = 0xC0; // both bits set
assert!(aacs::is_unit_encrypted(&unit));
unit[0] = 0x3F; // bits 6-7 clear
assert!(!aacs::is_unit_encrypted(&unit));
// A scrambled body (syncs destroyed) → scrambled.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
assert!(
aacs::is_aacs_scrambled(&scrambled),
"unit with no intact TS syncs must read as scrambled"
);
// Too short
let short = vec![0xC0u8; 100];
let short = vec![0xFFu8; 100];
assert!(
!aacs::is_unit_encrypted(&short),
!aacs::is_aacs_scrambled(&short),
"short buffer should not be detected"
);
}
/// Test: aacs_decrypt_unit_unencrypted_passthrough
///
/// A unit without encryption flags should pass through decrypt_unit unchanged.
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
#[test]
fn aacs_decrypt_unit_unencrypted_passthrough() {
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
unit[0] = 0x00; // no encryption flag
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
let original = unit.clone();
let key = [0xAA; 16];
assert!(!aacs::is_aacs_scrambled(&unit));
let result = aacs::decrypt_unit(&mut unit, &key);
assert!(result, "unencrypted unit should return true");
assert_eq!(unit, original, "unencrypted unit should be unchanged");
assert!(result, "clear unit should return true");
assert_eq!(unit, original, "clear unit should be unchanged");
}
// ── AACS cross-validation with independent AES implementation ──────────────
@@ -369,8 +379,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
plaintext[i] = (i % 251) as u8;
}
}
// Set encryption flag
plaintext[0] = 0xC0;
// No flag set: the CBC-encrypted body scrambles the packet syncs.
let expected = plaintext.clone();
@@ -401,14 +410,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
ok,
"decrypt_unit returned false (TS sync verification failed)"
);
assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared");
// Compare (byte 0 flag was cleared)
let mut expected_cleared = expected.clone();
expected_cleared[0] &= !0xC0;
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
&plaintext[..],
&expected[..],
"decrypted unit does not match original plaintext"
);
}
@@ -428,7 +434,7 @@ fn aacs_cross_validation_alternate_key() {
plaintext[off] = 0x47;
off += 192;
}
plaintext[0] = 0xC0;
// No flag set: the CBC-encrypted body scrambles the packet syncs.
let expected = plaintext.clone();
let mut header = [0u8; 16];
@@ -446,12 +452,8 @@ fn aacs_cross_validation_alternate_key() {
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
let mut expected_cleared = expected;
expected_cleared[0] &= !0xC0;
assert_eq!(
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
);
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(&plaintext[..], &expected[..]);
}
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
+3
View File
@@ -135,6 +135,7 @@ fn scan_options_default() {
fn scan_options_with_keydb() {
let opts = ScanOptions {
keydb_path: Some(("/tmp/KEYDB.cfg").into()),
..Default::default()
};
assert_eq!(
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
@@ -147,6 +148,7 @@ fn scan_options_with_keydb_pathbuf() {
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
let opts = ScanOptions {
keydb_path: Some(path.clone()),
..Default::default()
};
assert_eq!(opts.keydb_path.unwrap(), path);
}
@@ -517,6 +519,7 @@ fn resolve_encryption_no_keydb() {
// No keydb configured and no standard keydb on the system
let opts = ScanOptions {
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
..Default::default()
};
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
+15 -13
View File
@@ -82,22 +82,24 @@ fn decrypt_sectors_with_css_keys_works() {
/// Test: AACS unit encryption detection works.
#[test]
fn aacs_encryption_flag_detection() {
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
assert!(!aacs::is_aacs_scrambled(&unit));
// No encryption flag
assert!(!aacs::is_unit_encrypted(&unit));
// Set bit 6
unit[0] |= 0x40;
assert!(aacs::is_unit_encrypted(&unit));
// Set bit 7
unit[0] = 0x80;
assert!(aacs::is_unit_encrypted(&unit));
// Both bits set
// Flag bits on a synced unit do not make it look encrypted.
unit[0] = 0xC0;
assert!(aacs::is_unit_encrypted(&unit));
unit[7] = 0xC0;
assert!(!aacs::is_aacs_scrambled(&unit));
// Scrambled body (syncs gone) → encrypted.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
assert!(aacs::is_aacs_scrambled(&scrambled));
}
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
+1 -3
View File
@@ -9,9 +9,7 @@
//! [`ScsiSense::is_*`].
//!
//! The actual `ioctl(SG_IO, ...)` call is impossible to mock without a
//! kernel — see
//! `(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`
//! for why the audit recommends against libc shims here. These tests
//! kernel, so libc shims are deliberately avoided here. These tests
//! therefore pin the *contract* every backend must satisfy via a mock
//! `ScsiTransport`:
//!
+12 -12
View File
@@ -77,21 +77,21 @@ fn parse_url_disc_device() {
#[test]
fn parse_url_mkv() {
let u = parse_url("mkv://Dune.mkv");
let u = parse_url("mkv://Movie.mkv");
assert_eq!(u.scheme(), "mkv");
assert_eq!(u.path_str(), "Dune.mkv");
assert_eq!(u.path_str(), "Movie.mkv");
}
#[test]
fn parse_url_network() {
let u = parse_url("network://10.0.0.1:9000");
let u = parse_url("network://192.0.2.10:9000");
assert_eq!(u.scheme(), "network");
assert_eq!(u.path_str(), "10.0.0.1:9000");
assert_eq!(u.path_str(), "192.0.2.10:9000");
}
#[test]
fn parse_url_bare_path_rejected() {
let u = parse_url("Dune.mkv");
let u = parse_url("Movie.mkv");
assert_eq!(u.scheme(), "unknown");
}
@@ -104,21 +104,21 @@ fn parse_url_null() {
#[test]
fn parse_url_m2ts_with_path() {
let u = parse_url("m2ts:///tmp/Dune.m2ts");
let u = parse_url("m2ts:///tmp/Movie.m2ts");
assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path_str(), "/tmp/Dune.m2ts");
assert_eq!(u.path_str(), "/tmp/Movie.m2ts");
}
#[test]
fn parse_url_m2ts_relative() {
let u = parse_url("m2ts://Dune.m2ts");
let u = parse_url("m2ts://Movie.m2ts");
assert_eq!(u.scheme(), "m2ts");
assert_eq!(u.path_str(), "Dune.m2ts");
assert_eq!(u.path_str(), "Movie.m2ts");
}
#[test]
fn open_input_bare_path_errors() {
let result = libfreemkv::input("Dune.mkv", &libfreemkv::InputOptions::default());
let result = libfreemkv::input("Movie.mkv", &libfreemkv::InputOptions::default());
assert!(result.is_err());
let msg = match result {
Err(e) => e.to_string(),
@@ -134,7 +134,7 @@ fn open_input_bare_path_errors() {
#[test]
fn open_output_bare_path_errors() {
let dt = sample_disc_title();
let result = libfreemkv::output("Dune.mkv", &dt);
let result = libfreemkv::output("Movie.mkv", &dt);
assert!(result.is_err());
let msg = match result {
Err(e) => e.to_string(),
@@ -195,7 +195,7 @@ fn open_output_disc_errors() {
#[test]
fn open_input_network_no_port_errors() {
let result = libfreemkv::input("network://10.0.0.1", &libfreemkv::InputOptions::default());
let result = libfreemkv::input("network://192.0.2.1", &libfreemkv::InputOptions::default());
assert!(result.is_err());
let msg = match result {
Err(e) => e.to_string(),