Compare commits

...
12 Commits
Author SHA1 Message Date
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
MattJackson 8e14c9b850 v0.26.0: bump version 2026-05-21 15:18:39 -07:00
MattJackson e1c8343f77 aacs: OEM-driven VID retrieval — per-drive CDB from profile, cert fallback
When the drive is in extended-access state (unlocked), retrieve VID via
the per-drive `read_vid_cdb` from the bundled profile instead of the
cert-based AACS REPORT_KEY handshake. Cert handshake remains the
fallback for drives that don't enter extended-access state, or whose
profile lacks the required CDB.

Empirically verified on the BU40N (signature 999ec375) against
Barbie UHD: drive returns 36 bytes from buffer 0x44 at offset
0x10E291, VID at response[4..20]. The 16 bytes match Dune Part Two's
known VID in keydb.cfg byte-for-byte, cross-validating the path
against an independent oracle.

Architectural impact:

- Renames `Drive::is_libredrive_active()` → `Drive::is_unlocked()`.
  Internal `Mt1959::libredrive_active` becomes `Mt1959::unlocked`;
  the prior `unlocked` (init-success flag) becomes `init_complete`
  to avoid the name collision.
- `disc/encrypt.rs::Disc::read_vid` is the single entry point.
  When `is_unlocked()` is true, calls `read_vid_oem` (issues the
  per-drive CDB, validates the response signature high-3-bytes
  `00 22 00`, returns bytes [4..20]). Otherwise delegates to
  `read_vid_cert` (the existing AACS REPORT_KEY format 0x80 path).
- `DriveProfile` gains the per-drive CDB templates and identifier
  blocks extracted from each per-drive firmware payload — including
  `read_vid_cdb`, `read_disc_keys_cdb`, `drive_nominal_speed_cdb`,
  `set_speed_max_cdb`, two cache-prime canary CDBs, the buffer-0x45
  verify CDB, the firmware-upload CDB, and the unlock probe CDB.
  Variants A and B differ in which fields are populated. All optional;
  consumers fall back to the cert/handshake path when fields are
  absent.
- New error variants `Error::DriveProfileMissing` (E7020) and
  `Error::VidCdbUnavailable` (E7021). Both treated as
  "OEM unavailable → try cert path" by `read_vid`, not terminal.

Closes the v0.25.x gap where HRL-burned host certs (the public
libaacs leaked cert is on every recent drive's HRL) blocked all
post-handshake VID retrieval. With OEM-driven VID:

- AACS 1.0 BD on supported drives: rips end-to-end with our existing
  DKs walking the MKB.
- AACS 2.x UHD: fails honestly at the DK wall (E7018 "No usable DK"
  for v77+ MKBs) instead of the misleading E7017 "No Volume ID"
  the prior code surfaced. We have VID; we just don't have v77+ DK
  material — that gap is a key-acquisition problem, not a code
  problem.

Empirically verified on rip1 (BU40N + Barbie UHD, MKB v77,
2026-05-21): error code flipped from E7017 to E7018 as predicted.
The DK wall is now correctly the proximate failure for unrippable
modern UHD discs, instead of the indirect VID-retrieval wall the
v0.25.x cert-only path produced.

Renames and comment scrubs eliminate upstream-RE-vocabulary
references in the public crate per `feedback_no_breadcrumbs.md`.

674 tests pass (565 lib + 109 integration). No tradename leaks in
any modified file.
2026-05-21 15:18:21 -07:00
36 changed files with 4380 additions and 721 deletions
+3
View File
@@ -5,3 +5,6 @@ Cargo.lock
.DS_Store
.cargo/
.claude/worktrees/
# session scratch — never track (may contain RE breadcrumbs)
scratch/
+58 -30
View File
@@ -1,5 +1,36 @@
# Changelog
## 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 +79,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 +98,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 +224,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 +335,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 +485,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 +765,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 +893,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 +921,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 +963,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 +1035,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 +1064,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 +1082,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 +1094,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 +1136,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 +1263,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 +1311,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 +1355,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 +1410,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 +1439,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 +1472,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 +2359,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 +2376,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.25.14"
version = "0.26.3"
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
```
+2464 -206
View File
File diff suppressed because it is too large Load Diff
+126 -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,46 @@ 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 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 +276,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 +448,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 +459,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 +528,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",
+723 -214
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -17,6 +17,7 @@ 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.
@@ -26,12 +27,14 @@ pub use decrypt::{
is_unit_encrypted,
};
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,
};
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,
+105
View File
@@ -0,0 +1,105 @@
//! 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 HTTP keyserver 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()
}
/// 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 — 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))
}
}
+209 -24
View File
@@ -14,27 +14,115 @@ pub(super) struct HandshakeResult {
}
impl Disc {
/// SCSI handshake — AACS mutual auth via host certs from the keydb,
/// returning VID (and bus keys when applicable) on success.
/// Acquire the Volume ID. Tries the per-drive OEM CDB path first
/// when the drive reports `is_unlocked()` (extended-access state),
/// and falls back to the cert-based AACS mutual-auth handshake
/// otherwise.
///
/// `Drive::is_raw_read_active()` is logged for diagnostics but no
/// longer alters the auth path. v0.25.11 introduced a "raw-read VID"
/// shortcut that issued `READ_DISC_STRUCTURE` format 0x80 with
/// AGID=0 on raw-read-active drives, on the hypothesis that the
/// firmware-uploaded drive would serve VID without cert auth. The
/// BU40N returned 0x05/0x6F/0x02 (`KEY NOT ESTABLISHED`) to that
/// CDB — the AACS spec requires an AGID established via successful
/// `REPORT_KEY` / `SEND_KEY` before format 0x80 will return VID,
/// regardless of firmware-upload state. The shortcut was deleted
/// in v0.25.13. Firmware upload still helps — it removes bus
/// encryption and (per memory) may allow HRL-burned certs through
/// the cert handshake — but it doesn't bypass the AGID requirement.
/// The OEM path is a single READ_BUFFER CDB built from the drive
/// profile's `read_vid_cdb` template. The response carries a 3-byte
/// header (validated against `00 22 00`) followed by the 16-byte
/// VID at bytes [4..20]. Crucially, no AGID setup is required —
/// the drive's runtime firmware serves the VID directly when in
/// extended-access state.
///
/// The cert path is the standard AACS spec flow: ECDH key
/// agreement, bus-key derivation, then `REPORT_DISC_STRUCTURE`
/// format 0x80 to retrieve VID under bus-key MAC.
pub(super) fn read_vid(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> Result<[u8; 16]> {
if session.is_unlocked() {
let profile = session
.drive_profile()
.ok_or(Error::DriveProfileMissing)?
.clone();
return Self::read_vid_oem(session, &profile);
}
Self::read_vid_cert(session, opts)
}
/// OEM VID retrieval — issues the per-drive READ_BUFFER CDB and
/// parses the response.
///
/// Response layout (36 bytes):
/// * [0..3] 3-byte response signature; expected `00 22 00`
/// * [3] reserved
/// * [4..20] 16-byte Volume ID
/// * [20..36] reserved / per-drive padding
fn read_vid_oem(
session: &mut crate::drive::Drive,
profile: &crate::profile::DriveProfile,
) -> Result<[u8; 16]> {
const RESPONSE_LEN: usize = 36;
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
let cdb = profile.read_vid_cdb.ok_or(Error::VidCdbUnavailable)?;
let mut buf = vec![0u8; RESPONSE_LEN];
let result = session.scsi_execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)?;
if result.bytes_transferred < RESPONSE_LEN {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_short_response",
bytes_transferred = result.bytes_transferred,
"OEM VID CDB returned short response"
);
return Err(Error::AacsVidRead);
}
if buf[0..3] != EXPECTED_HEADER {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_bad_header",
header_0 = buf[0],
header_1 = buf[1],
header_2 = buf[2],
"OEM VID response header mismatch"
);
return Err(Error::AacsVidRead);
}
let mut vid = [0u8; 16];
vid.copy_from_slice(&buf[4..20]);
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_ok",
"OEM VID retrieved"
);
Ok(vid)
}
/// Cert-based VID retrieval — runs the full AACS mutual-auth
/// handshake and extracts VID from the bus-key-MAC'd
/// `REPORT_DISC_STRUCTURE` response.
fn read_vid_cert(session: &mut crate::drive::Drive, opts: &ScanOptions) -> Result<[u8; 16]> {
match Self::do_handshake_cert(session, opts) {
(Some(h), _) => Ok(h.volume_id),
(None, Some(e)) => Err(e),
(None, None) => Err(Error::AacsVidUnavailable),
}
}
/// SCSI handshake — drives the VID-acquisition flow and returns
/// a structured `HandshakeResult` for downstream key resolution.
/// Prefers the OEM path when `Drive::is_unlocked()` is true and
/// falls back to cert-based mutual auth otherwise.
///
/// The OEM path produces only VID (no bus-key, so no
/// `read_data_key`); the cert path can produce both. AACS 2.0
/// content that needs read_data_key for bus decryption requires
/// the cert path.
///
/// Returns `(handshake, error)`:
/// * `(Some(_), None)` — VID acquired
/// * `(None, Some(_))` — specific failure mode (see
/// `AacsHostCertRejected` / `AacsRawReadUnsupported` /
/// `AacsVidUnavailable` variants in `error.rs`)
/// `AacsVidUnavailable` / `DriveProfileMissing` /
/// `VidCdbUnavailable` variants in `error.rs`)
/// * `(None, None)` — handshake not attempted (no keydb;
/// resolution will proceed with VID=zero and rely on path 1
/// disc-hash → VUK lookup)
@@ -42,18 +130,51 @@ impl Disc {
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> (Option<HandshakeResult>, Option<Error>) {
let unlocked = session.is_unlocked();
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_entry",
raw_read_active = session.is_raw_read_active(),
unlocked,
"do_handshake entered"
);
if unlocked {
// Try OEM VID retrieval first. If the drive's profile
// doesn't carry the CDB template, or the response is
// malformed, fall through to cert-based auth.
match Self::read_vid(session, opts) {
Ok(volume_id) => {
return (
Some(HandshakeResult {
volume_id,
read_data_key: None,
}),
None,
);
}
Err(Error::DriveProfileMissing) | Err(Error::VidCdbUnavailable) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_oem_unavailable",
"OEM VID path unavailable for this drive; trying cert handshake"
);
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_oem_failed",
error_code = e.code(),
"OEM VID retrieval failed; trying cert handshake"
);
}
}
}
Self::do_handshake_cert(session, opts)
}
/// Cert-based AACS handshake. The only auth path post-v0.25.13;
/// `do_handshake` is now a thin diagnostic wrapper.
/// Cert-based AACS handshake. The legacy auth path; still used as
/// the fallback when the OEM VID path isn't available or fails.
fn do_handshake_cert(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
@@ -115,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.
//
@@ -303,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 {
@@ -332,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,
@@ -344,4 +467,66 @@ impl Disc {
volume_id,
})
}
/// Resolve encryption from a caller-supplied Unit Key (the keyserver
/// 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 (keyserver path)"
);
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]),
})
}
}
+130
View File
@@ -130,6 +130,11 @@ 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.
vid: Option<[u8; 16]>,
}
impl Mapfile {
@@ -154,6 +159,7 @@ impl Mapfile {
},
dirty: false,
last_flushed: Instant::now(),
vid: None,
};
// Eager initial persist so a resume can pick this up even if
// `record()` is never called.
@@ -168,6 +174,7 @@ 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;
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
@@ -178,6 +185,11 @@ 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());
}
continue;
}
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
@@ -227,6 +239,7 @@ impl Mapfile {
stats,
dirty: false,
last_flushed: Instant::now(),
vid,
})
}
@@ -316,6 +329,22 @@ 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
}
pub fn entries(&self) -> &[MapEntry] {
&self.entries
}
@@ -388,6 +417,18 @@ 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.
if let Some(vid) = self.vid {
let mut hex = String::with_capacity(32);
for b in vid {
use std::fmt::Write as _;
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 +458,22 @@ 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)
}
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 +640,79 @@ mod tests {
let _ = std::fs::remove_file(&p);
}
#[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");
+114 -21
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 keyserver 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,6 +945,13 @@ 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 — the second, mutually-exclusive key source
/// (the online-keyserver path). When set, libfreemkv skips keydb lookup
/// and all derivation and uses this key directly to decrypt. Takes
/// precedence over `keydb_path` if both are set. The caller obtains it
/// however it likes (e.g. POSTing the disc's `Unit_Key_RO.inf` + MKB to a
/// keyserver); libfreemkv stays free of any network dependency.
pub unit_key: Option<[u8; 16]>,
}
impl ScanOptions {
@@ -961,9 +981,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,
@@ -1043,9 +1063,10 @@ impl Disc {
/// The session must be open and unlocked (Drive::open handles this).
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
// AACS handshake (Blu-ray/UHD). Cert-based mutual auth; logs
// is_raw_read_active() as a diagnostic but the auth path no
// longer branches on it.
// AACS handshake (Blu-ray/UHD). Routes through Disc::read_vid,
// which prefers the per-drive OEM CDB path when the drive is
// in the extended-access state and falls back to cert-based
// mutual auth otherwise.
let (handshake, handshake_error) = Self::do_handshake(session, opts);
// Request max read speed — removes riplock on DVD
@@ -1130,6 +1151,45 @@ 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 keys
/// out-of-band (the keyserver path) — POST these to the keyserver, get the
/// Unit Key, then scan with `ScanOptions { unit_key: Some(uk), .. }`.
/// libfreemkv itself never makes the 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)?;
let mkb = udf_fs
.read_file(&mut reader, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
Ok((inf, mkb))
}
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. Keys are
/// needed *during* scan and the ISO only exists post-rip, so the keyserver
/// path fetches the disc's key files from the live drive before
/// [`Disc::scan`], POSTs them, 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)?;
let mkb = udf_fs
.read_file(&mut reader, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
Ok((inf, mkb))
}
/// Core scan pipeline — works with any SectorSource.
///
/// `handshake_error` is plumbed from `do_handshake` so failures
@@ -1149,7 +1209,16 @@ 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
// (keyserver path). 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 {
match opts.resolve_keydb() {
Some(keydb_path) => {
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
@@ -1197,8 +1266,6 @@ impl Disc {
(None, Some(final_err))
}
}
} else {
(None, None)
};
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
@@ -1478,6 +1545,7 @@ impl Disc {
skip_on_error: opts.multipass,
progress: opts.progress,
halt: opts.halt.clone(),
vid: opts.vid,
};
self.sweep(reader, path, &sweep_opts)
}
@@ -1569,13 +1637,22 @@ 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 AACS Volume ID into the mapfile header so it
// survives to deferred-mux / resume. ddrescue-safe (comment line);
// does not touch the ISO payload. On a resume-load the VID is
// already present, but re-setting it (idempotent) covers the case
// where Pass 1 created the mapfile before the VID was known.
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).
@@ -2047,6 +2124,11 @@ 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`.
pub vid: Option<[u8; 16]>,
}
#[derive(Debug, Clone, Copy)]
@@ -2068,6 +2150,9 @@ 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.
pub vid: Option<[u8; 16]>,
}
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
@@ -2578,6 +2663,7 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(
@@ -2602,6 +2688,7 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
@@ -2632,6 +2719,7 @@ mod tests {
multipass: false,
progress: None,
halt: None,
vid: None,
};
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
assert!(
@@ -2661,6 +2749,7 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
};
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
assert!(
@@ -2678,6 +2767,7 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
};
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
assert!(
@@ -2710,6 +2800,7 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
};
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
@@ -2722,6 +2813,7 @@ mod tests {
multipass: true,
progress: None,
halt: None,
vid: None,
};
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
assert!(
@@ -2754,6 +2846,7 @@ mod tests {
multipass: false,
progress: None,
halt: None,
vid: None,
};
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
+16 -9
View File
@@ -184,6 +184,13 @@ impl Drive {
self.profile.is_some()
}
/// Borrow the matched drive profile, if any. Used by callers that
/// need to issue per-drive OEM CDB templates (e.g. the OEM VID
/// retrieval path in `disc::encrypt`).
pub fn drive_profile(&self) -> Option<&DriveProfile> {
self.profile.as_ref()
}
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
self.scsi.as_mut()
@@ -432,23 +439,23 @@ impl Drive {
}
}
/// True if the drive is currently in raw-read mode.
/// True if the drive is currently in the extended-access state.
///
/// Detected by the platform driver during `init()` from the unlock
/// response's mode markers. When true:
/// - SCSI READ_10 returns plaintext sectors (no AACS bus
/// encryption applied)
/// - VID retrieval works without the cert-based AACS handshake
/// - VID retrieval works via the per-drive OEM CDB in
/// [`DriveProfile`] without the cert-based AACS handshake
/// - Disc-side Host Revocation List enforcement is effectively
/// bypassed by the alternate data path
///
/// AACS layer code should branch on this: if true, skip
/// `aacs::handshake::aacs_authenticate` (the cert dance) and read
/// VID via the alternate VID read path. If false, fall back to the
/// standard cert-based handshake.
pub fn is_raw_read_active(&self) -> bool {
/// AACS layer code branches on this: if true, issue the OEM
/// `read_vid_cdb` to retrieve VID directly; if false, fall back
/// to the cert-based mutual-auth handshake.
pub fn is_unlocked(&self) -> bool {
match self.driver {
Some(ref d) => d.is_raw_read_active(),
Some(ref d) => d.is_unlocked(),
None => false,
}
}
@@ -522,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
+12
View File
@@ -76,6 +76,8 @@ pub const E_AACS_RAW_READ_UNSUPPORTED: u16 = 7016;
pub const E_AACS_VID_UNAVAILABLE: u16 = 7017;
pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019;
pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
@@ -243,6 +245,14 @@ pub enum Error {
/// Disc-hash lookup in the keydb missed and no other path is
/// available (typically because VID is missing).
AacsVukNotInKeydb,
/// Drive identity did not match any bundled profile; per-drive CDB
/// templates aren't available so the OEM VID retrieval path can't
/// run.
DriveProfileMissing,
/// Drive's profile is present but doesn't carry a VID-retrieval CDB
/// template (older profile blob, or a drive class without an OEM
/// VID path).
VidCdbUnavailable,
// Keydb (8xxx)
KeydbConnect {
@@ -333,6 +343,8 @@ impl Error {
Error::AacsVidUnavailable => E_AACS_VID_UNAVAILABLE,
Error::AacsMkUnavailable => E_AACS_MK_UNAVAILABLE,
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
-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
+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)?;
//! }
+6 -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 (keyserver path) — 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,9 @@ 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,
};
// FileSectorSource is the sole file-backed sector source.
// It carries the platform-tuned SEQUENTIAL fadvise hint
+11 -10
View File
@@ -19,17 +19,18 @@ pub(crate) trait PlatformDriver: Send {
/// True after successful init().
fn is_ready(&self) -> bool;
/// True if the drive is currently in raw-read mode (the per-drive
/// runtime firmware has been uploaded AND the drive confirms
/// active mode via the marker bytes in the unlock response). When
/// true the host can read sectors without AACS bus encryption and
/// retrieve VID without cert-based mutual auth — the cert/HRL gate
/// on the drive's standard AACS path is effectively bypassed by
/// the alternate data path.
/// True if the drive is currently in the extended-access state —
/// per-drive runtime firmware uploaded AND the unlock response's
/// marker bytes confirm the mode is live. When true:
/// - host can issue the per-drive OEM CDBs in
/// [`crate::profile::DriveProfile`]
/// - VID retrieval works via the OEM CDB path (no cert-based
/// mutual auth required)
/// - SCSI READ_10 returns plaintext sectors (no bus encryption)
///
/// Default `false` — platforms that don't implement this mode are
/// always reported as inactive.
fn is_raw_read_active(&self) -> bool {
/// Default `false` — platforms without this mode always report
/// inactive.
fn is_unlocked(&self) -> bool {
false
}
}
+69 -46
View File
@@ -25,10 +25,14 @@ const SUB_CMD_INIT: u8 = 0x12;
const SUB_CMD_PROBE: u8 = 0x14;
const UNLOCK_RESPONSE_SIZE: u8 = 64;
const VALIDATE_RESPONSE_SIZE: u8 = 4;
/// Primary mode marker at bytes [12..16] of the unlock response — set
/// by the platform firmware when the runtime image is loaded and the
/// extended-access surface is live.
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
/// Mode-identifier marker repeated through bytes 16..64 of the unlock
/// response on a drive whose runtime firmware is uploaded and active.
/// Secondary mode marker repeated through bytes [16..64] of the unlock
/// response. Confirms the runtime firmware is the one driving the
/// response, not a stale image's residual buffer.
const FIRMWARE_MODE_OFFSET: usize = 16;
const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
@@ -50,12 +54,18 @@ pub struct Mt1959 {
pub(crate) profile: DriveProfile,
pub(crate) mode: u8,
pub(crate) buffer_id: u8,
pub(crate) unlocked: bool,
/// True after `run_init` has completed the unlock handshake (and any
/// required firmware upload). Gates probe + downstream control
/// commands; says nothing about whether the drive is in
/// extended-access mode.
pub(crate) init_complete: bool,
/// True when the unlock response carried both the per-drive
/// signature AND a 4-byte marker at offset 12 plus a secondary
/// 4-byte marker at offset 16. When true the drive will accept
/// raw-read SCSI traffic without AACS bus encryption / cert auth.
raw_read_active: bool,
/// signature AND the primary mode marker at offset 12 AND the
/// secondary mode marker at offset 16. When true the drive is in
/// the extended-access state — host can issue the per-drive
/// OEM CDBs and read sectors without the cert-based AACS bus
/// encryption / mutual-auth gate.
unlocked: bool,
probed: bool,
}
@@ -70,8 +80,8 @@ impl Mt1959 {
profile,
mode,
buffer_id,
init_complete: false,
unlocked: false,
raw_read_active: false,
probed: false,
}
}
@@ -151,20 +161,20 @@ impl Mt1959 {
return Err(Error::UnlockFailed);
}
// Raw-read mode is active when BOTH the per-drive signature
// matched AND the response carries the secondary 4-byte marker
// at offset 16, repeated through bytes 16..64. The active-mode
// signature at [12..16] checked above is the primary gate; the
// [16..20] marker is the redundant confirmation the firmware
// writes through the rest of the response. Requiring both
// before we tell the AACS layer "skip the cert dance" keeps
// any partial / corrupted response from steering us into the
// bypass.
self.raw_read_active = response.len() >= FIRMWARE_MODE_OFFSET + 4
// Extended-access state is active when BOTH the per-drive
// signature matched AND the response carries the secondary
// marker at offset 16 (repeated through bytes 16..64) AND the
// primary mode marker at [12..16] is present. The active-mode
// marker at [12..16] is the primary gate; the [16..20] marker
// is the redundant confirmation the firmware writes through
// the rest of the response. Requiring both before we tell the
// upper layer "OEM path is live" keeps any partial / corrupted
// response from steering us off the cert-auth fallback.
self.unlocked = response.len() >= FIRMWARE_MODE_OFFSET + 4
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG
&& response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
self.unlocked = true;
self.init_complete = true;
Ok(response)
}
@@ -200,11 +210,11 @@ impl Mt1959 {
// ── Init (unlock + firmware) ───────────────────────────────────────
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let mut unlocked = false;
let mut succeeded = false;
for _attempt in 0..3 {
match self.do_unlock(scsi) {
Ok(_) => {
unlocked = true;
succeeded = true;
break;
}
Err(Error::SignatureMismatch { .. }) => {
@@ -225,7 +235,7 @@ impl Mt1959 {
}
}
}
if !unlocked {
if !succeeded {
return Err(Error::UnlockFailed);
}
Ok(())
@@ -237,14 +247,14 @@ impl Mt1959 {
/// per region. Two passes, then SET_CD_SPEED(max). After this the
/// drive manages per-zone speeds internally.
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.unlocked {
if !self.init_complete {
self.do_unlock(scsi)?;
}
// Detect disc type from capacity to select probe mode.
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
// Verified from MakeMKV strace: BD and UHD use different init addresses.
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
let cap_cdb = [
SCSI_READ_CAPACITY,
0x00,
@@ -336,14 +346,14 @@ impl Mt1959 {
impl PlatformDriver for Mt1959 {
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if self.unlocked {
if self.init_complete {
return Ok(());
}
self.run_init(scsi)
}
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.unlocked {
if !self.init_complete {
// Don't retry init here — if init() failed, probing can't work either.
// Retrying causes repeated USB bus resets on BU40N.
return Ok(());
@@ -355,11 +365,11 @@ impl PlatformDriver for Mt1959 {
}
fn is_ready(&self) -> bool {
self.unlocked
self.init_complete
}
fn is_raw_read_active(&self) -> bool {
self.raw_read_active
fn is_unlocked(&self) -> bool {
self.unlocked
}
}
@@ -404,6 +414,19 @@ mod tests {
},
signature,
firmware: Vec::new(),
unlock_init_value: 0,
unlock_response_size: 0,
read_vid_cdb: None,
read_disc_keys_cdb: None,
drive_nominal_speed_cdb: None,
set_speed_max_cdb: None,
read10_raw_2sec_cdb: None,
read10_raw_1sec_cdb: None,
read_buffer_verify_cdb: None,
write_buffer_cdb: None,
read_buffer_unlock_cdb: None,
speed_zone_table: None,
speed_calc_table: None,
}
}
@@ -426,7 +449,7 @@ mod tests {
}
#[test]
fn do_unlock_sets_raw_read_active_when_both_markers_present() {
fn do_unlock_sets_unlocked_when_both_markers_present() {
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
let mut transport = ScriptedTransport { response };
@@ -434,28 +457,28 @@ mod tests {
let raw = mt.do_unlock(&mut transport).expect("unlock should succeed");
assert_eq!(raw.len(), 64);
assert!(mt.unlocked, "unlocked flag set after success");
assert!(mt.init_complete, "init_complete set after success");
assert!(
mt.is_raw_read_active(),
"both markers present -> raw_read_active"
mt.is_unlocked(),
"both markers present -> extended-access state"
);
}
#[test]
fn do_unlock_unlocked_but_not_raw_read_when_id_marker_missing() {
// Active-mode primary marker present (so unlock passes) but the
// secondary marker is replaced with zeros — drive isn't serving
// raw-read traffic on this path.
fn do_unlock_init_complete_but_not_unlocked_when_id_marker_missing() {
// Primary mode marker present (so init passes) but the
// secondary marker is replaced with zeros — drive isn't in
// extended-access state.
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile(sig), false);
mt.do_unlock(&mut transport).expect("unlock should succeed");
assert!(mt.unlocked);
assert!(mt.init_complete);
assert!(
!mt.is_raw_read_active(),
"missing secondary marker -> raw-read not active"
!mt.is_unlocked(),
"missing secondary marker -> not in extended-access state"
);
}
@@ -471,15 +494,15 @@ mod tests {
let err = mt.do_unlock(&mut transport).unwrap_err();
assert!(matches!(err, Error::SignatureMismatch { .. }));
assert!(!mt.unlocked);
assert!(!mt.is_raw_read_active());
assert!(!mt.init_complete);
assert!(!mt.is_unlocked());
}
#[test]
fn do_unlock_rejects_inactive_mode_marker() {
// Signature matches but the primary marker at [12..16] is
// missing -> drive is not in active mode; both unlock and the
// raw-read flag must stay false.
// missing -> drive is not in active mode; init_complete and the
// unlocked flag must both stay false.
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG);
let mut transport = ScriptedTransport { response };
@@ -487,7 +510,7 @@ mod tests {
let err = mt.do_unlock(&mut transport).unwrap_err();
assert!(matches!(err, Error::UnlockFailed));
assert!(!mt.unlocked);
assert!(!mt.is_raw_read_active());
assert!(!mt.init_complete);
assert!(!mt.is_unlocked());
}
}
+108
View File
@@ -35,6 +35,42 @@ pub struct DriveProfile {
pub signature: [u8; 4],
#[serde(default, deserialize_with = "deserialize_base64")]
pub firmware: Vec<u8>,
// ── OEM-extended-access CDB templates ──────────────────────────────
//
// All optional — older profile blobs that pre-date the CDB capture
// pipeline simply omit these fields and decode as `None`. Encoded
// in the JSON as lowercase hex strings without separators
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
#[serde(default)]
pub unlock_init_value: u8,
#[serde(default)]
pub unlock_response_size: u8,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_vid_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_disc_keys_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub set_speed_max_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_verify_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub write_buffer_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
// Per-drive identifier tables — variable-length hex strings.
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_zone_table: Option<Vec<u8>>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_calc_table: Option<Vec<u8>>,
}
/// Chipset + variant — determined by which section the profile was found in.
@@ -99,6 +135,78 @@ where
.map_err(serde::de::Error::custom)
}
// ── Fixed-length hex deserializers for CDB templates ────────────────────
//
// Profile JSON encodes CDBs as lowercase hex strings without separators.
// An empty string / null / missing field decodes as `None`.
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
if s.len() % 2 != 0 {
return Err("odd hex length");
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
let byte = u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex digit")?;
out.push(byte);
}
Ok(out)
}
fn deserialize_opt_hex_bytes_10<'de, D>(
deserializer: D,
) -> std::result::Result<Option<[u8; 10]>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
if bytes.len() != 10 {
return Err(serde::de::Error::custom("expected 10 bytes"));
}
let mut out = [0u8; 10];
out.copy_from_slice(&bytes);
Ok(Some(out))
}
fn deserialize_opt_hex_bytes_12<'de, D>(
deserializer: D,
) -> std::result::Result<Option<[u8; 12]>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
if bytes.len() != 12 {
return Err(serde::de::Error::custom("expected 12 bytes"));
}
let mut out = [0u8; 12];
out.copy_from_slice(&bytes);
Ok(Some(out))
}
fn deserialize_opt_hex_bytes<'de, D>(
deserializer: D,
) -> std::result::Result<Option<Vec<u8>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
Ok(Some(bytes))
}
// ── Loading ────────────────────────────────────────────────────────────
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
+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();
+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(),