mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd7e1de2a | ||
|
|
da62ee7cf2 | ||
|
|
575c76156f | ||
|
|
b518860d9c | ||
|
|
c8eb42b490 | ||
|
|
48570ac065 | ||
|
|
08e46640fd | ||
|
|
a9195824ff | ||
|
|
401fe23988 | ||
|
|
f89bce5851 | ||
|
|
dfccb85e15 | ||
|
|
ebedffb762 | ||
|
|
fdf63ccb7f | ||
|
|
c735d284da | ||
|
|
94ab7bc73c | ||
|
|
9f209fe066 | ||
|
|
32a1a6e095 | ||
|
|
1d3b8f5fb6 | ||
|
|
97b0ae7be2 | ||
|
|
7756f1feca | ||
|
|
1565da610a | ||
|
|
e134616422 | ||
|
|
8d54a3c64e | ||
|
|
c0478e1273 | ||
|
|
7d29168fec |
@@ -5,3 +5,6 @@ Cargo.lock
|
||||
.DS_Store
|
||||
.cargo/
|
||||
.claude/worktrees/
|
||||
|
||||
# session scratch — never track (may contain RE breadcrumbs)
|
||||
scratch/
|
||||
|
||||
+87
-30
@@ -1,5 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## 0.27.3 (2026-06-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **The mapfile now persists resolved AACS unit keys, not just the Volume ID.**
|
||||
A keyed disc writes its decrypted unit keys as `# freemkv-uk: <cps>:<hex>`
|
||||
comment headers; an unresolved disc writes only the `# freemkv-vid:` marker.
|
||||
The two are **mutually exclusive** (`Mapfile::set_unit_keys` clears the VID):
|
||||
unit keys are the final answer, so a deferred-mux / resume decrypts directly
|
||||
with no key-service round-trip, while the VID alone means "still unresolved —
|
||||
retry the key service." `CopyOptions`/`SweepOptions` gain a `unit_keys` field
|
||||
(written when non-empty, else the VID), and `Disc::inject_unit_keys` applies
|
||||
mapfile-recovered keys to a scanned disc (marking the source `ExternalUk`).
|
||||
|
||||
## 0.27.0 (2026-06-03)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AACS unit-encryption detection now reads the raw MPEG-TS sync bytes
|
||||
instead of header flag bits.** `is_unit_encrypted` is renamed
|
||||
`is_aacs_scrambled`: a unit is encrypted iff its body TS packet sync bytes
|
||||
(`0x47` at offset 4 and every 192 bytes) are not intact — the encrypted
|
||||
body destroys them. The previous check read the TP_extra copy-control bits
|
||||
(byte 0) or TS transport_scrambling_control bits (byte 7), which discs and
|
||||
players do not set reliably. A single predicate now backs both the decrypt
|
||||
gate and out-of-band key validation, so all callers agree on what
|
||||
"encrypted" means. Decryption restores the syncs, so a decrypted unit reads
|
||||
as clear and there is no encryption flag to clear.
|
||||
|
||||
## 0.26.1 (2026-05-22)
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS resolver path 5 — KEYDB unit-keys direct fallback.** When a
|
||||
KEYDB entry for the disc has no VUK field but does have pre-decrypted
|
||||
unit keys, the resolver now consumes those unit keys directly instead
|
||||
of treating the entry as unusable. Covers ~4,572 entries in the
|
||||
public keydb (≈2.5% of the database, heavily skewed toward MKBv76+
|
||||
UHD discs that DVDFab/FindVUK can no longer extract VUKs for).
|
||||
Surfaces as `KeySource::KeyDbUnitKeys`; `AacsState::vuk` and
|
||||
`ResolvedKeys::vuk` are `None` on this path because no VUK exists to
|
||||
return. Partial CPS-unit coverage is rejected so a disc is never
|
||||
half-decrypted.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AACS resolver path order reordered root-to-leaf.** Paths now run in
|
||||
derivation-strength order: device-key (1) → processing-key (2) →
|
||||
KEYDB-derived from MK+VID (3) → KEYDB VUK direct (4) → KEYDB unit
|
||||
keys direct (5). Previous order was leaf-first (KEYDB hash lookup,
|
||||
then derivation). Resolution runs once per disc, so the few extra
|
||||
milliseconds of MKB walking when a KEYDB VUK would have answered are
|
||||
invisible against the rip itself; the new order matches the AACS
|
||||
derivation hierarchy. `KeySource` enum variants are reordered to
|
||||
match.
|
||||
- **`AacsState::vuk` type is now `Option<[u8; 16]>`** (was `[u8; 16]`).
|
||||
Required by path 5, which has no VUK. Public-API break.
|
||||
- **`ResolvedKeys::vuk` type is now `Option<[u8; 16]>`** (was
|
||||
`[u8; 16]`). Same rationale.
|
||||
|
||||
## 0.25.14 (2026-05-21)
|
||||
|
||||
### Changed
|
||||
@@ -48,17 +108,17 @@ No behavioural change — purely a rename pass.
|
||||
- **Libredrive raw-read VID shortcut deleted.** v0.25.11 introduced a
|
||||
`do_handshake` branch that, on libredrive-active drives, skipped the
|
||||
AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80
|
||||
with AGID=0 directly. The hypothesis was that firmware-uploaded
|
||||
drives would serve VID without auth. Empirical test on rip1 (BU40N
|
||||
+ Barbie UHD, 2026-05-21) showed the drive returns
|
||||
with AGID=0 directly. The hypothesis was that unlocked
|
||||
drives would serve VID without auth. Empirical test (BU40N
|
||||
+ UHD disc, 2026-05-21) showed the drive returns
|
||||
`0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key
|
||||
exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of
|
||||
firmware-upload state. The AACS spec requires a successful
|
||||
drive-unlock state. The AACS spec requires a successful
|
||||
`REPORT_KEY` / `SEND_KEY` exchange to establish an AGID before
|
||||
format 0x80 returns VID; that requirement is enforced by the drive
|
||||
itself and isn't bypassed by libredrive firmware. The shortcut
|
||||
fired for every libredrive-active drive, so v0.25.11 / v0.25.12
|
||||
Barbie scans were stuck at E7017 instead of progressing to the
|
||||
MOVIE scans were stuck at E7017 instead of progressing to the
|
||||
real wall (no DK walks MKB v77).
|
||||
- `Disc::do_handshake` now always routes through `do_handshake_cert`.
|
||||
`Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker
|
||||
@@ -67,7 +127,7 @@ No behavioural change — purely a rename pass.
|
||||
- `read_volume_id_libredrive` deleted (~50 LOC).
|
||||
|
||||
The corollary: AACS resolution on HRL-burned drives + UHD discs now
|
||||
fails honestly. Either cert auth succeeds (firmware-upload may or
|
||||
fails honestly. Either cert auth succeeds (drive unlock may or
|
||||
may not bypass the HRL — that's the new empirical question) and we
|
||||
hit the actual DK wall (E7018 "No DK that walks this MKB" for v77+
|
||||
UHD without a v77+ DK in keydb), or cert auth fails and we surface
|
||||
@@ -193,7 +253,7 @@ a fast-fail firmware wedge state where every subsequent CDB returns
|
||||
`ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB` (sense 0x05/0x24) until the
|
||||
drive is physically power-cycled.
|
||||
|
||||
Live wedge event on rip1 2026-05-20 during a Barbie UHD scan
|
||||
Live wedge event 2026-05-20 during a UHD disc scan
|
||||
(KEYDB miss) confirmed the diagnosis and motivated this fix.
|
||||
|
||||
Defence-in-depth:
|
||||
@@ -304,7 +364,7 @@ runs on the caller's thread. Communication between stages is via
|
||||
bounded `crossbeam_channel` with a recycled buffer pool — no
|
||||
allocations or memcpys in the steady-state hot loop.
|
||||
|
||||
**Throughput on the rip1 testbed (Civil_War UHD, 62 GiB ISO →
|
||||
**Throughput on the test bed (a UHD disc, 62 GiB ISO →
|
||||
`null://`, single-thread caller):**
|
||||
|
||||
| | MB/s |
|
||||
@@ -454,8 +514,7 @@ in-tree consumers (autorip, the `freemkv` CLI) drove their own multipass
|
||||
loops in 0.18 rounds 1-3; 0.18.1 lands the cleanup with the deprecated names
|
||||
still alive for one minor-version window.
|
||||
|
||||
The 0.18 design notes are in
|
||||
`(internal)/memory/0_18_redesign.md` (private) — this entry sticks to
|
||||
The 0.18 design notes are kept privately — this entry sticks to
|
||||
what changed at the public surface.
|
||||
|
||||
#### Flat verbs
|
||||
@@ -735,7 +794,7 @@ recovery.
|
||||
`READ_RECOVERY_TIMEOUT_MS = 60_000` (the v0.17.3 baseline; the kernel-
|
||||
auto-retry pattern is now provided by the `/dev/sr0` fallback above).
|
||||
|
||||
### Empirical results (Dune Part Two UHD, BU40N direct SATA)
|
||||
### Empirical results (a UHD disc, BU40N direct SATA)
|
||||
|
||||
- Pass 1: **94.6 MB recovered** (28% of formerly-bad data, 33 sr0 fallback
|
||||
saves), **11 s of main-title content** restored. Patch completed all 47
|
||||
@@ -863,7 +922,7 @@ bridges (notably the Initio INIC-1618L) that crash on MEDIUM ERROR retries.
|
||||
- Patch instrumentation: counters for reads_ok/err, writes_ok/err, finished/unreadable.
|
||||
- `as_encoded_bytes()` replaces `as_bytes()` for portable OsStr handling.
|
||||
- Removed inline retry/reset from Drive::read — orchestration layer handles recovery.
|
||||
- Removed all `(internal)` references from public code.
|
||||
- Removed all internal references from public code.
|
||||
|
||||
## 0.13.26 (2026-04-27)
|
||||
|
||||
@@ -891,7 +950,7 @@ warnings` red on Linux CI that the Mac toolchain doesn't catch.
|
||||
|
||||
### Pre-commit gate uses CI's exact toolchain
|
||||
|
||||
`(internal)/scripts/precommit.sh` (new) runs `cargo +1.86 fmt
|
||||
A new pre-commit script runs `cargo +1.86 fmt
|
||||
--check`, `cargo +1.86 clippy -- -D warnings`, and `cargo +1.86 test
|
||||
--tests` across all 5 freemkv crates. Mirrors each repo's
|
||||
`.github/workflows/ci.yml` step-for-step. Use it as a pre-commit hook
|
||||
@@ -933,7 +992,7 @@ Through the entire 0.13.x line, every CHECK CONDITION reply from the
|
||||
drive (the standard way SCSI reports a sector failure) was being
|
||||
collapsed into a synthetic `status=0xFF, sense_key=0` "transport
|
||||
wedge" sentinel and the real sense data was thrown away. Live tracing
|
||||
on the BU40N reading Dune 2 on 2026-04-27 confirmed it: the drive was
|
||||
on the BU40N reading a UHD disc on 2026-04-27 confirmed it: the drive was
|
||||
returning `host_status=0, driver_status=8, status=2, exec_elapsed_ms=1416`
|
||||
on every bad sector — a clean CHECK CONDITION carrying full sense
|
||||
data — and the library was misclassifying it as a wedge and bailing.
|
||||
@@ -1005,7 +1064,7 @@ recovered or marked Unreadable. Calibration data
|
||||
|
||||
### Replace bisect-on-fail with hysteresis state machine (Block ↔ Single)
|
||||
|
||||
Live test on Dune 2 v0.13.21 showed bisect-on-fail recovered every
|
||||
Live test on a UHD disc v0.13.21 showed bisect-on-fail recovered every
|
||||
recoverable sector, but spent ~30 sec per damaged 60-block (paying a
|
||||
~5 sec kernel timeout at every bisection level). Each level descended
|
||||
log₂(60) ≈ 6 times on the failing branch.
|
||||
@@ -1034,7 +1093,7 @@ Per-block cost on a 60-sector damaged block with 1 bad sector:
|
||||
|
||||
Plus inside a damaged cluster spanning many 60-blocks, hysteresis
|
||||
pays the bpt=batch fail cost ONCE on entry; bisection paid it every
|
||||
60 sectors. For Dune 2's ~1248-sector boundary cluster that's ~21
|
||||
60 sectors. For the ~1248-sector boundary cluster that's ~21
|
||||
fewer 5-sec waits = ~100 sec saved.
|
||||
|
||||
Telemetry: new `phase=mode_change` trace event with `from`, `to`,
|
||||
@@ -1052,8 +1111,7 @@ synthetic BU40N-pattern reader; same 100% recovery expectation.
|
||||
### Fix: Disc::copy bisect-on-fail (replaces skip-forward)
|
||||
|
||||
Empirical live-hardware testing on the LG BU40N (see
|
||||
`(internal)/docs/audits/2026-04-26-test-plan-audit.md` and the
|
||||
TEST_PLAN.md run log) revealed that the drive often **fails
|
||||
the internal test-plan audit and run log) revealed that the drive often **fails
|
||||
multi-sector READ commands** in damaged regions but **succeeds when
|
||||
asked one sector at a time**. The old skip-forward strategy responded
|
||||
to multi-sector failures by jumping up to 1 % of the disc forward,
|
||||
@@ -1065,7 +1123,7 @@ half, retry each half, recurse down to single-sector reads. Sectors
|
||||
the drive can read individually are recovered in Pass 1; only sectors
|
||||
that fail at bpt=1 are marked NonTrimmed for the patch passes.
|
||||
|
||||
Empirical results on Dune 2 UHD on the BU40N:
|
||||
Empirical results on a UHD disc on the BU40N:
|
||||
- Old algorithm: 25 GB read in Pass 1, then ~6 GB skip-forwarded;
|
||||
retry passes failed to recover most of the skipped zone.
|
||||
- New algorithm: ~99 % of disc recovered in Pass 1; only the truly
|
||||
@@ -1107,7 +1165,7 @@ LUN/BUS/HOST reset. `READ_RECOVERY_TIMEOUT_MS` (60 s) unchanged.
|
||||
`ioctl(fd, SG_IO, &hdr)`. The old pattern abandoned slow-but-alive
|
||||
commands faster than the drive could drain its internal queue,
|
||||
deepening the BU40N wedge. Per the audit at
|
||||
`(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`,
|
||||
internal SCSI-architecture research,
|
||||
no reference project (MakeMKV / sg_dd / ddrescue) does what we did —
|
||||
all use sync blocking SG_IO with 8-60 s timeouts and let the kernel's
|
||||
mid-layer (`scsi_eh.rst`) run ABORT TASK / LUN RESET / BUS RESET /
|
||||
@@ -1234,7 +1292,7 @@ clean reads (Finished sectors) and freezes during skip-forward bad zones,
|
||||
which made every previous version's UI look hung at the bad-zone boundary.
|
||||
This was the v0.13.9 stall-guard origin bug.
|
||||
|
||||
Live trace from v0.13.14: Pass 1 hit a Dune 2 bad zone at 24 GB and
|
||||
Live trace from v0.13.14: Pass 1 hit a bad zone at 24 GB and
|
||||
appeared "stuck" for 14 minutes per autorip's UI (`bytes_good = 23.97 GB`
|
||||
unchanged). Disc trace events showed `pos` actually advanced from 25.8 GB
|
||||
to 70 GB during that window — Pass 1 was 83 % through the disc, marking
|
||||
@@ -1282,7 +1340,7 @@ trace events were silently dropped by the default `libfreemkv=warn` rule.
|
||||
|
||||
### Telemetry: instrument the rip pipeline for in-flight diagnosis
|
||||
|
||||
v0.13.12 shipped Fix 1+2+4 + cross-platform parity but a live test on Dune 2
|
||||
v0.13.12 shipped Fix 1+2+4 + cross-platform parity but a live test
|
||||
showed Pass 1 sat for 14 minutes with `bytes_good=0` while the inner loop
|
||||
appeared to iterate (the throttled `on_progress` log fired every 78s). The
|
||||
async fd_recovery design at §7 said each `execute()` call should bound at
|
||||
@@ -1326,7 +1384,7 @@ The v0.13.9 stall guard at `disc/mod.rs` exited Pass 1 early when
|
||||
`bytes_good` was flat for `stall_secs` (default 120s). This violated the
|
||||
ddrescue model: Pass 1 must sweep end-to-end, marking failed reads
|
||||
NonTrimmed for Pass 2 retry. The guard caused Pass 1 to bail at 30% on
|
||||
Dune 2 with 56 GB still NonTried, leaving Pass 2 nothing useful to do.
|
||||
a UHD disc with 56 GB still NonTried, leaving Pass 2 nothing useful to do.
|
||||
|
||||
- Deleted the stall-guard state vars and the `if cur_good != ...
|
||||
break 'outer;` block.
|
||||
@@ -1381,7 +1439,7 @@ same observable recovery contract as Linux:
|
||||
|
||||
`PatchResult` now reports `blocks_attempted`, `blocks_read_ok`,
|
||||
`blocks_read_failed`. Pass 2's "100 minutes recovered 0 bytes" mystery
|
||||
(Dune 2) becomes diagnosable from these counters: distinguish "drive
|
||||
(a UHD disc) becomes diagnosable from these counters: distinguish "drive
|
||||
returned Ok but write/record dropped data" from "every read was Err for
|
||||
the entire range" without instrumenting from outside the lib.
|
||||
|
||||
@@ -1410,7 +1468,7 @@ against the spawned close()). The intent was to escape the 60-s
|
||||
blocking reopen.
|
||||
|
||||
The cost was too high: a single transient poll timeout permanently
|
||||
killed the transport. Live test on Dune 2 (post-replug):
|
||||
killed the transport. Live test on a UHD disc (post-replug):
|
||||
- Pass 1 ran for **45 ms** then returned with 0 GB good and 80 GB
|
||||
pending.
|
||||
- The first SCSI READ timed out, fd went to -1, every subsequent
|
||||
@@ -1443,7 +1501,7 @@ Sync bump for the autorip-side fix in 0.13.10 (Pass 1 batch reporting).
|
||||
### Fix: Disc::copy silent stall + SgIoTransport reopen-after-timeout serialization
|
||||
|
||||
Two correlated fixes for a hang observed live on the LG BU40N during a
|
||||
v0.13.8 rip of Dune: Part Two. At ~30 % progress through Pass 1
|
||||
v0.13.8 rip of a UHD disc. At ~30 % progress through Pass 1
|
||||
(disc → ISO), `bytes_good` froze for 10+ minutes with `errs=0`,
|
||||
no error surfaced, drive not wedged.
|
||||
|
||||
@@ -2330,10 +2388,9 @@ Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full
|
||||
- **Kernel transfer limit detection**: auto-detect `max_hw_sectors_kb` via sysfs, resolve sg→block device. Previously hardcoded to 510 sectors (1MB) which exceeded the 120KB kernel limit, causing all reads to error and fall back to 6KB reads at 4.8 MB/s. Now auto-tunes to 48 sectors (96KB) or whatever the device supports.
|
||||
- **Result: 12.5 MB/s sustained, 23 MB/s peak** (was 4.8 MB/s)
|
||||
|
||||
### LibreDrive — full init pipeline
|
||||
### Drive init pipeline
|
||||
|
||||
- **All 10 ARM handlers translated**: unlock, firmware upload (A: WRITE_BUFFER, B: MODE SELECT), calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
|
||||
- **Cold boot firmware upload**: WRITE_BUFFER 1888B (A variant) or MODE SELECT 2496B (B variant) proven on hardware
|
||||
- **Full drive init pipeline**: unlock, calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
|
||||
- **Speed calibration**: 256+ disc surface probes, 64-entry speed table, triple SET_CD_SPEED
|
||||
- **Platform trait locked down**: `pub(crate)`, 3 methods only (init, set_read_speed, is_ready)
|
||||
- **Init guard**: prevents double-init, signature mismatch aborts early
|
||||
@@ -2348,7 +2405,7 @@ Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full
|
||||
### Profiles
|
||||
|
||||
- **206 profiles with full per-drive data**: ld_microcode (base64), all CDBs, speed tables, signatures
|
||||
- **Automated pipeline**: `sdf_unpack --profiles` → profiles.json (no manual merging)
|
||||
- **Automated pipeline**: profile import → profiles.json (no manual merging)
|
||||
|
||||
## 0.4.0 (2026-04-07)
|
||||
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.26.0"
|
||||
version = "0.27.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -31,8 +31,7 @@ tracing = "0.1"
|
||||
# Bounded MPSC channel with kernel-wakeup send_timeout. Used by `io::pipeline`
|
||||
# so the halt-aware send/finish loops can BLOCK on consumer drain instead of
|
||||
# polling — the 50 ms poll cadence of the previous mpsc-based impl capped mux
|
||||
# throughput at ~1 MB/s (see (internal)/memory/
|
||||
# feedback_send_with_halt_poll_throttle.md, 0.21.7).
|
||||
# throughput at ~1 MB/s (0.21.7).
|
||||
crossbeam-channel = "0.5"
|
||||
# Persistent work-stealing thread pool for parallel AACS unit
|
||||
# decryption. Per-call std::thread::scope spawned fresh OS threads
|
||||
|
||||
@@ -1,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
@@ -85,10 +85,10 @@ All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||
// PES pipeline (frame-level) — input() returns Box<dyn FrameSource>,
|
||||
// output() returns Box<dyn FrameSink>.
|
||||
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
|
||||
let input = libfreemkv::input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let output = libfreemkv::output("mkv://Dune.mkv", &title)?; // MkvOutputStream
|
||||
let output = libfreemkv::output("m2ts://Dune.m2ts", &title)?; // M2tsOutputStream
|
||||
let output = libfreemkv::output("network://10.0.0.1:9000", &title)?; // NetworkOutputStream
|
||||
let input = libfreemkv::input("iso://Movie.iso", &opts)?; // IsoStream
|
||||
let output = libfreemkv::output("mkv://Movie.mkv", &title)?; // MkvOutputStream
|
||||
let output = libfreemkv::output("m2ts://Movie.m2ts", &title)?; // M2tsOutputStream
|
||||
let output = libfreemkv::output("network://192.0.2.10:9000", &title)?; // NetworkOutputStream
|
||||
let output = libfreemkv::output("null://", &title)?; // NullOutputStream
|
||||
```
|
||||
|
||||
|
||||
+44
-23
@@ -69,16 +69,29 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
|
||||
// ── Content decryption ──────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits).
|
||||
pub fn is_unit_encrypted(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
|
||||
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
|
||||
///
|
||||
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
|
||||
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
|
||||
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
|
||||
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
|
||||
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
|
||||
/// across discs/players.
|
||||
///
|
||||
/// This is the single shared definition of "encrypted" for the whole ecosystem
|
||||
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
|
||||
/// service's validation gate all call THIS, so they always agree on what is
|
||||
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
|
||||
/// so the decrypt path never double-decrypts and there is no flag to clear.
|
||||
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Verify decrypted unit by checking TS sync bytes at expected offsets.
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
// In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header
|
||||
// then 188-byte TS packet, repeating every 192 bytes.
|
||||
// Sync byte 0x47 should appear at offset 4, 196, 388, ...
|
||||
/// Most TS packet positions in `unit` carry the `0x47` sync byte — i.e. the
|
||||
/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes
|
||||
/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body
|
||||
/// scrambles all but the first (which lives in the clear 16-byte seed).
|
||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
let mut count = 0;
|
||||
let mut offset = 4;
|
||||
while offset < unit.len() {
|
||||
@@ -87,11 +100,15 @@ fn verify_ts(unit: &[u8]) -> bool {
|
||||
}
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// Expect at least most packets to have sync bytes
|
||||
let total = (unit.len() - 4) / TS_PACKET_LEN + 1;
|
||||
count > total / 2
|
||||
}
|
||||
|
||||
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
|
||||
/// Returns true if decryption succeeded (verified by TS sync bytes).
|
||||
///
|
||||
@@ -99,12 +116,14 @@ fn verify_ts(unit: &[u8]) -> bool {
|
||||
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
|
||||
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
|
||||
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
|
||||
/// 4. Clear encryption flag bits
|
||||
///
|
||||
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
||||
/// there is no flag to clear.
|
||||
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true; // not encrypted
|
||||
}
|
||||
|
||||
@@ -124,16 +143,13 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
// Step 3: Decrypt bytes 16..6143 with AES-CBC
|
||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
|
||||
// Step 4: Clear encryption flag
|
||||
unit[0] &= !0xC0;
|
||||
|
||||
// Verify
|
||||
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
|
||||
verify_ts(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
|
||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
@@ -173,7 +189,7 @@ pub fn decrypt_unit_full(
|
||||
unit_key: &[u8; 16],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
) -> bool {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rdk) = read_data_key {
|
||||
@@ -200,10 +216,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_unit_unencrypted() {
|
||||
// Unit with 0xC0 bits clear should pass through unchanged
|
||||
// A clear unit (TS syncs intact) is not scrambled → passes through.
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0x00; // not encrypted
|
||||
let mut off = 4;
|
||||
while off < ALIGNED_UNIT_LEN {
|
||||
unit[off] = TS_SYNC;
|
||||
off += TS_PACKET_LEN;
|
||||
}
|
||||
let key = [0u8; 16];
|
||||
assert!(!is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &key));
|
||||
}
|
||||
|
||||
@@ -253,8 +274,8 @@ mod tests {
|
||||
plain[offset] = TS_SYNC;
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// Set encryption flag
|
||||
plain[0] |= 0xC0;
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
|
||||
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
|
||||
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
|
||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
@@ -281,9 +302,9 @@ mod tests {
|
||||
|
||||
// Now plain contains encrypted data. Decrypt it.
|
||||
let mut unit = plain;
|
||||
assert!(is_unit_encrypted(&unit));
|
||||
assert!(is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||
assert!(!is_unit_encrypted(&unit)); // flag should be cleared
|
||||
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
|
||||
|
||||
// Verify TS sync bytes
|
||||
let mut count = 0;
|
||||
|
||||
+133
-14
@@ -117,10 +117,19 @@ impl KeyDb {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Device Key
|
||||
// Device Key.
|
||||
// Two shapes are accepted:
|
||||
// 1. Positioned DK: `| DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...`
|
||||
// → loaded into `device_keys` (deterministic tree walk via `calc_pk_from_dk`).
|
||||
// 2. Orphan DK: `| DK | DEVICE_KEY 0x...` with no position fields.
|
||||
// → loaded into `processing_keys` (brute walker / terminal validation).
|
||||
// Per AACS spec a "PK" IS a DK at terminal position, so both row types
|
||||
// are DKs in the unified model; only the metadata differs.
|
||||
if line.starts_with("| DK") {
|
||||
if let Some(dk) = Self::parse_device_key(line) {
|
||||
db.device_keys.push(dk);
|
||||
} else if let Some(key) = Self::parse_orphan_dk(line) {
|
||||
db.processing_keys.push(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -195,8 +204,53 @@ impl KeyDb {
|
||||
.or_else(|| self.disc_entries.get(&hash))
|
||||
}
|
||||
|
||||
// ── Parsers ─────────────────────────────────────────────────────────────
|
||||
/// Iterate every disc entry. Used by Path 3 (scan for matching VID).
|
||||
pub fn iter_disc_entries(&self) -> impl Iterator<Item = &DiscEntry> {
|
||||
self.disc_entries.values()
|
||||
}
|
||||
}
|
||||
|
||||
// ── KeyProvider impl ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Lets `KeyDb` plug into `resolve_keys` via the trait. Cloning happens in the
|
||||
// bulk methods because the trait returns owned `Vec`s (so HTTP-backed providers
|
||||
// don't need to retain state across calls).
|
||||
|
||||
impl super::provider::KeyProvider for KeyDb {
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.device_keys.clone()
|
||||
}
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.processing_keys.clone()
|
||||
}
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
// Every per-disc Media Key in the db. The resolver dedups; MKs are
|
||||
// MKB-scoped so the same value recurs across a pressing's discs.
|
||||
self.iter_disc_entries()
|
||||
.filter_map(|e| e.media_key)
|
||||
.collect()
|
||||
}
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
self.host_certs.clone()
|
||||
}
|
||||
fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
let mut hex = String::with_capacity(42);
|
||||
hex.push_str("0x");
|
||||
for b in disc_hash {
|
||||
hex.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
self.find_disc(&hex).cloned()
|
||||
}
|
||||
fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.iter_disc_entries()
|
||||
.find(|e| matches!(e.disc_id, Some(id) if &id == volume_id))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private parsers (re-open the inherent impl) ─────────────────────────────
|
||||
|
||||
impl KeyDb {
|
||||
fn parse_device_key(line: &str) -> Option<DeviceKey> {
|
||||
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||||
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
|
||||
@@ -229,6 +283,30 @@ impl KeyDb {
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse an orphan DK row: a `| DK |` line carrying only the
|
||||
/// `DEVICE_KEY` field (no position metadata). The key is then
|
||||
/// treated like a terminal/unpositioned label by the resolver
|
||||
/// (Path 2's brute walker). Returns `None` if the line carries
|
||||
/// any position field — those are positioned DKs and parsed by
|
||||
/// [`Self::parse_device_key`] instead.
|
||||
fn parse_orphan_dk(line: &str) -> Option<[u8; 16]> {
|
||||
if line.contains("DEVICE_NODE")
|
||||
|| line.contains("KEY_UV")
|
||||
|| line.contains("KEY_U_MASK_SHIFT")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let key_str = line
|
||||
.split("DEVICE_KEY")
|
||||
.nth(1)?
|
||||
.split('|')
|
||||
.next()?
|
||||
.split(';')
|
||||
.next()?
|
||||
.trim();
|
||||
parse_hex16(key_str)
|
||||
}
|
||||
|
||||
fn parse_host_cert(line: &str) -> Option<HostCert> {
|
||||
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||
let priv_str = line
|
||||
@@ -377,9 +455,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_disc_entry() {
|
||||
let line = r#"***REMOVED*** = DUNE_PART_TWO (Dune: Part Two) | D | 2024-04-02 | M | ***REMOVED*** | I | ***REMOVED*** | V | ***REMOVED*** | U | 1-***REMOVED*** ; MKBv77"#;
|
||||
let line = r#"0x000102030405060708090A0B0C0D0E0F10111213 = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x000102030405060708090A0B0C0D0E0F | I | 0x101112131415161718191A1B1C1D1E1F | V | 0x202122232425262728292A2B2C2D2E2F | U | 1-0x303132333435363738393A3B3C3D3E3F ; MKBv77"#;
|
||||
let entry = KeyDb::parse_disc_entry(line).unwrap();
|
||||
assert_eq!(entry.title, "Dune: Part Two");
|
||||
assert_eq!(entry.title, "Sample Film");
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert_eq!(entry.unit_keys.len(), 1);
|
||||
@@ -388,17 +466,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_device_key() {
|
||||
let line = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let dk = KeyDb::parse_device_key(line).unwrap();
|
||||
assert_eq!(dk.node, 0x0800);
|
||||
assert_eq!(dk.u_mask_shift, 0x17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orphan_dk_row_loads_into_processing_keys() {
|
||||
// `| DK |` row without position fields = an orphan DK. Per the
|
||||
// unified model the resolver treats it like a terminal/PK
|
||||
// candidate: it lands in `processing_keys` and the brute walker
|
||||
// handles it.
|
||||
let cfg = r#"
|
||||
| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA ; orphan, no position fields
|
||||
| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
|
||||
| PK | 0xCAFEBABE0001020304050607080900BB ; legacy PK row still works
|
||||
"#;
|
||||
let db = KeyDb::parse(cfg);
|
||||
assert_eq!(
|
||||
db.device_keys.len(),
|
||||
1,
|
||||
"positioned DK row should land in device_keys"
|
||||
);
|
||||
// Orphan DK + legacy PK row both end up in processing_keys.
|
||||
assert_eq!(
|
||||
db.processing_keys.len(),
|
||||
2,
|
||||
"orphan DK row + legacy PK row both belong in processing_keys"
|
||||
);
|
||||
assert_eq!(db.processing_keys[0][..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(db.processing_keys[1][..4], [0xCA, 0xFE, 0xBA, 0xBE]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_orphan_dk_rejects_lines_with_position_fields() {
|
||||
// The parser must NOT pick up a positioned DK row as an orphan
|
||||
// (that would double-count). parse_orphan_dk explicitly checks.
|
||||
let positioned = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
|
||||
assert!(
|
||||
KeyDb::parse_orphan_dk(positioned).is_none(),
|
||||
"positioned DK must not match orphan parser"
|
||||
);
|
||||
let orphan = "| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA";
|
||||
let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse");
|
||||
assert_eq!(key[..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_host_cert() {
|
||||
let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked";
|
||||
let line = "| HC | HOST_PRIV_KEY 0xDEADBEEF000102030405060708090A0B0C0D0E0F | HOST_CERT 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B ; Revoked";
|
||||
let hc = KeyDb::parse_host_cert(line).unwrap();
|
||||
assert_eq!(hc.private_key[0], 0x90);
|
||||
assert_eq!(hc.private_key[0], 0xDE);
|
||||
assert_eq!(hc.certificate.len(), 92);
|
||||
}
|
||||
|
||||
@@ -416,15 +535,15 @@ mod tests {
|
||||
assert!(!db.host_certs.is_empty());
|
||||
assert!(db.disc_entries.len() > 170000);
|
||||
|
||||
// Look up Dune: Part Two
|
||||
let dune = db
|
||||
// Look up any disc entry carrying a full key set.
|
||||
let entry = db
|
||||
.disc_entries
|
||||
.values()
|
||||
.find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some())
|
||||
.expect("Dune: Part Two not found");
|
||||
assert!(dune.media_key.is_some());
|
||||
assert!(dune.vuk.is_some());
|
||||
assert!(!dune.unit_keys.is_empty());
|
||||
.find(|e| e.vuk.is_some() && e.media_key.is_some() && !e.unit_keys.is_empty())
|
||||
.expect("no disc entry with a full key set");
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert!(!entry.unit_keys.is_empty());
|
||||
|
||||
eprintln!(
|
||||
"Parsed {} disc entries, {} DK, {} PK",
|
||||
|
||||
+851
-215
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -17,21 +17,24 @@ pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod keydb;
|
||||
pub mod keys;
|
||||
pub mod provider;
|
||||
pub mod variants;
|
||||
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_encrypted,
|
||||
is_aacs_scrambled,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||
resolve_keys_v2, resolve_keys_v21,
|
||||
mkb_content_len, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
|
||||
resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Key source abstraction for the AACS resolve chain.
|
||||
//!
|
||||
//! libfreemkv keeps all crypto (AES-G primitives, SD-tree walking,
|
||||
//! validation, MK/VUK/TK derivation) but accepts key material from
|
||||
//! arbitrary backends via [`KeyProvider`].
|
||||
//!
|
||||
//! Methods come in two flavors:
|
||||
//!
|
||||
//! - **Bulk material** ([`device_keys`], [`processing_keys`],
|
||||
//! [`host_certs`]) — the resolver unions results across all
|
||||
//! providers and tries each candidate.
|
||||
//! - **Disc-keyed lookup** ([`lookup_disc_by_hash`],
|
||||
//! [`lookup_disc_by_vid`]) — the resolver short-circuits on the
|
||||
//! first hit, so providers are queried in array order with
|
||||
//! fastest/closest first.
|
||||
//!
|
||||
//! Default impls return empty / `None` so backends only override
|
||||
//! the methods they actually support — an external key service might
|
||||
//! implement only `lookup_disc_by_hash`, while a local file might
|
||||
//! implement all five.
|
||||
//!
|
||||
//! Calls may block (disk I/O, network round-trips). The resolver
|
||||
//! invokes each method at most a handful of times per scan; for
|
||||
//! per-disc memoization, implementations should cache internally.
|
||||
//!
|
||||
//! [`device_keys`]: KeyProvider::device_keys
|
||||
//! [`processing_keys`]: KeyProvider::processing_keys
|
||||
//! [`host_certs`]: KeyProvider::host_certs
|
||||
//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash
|
||||
//! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid
|
||||
|
||||
use super::keydb::{DeviceKey, DiscEntry, HostCert};
|
||||
|
||||
/// Source of AACS key material.
|
||||
///
|
||||
/// Implementors return raw material only — the resolver in
|
||||
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
|
||||
/// MK→VUK→TK derivation). See module docs for method semantics.
|
||||
pub trait KeyProvider: Send + Sync {
|
||||
/// Device keys (top-of-tree, walked by the resolver).
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Processing keys — terminal PKs or walk-input PKs. The
|
||||
/// resolver tries each as a terminal first (cheap validate).
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Every Media Key this provider holds, regardless of which disc it was
|
||||
/// filed under. An MK is MKB-scoped (shared across a pressing/MKB-family),
|
||||
/// so the resolver can verify each against the disc's MKB (`km_verifies`)
|
||||
/// and resolve a disc whose own hash/VID isn't directly keyed.
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// AACS host certificates (with their private keys) for drive
|
||||
/// authentication. Multiple in case some are revoked.
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Direct per-disc lookup by SHA-1 of `Unit_Key_RO.inf`. Returns
|
||||
/// `Some(entry)` if this provider has pre-computed material for
|
||||
/// the disc (paths 4 and 5). Short-circuits the resolver.
|
||||
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Lookup by Volume ID (path 3 — pre-computed MK + matching
|
||||
/// VID). Short-circuits the resolver on hit.
|
||||
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolver-side helpers that aggregate across a provider array.
|
||||
///
|
||||
/// The resolver consumes `&[&dyn KeyProvider]` directly; these
|
||||
/// helpers wrap the union-vs-short-circuit policy per method.
|
||||
pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]);
|
||||
|
||||
impl Providers<'_> {
|
||||
/// Union — gather DKs from every provider.
|
||||
pub fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.0.iter().flat_map(|p| p.device_keys()).collect()
|
||||
}
|
||||
|
||||
/// Union — gather PKs from every provider.
|
||||
pub fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.0.iter().flat_map(|p| p.processing_keys()).collect()
|
||||
}
|
||||
|
||||
/// Union of distinct Media Keys across every provider, for the MK-pool
|
||||
/// brute (`km_verifies` against the disc's MKB).
|
||||
pub fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.media_keys()).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Union — gather host certs from every provider. Not yet wired into
|
||||
/// the SCSI handshake (which still reads `KeyDb.host_certs` directly);
|
||||
/// kept here so a provider-aware handshake refactor is a drop-in.
|
||||
#[allow(dead_code)]
|
||||
pub fn host_certs(&self) -> Vec<HostCert> {
|
||||
self.0.iter().flat_map(|p| p.host_certs()).collect()
|
||||
}
|
||||
|
||||
/// Short-circuit — query providers in array order, first hit wins.
|
||||
pub fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_hash(disc_hash))
|
||||
}
|
||||
|
||||
/// Short-circuit — query providers in array order, first hit wins.
|
||||
pub fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
|
||||
}
|
||||
}
|
||||
+11
-10
@@ -185,12 +185,13 @@ pub fn decrypt_sectors(
|
||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
||||
let nunits = chunks.len();
|
||||
|
||||
// Per-unit decrypt closure. The is_unit_encrypted check is
|
||||
// a byte-0 heuristic; on a misfire we snapshot+restore via
|
||||
// the original bytes so non-m2ts (e.g. MPLS/CLPI nav files)
|
||||
// survive. See test `nav_file_unit_survives_decrypt_attempt`.
|
||||
// Per-unit decrypt closure. The is_aacs_scrambled check reads the
|
||||
// raw TS syncs; a non-m2ts unit (e.g. MPLS/CLPI nav file) can look
|
||||
// scrambled and trigger a decrypt attempt, so on a verify miss we
|
||||
// snapshot+restore the original bytes so it survives. See test
|
||||
// `nav_file_unit_survives_decrypt_attempt`.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
if chunk.len() == unit_len && aacs::is_aacs_scrambled(chunk) {
|
||||
let original: Vec<u8> = chunk.to_vec();
|
||||
if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) {
|
||||
chunk.copy_from_slice(&original);
|
||||
@@ -232,11 +233,11 @@ pub fn decrypt_sectors(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose
|
||||
/// first byte has the top 2 bits set (here: the ASCII letter 'M' that
|
||||
/// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`,
|
||||
/// gets AES-decrypted with the unit key, fails the TS-sync verification,
|
||||
/// and must be restored to its original bytes — not left scrambled.
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
||||
/// under `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails
|
||||
/// the TS-sync verification, and must be restored to its original bytes —
|
||||
/// not left scrambled.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_decrypt_attempt() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
+133
-6
@@ -236,7 +236,7 @@ impl Disc {
|
||||
// optical drives) responds by entering a fast-fail firmware
|
||||
// wedge state where every subsequent CDB returns
|
||||
// ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB (sense 05/24) until
|
||||
// power-cycled. Hit live on rip1 2026-05-20 during a Barbie
|
||||
// power-cycled. Hit live on rip1 2026-05-20 during a MOVIE
|
||||
// UHD scan: KEYDB miss → 16 cert attempts in a tight loop →
|
||||
// wedge → forced host reboot + drive disconnect to recover.
|
||||
//
|
||||
@@ -424,11 +424,12 @@ impl Disc {
|
||||
Some(s) => s,
|
||||
None => return Err(miss_error),
|
||||
};
|
||||
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
|
||||
let aacs_ctx = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro_data,
|
||||
content_cert: cc_data.as_deref(),
|
||||
volume_id: &volume_id,
|
||||
keydb: &keydb,
|
||||
providers,
|
||||
mkb: mkb_data.as_deref(),
|
||||
};
|
||||
let mut ctx = DrmContext {
|
||||
@@ -453,10 +454,11 @@ impl Disc {
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||
key_source: match resolved.key_source {
|
||||
1 => KeySource::KeyDb,
|
||||
2 => KeySource::KeyDbDerived,
|
||||
3 => KeySource::ProcessingKey,
|
||||
4 => KeySource::DeviceKey,
|
||||
1 => KeySource::DeviceKey,
|
||||
2 => KeySource::ProcessingKey,
|
||||
3 => KeySource::KeyDbDerived,
|
||||
4 => KeySource::KeyDb,
|
||||
5 => KeySource::KeyDbUnitKeys,
|
||||
_ => KeySource::KeyDb,
|
||||
},
|
||||
vuk: resolved.vuk,
|
||||
@@ -465,4 +467,129 @@ impl Disc {
|
||||
volume_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve encryption from a caller-supplied Unit Key (the external key service
|
||||
/// path). No keydb, no derivation: read `Unit_Key_RO.inf` for the disc
|
||||
/// hash + version/bus-encryption flags, then use `unit_key` directly as
|
||||
/// CPS unit 1's decryption key. The handshake (if any) still supplies the
|
||||
/// volume ID and AACS 2.0 read-data key for bus decryption.
|
||||
pub(super) fn resolve_encryption_static(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorSource,
|
||||
unit_key: [u8; 16],
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let dh = aacs::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::parse_content_cert);
|
||||
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
||||
let version = match cc.as_ref().map(|c| c.version) {
|
||||
Some(aacs::AacsVersion::V10) => 1,
|
||||
Some(_) => 2,
|
||||
None if bus_encryption => 2,
|
||||
None => 1,
|
||||
};
|
||||
|
||||
let mkb_ver = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::mkb_version);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_external_uk",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
"using caller-supplied unit key"
|
||||
);
|
||||
|
||||
Ok(AacsState {
|
||||
version,
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
key_source: KeySource::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: vec![(1, unit_key)],
|
||||
read_data_key: handshake.and_then(|h| h.read_data_key),
|
||||
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a keys-free AACS state that carries only the Volume ID (+ version
|
||||
/// metadata), for callers that resolve Unit Keys out-of-band and have
|
||||
/// disabled the local keydb. The VID is on-disc content read during the
|
||||
/// handshake; preserving it here lets the out-of-band path use it. No keys
|
||||
/// are present (`unit_keys` empty, `vuk` None), so the disc reports as
|
||||
/// "encrypted, no keys" until the caller re-scans with a resolved Unit Key.
|
||||
pub(super) fn resolve_vid_only(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorSource,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let dh = aacs::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::parse_content_cert);
|
||||
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
||||
let version = match cc.as_ref().map(|c| c.version) {
|
||||
Some(aacs::AacsVersion::V10) => 1,
|
||||
Some(_) => 2,
|
||||
None if bus_encryption => 2,
|
||||
None => 1,
|
||||
};
|
||||
// MKB_RO is the correctly-sized copy; avoid reading the padded RW region.
|
||||
let mkb_ver = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::mkb_version);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_vid_only",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
has_vid = handshake.is_some(),
|
||||
"keydb disabled — carrying VID only, keys resolved out-of-band"
|
||||
);
|
||||
|
||||
Ok(AacsState {
|
||||
version,
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
key_source: KeySource::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: vec![],
|
||||
read_data_key: handshake.and_then(|h| h.read_data_key),
|
||||
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,21 @@ pub struct Mapfile {
|
||||
/// Wall-clock timestamp of the last successful `write_to_disk` (or
|
||||
/// the moment the mapfile was constructed, whichever is later).
|
||||
last_flushed: Instant,
|
||||
/// AACS Volume ID (16 bytes) for the disc, persisted as a
|
||||
/// `# freemkv-vid:` comment header so it survives to deferred-mux /
|
||||
/// resume without altering the ISO payload or breaking ddrescue
|
||||
/// data-line parsing. `None` for unencrypted / non-AACS discs.
|
||||
///
|
||||
/// MUTUALLY EXCLUSIVE with `unit_keys`: a disc whose keys were resolved
|
||||
/// persists the keys (`unit_keys`) and NOT the VID — the keys are the final
|
||||
/// answer, so deferred-mux/resume decrypts directly with no key service. A
|
||||
/// disc that did NOT resolve persists only the VID, the retry-able "still
|
||||
/// need a key" marker (a future mux can re-ask the key service with it).
|
||||
vid: Option<[u8; 16]>,
|
||||
/// Decrypted AACS unit keys `(CPS unit, key)`, persisted as `# freemkv-uk:`
|
||||
/// comment headers when the disc was successfully keyed. Mutually exclusive
|
||||
/// with `vid` (see above). Empty when unresolved.
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
@@ -154,6 +169,8 @@ impl Mapfile {
|
||||
},
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
// Eager initial persist so a resume can pick this up even if
|
||||
// `record()` is never called.
|
||||
@@ -168,6 +185,8 @@ impl Mapfile {
|
||||
let mut entries = Vec::new();
|
||||
let mut saw_current_line = false;
|
||||
let mut version = String::from("unknown");
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
@@ -178,6 +197,17 @@ impl Mapfile {
|
||||
if let Some(v) = rest.strip_prefix("Rescue Logfile. Created by ") {
|
||||
version = v.to_string();
|
||||
}
|
||||
if let Some(hex) = rest.strip_prefix("freemkv-vid:") {
|
||||
// Best-effort: a malformed or short VID comment is
|
||||
// ignored rather than failing the whole load.
|
||||
vid = parse_vid_hex(hex.trim());
|
||||
}
|
||||
if let Some(uk) = rest.strip_prefix("freemkv-uk:") {
|
||||
// `<cps>:<32hex>`. Best-effort: a malformed line is skipped.
|
||||
if let Some(entry) = parse_uk_line(uk.trim()) {
|
||||
unit_keys.push(entry);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
|
||||
@@ -227,6 +257,8 @@ impl Mapfile {
|
||||
stats,
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid,
|
||||
unit_keys,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,6 +348,41 @@ impl Mapfile {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the disc's 16-byte AACS Volume ID so it persists in the
|
||||
/// mapfile's comment header. Marks the mapfile dirty; the next
|
||||
/// `flush()` / `Drop` writes the `# freemkv-vid:` line. Does not
|
||||
/// touch the ISO payload or the ddrescue data lines.
|
||||
pub fn set_vid(&mut self, vid: [u8; 16]) {
|
||||
self.vid = Some(vid);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The disc's AACS Volume ID, if one was set or parsed from a
|
||||
/// `# freemkv-vid:` comment on load. `None` for unencrypted /
|
||||
/// non-AACS discs.
|
||||
pub fn vid(&self) -> Option<[u8; 16]> {
|
||||
self.vid
|
||||
}
|
||||
|
||||
/// Record the disc's decrypted AACS unit keys so they persist in the
|
||||
/// mapfile header (`# freemkv-uk:` lines). The KEYED state: a deferred-mux /
|
||||
/// resume decrypts directly from these with no key-service round-trip.
|
||||
/// Setting keys clears any VID — the mapfile holds keys XOR VID, never both
|
||||
/// (keys are the final answer; VID is only the "still unresolved" marker).
|
||||
pub fn set_unit_keys(&mut self, keys: &[(u32, [u8; 16])]) {
|
||||
self.unit_keys = keys.to_vec();
|
||||
if !self.unit_keys.is_empty() {
|
||||
self.vid = None;
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The disc's decrypted AACS unit keys, if the disc was keyed (parsed from
|
||||
/// `# freemkv-uk:` comments on load). Empty = unresolved (check `vid()`).
|
||||
pub fn unit_keys(&self) -> &[(u32, [u8; 16])] {
|
||||
&self.unit_keys
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
@@ -388,6 +455,30 @@ impl Mapfile {
|
||||
let file = std::fs::File::create(&tmp)?;
|
||||
let mut w = std::io::BufWriter::new(file);
|
||||
writeln!(w, "# Rescue Logfile. Created by {}", self.version)?;
|
||||
// VID comment lives in the header block. ddrescue treats any
|
||||
// `#`-prefixed line as a comment, so this round-trips through
|
||||
// our `load()` without affecting the `pos size status` data
|
||||
// parser. 16 bytes → 32 lowercase hex chars.
|
||||
// KEYS XOR VID: a keyed disc persists its unit keys (the final
|
||||
// answer — deferred-mux decrypts directly); an unresolved disc
|
||||
// persists only the VID (the retry marker, so a future mux can
|
||||
// re-ask the key service). Never both.
|
||||
use std::fmt::Write as _;
|
||||
if !self.unit_keys.is_empty() {
|
||||
for (cps, key) in &self.unit_keys {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in key {
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-uk: {cps}:{hex}")?;
|
||||
}
|
||||
} else if let Some(vid) = self.vid {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in vid {
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-vid: {hex}")?;
|
||||
}
|
||||
writeln!(w, "# Current pos / status / pass / pass_time")?;
|
||||
writeln!(w, "0x000000000 ? 1 0")?;
|
||||
writeln!(w, "# pos size status")?;
|
||||
@@ -417,6 +508,31 @@ impl Drop for Mapfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a 32-char lowercase/uppercase hex string into a 16-byte VID.
|
||||
/// Returns `None` on any malformation (wrong length, non-hex) — the
|
||||
/// caller treats a bad VID comment as simply absent rather than an
|
||||
/// error, so a corrupt header never fails a mapfile load.
|
||||
fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
if s.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
|
||||
/// `None` on any malformation so a corrupt line is ignored, never fatal.
|
||||
fn parse_uk_line(s: &str) -> Option<(u32, [u8; 16])> {
|
||||
let (cps, hex) = s.split_once(':')?;
|
||||
let cps: u32 = cps.trim().parse().ok()?;
|
||||
let key = parse_vid_hex(hex.trim())?; // 32-hex → [u8; 16], shared parser
|
||||
Some((cps, key))
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
@@ -583,6 +699,138 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_keys_round_trip_and_are_mutually_exclusive_with_vid() {
|
||||
let p = tmpfile("uk_round_trips");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 500, SectorStatus::Finished).unwrap();
|
||||
// Set a VID first, then unit keys: keys must WIN and clear the VID.
|
||||
mf.set_vid([0xAA; 16]);
|
||||
let keys: Vec<(u32, [u8; 16])> = vec![
|
||||
(
|
||||
0,
|
||||
[
|
||||
0x57, 0x60, 0xcc, 0x83, 0x3d, 0x86, 0x0e, 0x48, 0x92, 0x1f, 0x88, 0x16, 0xe1,
|
||||
0x35, 0x9b, 0xad,
|
||||
],
|
||||
),
|
||||
(1, [0x11; 16]),
|
||||
];
|
||||
mf.set_unit_keys(&keys);
|
||||
assert_eq!(
|
||||
mf.vid(),
|
||||
None,
|
||||
"set_unit_keys must clear vid (keys XOR vid)"
|
||||
);
|
||||
mf.flush().unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 0:5760cc833d860e48921f8816e1359bad"),
|
||||
"uk comment format mismatch: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 1:11111111111111111111111111111111"),
|
||||
"second uk missing: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("# freemkv-vid:"),
|
||||
"VID must NOT be written when keys are present: {text}"
|
||||
);
|
||||
|
||||
// load() recovers the unit keys (and no VID).
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.unit_keys(), keys.as_slice());
|
||||
assert_eq!(loaded.vid(), None);
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
|
||||
// VID-only path (no keys) still persists the VID as the retry marker.
|
||||
let p2 = tmpfile("uk_vid_only");
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
|
||||
mf2.set_vid([0xBB; 16]);
|
||||
mf2.flush().unwrap();
|
||||
let loaded2 = Mapfile::load(&p2).unwrap();
|
||||
assert_eq!(loaded2.vid(), Some([0xBB; 16]));
|
||||
assert!(loaded2.unit_keys().is_empty());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vid_round_trips_and_data_lines_unaffected() {
|
||||
let p = tmpfile("vid_round_trips");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
|
||||
// Build a mapfile with some data ranges, set a VID, persist.
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 200, SectorStatus::Finished).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(700, 50, SectorStatus::NonTrimmed).unwrap();
|
||||
let vid: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||||
0xee, 0xff,
|
||||
];
|
||||
mf.set_vid(vid);
|
||||
mf.flush().unwrap();
|
||||
|
||||
// The saved file must contain the VID comment in lowercase hex.
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
text.contains("# freemkv-vid:"),
|
||||
"saved mapfile missing VID comment: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# freemkv-vid: 00112233445566778899aabbccddeeff"),
|
||||
"VID comment format mismatch: {text}"
|
||||
);
|
||||
|
||||
// load() recovers the VID and the identical data ranges.
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.vid(), Some(vid));
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
|
||||
// A mapfile WITHOUT the VID comment must parse the same +/-/?
|
||||
// data ranges as the one WITH it (comment ignored by parser).
|
||||
let p2 = tmpfile("vid_round_trips_novid");
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
|
||||
mf2.record(100, 200, SectorStatus::Finished).unwrap();
|
||||
mf2.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf2.record(700, 50, SectorStatus::NonTrimmed).unwrap();
|
||||
mf2.flush().unwrap();
|
||||
let loaded_novid = Mapfile::load(&p2).unwrap();
|
||||
assert_eq!(loaded_novid.vid(), None);
|
||||
assert_eq!(loaded_novid.entries(), loaded.entries());
|
||||
|
||||
// Malformed VID comments must not error the load (treated absent).
|
||||
let mut bad = text.replace("00112233445566778899aabbccddeeff", "zzzz");
|
||||
let pbad = tmpfile("vid_round_trips_bad");
|
||||
let _ = std::fs::remove_file(&pbad);
|
||||
std::fs::write(&pbad, &bad).unwrap();
|
||||
let loaded_bad = Mapfile::load(&pbad).unwrap();
|
||||
assert_eq!(loaded_bad.vid(), None);
|
||||
assert_eq!(loaded_bad.entries(), loaded.entries());
|
||||
|
||||
// A load->save cycle preserves the VID (the patch-pass path).
|
||||
bad.clear();
|
||||
let resaved = tmpfile("vid_round_trips_resave");
|
||||
let _ = std::fs::remove_file(&resaved);
|
||||
let mut reloaded = Mapfile::load(&p).unwrap();
|
||||
// Repoint at a fresh path and flush; mark dirty via a no-op record.
|
||||
reloaded.path = resaved.clone();
|
||||
reloaded.dirty = true;
|
||||
reloaded.flush().unwrap();
|
||||
let again = Mapfile::load(&resaved).unwrap();
|
||||
assert_eq!(again.vid(), Some(vid));
|
||||
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let _ = std::fs::remove_file(&pbad);
|
||||
let _ = std::fs::remove_file(&resaved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_consistent_after_split_record() {
|
||||
let p = tmpfile("stats_consistent_after_split");
|
||||
|
||||
+380
-18
@@ -883,8 +883,10 @@ pub struct AacsState {
|
||||
pub disc_hash: String,
|
||||
/// How keys were resolved
|
||||
pub key_source: KeySource,
|
||||
/// Volume Unique Key (16 bytes)
|
||||
pub vuk: [u8; 16],
|
||||
/// Volume Unique Key (16 bytes). `None` when keys were resolved
|
||||
/// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
|
||||
/// pre-decrypted unit keys without a VUK to derive them from.
|
||||
pub vuk: Option<[u8; 16]>,
|
||||
/// Decrypted unit keys (CPS unit number, key)
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
|
||||
@@ -893,26 +895,37 @@ pub struct AacsState {
|
||||
pub volume_id: [u8; 16],
|
||||
}
|
||||
|
||||
/// How AACS keys were resolved.
|
||||
/// How AACS keys were resolved. Variants are ordered root-of-trust →
|
||||
/// per-disc-leaf, matching the resolver's path-try order: the resolver
|
||||
/// attempts derivation from the strongest input it has first and falls
|
||||
/// back toward pre-computed per-disc material.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum KeySource {
|
||||
/// VUK found directly in KEYDB by disc hash
|
||||
KeyDb,
|
||||
/// Media key + Volume ID from KEYDB → derived VUK
|
||||
KeyDbDerived,
|
||||
/// MKB + processing keys → media key → VUK
|
||||
ProcessingKey,
|
||||
/// MKB + device keys → subset-difference tree → VUK
|
||||
DeviceKey,
|
||||
/// MKB + processing keys → media key → VUK
|
||||
ProcessingKey,
|
||||
/// Media key + Volume ID from KEYDB → derived VUK
|
||||
KeyDbDerived,
|
||||
/// VUK found directly in KEYDB by disc hash
|
||||
KeyDb,
|
||||
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
|
||||
/// No VUK present in the entry — `AacsState::vuk` is `None`.
|
||||
KeyDbUnitKeys,
|
||||
/// Unit key supplied directly by the caller (the external Unit Key path).
|
||||
/// No keydb, no derivation — `AacsState::vuk` is `None`.
|
||||
ExternalUk,
|
||||
}
|
||||
|
||||
impl KeySource {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
KeySource::KeyDb => "KEYDB",
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::ProcessingKey => "MKB + processing key",
|
||||
KeySource::DeviceKey => "MKB + device key",
|
||||
KeySource::ProcessingKey => "MKB + processing key",
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::KeyDb => "KEYDB",
|
||||
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
|
||||
KeySource::ExternalUk => "external UK",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -932,11 +945,27 @@ pub struct ScanOptions {
|
||||
/// Path to KEYDB.cfg for AACS key lookup.
|
||||
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
|
||||
pub keydb_path: Option<std::path::PathBuf>,
|
||||
/// Caller-supplied Unit Key — an alternative to keydb lookup. When set,
|
||||
/// libfreemkv skips keydb lookup and all derivation and uses this key
|
||||
/// directly to decrypt; it takes precedence over `keydb_path`. The caller
|
||||
/// obtains the key however it likes; libfreemkv stays free of any network
|
||||
/// dependency.
|
||||
pub unit_key: Option<[u8; 16]>,
|
||||
/// Disable KEYDB entirely: skip both the explicit `keydb_path` and the
|
||||
/// standard-location search, so no keydb is loaded for this scan. A caller
|
||||
/// that resolves keys out-of-band (e.g. a remote key service) sets this so
|
||||
/// a keydb that merely happens to sit in a default location does not shadow
|
||||
/// the out-of-band path. `unit_key` still takes precedence over everything.
|
||||
pub disable_keydb: bool,
|
||||
}
|
||||
|
||||
impl ScanOptions {
|
||||
/// Resolve KEYDB path: explicit path first, then standard locations.
|
||||
/// Returns `None` when `disable_keydb` is set — no keydb is consulted.
|
||||
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
|
||||
if self.disable_keydb {
|
||||
return None;
|
||||
}
|
||||
if let Some(p) = &self.keydb_path {
|
||||
if p.exists() {
|
||||
return Some(p.clone());
|
||||
@@ -961,9 +990,9 @@ impl ScanOptions {
|
||||
/// Quick disc identification — name, format, capacity. No title/stream parsing.
|
||||
#[derive(Debug)]
|
||||
pub struct DiscId {
|
||||
/// UDF Volume Identifier (always present, e.g. "V_FOR_VENDETTA")
|
||||
/// UDF Volume Identifier (always present, e.g. "SAMPLE_FILM")
|
||||
pub volume_id: String,
|
||||
/// Disc title from META/DL/bdmt_eng.xml (e.g. "V for Vendetta")
|
||||
/// Disc title from META/DL/bdmt_eng.xml (e.g. "Sample Film")
|
||||
pub meta_title: Option<String>,
|
||||
/// Disc format (BD, UHD, DVD) — UHD vs BD requires full scan to confirm
|
||||
pub format: DiscFormat,
|
||||
@@ -1131,6 +1160,60 @@ impl Disc {
|
||||
Self::scan_with(reader, capacity, None, None, opts, udf_fs)
|
||||
}
|
||||
|
||||
/// Read a disc's AACS key-input files from an ISO image: returns
|
||||
/// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve a Unit Key
|
||||
/// out-of-band: obtain the key however you like, then scan with
|
||||
/// `ScanOptions { unit_key: Some(uk), .. }`. libfreemkv never makes a
|
||||
/// network call.
|
||||
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let udf_fs = udf::read_filesystem(&mut reader)?;
|
||||
let inf = udf_fs
|
||||
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
|
||||
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
|
||||
// so reading either ships up to ~124 MiB of nothing — trim to the
|
||||
// record stream so callers send/store a few MB, not 128 MiB.
|
||||
let mut mkb = udf_fs
|
||||
.read_file(&mut reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let n = crate::aacs::mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
Ok((inf, mkb))
|
||||
}
|
||||
|
||||
/// Same as [`Disc::read_aacs_inputs`] but reads from a live drive. The
|
||||
/// out-of-band Unit Key path fetches the disc's key files from the drive,
|
||||
/// resolves a key from them however it likes, then scans with
|
||||
/// `ScanOptions { unit_key: Some(uk), .. }`. These files are plaintext UDF
|
||||
/// metadata — no AACS handshake or keys are required to read them.
|
||||
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
|
||||
let inf = udf_fs
|
||||
.read_file(&mut reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
// Prefer MKB_RO, fall back to MKB_RW, then TRIM to the real record
|
||||
// length. Both files are allocated to a fixed ~128 MiB and zero-padded,
|
||||
// so reading either ships up to ~124 MiB of nothing — trim to the
|
||||
// record stream so callers send/store a few MB, not 128 MiB.
|
||||
let mut mkb = udf_fs
|
||||
.read_file(&mut reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RW.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let n = crate::aacs::mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
Ok((inf, mkb))
|
||||
}
|
||||
|
||||
/// Core scan pipeline — works with any SectorSource.
|
||||
///
|
||||
/// `handshake_error` is plumbed from `do_handshake` so failures
|
||||
@@ -1150,7 +1233,25 @@ impl Disc {
|
||||
let encrypted =
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
|
||||
let (aacs, aacs_error) = if encrypted {
|
||||
let (aacs, aacs_error) = if !encrypted {
|
||||
(None, None)
|
||||
} else if let Some(unit_key) = opts.unit_key {
|
||||
// Second key source: caller supplied the Unit Key directly
|
||||
// (external Unit Key). Skip keydb entirely.
|
||||
match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) {
|
||||
Ok(state) => (Some(state), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
} else if opts.disable_keydb {
|
||||
// Keydb disabled: keys are resolved out-of-band. Still capture the
|
||||
// VID (read during the handshake) so the out-of-band path has it;
|
||||
// carry no keys (disc reports "encrypted, no keys" until re-scanned
|
||||
// with a resolved Unit Key).
|
||||
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
|
||||
Ok(state) => (Some(state), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
}
|
||||
} else {
|
||||
match opts.resolve_keydb() {
|
||||
Some(keydb_path) => {
|
||||
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
|
||||
@@ -1198,8 +1299,6 @@ impl Disc {
|
||||
(None, Some(final_err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
|
||||
@@ -1377,11 +1476,40 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// A decryption key handed to libfreemkv by the caller.
|
||||
///
|
||||
/// libfreemkv is **lookup-free**: it never reads a keydb, never talks to a key
|
||||
/// server, never searches paths. The application resolves a key from whatever
|
||||
/// source it likes (a local keydb, an online key service, a mapfile cache) and
|
||||
/// hands it in here; libfreemkv uses it to decrypt, deriving any remaining
|
||||
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
|
||||
///
|
||||
/// `#[non_exhaustive]`: AACS is a derivation chain
|
||||
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Today only the final
|
||||
/// [`Key::Unit`] entry point is wired; the higher entry points
|
||||
/// (device / media / volume key) land as the derivation is lifted out of the
|
||||
/// keydb-coupled scan path. Adding them is non-breaking thanks to
|
||||
/// `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub enum Key {
|
||||
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
|
||||
/// (keydb / key server) resolved these, or they were cached in the mapfile
|
||||
/// at sweep; libfreemkv decrypts directly with no further derivation.
|
||||
Unit(Vec<(u32, [u8; 16])>),
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Get the resolved decryption keys for this disc.
|
||||
/// Used by disc-to-ISO and other full-disc operations.
|
||||
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
|
||||
if let Some(ref aacs) = self.aacs {
|
||||
// An AACS state with NO unit keys is "encrypted, no keys" — e.g.
|
||||
// the VID-only state from out-of-band resolution before a Unit Key
|
||||
// is supplied. Report None so callers treat it as missing keys
|
||||
// (not a usable, empty key set).
|
||||
if aacs.unit_keys.is_empty() {
|
||||
return crate::decrypt::DecryptKeys::None;
|
||||
}
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: aacs.unit_keys.clone(),
|
||||
read_data_key: aacs.read_data_key,
|
||||
@@ -1395,6 +1523,61 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
|
||||
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
|
||||
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
||||
/// directly with NO key-service round-trip. Populates `self.aacs.unit_keys`
|
||||
/// so [`decrypt_keys`] returns them and marks the source `ExternalUk`.
|
||||
///
|
||||
/// If the scan built no AACS state (`self.aacs == None`) — which happens
|
||||
/// when the keydb was absent at scan time (`scan_aacs_no_keydb` →
|
||||
/// `aacs_error = KeydbLoad`) — this synthesizes a minimal `ExternalUk`
|
||||
/// state for an encrypted AACS disc. A Unit Key is the FINAL per-title
|
||||
/// decryption key; the keydb is only needed to *derive* it, and that
|
||||
/// derivation already happened at sweep (the UK is in the mapfile). So a UK
|
||||
/// alone is sufficient to decrypt the on-disk ISO — AACS 2.0 bus decryption
|
||||
/// was applied by the drive at read time, so `read_data_key` is unused for
|
||||
/// file-backed mux. Without this, a keyed disc swept without a keydb would
|
||||
/// recover its UK yet still report E8005 (no usable `decrypt_keys`) at
|
||||
/// remux. No-op for an unencrypted or CSS (DVD) disc.
|
||||
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
|
||||
if let Some(aacs) = self.aacs.as_mut() {
|
||||
aacs.unit_keys = keys;
|
||||
aacs.key_source = KeySource::ExternalUk;
|
||||
} else if self.encrypted && self.css.is_none() {
|
||||
self.aacs = Some(AacsState {
|
||||
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
|
||||
bus_encryption: self.format == DiscFormat::Uhd,
|
||||
mkb_version: None,
|
||||
disc_hash: String::new(),
|
||||
key_source: KeySource::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: keys,
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
});
|
||||
// The prior resolution error (e.g. KeydbLoad) is now moot — we have
|
||||
// the decryption key. Clear it so callers don't treat the disc as
|
||||
// keyless on the stale error.
|
||||
self.aacs_error = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
|
||||
/// decryption state. **Lookup-free**: no keydb, no network — the caller
|
||||
/// (an application, via a key source) does all resolution and hands the key
|
||||
/// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
|
||||
/// unit keys came from a key source or the mapfile cache, and libfreemkv
|
||||
/// decrypts directly (see [`Self::inject_unit_keys`]).
|
||||
pub fn decrypt_with(&mut self, key: Key) -> Result<()> {
|
||||
match key {
|
||||
Key::Unit(keys) => {
|
||||
self.inject_unit_keys(keys);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy disc sectors to an ISO image file.
|
||||
///
|
||||
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid
|
||||
@@ -1479,6 +1662,8 @@ impl Disc {
|
||||
skip_on_error: opts.multipass,
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
vid: opts.vid,
|
||||
unit_keys: opts.unit_keys.clone(),
|
||||
};
|
||||
self.sweep(reader, path, &sweep_opts)
|
||||
}
|
||||
@@ -1570,13 +1755,24 @@ impl Disc {
|
||||
if !opts.resume {
|
||||
let _ = std::fs::remove_file(&mapfile_path);
|
||||
}
|
||||
let map = mapfile::Mapfile::open_or_create(
|
||||
let mut map = mapfile::Mapfile::open_or_create(
|
||||
&mapfile_path,
|
||||
total_bytes,
|
||||
concat!("libfreemkv v", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
// Persist the disc's decryption state into the mapfile header so it
|
||||
// survives to deferred-mux / resume. ddrescue-safe (comment lines);
|
||||
// does not touch the ISO payload. KEYS XOR VID: a keyed disc writes its
|
||||
// unit keys (the final answer — deferred-mux decrypts directly, no key
|
||||
// service); an unresolved disc writes only the VID (the retry marker).
|
||||
if !opts.unit_keys.is_empty() {
|
||||
map.set_unit_keys(&opts.unit_keys);
|
||||
} else if let Some(vid) = opts.vid {
|
||||
map.set_vid(vid);
|
||||
}
|
||||
|
||||
// ISO file: if resuming and mapfile has Finished ranges, open existing;
|
||||
// otherwise create fresh and pre-size to total_bytes (sparse holes for
|
||||
// non-tried regions).
|
||||
@@ -2048,6 +2244,19 @@ pub struct CopyOptions<'a> {
|
||||
pub multipass: bool,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
/// AACS Volume ID (16 bytes) to persist into the mapfile during
|
||||
/// Pass 1 so it survives to deferred-mux / resume. `None` for
|
||||
/// unencrypted / non-AACS discs. Caller wires this from
|
||||
/// `Disc::aacs.volume_id`.
|
||||
///
|
||||
/// Persisted ONLY when `unit_keys` is empty (the disc didn't resolve a
|
||||
/// key): the VID is the "still unresolved, retry-able" marker.
|
||||
pub vid: Option<[u8; 16]>,
|
||||
/// Resolved AACS unit keys `(CPS unit, key)` to persist into the mapfile
|
||||
/// during Pass 1. When non-empty these are written (the final answer, so
|
||||
/// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
|
||||
/// Caller wires this from `Disc::aacs.unit_keys`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -2069,6 +2278,13 @@ pub struct SweepOptions<'a> {
|
||||
pub skip_on_error: bool,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
/// AACS Volume ID (16 bytes) persisted into the mapfile when the
|
||||
/// sweep creates / opens it. `None` for unencrypted discs. Written ONLY
|
||||
/// when `unit_keys` is empty (keys XOR VID — the VID is the retry marker).
|
||||
pub vid: Option<[u8; 16]>,
|
||||
/// Resolved AACS unit keys persisted into the mapfile when the sweep
|
||||
/// creates / opens it. When non-empty these win over `vid`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
|
||||
@@ -2563,6 +2779,136 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() {
|
||||
// Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a
|
||||
// keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted
|
||||
// in the mapfile. At remux the UK is recovered and injected — that MUST
|
||||
// yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd
|
||||
// (no aacs to mutate), decrypt_keys stayed None, and the mux deferred
|
||||
// forever with "No keys available (E8005)" despite holding the UK.
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
disc.aacs_error = Some(crate::error::Error::KeydbLoad {
|
||||
path: "<no keydb in search paths>".into(),
|
||||
});
|
||||
assert!(
|
||||
matches!(disc.decrypt_keys(), crate::decrypt::DecryptKeys::None),
|
||||
"precondition: encrypted disc with no aacs state => no decrypt keys"
|
||||
);
|
||||
|
||||
let uk = vec![(0u32, [0x11u8; 16])];
|
||||
disc.inject_unit_keys(uk.clone());
|
||||
|
||||
match disc.decrypt_keys() {
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
read_data_key,
|
||||
} => {
|
||||
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
|
||||
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
|
||||
}
|
||||
_ => panic!("expected Aacs decrypt keys after injecting a UK"),
|
||||
}
|
||||
assert!(
|
||||
disc.aacs_error.is_none(),
|
||||
"stale KeydbLoad must be cleared once a UK is in hand"
|
||||
);
|
||||
assert_eq!(
|
||||
disc.aacs.as_ref().unwrap().key_source,
|
||||
KeySource::ExternalUk
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an AacsState carrying the given unit keys (other fields are inert
|
||||
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
|
||||
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
|
||||
AacsState {
|
||||
version: 2,
|
||||
bus_encryption: true,
|
||||
mkb_version: None,
|
||||
disc_hash: String::new(),
|
||||
key_source: KeySource::DeviceKey,
|
||||
vuk: None,
|
||||
unit_keys,
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() {
|
||||
// VID-only state (resolved but no Unit Key yet) must read as None, not
|
||||
// an empty-but-usable key set — callers treat it as "keys missing".
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
disc.aacs = Some(aacs_with(Vec::new()));
|
||||
assert!(matches!(
|
||||
disc.decrypt_keys(),
|
||||
crate::decrypt::DecryptKeys::None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_replaces_existing_aacs_unit_keys_and_marks_external() {
|
||||
// When scan DID build an AACS state, decrypt_with must overwrite its
|
||||
// unit keys (not append) and mark the source ExternalUk.
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])]));
|
||||
let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])];
|
||||
disc.decrypt_with(Key::Unit(new.clone())).unwrap();
|
||||
match disc.decrypt_keys() {
|
||||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||||
assert_eq!(unit_keys, new, "must replace, preserving every CPS unit");
|
||||
}
|
||||
_ => panic!("expected Aacs decrypt keys"),
|
||||
}
|
||||
assert_eq!(
|
||||
disc.aacs.as_ref().unwrap().key_source,
|
||||
KeySource::ExternalUk
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_unit_key_yields_decrypt_keys() {
|
||||
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
|
||||
// decrypt_keys() must return usable AACS state (same path as the
|
||||
// deferred-mux resume — autorip resolves the UK and passes it in).
|
||||
let mut disc = make_test_disc(1000, "UHD");
|
||||
disc.encrypted = true;
|
||||
let uk = vec![(0u32, [0x44u8; 16])];
|
||||
disc.decrypt_with(Key::Unit(uk.clone())).unwrap();
|
||||
match disc.decrypt_keys() {
|
||||
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
|
||||
assert_eq!(unit_keys, uk);
|
||||
}
|
||||
_ => panic!("expected Aacs decrypt keys after decrypt_with(Key::Unit)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
|
||||
// Unencrypted disc: nothing to inject into, stays None.
|
||||
let mut plain = make_test_disc(1000, "PLAIN");
|
||||
plain.inject_unit_keys(vec![(0, [0x22; 16])]);
|
||||
assert!(plain.aacs.is_none());
|
||||
assert!(matches!(
|
||||
plain.decrypt_keys(),
|
||||
crate::decrypt::DecryptKeys::None
|
||||
));
|
||||
|
||||
// Encrypted CSS (DVD): an AACS UK must NOT synthesize an AACS state.
|
||||
let mut dvd = make_test_disc(1000, "DVD");
|
||||
dvd.format = DiscFormat::Dvd;
|
||||
dvd.encrypted = true;
|
||||
dvd.css = Some(crate::css::CssState {
|
||||
title_key: [0u8; 5],
|
||||
});
|
||||
dvd.inject_unit_keys(vec![(0, [0x33; 16])]);
|
||||
assert!(dvd.aacs.is_none(), "CSS disc must not gain an AACS state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_to_dev_null_no_enodev() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2579,6 +2925,8 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -2603,6 +2951,8 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -2633,6 +2983,8 @@ mod tests {
|
||||
multipass: false,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -2662,6 +3014,8 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
|
||||
assert!(
|
||||
@@ -2679,6 +3033,8 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
|
||||
assert!(
|
||||
@@ -2711,6 +3067,8 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
|
||||
|
||||
@@ -2723,6 +3081,8 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
|
||||
assert!(
|
||||
@@ -2755,6 +3115,8 @@ mod tests {
|
||||
multipass: false,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
let r = result.expect("100-batch clean sweep should succeed");
|
||||
|
||||
@@ -334,7 +334,7 @@ const FAIL_PAUSE_SECS: u64 = 5;
|
||||
/// FIRST read failure after a clean run, before the drive has had a
|
||||
/// chance to cycle in retries that push it toward fast-fail).
|
||||
///
|
||||
/// Empirical: 2026-05-11 Dune Pt 2 wedge incident showed 7 medium
|
||||
/// Empirical: a 2026-05-11 wedge incident showed 7 medium
|
||||
/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the
|
||||
/// BU40N's firmware into IllegalRequest fast-fail mode permanently.
|
||||
/// Once there, only physical eject + reload clears it. Giving the
|
||||
|
||||
+1
-1
@@ -529,7 +529,7 @@ impl Drive {
|
||||
|
||||
// /dev/sr0 pread fallback (Linux only). The kernel
|
||||
// sr_mod driver auto-retries failed reads (~5× per
|
||||
// command). Empirically (BU40N + Dune Part 2 UHD,
|
||||
// command). Empirically (BU40N + a UHD disc,
|
||||
// 2026-05-08) dd via /dev/sr0 recovers ~50% of bad
|
||||
// sectors that a single-shot SG_IO READ misses.
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -10,8 +10,7 @@
|
||||
//! - English text → [`LabelPurpose`] (Commentary / Descriptive / etc.).
|
||||
//! - English text → [`LabelQualifier`] (SDH / Forced / Descriptive Service).
|
||||
//!
|
||||
//! Rules of engagement (carried over from
|
||||
//! `(internal)/memory/feedback_label_data_rules.md`):
|
||||
//! Rules of engagement:
|
||||
//!
|
||||
//! 1. Only map values we are 100% certain about — published codec
|
||||
//! names, well-known ISO 639-2 mappings, vendor-documented purpose
|
||||
|
||||
+3
-3
@@ -170,9 +170,9 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
|
||||
LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, Stream,
|
||||
SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, Key, KeySource,
|
||||
LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions,
|
||||
Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
};
|
||||
|
||||
// ─── Streams ────────────────────────────────────────────────────────────────
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
//! ```text
|
||||
//! let mut input = input("iso://Disc.iso", &opts)?;
|
||||
//! let title = input.info().clone();
|
||||
//! let mut output = output("mkv://Dune.mkv", &title)?;
|
||||
//! let mut output = output("mkv://Movie.mkv", &title)?;
|
||||
//! while let Ok(Some(frame)) = input.read() {
|
||||
//! output.write(&frame)?;
|
||||
//! }
|
||||
|
||||
+7
-5
@@ -163,6 +163,9 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
#[derive(Default)]
|
||||
pub struct InputOptions {
|
||||
pub keydb_path: Option<String>,
|
||||
/// Caller-supplied Unit Key (external Unit Key) — the second, mutually
|
||||
/// exclusive key source. Takes precedence over `keydb_path`.
|
||||
pub unit_key: Option<[u8; 16]>,
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
@@ -181,11 +184,10 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions {
|
||||
keydb_path: Some(p.into()),
|
||||
},
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
let scan_opts = crate::disc::ScanOptions {
|
||||
keydb_path: opts.keydb_path.as_ref().map(Into::into),
|
||||
unit_key: opts.unit_key,
|
||||
..Default::default()
|
||||
};
|
||||
// FileSectorSource is the sole file-backed sector source.
|
||||
// It carries the platform-tuned SEQUENTIAL fadvise hint
|
||||
|
||||
+55
-53
@@ -89,8 +89,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
plain[offset] = 0x47; // TS sync byte
|
||||
offset += 192;
|
||||
}
|
||||
// Set encryption flag (bits 6-7 of byte 0)
|
||||
plain[0] |= 0xC0;
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's TS
|
||||
// syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
|
||||
// Save original plaintext for comparison
|
||||
let expected = plain.clone();
|
||||
@@ -126,8 +126,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
}
|
||||
|
||||
// Verify it looks encrypted
|
||||
assert!(aacs::is_unit_encrypted(&plain));
|
||||
// Verify it looks encrypted (body TS syncs scrambled)
|
||||
assert!(aacs::is_aacs_scrambled(&plain));
|
||||
|
||||
// Now decrypt
|
||||
let result = aacs::decrypt_unit(&mut plain, &unit_key);
|
||||
@@ -136,8 +136,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
"decrypt_unit should return true on valid encrypted unit"
|
||||
);
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&plain),
|
||||
"encryption flag should be cleared"
|
||||
!aacs::is_aacs_scrambled(&plain),
|
||||
"decrypted unit should read as clear (TS syncs restored)"
|
||||
);
|
||||
|
||||
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
|
||||
@@ -156,17 +156,11 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
sync_count, expected_syncs
|
||||
);
|
||||
|
||||
// Compare all bytes except byte 0 (encryption flag cleared)
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plain[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected[1..aacs::ALIGNED_UNIT_LEN],
|
||||
"decrypted unit body does not match original"
|
||||
);
|
||||
// Byte 0: original had 0xC0 set, decrypted has it cleared
|
||||
assert_eq!(
|
||||
plain[0] & !0xC0,
|
||||
expected[0] & !0xC0,
|
||||
"byte 0 mismatch ignoring flag"
|
||||
&plain[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,49 +254,65 @@ fn aacs_vuk_derivation_roundtrip() {
|
||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||
}
|
||||
|
||||
/// Test: aacs_is_unit_encrypted detects encryption flags correctly.
|
||||
/// Test: aacs_is_aacs_scrambled detects scrambled units via the raw TS syncs.
|
||||
#[test]
|
||||
fn aacs_is_unit_encrypted_detection() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
fn aacs_is_aacs_scrambled_detection() {
|
||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||
// scrambled. (Flag bits play no role.)
|
||||
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
clear[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&unit),
|
||||
"zero unit should not be encrypted"
|
||||
!aacs::is_aacs_scrambled(&clear),
|
||||
"clear unit (syncs intact) must not be scrambled"
|
||||
);
|
||||
|
||||
unit[0] = 0x40; // bit 6 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
// Flag bits do NOT make a synced unit look scrambled.
|
||||
let mut flagged = clear.clone();
|
||||
flagged[0] = 0xC0; // copy-control bits
|
||||
flagged[7] = 0xC0; // TSC bits
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&flagged),
|
||||
"flag bits must not be read as encryption"
|
||||
);
|
||||
|
||||
unit[0] = 0x80; // bit 7 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0xC0; // both bits set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0x3F; // bits 6-7 clear
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
// A scrambled body (syncs destroyed) → scrambled.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&scrambled),
|
||||
"unit with no intact TS syncs must read as scrambled"
|
||||
);
|
||||
|
||||
// Too short
|
||||
let short = vec![0xC0u8; 100];
|
||||
let short = vec![0xFFu8; 100];
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&short),
|
||||
!aacs::is_aacs_scrambled(&short),
|
||||
"short buffer should not be detected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: aacs_decrypt_unit_unencrypted_passthrough
|
||||
///
|
||||
/// A unit without encryption flags should pass through decrypt_unit unchanged.
|
||||
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0x00; // no encryption flag
|
||||
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
let original = unit.clone();
|
||||
let key = [0xAA; 16];
|
||||
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
let result = aacs::decrypt_unit(&mut unit, &key);
|
||||
assert!(result, "unencrypted unit should return true");
|
||||
assert_eq!(unit, original, "unencrypted unit should be unchanged");
|
||||
assert!(result, "clear unit should return true");
|
||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
||||
}
|
||||
|
||||
// ── AACS cross-validation with independent AES implementation ──────────────
|
||||
@@ -369,8 +379,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
plaintext[i] = (i % 251) as u8;
|
||||
}
|
||||
}
|
||||
// Set encryption flag
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
|
||||
let expected = plaintext.clone();
|
||||
|
||||
@@ -401,14 +410,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
ok,
|
||||
"decrypt_unit returned false (TS sync verification failed)"
|
||||
);
|
||||
assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared");
|
||||
|
||||
// Compare (byte 0 flag was cleared)
|
||||
let mut expected_cleared = expected.clone();
|
||||
expected_cleared[0] &= !0xC0;
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&plaintext[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
@@ -428,7 +434,7 @@ fn aacs_cross_validation_alternate_key() {
|
||||
plaintext[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
let expected = plaintext.clone();
|
||||
|
||||
let mut header = [0u8; 16];
|
||||
@@ -446,12 +452,8 @@ fn aacs_cross_validation_alternate_key() {
|
||||
|
||||
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
|
||||
|
||||
let mut expected_cleared = expected;
|
||||
expected_cleared[0] &= !0xC0;
|
||||
assert_eq!(
|
||||
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(&plaintext[..], &expected[..]);
|
||||
}
|
||||
|
||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
||||
|
||||
@@ -135,6 +135,7 @@ fn scan_options_default() {
|
||||
fn scan_options_with_keydb() {
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/tmp/KEYDB.cfg").into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
|
||||
@@ -147,6 +148,7 @@ fn scan_options_with_keydb_pathbuf() {
|
||||
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(path.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(opts.keydb_path.unwrap(), path);
|
||||
}
|
||||
@@ -517,6 +519,7 @@ fn resolve_encryption_no_keydb() {
|
||||
// No keydb configured and no standard keydb on the system
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
|
||||
..Default::default()
|
||||
};
|
||||
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
|
||||
|
||||
|
||||
+15
-13
@@ -82,22 +82,24 @@ fn decrypt_sectors_with_css_keys_works() {
|
||||
/// Test: AACS unit encryption detection works.
|
||||
#[test]
|
||||
fn aacs_encryption_flag_detection() {
|
||||
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
|
||||
// No encryption flag
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 6
|
||||
unit[0] |= 0x40;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 7
|
||||
unit[0] = 0x80;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Both bits set
|
||||
// Flag bits on a synced unit do not make it look encrypted.
|
||||
unit[0] = 0xC0;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
unit[7] = 0xC0;
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
|
||||
// Scrambled body (syncs gone) → encrypted.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(aacs::is_aacs_scrambled(&scrambled));
|
||||
}
|
||||
|
||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user