mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e46640fd | ||
|
|
a9195824ff | ||
|
|
401fe23988 | ||
|
|
f89bce5851 | ||
|
|
dfccb85e15 | ||
|
|
ebedffb762 | ||
|
|
fdf63ccb7f | ||
|
|
c735d284da | ||
|
|
94ab7bc73c | ||
|
|
9f209fe066 | ||
|
|
32a1a6e095 | ||
|
|
1d3b8f5fb6 | ||
|
|
97b0ae7be2 | ||
|
|
7756f1feca | ||
|
|
1565da610a | ||
|
|
e134616422 | ||
|
|
8d54a3c64e | ||
|
|
c0478e1273 |
@@ -5,3 +5,6 @@ Cargo.lock
|
||||
.DS_Store
|
||||
.cargo/
|
||||
.claude/worktrees/
|
||||
|
||||
# session scratch — never track (may contain RE breadcrumbs)
|
||||
scratch/
|
||||
|
||||
+27
-30
@@ -79,17 +79,17 @@ No behavioural change — purely a rename pass.
|
||||
- **Libredrive raw-read VID shortcut deleted.** v0.25.11 introduced a
|
||||
`do_handshake` branch that, on libredrive-active drives, skipped the
|
||||
AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80
|
||||
with AGID=0 directly. The hypothesis was that firmware-uploaded
|
||||
drives would serve VID without auth. Empirical test on rip1 (BU40N
|
||||
+ Barbie UHD, 2026-05-21) showed the drive returns
|
||||
with AGID=0 directly. The hypothesis was that unlocked
|
||||
drives would serve VID without auth. Empirical test (BU40N
|
||||
+ UHD disc, 2026-05-21) showed the drive returns
|
||||
`0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key
|
||||
exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of
|
||||
firmware-upload state. The AACS spec requires a successful
|
||||
drive-unlock state. The AACS spec requires a successful
|
||||
`REPORT_KEY` / `SEND_KEY` exchange to establish an AGID before
|
||||
format 0x80 returns VID; that requirement is enforced by the drive
|
||||
itself and isn't bypassed by libredrive firmware. The shortcut
|
||||
fired for every libredrive-active drive, so v0.25.11 / v0.25.12
|
||||
Barbie scans were stuck at E7017 instead of progressing to the
|
||||
MOVIE scans were stuck at E7017 instead of progressing to the
|
||||
real wall (no DK walks MKB v77).
|
||||
- `Disc::do_handshake` now always routes through `do_handshake_cert`.
|
||||
`Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker
|
||||
@@ -98,7 +98,7 @@ No behavioural change — purely a rename pass.
|
||||
- `read_volume_id_libredrive` deleted (~50 LOC).
|
||||
|
||||
The corollary: AACS resolution on HRL-burned drives + UHD discs now
|
||||
fails honestly. Either cert auth succeeds (firmware-upload may or
|
||||
fails honestly. Either cert auth succeeds (drive unlock may or
|
||||
may not bypass the HRL — that's the new empirical question) and we
|
||||
hit the actual DK wall (E7018 "No DK that walks this MKB" for v77+
|
||||
UHD without a v77+ DK in keydb), or cert auth fails and we surface
|
||||
@@ -224,7 +224,7 @@ a fast-fail firmware wedge state where every subsequent CDB returns
|
||||
`ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB` (sense 0x05/0x24) until the
|
||||
drive is physically power-cycled.
|
||||
|
||||
Live wedge event on rip1 2026-05-20 during a Barbie UHD scan
|
||||
Live wedge event 2026-05-20 during a UHD disc scan
|
||||
(KEYDB miss) confirmed the diagnosis and motivated this fix.
|
||||
|
||||
Defence-in-depth:
|
||||
@@ -335,7 +335,7 @@ runs on the caller's thread. Communication between stages is via
|
||||
bounded `crossbeam_channel` with a recycled buffer pool — no
|
||||
allocations or memcpys in the steady-state hot loop.
|
||||
|
||||
**Throughput on the rip1 testbed (Civil_War UHD, 62 GiB ISO →
|
||||
**Throughput on the test bed (a UHD disc, 62 GiB ISO →
|
||||
`null://`, single-thread caller):**
|
||||
|
||||
| | MB/s |
|
||||
@@ -485,8 +485,7 @@ in-tree consumers (autorip, the `freemkv` CLI) drove their own multipass
|
||||
loops in 0.18 rounds 1-3; 0.18.1 lands the cleanup with the deprecated names
|
||||
still alive for one minor-version window.
|
||||
|
||||
The 0.18 design notes are in
|
||||
`(internal)/memory/0_18_redesign.md` (private) — this entry sticks to
|
||||
The 0.18 design notes are kept privately — this entry sticks to
|
||||
what changed at the public surface.
|
||||
|
||||
#### Flat verbs
|
||||
@@ -766,7 +765,7 @@ recovery.
|
||||
`READ_RECOVERY_TIMEOUT_MS = 60_000` (the v0.17.3 baseline; the kernel-
|
||||
auto-retry pattern is now provided by the `/dev/sr0` fallback above).
|
||||
|
||||
### Empirical results (Dune Part Two UHD, BU40N direct SATA)
|
||||
### Empirical results (a UHD disc, BU40N direct SATA)
|
||||
|
||||
- Pass 1: **94.6 MB recovered** (28% of formerly-bad data, 33 sr0 fallback
|
||||
saves), **11 s of main-title content** restored. Patch completed all 47
|
||||
@@ -894,7 +893,7 @@ bridges (notably the Initio INIC-1618L) that crash on MEDIUM ERROR retries.
|
||||
- Patch instrumentation: counters for reads_ok/err, writes_ok/err, finished/unreadable.
|
||||
- `as_encoded_bytes()` replaces `as_bytes()` for portable OsStr handling.
|
||||
- Removed inline retry/reset from Drive::read — orchestration layer handles recovery.
|
||||
- Removed all `(internal)` references from public code.
|
||||
- Removed all internal references from public code.
|
||||
|
||||
## 0.13.26 (2026-04-27)
|
||||
|
||||
@@ -922,7 +921,7 @@ warnings` red on Linux CI that the Mac toolchain doesn't catch.
|
||||
|
||||
### Pre-commit gate uses CI's exact toolchain
|
||||
|
||||
`(internal)/scripts/precommit.sh` (new) runs `cargo +1.86 fmt
|
||||
A new pre-commit script runs `cargo +1.86 fmt
|
||||
--check`, `cargo +1.86 clippy -- -D warnings`, and `cargo +1.86 test
|
||||
--tests` across all 5 freemkv crates. Mirrors each repo's
|
||||
`.github/workflows/ci.yml` step-for-step. Use it as a pre-commit hook
|
||||
@@ -964,7 +963,7 @@ Through the entire 0.13.x line, every CHECK CONDITION reply from the
|
||||
drive (the standard way SCSI reports a sector failure) was being
|
||||
collapsed into a synthetic `status=0xFF, sense_key=0` "transport
|
||||
wedge" sentinel and the real sense data was thrown away. Live tracing
|
||||
on the BU40N reading Dune 2 on 2026-04-27 confirmed it: the drive was
|
||||
on the BU40N reading a UHD disc on 2026-04-27 confirmed it: the drive was
|
||||
returning `host_status=0, driver_status=8, status=2, exec_elapsed_ms=1416`
|
||||
on every bad sector — a clean CHECK CONDITION carrying full sense
|
||||
data — and the library was misclassifying it as a wedge and bailing.
|
||||
@@ -1036,7 +1035,7 @@ recovered or marked Unreadable. Calibration data
|
||||
|
||||
### Replace bisect-on-fail with hysteresis state machine (Block ↔ Single)
|
||||
|
||||
Live test on Dune 2 v0.13.21 showed bisect-on-fail recovered every
|
||||
Live test on a UHD disc v0.13.21 showed bisect-on-fail recovered every
|
||||
recoverable sector, but spent ~30 sec per damaged 60-block (paying a
|
||||
~5 sec kernel timeout at every bisection level). Each level descended
|
||||
log₂(60) ≈ 6 times on the failing branch.
|
||||
@@ -1065,7 +1064,7 @@ Per-block cost on a 60-sector damaged block with 1 bad sector:
|
||||
|
||||
Plus inside a damaged cluster spanning many 60-blocks, hysteresis
|
||||
pays the bpt=batch fail cost ONCE on entry; bisection paid it every
|
||||
60 sectors. For Dune 2's ~1248-sector boundary cluster that's ~21
|
||||
60 sectors. For the ~1248-sector boundary cluster that's ~21
|
||||
fewer 5-sec waits = ~100 sec saved.
|
||||
|
||||
Telemetry: new `phase=mode_change` trace event with `from`, `to`,
|
||||
@@ -1083,8 +1082,7 @@ synthetic BU40N-pattern reader; same 100% recovery expectation.
|
||||
### Fix: Disc::copy bisect-on-fail (replaces skip-forward)
|
||||
|
||||
Empirical live-hardware testing on the LG BU40N (see
|
||||
`(internal)/docs/audits/2026-04-26-test-plan-audit.md` and the
|
||||
TEST_PLAN.md run log) revealed that the drive often **fails
|
||||
the internal test-plan audit and run log) revealed that the drive often **fails
|
||||
multi-sector READ commands** in damaged regions but **succeeds when
|
||||
asked one sector at a time**. The old skip-forward strategy responded
|
||||
to multi-sector failures by jumping up to 1 % of the disc forward,
|
||||
@@ -1096,7 +1094,7 @@ half, retry each half, recurse down to single-sector reads. Sectors
|
||||
the drive can read individually are recovered in Pass 1; only sectors
|
||||
that fail at bpt=1 are marked NonTrimmed for the patch passes.
|
||||
|
||||
Empirical results on Dune 2 UHD on the BU40N:
|
||||
Empirical results on a UHD disc on the BU40N:
|
||||
- Old algorithm: 25 GB read in Pass 1, then ~6 GB skip-forwarded;
|
||||
retry passes failed to recover most of the skipped zone.
|
||||
- New algorithm: ~99 % of disc recovered in Pass 1; only the truly
|
||||
@@ -1138,7 +1136,7 @@ LUN/BUS/HOST reset. `READ_RECOVERY_TIMEOUT_MS` (60 s) unchanged.
|
||||
`ioctl(fd, SG_IO, &hdr)`. The old pattern abandoned slow-but-alive
|
||||
commands faster than the drive could drain its internal queue,
|
||||
deepening the BU40N wedge. Per the audit at
|
||||
`(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`,
|
||||
internal SCSI-architecture research,
|
||||
no reference project (MakeMKV / sg_dd / ddrescue) does what we did —
|
||||
all use sync blocking SG_IO with 8-60 s timeouts and let the kernel's
|
||||
mid-layer (`scsi_eh.rst`) run ABORT TASK / LUN RESET / BUS RESET /
|
||||
@@ -1265,7 +1263,7 @@ clean reads (Finished sectors) and freezes during skip-forward bad zones,
|
||||
which made every previous version's UI look hung at the bad-zone boundary.
|
||||
This was the v0.13.9 stall-guard origin bug.
|
||||
|
||||
Live trace from v0.13.14: Pass 1 hit a Dune 2 bad zone at 24 GB and
|
||||
Live trace from v0.13.14: Pass 1 hit a bad zone at 24 GB and
|
||||
appeared "stuck" for 14 minutes per autorip's UI (`bytes_good = 23.97 GB`
|
||||
unchanged). Disc trace events showed `pos` actually advanced from 25.8 GB
|
||||
to 70 GB during that window — Pass 1 was 83 % through the disc, marking
|
||||
@@ -1313,7 +1311,7 @@ trace events were silently dropped by the default `libfreemkv=warn` rule.
|
||||
|
||||
### Telemetry: instrument the rip pipeline for in-flight diagnosis
|
||||
|
||||
v0.13.12 shipped Fix 1+2+4 + cross-platform parity but a live test on Dune 2
|
||||
v0.13.12 shipped Fix 1+2+4 + cross-platform parity but a live test
|
||||
showed Pass 1 sat for 14 minutes with `bytes_good=0` while the inner loop
|
||||
appeared to iterate (the throttled `on_progress` log fired every 78s). The
|
||||
async fd_recovery design at §7 said each `execute()` call should bound at
|
||||
@@ -1357,7 +1355,7 @@ The v0.13.9 stall guard at `disc/mod.rs` exited Pass 1 early when
|
||||
`bytes_good` was flat for `stall_secs` (default 120s). This violated the
|
||||
ddrescue model: Pass 1 must sweep end-to-end, marking failed reads
|
||||
NonTrimmed for Pass 2 retry. The guard caused Pass 1 to bail at 30% on
|
||||
Dune 2 with 56 GB still NonTried, leaving Pass 2 nothing useful to do.
|
||||
a UHD disc with 56 GB still NonTried, leaving Pass 2 nothing useful to do.
|
||||
|
||||
- Deleted the stall-guard state vars and the `if cur_good != ...
|
||||
break 'outer;` block.
|
||||
@@ -1412,7 +1410,7 @@ same observable recovery contract as Linux:
|
||||
|
||||
`PatchResult` now reports `blocks_attempted`, `blocks_read_ok`,
|
||||
`blocks_read_failed`. Pass 2's "100 minutes recovered 0 bytes" mystery
|
||||
(Dune 2) becomes diagnosable from these counters: distinguish "drive
|
||||
(a UHD disc) becomes diagnosable from these counters: distinguish "drive
|
||||
returned Ok but write/record dropped data" from "every read was Err for
|
||||
the entire range" without instrumenting from outside the lib.
|
||||
|
||||
@@ -1441,7 +1439,7 @@ against the spawned close()). The intent was to escape the 60-s
|
||||
blocking reopen.
|
||||
|
||||
The cost was too high: a single transient poll timeout permanently
|
||||
killed the transport. Live test on Dune 2 (post-replug):
|
||||
killed the transport. Live test on a UHD disc (post-replug):
|
||||
- Pass 1 ran for **45 ms** then returned with 0 GB good and 80 GB
|
||||
pending.
|
||||
- The first SCSI READ timed out, fd went to -1, every subsequent
|
||||
@@ -1474,7 +1472,7 @@ Sync bump for the autorip-side fix in 0.13.10 (Pass 1 batch reporting).
|
||||
### Fix: Disc::copy silent stall + SgIoTransport reopen-after-timeout serialization
|
||||
|
||||
Two correlated fixes for a hang observed live on the LG BU40N during a
|
||||
v0.13.8 rip of Dune: Part Two. At ~30 % progress through Pass 1
|
||||
v0.13.8 rip of a UHD disc. At ~30 % progress through Pass 1
|
||||
(disc → ISO), `bytes_good` froze for 10+ minutes with `errs=0`,
|
||||
no error surfaced, drive not wedged.
|
||||
|
||||
@@ -2361,10 +2359,9 @@ Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full
|
||||
- **Kernel transfer limit detection**: auto-detect `max_hw_sectors_kb` via sysfs, resolve sg→block device. Previously hardcoded to 510 sectors (1MB) which exceeded the 120KB kernel limit, causing all reads to error and fall back to 6KB reads at 4.8 MB/s. Now auto-tunes to 48 sectors (96KB) or whatever the device supports.
|
||||
- **Result: 12.5 MB/s sustained, 23 MB/s peak** (was 4.8 MB/s)
|
||||
|
||||
### LibreDrive — full init pipeline
|
||||
### Drive init pipeline
|
||||
|
||||
- **All 10 ARM handlers translated**: unlock, firmware upload (A: WRITE_BUFFER, B: MODE SELECT), calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
|
||||
- **Cold boot firmware upload**: WRITE_BUFFER 1888B (A variant) or MODE SELECT 2496B (B variant) proven on hardware
|
||||
- **Full drive init pipeline**: unlock, calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
|
||||
- **Speed calibration**: 256+ disc surface probes, 64-entry speed table, triple SET_CD_SPEED
|
||||
- **Platform trait locked down**: `pub(crate)`, 3 methods only (init, set_read_speed, is_ready)
|
||||
- **Init guard**: prevents double-init, signature mismatch aborts early
|
||||
@@ -2379,7 +2376,7 @@ Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full
|
||||
### Profiles
|
||||
|
||||
- **206 profiles with full per-drive data**: ld_microcode (base64), all CDBs, speed tables, signatures
|
||||
- **Automated pipeline**: `sdf_unpack --profiles` → profiles.json (no manual merging)
|
||||
- **Automated pipeline**: profile import → profiles.json (no manual merging)
|
||||
|
||||
## 0.4.0 (2026-04-07)
|
||||
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.26.1"
|
||||
version = "0.26.8"
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
+126
-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,46 @@ impl KeyDb {
|
||||
.or_else(|| self.disc_entries.get(&hash))
|
||||
}
|
||||
|
||||
// ── Parsers ─────────────────────────────────────────────────────────────
|
||||
/// Iterate every disc entry. Used by Path 3 (scan for matching VID).
|
||||
pub fn iter_disc_entries(&self) -> impl Iterator<Item = &DiscEntry> {
|
||||
self.disc_entries.values()
|
||||
}
|
||||
}
|
||||
|
||||
// ── KeyProvider impl ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Lets `KeyDb` plug into `resolve_keys` via the trait. Cloning happens in the
|
||||
// bulk methods because the trait returns owned `Vec`s (so HTTP-backed providers
|
||||
// don't need to retain state across calls).
|
||||
|
||||
impl super::provider::KeyProvider for KeyDb {
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.device_keys.clone()
|
||||
}
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.processing_keys.clone()
|
||||
}
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
self.host_certs.clone()
|
||||
}
|
||||
fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
let mut hex = String::with_capacity(42);
|
||||
hex.push_str("0x");
|
||||
for b in disc_hash {
|
||||
hex.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
self.find_disc(&hex).cloned()
|
||||
}
|
||||
fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.iter_disc_entries()
|
||||
.find(|e| matches!(e.disc_id, Some(id) if &id == volume_id))
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private parsers (re-open the inherent impl) ─────────────────────────────
|
||||
|
||||
impl KeyDb {
|
||||
fn parse_device_key(line: &str) -> Option<DeviceKey> {
|
||||
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||||
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
|
||||
@@ -229,6 +276,30 @@ impl KeyDb {
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse an orphan DK row: a `| DK |` line carrying only the
|
||||
/// `DEVICE_KEY` field (no position metadata). The key is then
|
||||
/// treated like a terminal/unpositioned label by the resolver
|
||||
/// (Path 2's brute walker). Returns `None` if the line carries
|
||||
/// any position field — those are positioned DKs and parsed by
|
||||
/// [`Self::parse_device_key`] instead.
|
||||
fn parse_orphan_dk(line: &str) -> Option<[u8; 16]> {
|
||||
if line.contains("DEVICE_NODE")
|
||||
|| line.contains("KEY_UV")
|
||||
|| line.contains("KEY_U_MASK_SHIFT")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let key_str = line
|
||||
.split("DEVICE_KEY")
|
||||
.nth(1)?
|
||||
.split('|')
|
||||
.next()?
|
||||
.split(';')
|
||||
.next()?
|
||||
.trim();
|
||||
parse_hex16(key_str)
|
||||
}
|
||||
|
||||
fn parse_host_cert(line: &str) -> Option<HostCert> {
|
||||
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||
let priv_str = line
|
||||
@@ -377,9 +448,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_disc_entry() {
|
||||
let line = r#"***REMOVED*** = DUNE_PART_TWO (Dune: Part Two) | D | 2024-04-02 | M | ***REMOVED*** | I | ***REMOVED*** | V | ***REMOVED*** | U | 1-***REMOVED*** ; MKBv77"#;
|
||||
let line = r#"0x000102030405060708090A0B0C0D0E0F10111213 = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x000102030405060708090A0B0C0D0E0F | I | 0x101112131415161718191A1B1C1D1E1F | V | 0x202122232425262728292A2B2C2D2E2F | U | 1-0x303132333435363738393A3B3C3D3E3F ; MKBv77"#;
|
||||
let entry = KeyDb::parse_disc_entry(line).unwrap();
|
||||
assert_eq!(entry.title, "Dune: Part Two");
|
||||
assert_eq!(entry.title, "Sample Film");
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert_eq!(entry.unit_keys.len(), 1);
|
||||
@@ -388,17 +459,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_device_key() {
|
||||
let line = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let dk = KeyDb::parse_device_key(line).unwrap();
|
||||
assert_eq!(dk.node, 0x0800);
|
||||
assert_eq!(dk.u_mask_shift, 0x17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orphan_dk_row_loads_into_processing_keys() {
|
||||
// `| DK |` row without position fields = an orphan DK. Per the
|
||||
// unified model the resolver treats it like a terminal/PK
|
||||
// candidate: it lands in `processing_keys` and the brute walker
|
||||
// handles it.
|
||||
let cfg = r#"
|
||||
| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA ; orphan, no position fields
|
||||
| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
|
||||
| PK | 0xCAFEBABE0001020304050607080900BB ; legacy PK row still works
|
||||
"#;
|
||||
let db = KeyDb::parse(cfg);
|
||||
assert_eq!(
|
||||
db.device_keys.len(),
|
||||
1,
|
||||
"positioned DK row should land in device_keys"
|
||||
);
|
||||
// Orphan DK + legacy PK row both end up in processing_keys.
|
||||
assert_eq!(
|
||||
db.processing_keys.len(),
|
||||
2,
|
||||
"orphan DK row + legacy PK row both belong in processing_keys"
|
||||
);
|
||||
assert_eq!(db.processing_keys[0][..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(db.processing_keys[1][..4], [0xCA, 0xFE, 0xBA, 0xBE]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_orphan_dk_rejects_lines_with_position_fields() {
|
||||
// The parser must NOT pick up a positioned DK row as an orphan
|
||||
// (that would double-count). parse_orphan_dk explicitly checks.
|
||||
let positioned = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
|
||||
assert!(
|
||||
KeyDb::parse_orphan_dk(positioned).is_none(),
|
||||
"positioned DK must not match orphan parser"
|
||||
);
|
||||
let orphan = "| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA";
|
||||
let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse");
|
||||
assert_eq!(key[..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_host_cert() {
|
||||
let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked";
|
||||
let line = "| HC | HOST_PRIV_KEY 0xDEADBEEF000102030405060708090A0B0C0D0E0F | HOST_CERT 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B ; Revoked";
|
||||
let hc = KeyDb::parse_host_cert(line).unwrap();
|
||||
assert_eq!(hc.private_key[0], 0x90);
|
||||
assert_eq!(hc.private_key[0], 0xDE);
|
||||
assert_eq!(hc.certificate.len(), 92);
|
||||
}
|
||||
|
||||
@@ -416,15 +528,15 @@ mod tests {
|
||||
assert!(!db.host_certs.is_empty());
|
||||
assert!(db.disc_entries.len() > 170000);
|
||||
|
||||
// Look up Dune: Part Two
|
||||
let dune = db
|
||||
// Look up any disc entry carrying a full key set.
|
||||
let entry = db
|
||||
.disc_entries
|
||||
.values()
|
||||
.find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some())
|
||||
.expect("Dune: Part Two not found");
|
||||
assert!(dune.media_key.is_some());
|
||||
assert!(dune.vuk.is_some());
|
||||
assert!(!dune.unit_keys.is_empty());
|
||||
.find(|e| e.vuk.is_some() && e.media_key.is_some() && !e.unit_keys.is_empty())
|
||||
.expect("no disc entry with a full key set");
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert!(!entry.unit_keys.is_empty());
|
||||
|
||||
eprintln!(
|
||||
"Parsed {} disc entries, {} DK, {} PK",
|
||||
|
||||
+499
-93
@@ -1,7 +1,7 @@
|
||||
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
|
||||
|
||||
use super::decrypt::aes_ecb_decrypt;
|
||||
use super::keydb::{DeviceKey, KeyDb};
|
||||
use super::keydb::DeviceKey;
|
||||
|
||||
// ── AACS version ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -205,21 +205,59 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
|
||||
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
|
||||
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
|
||||
/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues)
|
||||
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
|
||||
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
|
||||
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||
// Parse MKB records
|
||||
derive_media_key_from_pk_walked(mkb, processing_keys, PK_WALK_MAX_DEPTH)
|
||||
}
|
||||
|
||||
/// SD-tree walk depth applied to every entry in `processing_keys`.
|
||||
///
|
||||
/// Each entry is treated as a node-key (label) at unknown depth. The
|
||||
/// resolver applies `AES-G3(K, 1)` to derive the PK at this node, then
|
||||
/// descends via `AES-G3(K, 0)` (left child) and `AES-G3(K, 2)` (right
|
||||
/// child) up to this many additional levels — try-everything since we
|
||||
/// have no path bits per entry.
|
||||
///
|
||||
/// Each level doubles the candidate count. Cost per entry per MKB
|
||||
/// cvalue ≈ `2 × (2^(D+1) - 1)` AES decrypts. For a ~100-cvalue MKB
|
||||
/// (typical UHD) at depth 2: ~14 × 100 = 1400 ops per entry; for 1.5k
|
||||
/// entries that's ~2 M validate calls, sub-second with AES-NI.
|
||||
///
|
||||
/// Set to 0 to disable walking (entries tried only as terminal PKs).
|
||||
const PK_WALK_MAX_DEPTH: u8 = 3;
|
||||
|
||||
/// Same as [`derive_media_key_from_pk`] but with explicit walk depth.
|
||||
/// Each entry is tried as a terminal PK at depth 0, then as a node-key
|
||||
/// whose PK and children are derived via `AES-G3(K, 0|1|2)` for up to
|
||||
/// `max_depth` additional levels.
|
||||
pub fn derive_media_key_from_pk_walked(
|
||||
mkb: &[u8],
|
||||
processing_keys: &[[u8; 16]],
|
||||
max_depth: u8,
|
||||
) -> Option<[u8; 16]> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
walk_pk_against_tables_impl(processing_keys, &uvs, &cvalues, &mk_dv, max_depth)
|
||||
}
|
||||
|
||||
// Count UV entries (each 5 bytes, stop when high bits set)
|
||||
/// Core Subset-Difference PK walk over explicit record bodies. Shared by
|
||||
/// [`derive_media_key_from_pk_walked`] (production, records auto-selected) and
|
||||
/// [`probe::walk_pk_against_tables`] (harness, records caller-pinned).
|
||||
fn walk_pk_against_tables_impl(
|
||||
processing_keys: &[[u8; 16]],
|
||||
uvs: &[u8],
|
||||
cvalues: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
max_depth: u8,
|
||||
) -> Option<[u8; 16]> {
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
// Try each processing key against each UV/cvalue pair
|
||||
for pk in processing_keys {
|
||||
let try_against_mkb = |pk: &[u8; 16]| -> Option<[u8; 16]> {
|
||||
for i in 0..num_uvs {
|
||||
if (i + 1) * 16 > cvalues.len() {
|
||||
continue;
|
||||
@@ -228,13 +266,54 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
|
||||
if record_start + 5 > uvs.len() {
|
||||
continue;
|
||||
}
|
||||
let _u_mask_shift = uvs[record_start];
|
||||
let uv = &uvs[record_start + 1..record_start + 5];
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
if let Some(mk) = validate_processing_key(pk, cv, uv, &mk_dv) {
|
||||
if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) {
|
||||
return Some(mk);
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// Two interpretations per entry:
|
||||
// (a) entry IS already a terminal PK → validate directly
|
||||
// (b) entry is a node key (label) → derive PK via aesg3(K, 1) and validate
|
||||
// Then descend to children's node keys via aesg3(K, 0) / aesg3(K, 2) and
|
||||
// repeat up to max_depth levels deep.
|
||||
for entry in processing_keys {
|
||||
// Depth-0 attempts on the raw entry
|
||||
if let Some(mk) = try_against_mkb(entry) {
|
||||
return Some(mk);
|
||||
}
|
||||
let pk_at_node = aesg3(entry, 1);
|
||||
if let Some(mk) = try_against_mkb(&pk_at_node) {
|
||||
return Some(mk);
|
||||
}
|
||||
if max_depth == 0 {
|
||||
continue;
|
||||
}
|
||||
// Walk: BFS through child node keys
|
||||
let mut frontier: Vec<[u8; 16]> = vec![aesg3(entry, 0), aesg3(entry, 2)];
|
||||
for depth in 1..=max_depth {
|
||||
let mut next = Vec::with_capacity(frontier.len() * 2);
|
||||
for nk in &frontier {
|
||||
// Try this node's PK (label → PK at this level)
|
||||
let pk_here = aesg3(nk, 1);
|
||||
if let Some(mk) = try_against_mkb(&pk_here) {
|
||||
return Some(mk);
|
||||
}
|
||||
// Some leaked materials are themselves PKs at this depth, so
|
||||
// also try the node-key bytes directly.
|
||||
if let Some(mk) = try_against_mkb(nk) {
|
||||
return Some(mk);
|
||||
}
|
||||
if depth < max_depth {
|
||||
next.push(aesg3(nk, 0));
|
||||
next.push(aesg3(nk, 2));
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -276,6 +355,78 @@ fn validate_processing_key(
|
||||
None
|
||||
}
|
||||
|
||||
/// Public, side-effect-free accessors over the MKB record helpers, exposed so
|
||||
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
|
||||
/// exercise the exact same parser + verify primitives the production walk uses.
|
||||
/// These are thin wrappers — no new logic.
|
||||
pub mod probe {
|
||||
use super::aes_ecb_decrypt;
|
||||
|
||||
/// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86).
|
||||
pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
super::mkb_find_mk_dv(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Subset-Difference Index record (type 0x04).
|
||||
pub fn mkb_subdiff(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_subdiff_records(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Media-Key-Data (cvalues) record. Selects record
|
||||
/// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference
|
||||
/// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05`
|
||||
/// is absent.
|
||||
pub fn mkb_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_cvalues(mkb)
|
||||
}
|
||||
|
||||
/// Body (header stripped) of the first MKB record of `rec_type`. Lets a
|
||||
/// harness pin an exact record type for cross-checking the production
|
||||
/// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes).
|
||||
pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option<Vec<u8>> {
|
||||
super::find_record_body(mkb, rec_type)
|
||||
}
|
||||
|
||||
/// AES-128-ECB single-block decrypt (the AACS verify primitive).
|
||||
pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(key, block)
|
||||
}
|
||||
|
||||
/// Does `km` satisfy the MKB's Verify-Media-Key relation?
|
||||
/// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`.
|
||||
pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool {
|
||||
match super::mkb_find_mk_dv(mkb) {
|
||||
Some(mk_dv) => {
|
||||
aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the exact production Subset-Difference PK walk
|
||||
/// ([`super::derive_media_key_from_pk_walked`]) but against
|
||||
/// CALLER-SUPPLIED record bodies — so a harness can pin a specific
|
||||
/// Media-Key-Data table (record `0x05` on AACS 2.x UHD MKBs, which the
|
||||
/// production `mkb_find_cvalues` now selects) and the matching `0x04`
|
||||
/// Subset-Difference Index, across ALL entries.
|
||||
///
|
||||
/// `subdiff` is the type-0x04 body (5-byte entries
|
||||
/// `[u_mask_shift][uv:be32]`); `cvalues` is the chosen cvalue table
|
||||
/// (16-byte entries); `mk_dv` is from the verify record. Each entry in
|
||||
/// `keys` is tried as a terminal PK and as an SD node-key descending via
|
||||
/// `AES-G3(K, 0|1|2)` for `max_depth` levels — identical logic to the
|
||||
/// production walk. Returns the verified Media Key, if any.
|
||||
pub fn walk_pk_against_tables(
|
||||
keys: &[[u8; 16]],
|
||||
subdiff: &[u8],
|
||||
cvalues: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
max_depth: u8,
|
||||
) -> Option<[u8; 16]> {
|
||||
super::walk_pk_against_tables_impl(keys, subdiff, cvalues, mk_dv, max_depth)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
let mut pos = 0;
|
||||
@@ -337,17 +488,26 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB.
|
||||
///
|
||||
/// libaacs hard-codes record type `0x05` (matches AACS 1.0 and BD type-3/4
|
||||
/// MKBs), but on AACS 2.x Category-C MKBs the cvalues table moved to
|
||||
/// record type `0x07` and `0x05` now carries the host-revocation
|
||||
/// signature. To stay correct on both lines we prefer `0x07` first (the
|
||||
/// AACS 2.x layout used by every modern UHD disc) and fall back to
|
||||
/// `0x05` for AACS 1.0 MKBs.
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04`. This matches
|
||||
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
|
||||
/// reads `0x04`.
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
/// the giant `0x04` index), while record `0x07` (Explicit
|
||||
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
|
||||
/// is NOT the cvalue table. An earlier version of this function preferred
|
||||
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
|
||||
/// prevented the DK→walk path from ever finding the matching uv. The
|
||||
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
|
||||
/// malformed/legacy MKBs that somehow lack a `0x05` record.
|
||||
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(body) = find_record_body(mkb, 0x07) {
|
||||
if let Some(body) = find_record_body(mkb, 0x05) {
|
||||
return Some(body);
|
||||
}
|
||||
find_record_body(mkb, 0x05)
|
||||
find_record_body(mkb, 0x07)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the payload (header stripped) of the first
|
||||
@@ -372,6 +532,27 @@ fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
// A zero type, a zero/short length, or an overrun = records done, padding begun.
|
||||
if rec_type == 0x00 || rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
@@ -670,8 +851,10 @@ pub struct ResolveContext<'a> {
|
||||
/// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
|
||||
/// "no VID" sentinel and disables paths 1-3.
|
||||
pub volume_id: &'a [u8; 16],
|
||||
/// Key database.
|
||||
pub keydb: &'a KeyDb,
|
||||
/// Key sources — checked in array order for disc-keyed lookups,
|
||||
/// union'd across all entries for bulk material (DKs, PKs, HCs).
|
||||
/// A keydb file, a webservice, an OEM provider can all coexist.
|
||||
pub providers: &'a [&'a dyn super::provider::KeyProvider],
|
||||
/// MKB raw bytes (optional — paths 1/2 require it).
|
||||
pub mkb: Option<&'a [u8]>,
|
||||
}
|
||||
@@ -755,14 +938,17 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
"resolve_keys_v21: starting"
|
||||
);
|
||||
|
||||
let providers = super::provider::Providers(ctx.providers);
|
||||
|
||||
if has_vid {
|
||||
// Path 1: Variant chain (V21's analogue of classical Path 1's
|
||||
// DK derivation). Placeholder until KCD constant is supplied.
|
||||
if let Some(mkb) = ctx.mkb {
|
||||
let recs = super::variants::walk_mkb(mkb);
|
||||
let all_dks = providers.device_keys();
|
||||
match super::variants::derive_media_key_variant(
|
||||
&recs,
|
||||
&ctx.keydb.device_keys,
|
||||
&all_dks,
|
||||
&super::variants::KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
ctx.volume_id,
|
||||
) {
|
||||
@@ -785,14 +971,13 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
}
|
||||
}
|
||||
|
||||
// Path 3: KEYDB MK + matching VID → derived VUK
|
||||
for entry in ctx.keydb.disc_entries.values() {
|
||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||
if did == *ctx.volume_id {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 3));
|
||||
}
|
||||
// Path 3: pre-computed MK + matching VID → derived VUK.
|
||||
// Short-circuit: first provider with a matching VID wins.
|
||||
if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
|
||||
if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 3));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -804,7 +989,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
}
|
||||
|
||||
// Paths 4 and 5: hash lookup, prefer V over U on the same entry.
|
||||
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||
if let Some(entry) = providers.lookup_disc_by_hash(&uk_file.disc_hash) {
|
||||
if let Some(vuk) = entry.vuk {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path4_hit", "VUK from KEYDB");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 4));
|
||||
@@ -881,6 +1066,8 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
"resolve_keys: starting"
|
||||
);
|
||||
|
||||
let providers = super::provider::Providers(ctx.providers);
|
||||
|
||||
// Paths 1 and 2 need both MKB and VID. Logged as a single skip when
|
||||
// either is absent so operators see one reason, not two.
|
||||
if has_vid {
|
||||
@@ -900,37 +1087,36 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
);
|
||||
|
||||
// Path 1: MKB + device keys → media key → VUK
|
||||
if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) {
|
||||
let all_dks = providers.device_keys();
|
||||
if let Some(mk) = derive_media_key_from_dk(mkb, &all_dks) {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit", "media key derived from device key");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 1));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", dk_count = all_dks.len(), "DK derivation failed");
|
||||
|
||||
// Path 2: MKB + processing keys → media key → VUK
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
|
||||
let all_pks = providers.processing_keys();
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &all_pks) {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "media key derived from processing key");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 2));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
|
||||
}
|
||||
|
||||
// Path 3: KEYDB MK + matching VID → derived VUK
|
||||
let mut path3_mk_did_count = 0usize;
|
||||
for entry in ctx.keydb.disc_entries.values() {
|
||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||
path3_mk_did_count += 1;
|
||||
if did == *ctx.volume_id {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 3));
|
||||
}
|
||||
// Path 3: pre-computed MK + matching VID → derived VUK.
|
||||
// Short-circuit: first provider with a matching VID wins.
|
||||
if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
|
||||
if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 3));
|
||||
}
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", mk_did_entries = path3_mk_did_count, "no MK+VID entry matched volume_id");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", "no MK+VID entry matched volume_id");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -939,26 +1125,26 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
);
|
||||
}
|
||||
|
||||
// Paths 4 and 5: single hash-table lookup, prefer V (path 4) over
|
||||
// Paths 4 and 5: single hash-keyed lookup, prefer V (path 4) over
|
||||
// U (path 5). They are not independent checks — path 5 only fires
|
||||
// because path 4 had no VUK on the same entry.
|
||||
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in keydb");
|
||||
if let Some(entry) = providers.lookup_disc_by_hash(&uk_file.disc_hash) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in provider");
|
||||
if let Some(vuk) = entry.vuk {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "VUK from KEYDB");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "VUK from provider");
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 4));
|
||||
} else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_path5_hit",
|
||||
uk_count = unit_keys.len(),
|
||||
"unit keys from KEYDB (no VUK)"
|
||||
"unit keys from provider (no VUK)"
|
||||
);
|
||||
return Some(build(None, unit_keys, 5));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_no_keys", "KEYDB entry has neither VUK nor matching unit keys");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_no_keys", "provider entry has neither VUK nor matching unit keys");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_miss", "disc hash NOT in KEYDB");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_miss", "disc hash NOT found in any provider");
|
||||
}
|
||||
|
||||
None
|
||||
@@ -998,8 +1184,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_vuk_derivation() {
|
||||
// Civil War UHD: known MK, VID, VUK from KEYDB
|
||||
// MK = 15665F98..., VID (disc_id) = from entry, VUK = F96D7908...
|
||||
// Pick any UHD entry with a known MK, VID, and VUK from KEYDB.
|
||||
// VUK = AES-DEC(MK, VID) XOR VID
|
||||
let path = match keydb_path() {
|
||||
Some(p) => p,
|
||||
@@ -1072,7 +1257,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_real_unit() {
|
||||
// Try decrypting a real encrypted aligned unit from Civil War UHD
|
||||
// Try decrypting a real encrypted aligned unit from a UHD sample.
|
||||
// This disc is AACS 2.0 (BEE) so unit key alone won't work —
|
||||
// we need bus decryption first. But this verifies the pipeline.
|
||||
let unit_path = std::path::Path::new("/tmp/encrypted_unit.bin");
|
||||
@@ -1093,20 +1278,17 @@ mod tests {
|
||||
};
|
||||
let db = KeyDb::load(&kp).unwrap();
|
||||
|
||||
// Civil War UHD entries
|
||||
let civil_war_entries: Vec<&DiscEntry> = db
|
||||
// Candidate entries: any UHD entry that carries unit keys.
|
||||
let candidate_entries: Vec<&DiscEntry> = db
|
||||
.disc_entries
|
||||
.values()
|
||||
.filter(|e| e.title.contains("CIVIL WAR") && !e.unit_keys.is_empty())
|
||||
.filter(|e| !e.unit_keys.is_empty())
|
||||
.collect();
|
||||
|
||||
eprintln!(
|
||||
"Found {} Civil War entries with unit keys",
|
||||
civil_war_entries.len()
|
||||
);
|
||||
eprintln!("Found {} entries with unit keys", candidate_entries.len());
|
||||
|
||||
// Try each entry's unit keys
|
||||
for entry in &civil_war_entries {
|
||||
for entry in &candidate_entries {
|
||||
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect();
|
||||
let mut unit = original.clone();
|
||||
|
||||
@@ -1139,10 +1321,11 @@ mod tests {
|
||||
#[test]
|
||||
fn test_disc_hash_hex() {
|
||||
let hash = [
|
||||
***REMOVED***,
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13,
|
||||
];
|
||||
let hex = disc_hash_hex(&hash);
|
||||
assert_eq!(hex, "***REMOVED***");
|
||||
assert_eq!(hex, "0x000102030405060708090A0B0C0D0E0F10111213");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1212,6 +1395,26 @@ mod tests {
|
||||
assert_eq!(mkb_version(&mkb), Some(77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_content_len_trims_trailing_padding() {
|
||||
// Two real records (0x10 type/version + 0x86 verify), then 128 KiB of
|
||||
// zero padding (the fixed-region tail). Content length must stop at the
|
||||
// end of the records, not include the padding.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
let records_len = mkb.len();
|
||||
mkb.extend(std::iter::repeat(0u8).take(128 * 1024)); // padding
|
||||
assert_eq!(mkb_content_len(&mkb), records_len);
|
||||
// No padding → returns the full length.
|
||||
assert_eq!(mkb_content_len(&mkb[..records_len]), records_len);
|
||||
// Empty → 0.
|
||||
assert_eq!(mkb_content_len(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_version_returns_none_on_empty() {
|
||||
assert_eq!(mkb_version(&[]), None);
|
||||
@@ -1240,6 +1443,53 @@ mod tests {
|
||||
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_walk_pk_against_tables_accepts_planted_pk_rejects_corrupt() {
|
||||
// Lock in the shared SD walk used by both production
|
||||
// (`derive_media_key_from_pk_walked`) and the independent-reproduction
|
||||
// harness (`probe::walk_pk_against_tables`). Plant a terminal PK whose
|
||||
// derived Media Key satisfies a synthetic verify record; confirm the
|
||||
// walk ACCEPTS it against caller-supplied SD/cvalue tables and REJECTS a
|
||||
// 1-byte corruption.
|
||||
use super::super::decrypt::aes_ecb_encrypt as enc;
|
||||
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = enc(&pk, &mk_raw); // AES-D(pk, cv) == mk_raw
|
||||
let mut vd = [0u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = enc(&mk, &vd); // AES-D(mk, mk_dv) starts with sentinel
|
||||
|
||||
// 0x04 SD body: one entry [u_mask_shift=0][uv].
|
||||
let mut subdiff = vec![0u8];
|
||||
subdiff.extend_from_slice(&uv);
|
||||
|
||||
assert_eq!(
|
||||
probe::walk_pk_against_tables(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1),
|
||||
Some(mk),
|
||||
"planted terminal PK must verify"
|
||||
);
|
||||
let mut bad = pk;
|
||||
bad[0] ^= 0xFF;
|
||||
assert_eq!(
|
||||
probe::walk_pk_against_tables(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1),
|
||||
None,
|
||||
"corrupted PK must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_processing_key_round_trip_with_nonzero_uv() {
|
||||
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
|
||||
@@ -1294,27 +1544,176 @@ mod tests {
|
||||
assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_find_cvalues_prefers_0x07_then_falls_back_to_0x05() {
|
||||
// AACS 2.x: type 0x07 carries cvalues; 0x05 is the host-revocation
|
||||
// signature. Mixed-record MKB → 0x07 wins.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
// type=0x05, body = [0xAA; 4]
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xAA, 0xAA, 0xAA, 0xAA]);
|
||||
// type=0x07, body = [0xBB; 4]
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x08, 0xBB, 0xBB, 0xBB, 0xBB]);
|
||||
let body = mkb_find_cvalues(&mkb).expect("cvalues record must be found");
|
||||
assert_eq!(body, vec![0xBB, 0xBB, 0xBB, 0xBB], "0x07 must be preferred");
|
||||
// ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
|
||||
//
|
||||
// The cvalue (Media Key Data) table is record 0x05; the
|
||||
// Subset-Difference index is record 0x04. This matches libaacs
|
||||
// (`mkb_cvalues` → 0x05, `mkb_subdiff_records` → 0x04). Record 0x07
|
||||
// (Explicit Subset-Difference Record) is NOT the cvalue table. On real
|
||||
// in-drive AACS 2.x UHD MKBs 0x07 is small (~96 entries) while the 0x05
|
||||
// table is large (181270 entries, 1:1 with 0x04). An earlier
|
||||
// `mkb_find_cvalues` preferred 0x07, which under-tested the SD walk and
|
||||
// broke the DK→walk path. The selector must prefer 0x05.
|
||||
|
||||
// AACS 1.0: only 0x05 present → fall back to it.
|
||||
let mut mkb1 = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
/// Build a 4-byte MKB record header (type + 3-byte big-endian total
|
||||
/// length, header included) and append `body`.
|
||||
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut rec = Vec::with_capacity(total);
|
||||
rec.push(rec_type);
|
||||
rec.push(((total >> 16) & 0xFF) as u8);
|
||||
rec.push(((total >> 8) & 0xFF) as u8);
|
||||
rec.push((total & 0xFF) as u8);
|
||||
rec.extend_from_slice(body);
|
||||
rec
|
||||
}
|
||||
|
||||
/// Synthesize an AACS-2.x-shaped MKB carrying BOTH a small 0x07 record
|
||||
/// and the real 0x05 cvalue table, with 0x07 placed first so a
|
||||
/// "0x07-first" selector would pick the wrong record. The 0x05 table
|
||||
/// has `n` 16-byte entries (1:1 with the `n`-entry 0x04 SD index); the
|
||||
/// 0x07 decoy has `decoy` 16-byte entries.
|
||||
fn synth_aacs2_mkb(n: usize, decoy: usize) -> Vec<u8> {
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &[0xABu8; 16]));
|
||||
let mut sd = Vec::with_capacity(n * 5);
|
||||
for i in 0..n {
|
||||
sd.push(0x00); // u_mask_shift, top bits clear → not revoked
|
||||
sd.extend_from_slice(&((i as u32) + 1).to_be_bytes());
|
||||
}
|
||||
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
||||
mkb.extend_from_slice(&mkb_record(0x07, &vec![0x11u8; decoy * 16])); // decoy first
|
||||
mkb.extend_from_slice(&mkb_record(0x05, &vec![0x22u8; n * 16])); // real cvalues
|
||||
mkb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cvalue_selection_prefers_0x05_over_0x07() {
|
||||
// AACS-2.x layout: large 0x05 (1:1 with 0x04) + smaller decoy 0x07
|
||||
// placed earlier in the record stream.
|
||||
let n = 1500;
|
||||
let decoy = 96;
|
||||
let mkb = synth_aacs2_mkb(n, decoy);
|
||||
|
||||
let sd = probe::mkb_subdiff(&mkb).expect("0x04 present");
|
||||
let r05 = probe::mkb_record_body(&mkb, 0x05).expect("0x05 present");
|
||||
let r07 = probe::mkb_record_body(&mkb, 0x07).expect("0x07 present");
|
||||
let selected = mkb_find_cvalues(&mkb).expect("cvalues selected");
|
||||
|
||||
assert_eq!(sd.len() / 5, n, "0x04 SD index entry count");
|
||||
assert_eq!(r05.len() / 16, n, "0x05 cvalue entry count");
|
||||
assert_eq!(r07.len() / 16, decoy, "0x07 decoy entry count");
|
||||
|
||||
// The fix: selection MUST pick 0x05 (the large 1:1 table), NOT the
|
||||
// 0x07 decoy a "0x07-first" rule would return.
|
||||
assert_eq!(
|
||||
selected.len() / 16,
|
||||
n,
|
||||
"cvalue selection must use the large 0x05 table, not the {decoy}-entry 0x07 decoy"
|
||||
);
|
||||
assert_eq!(
|
||||
selected, r05,
|
||||
"selected body must be the 0x05 record verbatim"
|
||||
);
|
||||
assert_eq!(
|
||||
selected.len() / 16,
|
||||
sd.len() / 5,
|
||||
"cvalue table must be 1:1 with the 0x04 Subset-Difference index"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cvalue_selection_falls_back_to_0x07_when_no_0x05() {
|
||||
// Malformed/legacy MKB with only a 0x07 record and no 0x05: the
|
||||
// selector falls back to 0x07 rather than returning None.
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x10, 0, 0, 0, 1]));
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &[0xCDu8; 16]));
|
||||
mkb.extend_from_slice(&mkb_record(0x04, &[0x00, 0, 0, 0, 1]));
|
||||
let only07 = vec![0x33u8; 16];
|
||||
mkb.extend_from_slice(&mkb_record(0x07, &only07));
|
||||
|
||||
assert!(probe::mkb_record_body(&mkb, 0x05).is_none());
|
||||
let selected = mkb_find_cvalues(&mkb).expect("falls back to 0x07");
|
||||
assert_eq!(selected, only07, "fallback returns the 0x07 body");
|
||||
}
|
||||
|
||||
/// Locate a captured MKB sample under the optional `MKB_SAMPLE_DIR`.
|
||||
/// Returns `None` (skip) when the directory or file is absent.
|
||||
fn mkb_sample(rel: &str) -> Option<std::path::PathBuf> {
|
||||
let dir = std::env::var("MKB_SAMPLE_DIR").ok()?;
|
||||
let p = std::path::Path::new(&dir).join(rel);
|
||||
if p.exists() { Some(p) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_aacs2_samples_select_large_0x05_not_small_0x07() {
|
||||
// Real in-drive AACS 2.x UHD MKBs carry BOTH a small 0x07
|
||||
// Explicit-Subset-Difference record (96 16-byte entries) AND the
|
||||
// large 0x05 Media Key Data / cvalue table (181270 entries, 1:1
|
||||
// with the 0x04 index). The production selector must return the
|
||||
// LARGE 0x05 body, not the small 0x07 one. This is the exact
|
||||
// regression #259 found. Skips when no sample dir is present.
|
||||
let samples = [
|
||||
"sample-a/MKB_RO.inf",
|
||||
"sample-b/MKB_RO.inf",
|
||||
"sample-c/MKB_RO.inf",
|
||||
];
|
||||
mkb1.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xCC, 0xCC, 0xCC, 0xCC]);
|
||||
let body = mkb_find_cvalues(&mkb1).expect("0x05 fallback must work for AACS 1.0");
|
||||
assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]);
|
||||
let mut checked = 0;
|
||||
for rel in samples {
|
||||
let path = match mkb_sample(rel) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let data = std::fs::read(&path).expect("read sample MKB");
|
||||
|
||||
let r05 = probe::mkb_record_body(&data, 0x05)
|
||||
.unwrap_or_else(|| panic!("{rel}: expected a 0x05 Media Key Data record"));
|
||||
let r07 = probe::mkb_record_body(&data, 0x07)
|
||||
.unwrap_or_else(|| panic!("{rel}: expected a 0x07 record"));
|
||||
let sd = probe::mkb_subdiff(&data)
|
||||
.unwrap_or_else(|| panic!("{rel}: expected a 0x04 Subset-Difference index"));
|
||||
|
||||
let n05 = r05.len() / 16;
|
||||
let n07 = r07.len() / 16;
|
||||
|
||||
// The discriminating facts the bug report cited.
|
||||
assert!(
|
||||
n05 > n07 * 100,
|
||||
"{rel}: 0x05 ({n05}) must dwarf 0x07 ({n07})"
|
||||
);
|
||||
assert_eq!(n05, 181270, "{rel}: full 0x05 cvalue table size");
|
||||
assert_eq!(n07, 96, "{rel}: small 0x07 record size");
|
||||
|
||||
// Production selection must be the large 0x05 table.
|
||||
let selected = mkb_find_cvalues(&data)
|
||||
.unwrap_or_else(|| panic!("{rel}: cvalue selection returned None"));
|
||||
assert_eq!(
|
||||
selected, r05,
|
||||
"{rel}: selector must return the large 0x05 body, not 0x07"
|
||||
);
|
||||
|
||||
// And it is 1:1 with the 0x04 SD index the walk iterates: the
|
||||
// walk's UV count (take_while top-2-bits clear) lines up with
|
||||
// the cvalue count to within the trailing padding entry.
|
||||
let uv_entries = sd
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
assert!(
|
||||
uv_entries >= n05 - 2 && uv_entries <= n05,
|
||||
"{rel}: 0x04 UV count ({uv_entries}) should match 0x05 cvalue count ({n05})"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"{rel}: 0x05={n05} cvalues, 0x07={n07}, 0x04 UVs={uv_entries} — selected 0x05"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
if checked == 0 {
|
||||
eprintln!("no MKB samples present; skipping real-sample assertion");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1343,29 +1742,32 @@ mod tests {
|
||||
};
|
||||
let db = KeyDb::load(&path).unwrap();
|
||||
|
||||
// Find V for Vendetta BD — has VUK and unit keys
|
||||
// hash: ***REMOVED***
|
||||
let entry = db.find_disc("***REMOVED***");
|
||||
// Find any BD entry that carries a VUK and unit keys, then exercise
|
||||
// the lookup-by-hash + VUK-derivation chain against it.
|
||||
let entry = db
|
||||
.disc_entries
|
||||
.values()
|
||||
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.disc_id.is_some());
|
||||
if entry.is_none() {
|
||||
return;
|
||||
}
|
||||
let entry = entry.unwrap();
|
||||
let vuk = entry.vuk.unwrap();
|
||||
let vid = entry.disc_id.unwrap();
|
||||
let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x"));
|
||||
|
||||
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
|
||||
// Since we don't have it, we can at least test that the KEYDB lookup
|
||||
// works with a known hash.
|
||||
let hash_hex = "***REMOVED***";
|
||||
let found = db.find_disc(hash_hex);
|
||||
let found = db.find_disc(&hash_hex);
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().vuk, Some(vuk));
|
||||
|
||||
// Verify VUK derivation if we have MK + VID
|
||||
if let Some(mk) = entry.media_key {
|
||||
let derived = derive_vuk(&mk, &vid);
|
||||
assert_eq!(derived, vuk, "VUK derivation mismatch for V for Vendetta");
|
||||
eprintln!("V for Vendetta VUK derivation verified");
|
||||
assert_eq!(derived, vuk, "VUK derivation mismatch");
|
||||
eprintln!("VUK derivation verified");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1414,11 +1816,12 @@ mod tests {
|
||||
);
|
||||
keydb.processing_keys.push([0u8; 16]);
|
||||
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &zero_vid,
|
||||
keydb: &keydb,
|
||||
providers,
|
||||
mkb: None,
|
||||
};
|
||||
let result = resolve_keys_v1(&ctx);
|
||||
@@ -1455,11 +1858,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let vid = [0u8; 16];
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
providers,
|
||||
mkb: None,
|
||||
};
|
||||
let resolved =
|
||||
@@ -1495,11 +1899,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let vid = [0u8; 16];
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
providers,
|
||||
mkb: None,
|
||||
};
|
||||
let resolved =
|
||||
@@ -1535,11 +1940,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let vid = [0u8; 16];
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
providers,
|
||||
mkb: None,
|
||||
};
|
||||
assert!(
|
||||
|
||||
+5
-2
@@ -17,6 +17,7 @@ pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod keydb;
|
||||
pub mod keys;
|
||||
pub mod provider;
|
||||
pub mod variants;
|
||||
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
@@ -26,12 +27,14 @@ pub use decrypt::{
|
||||
is_unit_encrypted,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||
resolve_keys_v2, resolve_keys_v21,
|
||||
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,105 @@
|
||||
//! Key source abstraction for the AACS resolve chain.
|
||||
//!
|
||||
//! libfreemkv keeps all crypto (AES-G primitives, SD-tree walking,
|
||||
//! validation, MK/VUK/TK derivation) but accepts key material from
|
||||
//! arbitrary backends via [`KeyProvider`].
|
||||
//!
|
||||
//! Methods come in two flavors:
|
||||
//!
|
||||
//! - **Bulk material** ([`device_keys`], [`processing_keys`],
|
||||
//! [`host_certs`]) — the resolver unions results across all
|
||||
//! providers and tries each candidate.
|
||||
//! - **Disc-keyed lookup** ([`lookup_disc_by_hash`],
|
||||
//! [`lookup_disc_by_vid`]) — the resolver short-circuits on the
|
||||
//! first hit, so providers are queried in array order with
|
||||
//! fastest/closest first.
|
||||
//!
|
||||
//! Default impls return empty / `None` so backends only override
|
||||
//! the methods they actually support — an 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()
|
||||
}
|
||||
|
||||
/// AACS host certificates (with their private keys) for drive
|
||||
/// authentication. Multiple in case some are revoked.
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Direct per-disc lookup by SHA-1 of `Unit_Key_RO.inf`. Returns
|
||||
/// `Some(entry)` if this provider has pre-computed material for
|
||||
/// the disc (paths 4 and 5). Short-circuits the resolver.
|
||||
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Lookup by Volume ID (path 3 — pre-computed MK + matching
|
||||
/// VID). Short-circuits the resolver on hit.
|
||||
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolver-side helpers that aggregate across a provider array.
|
||||
///
|
||||
/// The resolver consumes `&[&dyn KeyProvider]` directly; these
|
||||
/// helpers wrap the union-vs-short-circuit policy per method.
|
||||
pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]);
|
||||
|
||||
impl Providers<'_> {
|
||||
/// Union — gather DKs from every provider.
|
||||
pub fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.0.iter().flat_map(|p| p.device_keys()).collect()
|
||||
}
|
||||
|
||||
/// Union — gather PKs from every provider.
|
||||
pub fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.0.iter().flat_map(|p| p.processing_keys()).collect()
|
||||
}
|
||||
|
||||
/// Union — gather host certs from every provider. Not yet wired into
|
||||
/// the SCSI handshake (which still reads `KeyDb.host_certs` directly);
|
||||
/// kept here so a provider-aware handshake refactor is a drop-in.
|
||||
#[allow(dead_code)]
|
||||
pub fn host_certs(&self) -> Vec<HostCert> {
|
||||
self.0.iter().flat_map(|p| p.host_certs()).collect()
|
||||
}
|
||||
|
||||
/// Short-circuit — query providers in array order, first hit wins.
|
||||
pub fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_hash(disc_hash))
|
||||
}
|
||||
|
||||
/// Short-circuit — query providers in array order, first hit wins.
|
||||
pub fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
|
||||
}
|
||||
}
|
||||
+128
-2
@@ -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 {
|
||||
@@ -466,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,11 @@ pub struct Mapfile {
|
||||
/// Wall-clock timestamp of the last successful `write_to_disk` (or
|
||||
/// the moment the mapfile was constructed, whichever is later).
|
||||
last_flushed: Instant,
|
||||
/// AACS Volume ID (16 bytes) for the disc, persisted as a
|
||||
/// `# freemkv-vid:` comment header so it survives to deferred-mux /
|
||||
/// resume without altering the ISO payload or breaking ddrescue
|
||||
/// data-line parsing. `None` for unencrypted / non-AACS discs.
|
||||
vid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
@@ -154,6 +159,7 @@ impl Mapfile {
|
||||
},
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid: None,
|
||||
};
|
||||
// Eager initial persist so a resume can pick this up even if
|
||||
// `record()` is never called.
|
||||
@@ -168,6 +174,7 @@ impl Mapfile {
|
||||
let mut entries = Vec::new();
|
||||
let mut saw_current_line = false;
|
||||
let mut version = String::from("unknown");
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
@@ -178,6 +185,11 @@ impl Mapfile {
|
||||
if let Some(v) = rest.strip_prefix("Rescue Logfile. Created by ") {
|
||||
version = v.to_string();
|
||||
}
|
||||
if let Some(hex) = rest.strip_prefix("freemkv-vid:") {
|
||||
// Best-effort: a malformed or short VID comment is
|
||||
// ignored rather than failing the whole load.
|
||||
vid = parse_vid_hex(hex.trim());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
|
||||
@@ -227,6 +239,7 @@ impl Mapfile {
|
||||
stats,
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,6 +329,22 @@ impl Mapfile {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the disc's 16-byte AACS Volume ID so it persists in the
|
||||
/// mapfile's comment header. Marks the mapfile dirty; the next
|
||||
/// `flush()` / `Drop` writes the `# freemkv-vid:` line. Does not
|
||||
/// touch the ISO payload or the ddrescue data lines.
|
||||
pub fn set_vid(&mut self, vid: [u8; 16]) {
|
||||
self.vid = Some(vid);
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The disc's AACS Volume ID, if one was set or parsed from a
|
||||
/// `# freemkv-vid:` comment on load. `None` for unencrypted /
|
||||
/// non-AACS discs.
|
||||
pub fn vid(&self) -> Option<[u8; 16]> {
|
||||
self.vid
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
@@ -388,6 +417,18 @@ impl Mapfile {
|
||||
let file = std::fs::File::create(&tmp)?;
|
||||
let mut w = std::io::BufWriter::new(file);
|
||||
writeln!(w, "# Rescue Logfile. Created by {}", self.version)?;
|
||||
// VID comment lives in the header block. ddrescue treats any
|
||||
// `#`-prefixed line as a comment, so this round-trips through
|
||||
// our `load()` without affecting the `pos size status` data
|
||||
// parser. 16 bytes → 32 lowercase hex chars.
|
||||
if let Some(vid) = self.vid {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in vid {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-vid: {hex}")?;
|
||||
}
|
||||
writeln!(w, "# Current pos / status / pass / pass_time")?;
|
||||
writeln!(w, "0x000000000 ? 1 0")?;
|
||||
writeln!(w, "# pos size status")?;
|
||||
@@ -417,6 +458,22 @@ impl Drop for Mapfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a 32-char lowercase/uppercase hex string into a 16-byte VID.
|
||||
/// Returns `None` on any malformation (wrong length, non-hex) — the
|
||||
/// caller treats a bad VID comment as simply absent rather than an
|
||||
/// error, so a corrupt header never fails a mapfile load.
|
||||
fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
if s.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
@@ -583,6 +640,79 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vid_round_trips_and_data_lines_unaffected() {
|
||||
let p = tmpfile("vid_round_trips");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
|
||||
// Build a mapfile with some data ranges, set a VID, persist.
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 200, SectorStatus::Finished).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(700, 50, SectorStatus::NonTrimmed).unwrap();
|
||||
let vid: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
|
||||
0xee, 0xff,
|
||||
];
|
||||
mf.set_vid(vid);
|
||||
mf.flush().unwrap();
|
||||
|
||||
// The saved file must contain the VID comment in lowercase hex.
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
text.contains("# freemkv-vid:"),
|
||||
"saved mapfile missing VID comment: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# freemkv-vid: 00112233445566778899aabbccddeeff"),
|
||||
"VID comment format mismatch: {text}"
|
||||
);
|
||||
|
||||
// load() recovers the VID and the identical data ranges.
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.vid(), Some(vid));
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
|
||||
// A mapfile WITHOUT the VID comment must parse the same +/-/?
|
||||
// data ranges as the one WITH it (comment ignored by parser).
|
||||
let p2 = tmpfile("vid_round_trips_novid");
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
|
||||
mf2.record(100, 200, SectorStatus::Finished).unwrap();
|
||||
mf2.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf2.record(700, 50, SectorStatus::NonTrimmed).unwrap();
|
||||
mf2.flush().unwrap();
|
||||
let loaded_novid = Mapfile::load(&p2).unwrap();
|
||||
assert_eq!(loaded_novid.vid(), None);
|
||||
assert_eq!(loaded_novid.entries(), loaded.entries());
|
||||
|
||||
// Malformed VID comments must not error the load (treated absent).
|
||||
let mut bad = text.replace("00112233445566778899aabbccddeeff", "zzzz");
|
||||
let pbad = tmpfile("vid_round_trips_bad");
|
||||
let _ = std::fs::remove_file(&pbad);
|
||||
std::fs::write(&pbad, &bad).unwrap();
|
||||
let loaded_bad = Mapfile::load(&pbad).unwrap();
|
||||
assert_eq!(loaded_bad.vid(), None);
|
||||
assert_eq!(loaded_bad.entries(), loaded.entries());
|
||||
|
||||
// A load->save cycle preserves the VID (the patch-pass path).
|
||||
bad.clear();
|
||||
let resaved = tmpfile("vid_round_trips_resave");
|
||||
let _ = std::fs::remove_file(&resaved);
|
||||
let mut reloaded = Mapfile::load(&p).unwrap();
|
||||
// Repoint at a fresh path and flush; mark dirty via a no-op record.
|
||||
reloaded.path = resaved.clone();
|
||||
reloaded.dirty = true;
|
||||
reloaded.flush().unwrap();
|
||||
let again = Mapfile::load(&resaved).unwrap();
|
||||
assert_eq!(again.vid(), Some(vid));
|
||||
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let _ = std::fs::remove_file(&pbad);
|
||||
let _ = std::fs::remove_file(&resaved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_consistent_after_split_record() {
|
||||
let p = tmpfile("stats_consistent_after_split");
|
||||
|
||||
+129
-6
@@ -912,6 +912,9 @@ pub enum KeySource {
|
||||
/// 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 {
|
||||
@@ -922,6 +925,7 @@ impl KeySource {
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::KeyDb => "KEYDB",
|
||||
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
|
||||
KeySource::ExternalUk => "external UK",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -941,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());
|
||||
@@ -970,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,
|
||||
@@ -1140,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
|
||||
@@ -1159,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())
|
||||
@@ -1207,8 +1299,6 @@ impl Disc {
|
||||
(None, Some(final_err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
|
||||
@@ -1391,6 +1481,13 @@ impl 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,
|
||||
@@ -1488,6 +1585,7 @@ impl Disc {
|
||||
skip_on_error: opts.multipass,
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
vid: opts.vid,
|
||||
};
|
||||
self.sweep(reader, path, &sweep_opts)
|
||||
}
|
||||
@@ -1579,13 +1677,22 @@ impl Disc {
|
||||
if !opts.resume {
|
||||
let _ = std::fs::remove_file(&mapfile_path);
|
||||
}
|
||||
let map = mapfile::Mapfile::open_or_create(
|
||||
let mut map = mapfile::Mapfile::open_or_create(
|
||||
&mapfile_path,
|
||||
total_bytes,
|
||||
concat!("libfreemkv v", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
// Persist the disc's AACS Volume ID into the mapfile header so it
|
||||
// survives to deferred-mux / resume. ddrescue-safe (comment line);
|
||||
// does not touch the ISO payload. On a resume-load the VID is
|
||||
// already present, but re-setting it (idempotent) covers the case
|
||||
// where Pass 1 created the mapfile before the VID was known.
|
||||
if let Some(vid) = opts.vid {
|
||||
map.set_vid(vid);
|
||||
}
|
||||
|
||||
// ISO file: if resuming and mapfile has Finished ranges, open existing;
|
||||
// otherwise create fresh and pre-size to total_bytes (sparse holes for
|
||||
// non-tried regions).
|
||||
@@ -2057,6 +2164,11 @@ pub struct CopyOptions<'a> {
|
||||
pub multipass: bool,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
/// AACS Volume ID (16 bytes) to persist into the mapfile during
|
||||
/// Pass 1 so it survives to deferred-mux / resume. `None` for
|
||||
/// unencrypted / non-AACS discs. Caller wires this from
|
||||
/// `Disc::aacs.volume_id`.
|
||||
pub vid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -2078,6 +2190,9 @@ pub struct SweepOptions<'a> {
|
||||
pub skip_on_error: bool,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
/// AACS Volume ID (16 bytes) persisted into the mapfile when the
|
||||
/// sweep creates / opens it. `None` for unencrypted discs.
|
||||
pub vid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
|
||||
@@ -2588,6 +2703,7 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -2612,6 +2728,7 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -2642,6 +2759,7 @@ mod tests {
|
||||
multipass: false,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -2671,6 +2789,7 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
|
||||
assert!(
|
||||
@@ -2688,6 +2807,7 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
|
||||
assert!(
|
||||
@@ -2720,6 +2840,7 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
|
||||
|
||||
@@ -2732,6 +2853,7 @@ mod tests {
|
||||
multipass: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
|
||||
assert!(
|
||||
@@ -2764,6 +2886,7 @@ mod tests {
|
||||
multipass: false,
|
||||
progress: None,
|
||||
halt: None,
|
||||
vid: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
let r = result.expect("100-batch clean sweep should succeed");
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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