Compare commits

..
13 Commits
Author SHA1 Message Date
Matthew Jackson 1810161256 keyproof: answer only for a provable encrypted BD-TS unit; differential tests
unit_key_opens is false for other formats, units that are short, clear, or carry
fewer than KEY_PROOF_PACKETS content packets (before, an empty slice or an
all-zero unit opened under any key). The fast path returns packet counts, and a
seeded differential pins them to decrypt_unit's over padding, zero-sync and
corrupted packets; it fails if either skip branch is removed. The compile_fail
door names E0425.
2026-10-07 11:09:36 -07:00
Matthew Jackson 1a17ad6c14 keyproof: trim comments to the guard limits, rustfmt, and build/test it in CI 2026-10-07 10:33:27 -07:00
Matthew Jackson 33155eb9bf keyproof: data-free unit_key_opens predicate (gated feature)
Add `aacs::content::unit_key_opens(unit, key, format) -> bool` behind a new
non-default `keyproof` feature — one bit (key right/wrong), never plaintext —
for a private offline key-resolver (kdb). BD-TS per-packet fast reject, with a
decrypt_unit + is_clean fallback for PS/odd units. A not(keyproof)-gated
compile_fail door in keys/public_api_cannot_decrypt_aacs pins its absence by
default, so the default public API still cannot decrypt AACS (KU §2.2). Also
expose test_util::encrypt_unit alongside decrypt_unit for consumer fixtures.
Equivalence test pins the fast path to decrypt_unit + is_clean. Version left to
the release owner.
2026-10-07 10:17:23 -07:00
Matthew Jackson c0be981300 docs: escape [BD] in the one-unit resolver note 2026-10-07 10:14:53 -07:00
Matthew Jackson 7aa28ba019 docs: 1.8.2 release notes 2026-10-07 09:59:53 -07:00
Matthew Jackson 61b9c31c86 Keep comments within the comment-guard limits
Function notes become item docs; the per-unit request note is shorter.
2026-10-07 09:59:53 -07:00
Matthew Jackson 15f7829468 scan, keys: read less on a live drive; FMTS discs on the trusted key path
- Key evidence reads the filesystem in batches with the metadata partition
  prefetched, not one command per sector (~1 s on a USB UHD).
- The live Blu-ray scan prefetches only the files it parses (~22 MB and
  three long seeks less on a UHD). The MKB is read from a 4 MiB prefix with
  its record headers walked; its bytes are unchanged.
- macOS: no fixed 500 ms sleep after unmounting before claiming the drive.
- FMTS discs use the trusted CPS path; unit-key samples skip forensic
  segment units. The parity_image_fmts_alternate_phase golden changes only
  in request count, on a fixture title under two keys (not a real disc).
2026-10-07 08:47:00 -07:00
Matthew Jackson af62d6d5d8 keys: trust the declared CPS units instead of probing every stream file
1.8.0 read up to 32 units from every stream file before asking any key
source: 1,472 random reads (~95 s over USB) on a 46-file UHD that declares
one CPS unit, answered by one keyserver request.

One declared unit: the main title's samples ask once and the key that
opens them keys every file. Several: one encrypted unit per file, then
each unopened unit asks with 8 of its own units; one answer may open many
files. A source answering no key is not asked again, and a disc no key
opens is refused at once (blanked in a whole-disc copy) rather than
probed. Discs with an FMTS layout in scope keep the probed path.

Key acquisition also logs its phase timings.
2026-10-07 03:31:29 -07:00
Matthew Jackson 6acc55b246 fix(linux): a bare node name opens under /dev
`sg3` (or `sr0` with no sg node) was opened relative to the current
directory. It now opens /dev/sg3, as the full path does.
2026-10-07 00:33:15 -07:00
Matthew Jackson 959800adec fix(windows): a bare drive letter opens that drive
`E` was normalized to \\.\E, which does not exist, so `fmv-key E` and
`freemkv-flash E` failed with E1000. It now opens \\.\E:, as `E:` does.
2026-10-07 00:31:32 -07:00
Matthew Jackson 6980b04e63 v1.8.1: bump version 2026-10-07 03:56:38 +00:00
Matthew Jackson 777cd6275c docs: 1.8.1 release notes 2026-10-06 20:19:16 -07:00
Matthew Jackson 4d8cba6ff8 feat: report each drive's OS display name
DriveInfo gains display_name: the drive letter on Windows, /dev/srN on
Linux and diskN on macOS while a disc is mounted, or the path when the OS
has no name. path stays the selector to open. Windows lists lettered drives
in letter order, then unlettered drives by CdRomN.
2026-10-06 18:21:41 -07:00
33 changed files with 1686 additions and 278 deletions
+5
View File
@@ -82,6 +82,8 @@ jobs:
working-directory: libfreemkv
- run: cargo +stable clippy --all-targets -- -D warnings
working-directory: libfreemkv
- run: cargo +stable clippy --all-targets --features keyproof -- -D warnings
working-directory: libfreemkv
test:
needs: resolve-msrv
@@ -107,6 +109,9 @@ jobs:
# `--tests` skips doctests; the keys compile_fail doctests enforce spec LK19.
- run: cargo test --doc
working-directory: libfreemkv
# `keyproof` is off by default, so its oracle compiles and is tested only here.
- run: cargo test --lib --features keyproof aacs::content
working-directory: libfreemkv
# The memory orderings in `scsi::fd_handoff` (and `halt`) compile to the same instructions
# on x86_64, so the WRONG ones pass every run; loom enumerates the executions
+1
View File
@@ -22,3 +22,4 @@ CLAUDE.md
# cargo-mutants working output: large, machine-specific, never committed
mutants.out/
mutants.out.old/
.cargo/config.toml
+13 -1
View File
@@ -1,7 +1,19 @@
# Changelog
## Unreleased
## [1.8.2] — Unreleased
- New non-default `keyproof` feature: `aacs::content::unit_key_opens(unit, key, format)` answers whether a key opens an encrypted BD-TS aligned unit (one bit, never plaintext). It is `false` for anything that proves nothing (other formats, short or clear units, fewer than four content packets); a wrong key passes about once in 10^5 units, so confirm a match on several. `test_util::encrypt_unit` builds ciphertext fixtures. The default build is unchanged.
- Key lookup trusts the disc's declared CPS units: samples from each unit instead of 32 reads from every stream file (a 46-file UHD over USB: ~95 s → ~1.5 s). Each source is asked once; a disc no key opens is refused at once. FMTS discs included.
- Key evidence reads the filesystem in batches, not one command per sector (~1 s less).
- The live scan prefetches only the files it parses (~22 MB less on a UHD) and reads the MKB from a 4 MiB prefix, not 16 MiB; the MKB bytes are unchanged.
- macOS: opening a drive no longer waits a fixed 500 ms after unmounting.
- Key acquisition logs its phase timings.
- Windows: a bare drive letter (`E`) opens that drive, as `E:` does; it was opened as `\\.\E`, which does not exist (E1000).
- Linux: a bare node name (`sg3`, `sr0`) opens `/dev/sg3` / `/dev/sr0`; it was opened relative to the current directory.
## [1.8.1] — 2026-10-07
- `DriveInfo::display_name` is the name the OS shows users: the drive letter (`E:`) on Windows, `/dev/srN` on Linux and `diskN` on macOS while a disc is mounted; it equals `path` when the OS has no name. `path` remains the selector to open. Windows lists lettered drives in letter order, then unlettered drives by `CdRomN`.
- Drive listing enumerates instead of guessing device numbers. Windows lists every `CdRomN` from `QueryDosDeviceW` plus optical drive letters (previously only `CdRom0`–`15`), retries INQUIRY with 36 bytes for strict USB bridges, and logs skipped devices. Linux also lists optical `sr` devices that have no `sg` node (the `sg` module is not loaded) and, without sysfs, reads `/dev` instead of probing `sg0`–`15`. macOS no longer stops at 8 drives.
- macOS CLI/GUI/server transport diagnostics include selected IOKit service, plug-in metadata, interface initialization stages, exclusive-access attempts and cleanup results.
Generated
+2 -2
View File
@@ -221,7 +221,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.8.0"
version = "1.8.1"
dependencies = [
"aes",
"base64",
@@ -318,7 +318,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.8.0"
version = "1.8.1"
dependencies = [
"aes",
"base64",
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.8.0"
version = "1.8.1"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -56,6 +56,12 @@ rip = [
# `test-util`: `libfreemkv::test_util` fixtures for consumers' tests (dev-dependency
# only; never in a release build). Implies the rip tree the fixtures build on.
test-util = ["rip"]
# `keyproof`: exposes `aacs::content::unit_key_opens` — a data-free key→content
# ORACLE (one bit, never plaintext) for a PRIVATE offline key-resolver (kdb).
# NOT in `default`, so the open public API still cannot decrypt AACS (KU §2.2;
# the `keys::public_api_cannot_decrypt_aacs` guard pins its absence by default).
# Needs the `rip` crypto tree (`aes`).
keyproof = ["rip"]
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
@@ -146,7 +152,7 @@ harness = false
# freemkv-unlock is off crates.io: resolve it from its GIT TAG. Consumers carry
# the same patch; a gitignored .cargo/config.toml overrides it to a local path.
[patch.crates-io]
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.0" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.1" }
[lints.rust]
# `loom` is set by RUSTFLAGS for the model checker above, never by a feature.
+109
View File
@@ -156,6 +156,12 @@ pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
// Structural "does this unit carry enough valid MPEG-TS to prove a key opened it?" (the mux's
// key-selection/verify signal): synced >= min(E, KEY_PROOF_PACKETS) over non-padding packets.
fn is_clean_ts(unit: &[u8]) -> bool {
let (content, synced) = ts_packet_counts(unit);
content == 0 || synced >= content.min(KEY_PROOF_PACKETS)
}
// `(content, synced)`: non-padding packets after packet 0, and those whose sync byte is 0x47.
fn ts_packet_counts(unit: &[u8]) -> (usize, usize) {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut content = 0usize;
@@ -173,9 +179,112 @@ fn is_clean_ts(unit: &[u8]) -> bool {
}
off += PKT;
}
(content, synced)
}
/// Does `unit_key` open this encrypted BD-TS aligned `unit`? One bit, never plaintext.
/// `true` only for a whole 6144-byte unit flagged encrypted whose plaintext would carry
/// at least `KEY_PROOF_PACKETS` content packets, all synced: anything else proves nothing
/// and is `false` (other formats, short or clear units, too little content). A wrong key
/// passes about once in 10^5 units, so confirm a match on several units.
///
/// Behind the `keyproof` feature so the default public API still cannot decrypt AACS: a
/// key oracle, not a decryptor (KU §2.2; see `keys::public_api_cannot_decrypt_aacs`).
#[cfg(feature = "keyproof")]
pub fn unit_key_opens(
unit: &[u8],
unit_key: &[u8; 16],
format: crate::disc::ContentFormat,
) -> bool {
use crate::disc::ContentFormat;
if !matches!(format, ContentFormat::BdTs)
|| unit.len() != ALIGNED_UNIT_LEN
|| !aacs_unit_encrypted(unit, format)
{
return false;
}
let (content, synced) = ts_unit_key_proof(unit, unit_key);
content >= KEY_PROOF_PACKETS && synced >= KEY_PROOF_PACKETS
}
/// [`is_clean_ts`]'s verdict for `decrypt_unit(unit, unit_key)`, from the packet counts of
/// `ts_unit_key_proof`.
#[cfg(test)]
fn ts_unit_key_opens(unit: &[u8], unit_key: &[u8; 16]) -> bool {
let (content, synced) = ts_unit_key_proof(unit, unit_key);
content == 0 || synced >= content.min(KEY_PROOF_PACKETS)
}
/// The `(content, synced)` counts `ts_packet_counts` would see after `decrypt_unit`, from
/// one AES block per source packet. CBC decrypts any block alone
/// (`P[i] = AES_dec(C[i]) ⊕ C[i-1]`), and packet heads sit at block-aligned offsets ≥ 192.
/// Packet 0 is skipped (its sync byte is in the clear seed), as are all-zero padding
/// packets, which `decrypt_unit` zeroes.
#[cfg(any(test, feature = "keyproof"))]
fn ts_unit_key_proof(unit: &[u8], unit_key: &[u8; 16]) -> (usize, usize) {
use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use aes::{Aes128, Block};
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
const NPKT: usize = ALIGNED_UNIT_LEN / PKT;
debug_assert_eq!(unit.len(), ALIGNED_UNIT_LEN);
let block_at =
|o: usize| -> Block { Array::try_from(&unit[o..o + 16]).expect("16-byte block") };
// Block key = AES-128E(unit_key, seed) ⊕ seed — `decrypt_unit`'s derivation.
let mut bk = block_at(0);
Aes128::new(&(*unit_key).into()).encrypt_block(&mut bk);
for (b, s) in bk.iter_mut().zip(&unit[..16]) {
*b ^= s;
}
let cipher = Aes128::new(&bk);
// Plaintext of the 16-byte block at `o` (o ≥ 192, so C[i-1] is ciphertext).
let plain = |o: usize| {
let mut b = block_at(o);
cipher.decrypt_block(&mut b);
for (x, c) in b.iter_mut().zip(&unit[o - 16..o]) {
*x ^= c;
}
b
};
// Batch the head block of every non-padding packet (the backend pipelines it).
let mut offs = [0usize; NPKT];
let mut heads = [Block::default(); NPKT];
let mut n = 0;
for p in 1..NPKT {
let off = p * PKT;
if unit[off..off + PKT].iter().all(|&b| b == 0) {
continue; // padding — decrypt_unit restores it to zeros: not content
}
offs[n] = off;
heads[n] = block_at(off);
n += 1;
}
cipher.decrypt_blocks(&mut heads[..n]);
let (mut content, mut synced) = (0usize, 0usize);
for (head, &off) in heads[..n].iter().zip(&offs[..n]) {
match head[4] ^ unit[off - 16 + 4] {
TS_SYNC => {
content += 1;
synced += 1;
}
0 => {
// Sync byte decrypted to 0x00: content only if the rest of the
// payload isn't all zero too.
let first = plain(off);
let nonzero = first[4..].iter().any(|&b| b != 0)
|| (1..PKT / 16).any(|j| plain(off + 16 * j).iter().any(|&b| b != 0));
if nonzero {
content += 1;
}
}
_ => content += 1,
}
}
(content, synced)
}
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
+153
View File
@@ -1254,3 +1254,156 @@ fn ts_packet_total_for_various_lengths() {
// 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
}
// The decrypt-less BD-TS path `ts_unit_key_opens` returns the same verdict as
// `decrypt_unit` + `is_clean(BdTs)` for the right key and wrong keys alike.
#[test]
fn ts_unit_key_opens_matches_decrypt_unit_then_is_clean() {
let fmt = crate::disc::ContentFormat::BdTs;
// A content unit: TS sync at each packet's offset 4 + non-zero payload (so no
// packet is all-zero ciphertext padding once encrypted).
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = TS_SYNC;
for j in 1..(BD_SOURCE_PACKET_BYTES - 4) {
clear[off + j] = (j as u8).wrapping_mul(7).wrapping_add(1);
}
off += BD_SOURCE_PACKET_BYTES;
}
let right = [0x5Au8; 16];
let mut enc = clear.clone();
aacs_encrypt_unit(&mut enc, &right);
let full = |key: &[u8; 16]| -> bool {
let mut u = enc.clone();
decrypt_unit(&mut u, key);
is_clean(&u, fmt)
};
assert!(
full(&right),
"sanity: the right key opens via the full path"
);
assert_eq!(
ts_unit_key_opens(&enc, &right),
full(&right),
"fast path must agree with decrypt+is_clean for the RIGHT key"
);
for n in 0..64u8 {
let mut wrong = [0u8; 16];
wrong[0] = n.wrapping_add(1);
wrong[15] = n ^ 0xA5;
assert_eq!(
ts_unit_key_opens(&enc, &wrong),
full(&wrong),
"fast path disagreed with decrypt+is_clean for wrong key {n}"
);
}
}
fn xorshift(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
/// A flagged, encrypted BD-TS unit whose packets mix synced content, header-only and
/// zero-sync packets, zero-ciphertext padding and stray corruption, as seeded by `state`.
fn mixed_encrypted_unit(state: &mut u64, key: &[u8; 16]) -> Vec<u8> {
const PKT: usize = BD_SOURCE_PACKET_BYTES;
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
let mut kinds = Vec::new();
for p in 0..ALIGNED_UNIT_LEN / PKT {
let off = p * PKT;
let kind = xorshift(state) % 5;
kinds.push(kind);
match kind {
0 | 1 => {
unit[off + 4] = TS_SYNC;
for b in &mut unit[off + 5..off + PKT] {
*b = xorshift(state) as u8;
}
}
2 => unit[off + 4] = TS_SYNC,
3 => unit[off + 4 + 1 + (xorshift(state) as usize % (PKT - 5))] = 1,
_ => {}
}
}
aacs_encrypt_unit(&mut unit, key);
for (p, &kind) in kinds.iter().enumerate().skip(1) {
if kind == 4 && xorshift(state).is_multiple_of(2) {
unit[p * PKT..(p + 1) * PKT].fill(0);
}
}
if xorshift(state).is_multiple_of(4) {
let i = 16 + xorshift(state) as usize % (ALIGNED_UNIT_LEN - 16);
unit[i] ^= 1 + xorshift(state) as u8 % 255;
}
unit
}
// Seeded differential: the fast path's packet counts and verdict match `decrypt_unit`'s over
// padding, header-only and zero-sync packets and corruption, for right and wrong keys.
#[test]
fn ts_unit_key_opens_matches_the_full_path_on_mixed_units() {
let fmt = crate::disc::ContentFormat::BdTs;
let mut state = 0x9E37_79B9_7F4A_7C15u64;
for _ in 0..400 {
let mut right = [0u8; 16];
right
.iter_mut()
.for_each(|b| *b = xorshift(&mut state) as u8);
let enc = mixed_encrypted_unit(&mut state, &right);
let mut wrong = right;
wrong[xorshift(&mut state) as usize % 16] ^= 0x80;
for key in [right, wrong] {
let mut full = enc.clone();
decrypt_unit(&mut full, &key);
assert_eq!(ts_unit_key_proof(&enc, &key), ts_packet_counts(&full));
assert_eq!(ts_unit_key_opens(&enc, &key), is_clean(&full, fmt));
}
}
}
#[cfg(feature = "keyproof")]
#[test]
fn unit_key_opens_answers_only_for_a_provable_encrypted_bd_ts_unit() {
use crate::disc::ContentFormat::{BdTs, MpegPs};
let right = [0x5Au8; 16];
let mut enc = clear_unit();
for (i, b) in enc.iter_mut().enumerate() {
if i % BD_SOURCE_PACKET_BYTES > 4 {
*b = (i as u8).wrapping_mul(7) | 1;
}
}
aacs_encrypt_unit(&mut enc, &right);
assert!(unit_key_opens(&enc, &right, BdTs));
for n in 0..64u8 {
assert!(!unit_key_opens(&enc, &[n; 16], BdTs), "wrong key {n}");
}
assert!(
!unit_key_opens(&enc, &right, MpegPs),
"only BD-TS is answered"
);
let mut clear_flag = enc.clone();
clear_flag[0] &= 0x3F;
assert!(!unit_key_opens(&clear_flag, &right, BdTs), "a clear unit");
for len in [0, 383, ALIGNED_UNIT_LEN - 1] {
assert!(!unit_key_opens(&enc[..len], &right, BdTs), "{len} bytes");
}
let mut zero = vec![0u8; ALIGNED_UNIT_LEN];
zero[0] = 0xC0;
assert!(
!unit_key_opens(&zero, &right, BdTs),
"no content proves nothing"
);
// Three content packets, the rest zero-ciphertext padding: too little to prove a key.
let mut thin = enc.clone();
thin[4 * BD_SOURCE_PACKET_BYTES..].fill(0);
assert!(
!unit_key_opens(&thin, &right, BdTs),
"below the proof floor"
);
}
+22
View File
@@ -247,6 +247,28 @@ pub fn mkb_content_len(mkb: &[u8]) -> usize {
.unwrap_or(0)
}
/// How far a prefix `buf` of an MKB file shows its record stream, walking the record
/// headers (4 bytes: type + BE24 length) as [`mkb_content_len`] does. `Ok(end)`: the walk
/// stopped on a header inside `buf` (the `00 000000` end marker, padding, or a header that
/// frames no record), so the stream ends at `end`. `Err(need)`: a record, or the header
/// after it, runs past `buf`; the stream needs at least `need` bytes to be seen.
pub(crate) fn mkb_prefix_end(buf: &[u8]) -> std::result::Result<usize, usize> {
let mut pos = 0usize;
loop {
let Some(head) = buf.get(pos..pos + 4) else {
return Err(pos + 4);
};
let rec_len = ((head[1] as usize) << 16) | ((head[2] as usize) << 8) | (head[3] as usize);
if (head[0] == 0 && rec_len == 0) || rec_len < 4 {
return Ok(pos);
}
if pos + rec_len > buf.len() {
return Err(pos + rec_len + 4);
}
pos += rec_len;
}
}
/// Trim an MKB's trailing fixed-region padding to its real content length —
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
+29
View File
@@ -295,3 +295,32 @@ fn aacs_version_from_major_only_bd_is_v10() {
assert_eq!(AacsVersion::from_major(m).unit_key_stride(), 64);
}
}
// `mkb_prefix_end`: the stream ends inside the prefix only where the walk stops on a header
// the prefix holds; a record or header cut by the prefix asks for the bytes it needs.
#[test]
fn prefix_end_finds_the_end_marker_or_names_the_bytes_still_needed() {
let a = rec(0x10, &[0u8; 8]); // 12 bytes
let b = rec(0x04, &[0xAA; 100]); // 104 bytes
let mut mkb = [a.clone(), b.clone()].concat();
let content = mkb.len();
mkb.extend_from_slice(&[0, 0, 0, 0]);
mkb.resize(content + 64, 0);
// Whole stream and its end marker seen: the end, equal to `mkb_content_len`.
assert_eq!(mkb_prefix_end(&mkb), Ok(content));
assert_eq!(mkb_content_len(&mkb), content);
// The prefix cuts record `b`: its end plus the next header are needed. The record
// walk alone stops early here, at the end of `a`.
let cut = &mkb[..a.len() + 50];
assert_eq!(mkb_prefix_end(cut), Err(content + 4));
assert_eq!(mkb_content_len(cut), a.len());
// The prefix ends exactly after `b`, or inside the header after it: that header.
assert_eq!(mkb_prefix_end(&mkb[..content]), Err(content + 4));
assert_eq!(mkb_prefix_end(&mkb[..content + 2]), Err(content + 4));
// A header that frames no record ends the stream where it stands.
let mut bad = a.clone();
bad.extend_from_slice(&[0x04, 0, 0, 2]);
assert_eq!(mkb_prefix_end(&bad), Ok(a.len()));
assert_eq!(mkb_prefix_end(&[0x04, 0, 0, 2]), Ok(0));
assert_eq!(mkb_prefix_end(&[]), Err(4));
}
+70 -14
View File
@@ -1778,6 +1778,56 @@ impl DiscId {
}
}
/// Whether a live scan parses the file at `path` (absolute, any case), so the metadata
/// prefetch bulk-loads it. On a BD tree only what the scan reads: the nav and clip files,
/// BD-J objects, jar archives and the config files the labels read beside them, META XML,
/// and the AACS files the capture reads first. Not /BDMV/BACKUP, BD-J image assets,
/// /AACS/DUPLICATE or the other AACS files: a fallback read of one still works, unprefetched.
/// Any other tree (DVD, HD DVD) keeps every file.
pub(crate) fn scan_parses(bd: bool, path: &str) -> bool {
if !bd {
return true;
}
let p = path.to_ascii_uppercase();
let in_dir = |dir: &str| {
p.strip_prefix(dir)
.and_then(|rest| rest.strip_prefix('/'))
.filter(|name| !name.contains('/'))
};
if let Some(name) = in_dir("/BDMV") {
return matches!(name, "INDEX.BDMV" | "MOVIEOBJECT.BDMV");
}
if ["/BDMV/PLAYLIST", "/BDMV/CLIPINF", "/BDMV/BDJO"]
.iter()
.any(|d| in_dir(d).is_some())
{
return true;
}
if p.starts_with("/BDMV/META/") {
return p.ends_with(".XML");
}
if let Some(name) = in_dir("/BDMV/JAR") {
return name.ends_with(".JAR");
}
if let Some(rest) = p.strip_prefix("/BDMV/JAR/") {
// A jar subdirectory's own files: only those a label reads.
return rest.split_once('/').is_some_and(|(_, name)| {
!name.contains('/')
&& crate::labels::JAR_DIR_FILES
.iter()
.any(|f| f.eq_ignore_ascii_case(name))
});
}
[
crate::aacs::PATH_UNIT_KEY_RO,
crate::aacs::PATH_CONTENT_CERT,
crate::aacs::PATH_CONTENT_CERT_ALT,
crate::aacs::PATH_MKB_RO,
]
.iter()
.any(|f| f.eq_ignore_ascii_case(path))
}
impl Disc {
/// Fast disc identification — reads only UDF metadata for name and format.
/// No AACS handshake, no playlist parsing, no CLPI, no labels.
@@ -1957,8 +2007,10 @@ impl Disc {
tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read");
// Pre-read small files (AACS, MPLS, CLPI, META, *.bdmv): one command each otherwise.
match udf_fs.metadata_sector_ranges(&mut buffered) {
// Pre-read the small files the scan parses (AACS, MPLS, CLPI, META, *.bdmv): one
// command each otherwise.
let bd = udf_fs.find_dir("/BDMV").is_some();
match udf_fs.metadata_sector_ranges_for(&mut buffered, &|p| scan_parses(bd, p)) {
Ok(ranges) => buffered.prefetch_ranges(&ranges)?,
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => {} // prefetch is optional
@@ -2287,11 +2339,12 @@ impl Disc {
Ok(crate::aacs::mkb::resolve_aacs_version(cert_major, mkb, index).major())
}
// Reads the AACS MKB's real record stream — NOT its ~128 MiB zero padding. Reads a bounded,
// growing prefix instead, avoiding both the padding read and the read_file MAX_FILE_BYTES
// cap.
/// Reads the AACS MKB's real record stream — NOT its ~128 MiB zero padding. Reads a bounded
/// prefix (4 MiB holds a real MKB, ~4 MB on a UHD) and walks the record headers: a stream
/// the prefix cuts is re-read with room for the record it cuts, never returned short. Also
/// avoids the read_file MAX_FILE_BYTES cap.
fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
const START_BYTES: usize = 16 * 1024 * 1024;
const START_BYTES: usize = 4 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
@@ -2299,17 +2352,20 @@ impl Disc {
&crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::Mkb),
|p| udf_fs.read_file_prefix(reader, p, want),
)?;
let n = crate::aacs::mkb::mkb_content_len(&buf);
// `n` strictly inside `buf` or `buf` shorter than `want` means the
// whole content is captured; otherwise records may run past the
// prefix — grow and retry, bounded by MAX_BYTES.
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
// The stream ends inside `buf`, or `buf` is the whole file, or the cap is
// reached: done. An unparseable first record (end 0) grows as before.
let need = match crate::aacs::mkb::mkb_prefix_end(&buf) {
Ok(n) if n > 0 => None,
Ok(_) => Some(0),
Err(need) => Some(need),
};
let Some(need) = need.filter(|_| buf.len() >= want && want < MAX_BYTES) else {
// The prefix buffer is sized for the padded file; release the unused capacity.
let mut mkb = crate::aacs::mkb::trim_mkb(buf);
mkb.shrink_to_fit();
return Ok(mkb);
}
want = (want * 2).min(MAX_BYTES);
};
want = (want * 2).max(need).min(MAX_BYTES);
}
}
@@ -3336,7 +3392,7 @@ fn random_u64() -> u64 {
}
const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
pub(crate) const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192;
const MIN_BATCH_SECTORS: u16 = 3;
+130
View File
@@ -1337,6 +1337,86 @@ fn read_mkb_content_grows_prefix_past_16mib_when_records_run_longer() {
);
}
// An MKB_RO.inf holding `mkb` (zero-padded to `file_len`) at LBA 1000 of a bare UDF tree.
fn mkb_disc(mut mkb: Vec<u8>, file_len: usize) -> (crate::udf::fixture::MemDisc, udf::UdfFs) {
use crate::udf::fixture::*;
mkb.resize(file_len, 0);
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "AACS".into(),
icb_lba: 12,
dir_data_lba: 13,
files: vec![file_with("MKB_RO.inf", 14, 1000, mkb, true)],
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
(disc, udf)
}
// An MKB record stream: `lens` records (header included) of filler, then the end marker.
fn mkb_stream(lens: &[usize]) -> Vec<u8> {
let mut mkb = Vec::new();
for &len in lens {
mkb.extend_from_slice(&[0x04, (len >> 16) as u8, (len >> 8) as u8, len as u8]);
mkb.resize(mkb.len() + len - 4, 0xAA);
}
mkb.extend_from_slice(&[0, 0, 0, 0]);
mkb
}
// Counts the sectors read off the wrapped source.
struct SectorCount<'a>(&'a mut crate::udf::fixture::MemDisc, u64);
impl SectorSource for SectorCount<'_> {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], r: bool) -> Result<usize> {
self.1 += u64::from(count);
self.0.read_sectors(lba, count, buf, r)
}
}
// A real-sized MKB (3,809,272 bytes of records on a live UHD, in a ~128 MiB padded file) is
// read from a 4 MiB prefix, not 16 MiB, and returned byte for byte.
#[test]
fn read_mkb_content_reads_a_4mib_prefix_for_a_real_sized_mkb() {
let records = [12, 3_809_272 - 12 - 4];
let stream = mkb_stream(&records);
assert_eq!(stream.len(), 3_809_272);
let (mut disc, udf) = mkb_disc(stream.clone(), 20 * 1024 * 1024);
let mut src = SectorCount(&mut disc, 0);
let got = Disc::read_mkb_content(&mut src, &udf).expect("mkb");
assert_eq!(
got,
stream[..stream.len() - 4],
"the record stream, byte for byte"
);
let data_sectors = (4 * 1024 * 1024 / 2048) as u64;
assert!(
src.1 <= data_sectors + 4,
"read {} sectors: more than the 4 MiB prefix and the File Entry",
src.1
);
}
// A record the 4 MiB prefix cuts is read whole: the walk asks for the bytes it needs and the
// prefix grows, rather than returning the stream up to the cut record.
#[test]
fn read_mkb_content_rereads_a_record_the_prefix_cuts() {
const MIB: usize = 1024 * 1024;
let stream = mkb_stream(&[3 * MIB, 3 * MIB]);
let (mut disc, udf) = mkb_disc(stream.clone(), 20 * MIB);
let got = Disc::read_mkb_content(&mut disc, &udf).expect("mkb");
assert_eq!(got.len(), 6 * MIB, "both records, not the first alone");
assert_eq!(got, stream[..6 * MIB]);
}
// ── identify(): AACS-directory encrypted gate (finding 6) ──────────────
// A ScsiTransport serving a synthetic UDF image through real READ(10),
// so Disc::identify is exercised end-to-end, not mocked itself.
@@ -5786,3 +5866,53 @@ fn read_capacity_rejects_a_short_transfer_instead_of_reporting_one_sector() {
Err(crate::error::Error::DiscCapacityMalformed)
));
}
/// The scan's metadata prefetch on a BD tree loads only what the scan parses: nav, clip,
/// BD-J object, jar, META XML, the label config files beside the jars and the primary AACS
/// files. Backups, duplicates, image assets and unread AACS files stay on the disc (a
/// fallback read of one still works, unprefetched). Other trees keep every file.
#[test]
fn scan_prefetches_only_the_files_a_bd_scan_parses() {
for p in [
"/BDMV/index.bdmv",
"/BDMV/MovieObject.bdmv",
"/BDMV/PLAYLIST/00800.mpls",
"/BDMV/CLIPINF/00055.clpi",
"/BDMV/BDJO/00000.bdjo",
"/BDMV/META/DL/bdmt_eng.xml",
"/BDMV/JAR/00000.jar",
"/BDMV/JAR/00001/config.xml",
"/BDMV/JAR/00002/DCX.XML",
"/AACS/Unit_Key_RO.inf",
"/AACS/Content000.cer",
"/AACS/Content001.cer",
"/AACS/MKB_RO.inf",
"/aacs/unit_key_ro.inf",
] {
assert!(scan_parses(true, p), "{p} is parsed: prefetch it");
}
for p in [
"/BDMV/BACKUP/index.bdmv",
"/BDMV/BACKUP/PLAYLIST/00800.mpls",
"/BDMV/JAR/00001/menu_bg.png",
"/BDMV/JAR/00001/sub/config.xml",
"/BDMV/META/DL/bdmt_eng_thumb.jpg",
"/BDMV/AUXDATA/sound.bdmv",
"/BDMV/PLAYLIST/x/00800.mpls",
"/AACS/DUPLICATE/Unit_Key_RO.inf",
"/AACS/DUPLICATE/Content000.cer",
"/AACS/ContentHash000.tbl",
"/AACS/ContentRevocation.lst",
"/AACS/CPSUnit00001.cci",
"/CERTIFICATE/id.bdmv",
] {
assert!(!scan_parses(true, p), "{p} is not parsed: leave it");
}
for p in [
"/VIDEO_TS/VIDEO_TS.IFO",
"/HVDVD_TS/FEATURE.MAP",
"/AACS/DUPLICATE/x",
] {
assert!(scan_parses(false, p), "{p}: a non-BD tree keeps every file");
}
}
+129 -27
View File
@@ -232,24 +232,13 @@ impl KeyEvidence {
volume_label: inputs.volume_label,
});
let whole = ev.scope == KeyScope::WholeDisc;
if ev.detector == Detector::PerPack {
// Listed whatever the declared count: acquisition judges a copy in the clear from
// its pieces before it refuses a multi-key HD DVD.
let files = match crate::whole_disc::content_files(reader) {
Ok(f) => f,
Err(e) if whole => return Err(e),
Err(e) => {
tracing::warn!(target: "freemkv::keys", error = %e, "no HD DVD file list: keying title extents");
Vec::new()
}
};
ev.no_stream_files = files.is_empty();
ev.pieces = pieces(disc, &files, &sel, whole);
return Ok(ev);
}
// The filesystem the scan already read, re-read as the scan reads it: batched, with
// the metadata partition prefetched, so the tree walk and every stream file's File
// Entry cost a few commands, not one per sector.
let mut buffered = crate::udf::BufferedSectorReader::new(reader, FS_BATCH_SECTORS);
// An unreadable filesystem fails a whole-disc copy (it would otherwise ship
// ciphertext); a title rip keys its title extents instead.
let fs = match crate::udf::read_filesystem(reader) {
let fs = match buffered_filesystem(&mut buffered) {
Ok(fs) => Some(fs),
Err(e) if whole => return Err(e),
Err(e) => {
@@ -258,7 +247,7 @@ impl KeyEvidence {
}
};
let files = match &fs {
Some(fs) => match crate::whole_disc::content_files_in(fs, reader) {
Some(fs) => match crate::whole_disc::content_files_in(fs, &mut buffered) {
Ok(f) => f,
Err(e) if whole => return Err(e),
Err(e) => {
@@ -270,14 +259,30 @@ impl KeyEvidence {
};
ev.no_stream_files = files.is_empty();
ev.pieces = pieces(disc, &files, &sel, whole);
ev.fmts = match &fs {
Some(fs) => super::fmts::layout(fs, reader)?,
None => None,
};
// HD DVD: listed whatever the declared count (acquisition judges a copy in the clear
// from its pieces before it refuses a multi-key HD DVD); it has no forensic layout.
if ev.detector != Detector::PerPack {
ev.fmts = match &fs {
Some(fs) => super::fmts::layout(fs, &mut buffered)?,
None => None,
};
}
Ok(ev)
}
}
/// Sectors per batched read of the filesystem: the optical default the scan uses.
const FS_BATCH_SECTORS: u16 = crate::disc::DEFAULT_BATCH_SECTORS_OPTICAL;
// The UDF tree, then the metadata partition (every directory and File Entry) prefetched.
fn buffered_filesystem<S: SectorSource + ?Sized>(
reader: &mut crate::udf::BufferedSectorReader<'_, S>,
) -> Result<crate::udf::UdfFs> {
let fs = crate::udf::read_filesystem(reader)?;
reader.prefetch(fs.metadata_start(), fs.metadata_sectors())?;
Ok(fs)
}
pub(crate) fn sorted_ranges(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
v.sort_unstable();
let mut out: Vec<(u32, u32)> = Vec::with_capacity(v.len());
@@ -377,6 +382,16 @@ pub(crate) fn pieces(
/// raw (never decrypted) source the evidence describes. Random access is required.
pub struct Sampler<'a> {
reader: &'a mut dyn SectorSource,
stats: ReadStats,
}
/// Read accounting for the sampler's units: how long key acquisition spent on the drive.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ReadStats {
pub(crate) reads: u32,
pub(crate) faults: u32,
pub(crate) total: std::time::Duration,
pub(crate) slowest: std::time::Duration,
}
/// A sampled unit: `Enc` ciphertext, `Clear`, or a `Fault` (a soft read error).
@@ -389,7 +404,14 @@ pub(crate) enum Sample {
impl<'a> Sampler<'a> {
/// A sampler over the raw source `reader`.
pub fn new(reader: &'a mut dyn SectorSource) -> Self {
Sampler { reader }
Sampler {
reader,
stats: ReadStats::default(),
}
}
pub(crate) fn stats(&self) -> ReadStats {
self.stats
}
pub(crate) fn source(&mut self) -> &mut dyn SectorSource {
@@ -399,7 +421,17 @@ impl<'a> Sampler<'a> {
// The aligned unit at `lba`, or `None` on a short or failed read. A Stop, a gone source
// or a transport failure is `Err`: not a soft fault.
pub(crate) fn unit(&mut self, lba: u32) -> Result<Option<Vec<u8>>> {
read_unit(self.reader, lba)
let t0 = std::time::Instant::now();
let r = read_unit(self.reader, lba);
let took = t0.elapsed();
self.stats.reads += 1;
self.stats.total += took;
self.stats.slowest = self.stats.slowest.max(took);
if matches!(r, Ok(None)) {
self.stats.faults += 1;
}
tracing::trace!(target: "freemkv::keys", lba, took_ms = took.as_millis() as u64, "sample unit read");
r
}
// Up to 32 units on `p`'s grid (KU §2.3 step 7), skipping FMTS segment units, each
@@ -425,10 +457,80 @@ impl<'a> Sampler<'a> {
Ok((units, out))
}
// Up to `n` encrypted units spread over the main feature.
pub(crate) fn main_samples(&mut self, main: Option<&MainTitle>, n: usize) -> Vec<Vec<u8>> {
main.map(|m| crate::keysource::encrypted_units_in(self.reader, &m.extents, m.format, n))
.unwrap_or_default()
// The first encrypted unit on `p`'s probe grid, stopping there: a stream file sits in one
// CPS unit (KS-10), so one unit names its key. (grid units, the unit, soft faults seen).
pub(crate) fn first_encrypted(
&mut self,
p: &Piece,
segments: &[(u32, u32)],
format: ContentFormat,
detector: Detector,
halt: &Halt,
) -> Result<(u64, Option<Vec<u8>>, usize)> {
let (units, lbas) = probe_lbas(p, segments);
let mut faults = 0;
for lba in lbas {
halt.check()?;
match self.unit(lba)? {
Some(u) if detector.unit_encrypted(&u, format) => {
return Ok((units, Some(u), faults));
}
Some(_) => {}
None => faults += 1,
}
}
Ok((units, None, faults))
}
// Up to `n` encrypted units on `p`'s probe grid: enough ciphertext to ask a source for a
// unit the first request left unopened.
pub(crate) fn encrypted_units(
&mut self,
p: &Piece,
segments: &[(u32, u32)],
format: ContentFormat,
detector: Detector,
halt: &Halt,
n: usize,
) -> Result<Vec<Vec<u8>>> {
let (_, lbas) = probe_lbas(p, segments);
let mut out = Vec::new();
for lba in lbas {
if out.len() >= n {
break;
}
halt.check()?;
if let Some(u) = self.unit(lba)?
&& detector.unit_encrypted(&u, format)
{
out.push(u);
}
}
Ok(out)
}
// Up to `n` encrypted units spread over the main feature, none from an FMTS segment
// (`segments`: its units carry a forensic key, not the unit key).
pub(crate) fn main_samples(
&mut self,
main: Option<&MainTitle>,
n: usize,
segments: &[(u32, u32)],
) -> Vec<Vec<u8>> {
let t0 = std::time::Instant::now();
let out = main
.map(|m| {
crate::keysource::encrypted_units_outside(
self.reader,
&m.extents,
m.format,
n,
segments,
)
})
.unwrap_or_default();
tracing::info!(target: "freemkv::keys", phase = "main_samples", wanted = n, got = out.len(), elapsed_ms = t0.elapsed().as_millis() as u64, "main-title samples read");
out
}
}
+6 -1
View File
@@ -362,8 +362,13 @@ impl KeyRing {
opts: AcquireOptions,
ctx: &Ctx,
) -> Result<KeyResolution> {
let t0 = std::time::Instant::now();
let ev = KeyEvidence::from_disc(disc, reader, scope, ctx)?;
Self::acquire(&ev, &mut Sampler::new(reader), sources, opts, ctx)
tracing::info!(target: "freemkv::keys", phase = "evidence", pieces = ev.pieces.len(), elapsed_ms = t0.elapsed().as_millis() as u64, "key evidence read from the disc");
let t1 = std::time::Instant::now();
let r = Self::acquire(&ev, &mut Sampler::new(reader), sources, opts, ctx);
tracing::info!(target: "freemkv::keys", phase = "acquire", ok = r.is_ok(), elapsed_ms = t1.elapsed().as_millis() as u64, "key acquisition finished");
r
}
/// A set holding no keys: for a raw copy, or a disc with no AACS.
@@ -42,3 +42,17 @@
//! ```compile_fail
//! libfreemkv::aacs::content::decrypt_unit(&mut [0u8; 6144], &[0u8; 16]);
//! ```
//!
//! `aacs::content::unit_key_opens` is a `keyproof`-gated key→content oracle, so
//! the DEFAULT public API carries no "did this key open this unit" answer. The
//! door below is itself gated to `not(keyproof)` so it asserts absence only in a
//! build where the oracle is meant to be absent (and does not false-fail under
//! `--features keyproof`, where the symbol is intentionally present).
/// With `keyproof` OFF, `aacs::content::unit_key_opens` must not exist:
/// ```compile_fail,E0425
/// libfreemkv::aacs::content::unit_key_opens(
/// &[0u8; 6144], &[0u8; 16], libfreemkv::disc::ContentFormat::BdTs);
/// ```
#[cfg(not(feature = "keyproof"))]
pub(crate) mod keyproof_gated_off {}
+357 -8
View File
@@ -487,6 +487,53 @@ struct Scoped<'e> {
// clip is in scope, and each piece probed by the format's own detector.
fn probe_scope<'e>(ev: &'e KeyEvidence, sampler: &mut Sampler, halt: &Halt) -> Result<Scoped<'e>> {
let mut ps: Vec<Probed> = ev.pieces.iter().cloned().map(Probed::new).collect();
let (layout, clip) = scope_layout(ev);
let segments = layout_segments(layout);
let t0 = std::time::Instant::now();
let before = sampler.stats();
for p in &mut ps {
let tp = std::time::Instant::now();
let reads_before = sampler.stats().reads;
probe(sampler, p, &segments, ev.container, ev.detector, halt)?;
tracing::debug!(
target: "freemkv::keys",
phase = "probe_piece",
piece = p.id(),
units = p.units,
reads = sampler.stats().reads - reads_before,
enc = p.enc.len(),
faults = p.faults,
elapsed_ms = tp.elapsed().as_millis() as u64,
"piece probed"
);
}
let after = sampler.stats();
let reads = after.reads - before.reads;
let read_ms = (after.total - before.total).as_millis() as u64;
tracing::info!(
target: "freemkv::keys",
phase = "probe_scope",
pieces = ps.len(),
reads,
faults = after.faults - before.faults,
read_ms,
avg_read_ms = read_ms.checked_div(u64::from(reads)).unwrap_or(0),
slowest_read_ms = after.slowest.as_millis() as u64,
elapsed_ms = t0.elapsed().as_millis() as u64,
"sampled every piece before asking a key source"
);
Ok(Scoped { ps, layout, clip })
}
// The forensic segments' sectors, sorted and merged: no unit there is a unit-key sample.
fn layout_segments(layout: Option<&super::fmts::Layout>) -> Vec<(u32, u32)> {
layout
.map(|l| sorted_ranges(l.ranges.iter().map(|&(s, e, _)| (s, e)).collect()))
.unwrap_or_default()
}
// The forensic layout when its clip is in scope (KU §2.3 step 6), and that clip. Reads nothing.
fn scope_layout(ev: &KeyEvidence) -> (Option<&super::fmts::Layout>, Vec<(u32, u32)>) {
let layout = ev.fmts.as_ref();
let clip: Vec<(u32, u32)> = layout
.map(|l| {
@@ -497,16 +544,307 @@ fn probe_scope<'e>(ev: &'e KeyEvidence, sampler: &mut Sampler, halt: &Halt) -> R
})
.unwrap_or_default();
let layout = layout.filter(|_| {
ps.iter()
ev.pieces
.iter()
.any(|p| p.ranges().any(|(s, e)| overlaps(&clip, s, e)))
});
let segments: Vec<(u32, u32)> = layout
.map(|l| sorted_ranges(l.ranges.iter().map(|&(s, e, _)| (s, e)).collect()))
.unwrap_or_default();
for p in &mut ps {
probe(sampler, p, &segments, ev.container, ev.detector, halt)?;
(layout, clip)
}
/// KS-14 \[BD\] §3.9.3: one declared CPS unit is one Unit Key for every stream file, so the disc
/// is trusted as declared: the main title's samples ask the sources once, and the key that
/// opens them keys every piece in scope. No per-piece probing (32 reads a piece: minutes of
/// seeks on a many-clip disc). FMTS segment units are never samples; the segments are keyed
/// by their forensic set. `None` (no ciphertext found, or no key opens it) falls back to the
/// probed path, which decides clear content and refusals.
fn resolve_one_unit(
ev: &KeyEvidence,
sampler: &mut Sampler,
disc_hash: &str,
seed: Option<&KeyRing>,
run: &mut Run,
) -> Result<Option<Inner>> {
let (layout, clip) = scope_layout(ev);
let samples =
sampler.main_samples(ev.main.as_ref(), MIN_SAMPLE_UNITS, &layout_segments(layout));
if samples.is_empty() {
return Ok(None);
}
Ok(Scoped { ps, layout, clip })
// Two opened samples prove a key (rule 1), or the one when only one was found.
let need = samples.len().min(2);
let opener = |run: &Run| {
(0..run.pool.len())
.find(|&slot| samples.iter().filter(|u| run.opens(u, slot)).count() >= need)
};
let mut slot = opener(run);
// Sample-independent sources (a local keydb) before those that need the samples.
let mut order: Vec<usize> = (0..run.sources.len()).collect();
order.sort_by_key(|&i| run.sources[i].answer_depends_on_samples());
let mut tried: Vec<usize> = Vec::new();
run.check_halt()?;
for i in order {
if slot.is_some() {
break;
}
// Too little ciphertext to ask with unambiguously (KU step 9.2): the probed path decides.
if run.sources[i].answer_depends_on_samples() && samples.len() < MIN_SAMPLE_UNITS {
continue;
}
run.check_halt()?;
if let Asked::Keys(k, _) = run.ask(i, &samples, false)? {
let who = run.sources[i].label();
run.add_keys(&k, who);
slot = opener(run);
}
tried.push(i);
}
let Some(slot) = slot else {
// Encrypted, sources asked, no key held at all: one declared unit means nothing in
// scope can be keyed, so refuse now rather than probe every piece to the same end.
if !tried.is_empty() && run.pool.is_empty() {
let failure = run.first_failure.take();
return Err(failure.unwrap_or_else(|| missing_error(&ev.scope, disc_hash)));
}
// Asked already: the probed path never asks these sources again.
for i in tried {
run.dead[i] = true;
}
return Ok(None);
};
let ps: Vec<Probed> = ev
.pieces
.iter()
.cloned()
.map(|piece| {
let mut p = Probed::new(piece);
p.units = p.piece.grid().iter().map(|g| g.1).sum();
p.verdict = if p.units == 0 {
Verdict::Clear
} else {
Verdict::Keyed(slot)
};
p
})
.collect();
tracing::info!(
target: "freemkv::keys",
phase = "one_cps_unit",
pieces = ps.len(),
samples = samples.len(),
"one CPS unit declared: every piece keyed by the key that opens the main title"
);
let mut inner = aacs_inner(run);
inner.no_stream_files = ev.no_stream_files;
let forensic = match layout {
None => None,
Some(l) => Some(resolve_forensic(sampler, l, seed, run, ev.container)?),
};
build(&mut inner, &ps, layout, forensic, run, clip);
Ok(Some(inner))
}
/// Several declared CPS units: each stream file sits in exactly one (KS-10), so one encrypted
/// unit per piece (the first its probe grid finds, FMTS segment units skipped) names its key.
/// The main title's samples ask first; pieces no held key opens then ask with their own units,
/// at most `n_decl` requests. Every piece read clear and so do the forensic segments in scope:
/// content in the clear, no source asked. `None` (a piece unreadable, or one no key opens)
/// falls back to the probed path.
fn resolve_per_unit(
ev: &KeyEvidence,
sampler: &mut Sampler,
disc_hash: &str,
seed: Option<&KeyRing>,
run: &mut Run,
n_decl: usize,
) -> Result<Option<Inner>> {
let (layout, clip) = scope_layout(ev);
let segments = layout_segments(layout);
let mut ps: Vec<Probed> = Vec::with_capacity(ev.pieces.len());
for piece in &ev.pieces {
let mut p = Probed::new(piece.clone());
let (units, unit, faults) =
sampler.first_encrypted(&p.piece, &segments, ev.container, ev.detector, run.halt)?;
p.units = units;
p.faults = faults;
match unit {
Some(u) => p.enc.push(u),
// Faults with no ciphertext found: not trusted, the probed path decides.
None if faults > 0 => return Ok(None),
None => p.verdict = Verdict::Clear,
}
ps.push(p);
}
// The one encryption decision (`in_clear`): pieces and forensic segments in the clear ask
// no source, forensic keys included.
if let Some(l) = layout
&& ps.iter().all(|p| p.verdict == Verdict::Clear)
&& super::fmts::segments_clear(sampler.source(), l, ev.container, run.halt)?
{
tracing::info!(target: "freemkv::keys", pieces = ps.len(), "content and forensic segments in the clear: no key source asked");
let mut inner = aacs_inner(run);
inner.no_stream_files = ev.no_stream_files;
build(&mut inner, &ps, None, None, run, Vec::new());
return Ok(Some(inner));
}
let opened = |run: &Run, p: &Probed| -> Option<usize> {
p.enc
.first()
.and_then(|u| (0..run.pool.len()).find(|&s| run.opens(u, s)))
};
let unopened = |run: &Run, ps: &[Probed]| -> Vec<usize> {
(0..ps.len())
.filter(|&i| ps[i].verdict == Verdict::Ask && opened(run, &ps[i]).is_none())
.collect()
};
let mut tried: Vec<usize> = Vec::new();
// Sample-independent sources (a local keydb) give their whole answer once.
if !unopened(run, &ps).is_empty() {
let main = sampler.main_samples(ev.main.as_ref(), MIN_SAMPLE_UNITS, &segments);
for i in 0..run.sources.len() {
if run.sources[i].answer_depends_on_samples() {
continue;
}
run.check_halt()?;
let asked = run.ask(i, &main, false)?;
if !matches!(asked, Asked::Skipped) {
tried.push(i);
}
if let Asked::Keys(k, _) = asked {
let who = run.sources[i].label();
run.add_keys(&k, who);
}
}
}
// Largest piece first, each unopened unit asks with its own ciphertext (topped up from
// its title's pieces, KS-10), at most `n_decl` requests. A source that answers with no
// key has none for this disc: it is not asked again.
let mut budget = n_decl;
let mut thin: Vec<usize> = Vec::new();
let mut gave_up: Vec<usize> = Vec::new();
loop {
let mut todo: Vec<usize> = unopened(run, &ps)
.into_iter()
.filter(|i| !thin.contains(i) && !gave_up.contains(i))
.collect();
let live: Vec<usize> = (0..run.sources.len())
.filter(|&i| run.sources[i].answer_depends_on_samples() && !run.dead[i])
.collect();
if todo.is_empty() || budget == 0 || live.is_empty() {
break;
}
todo.sort_by_key(|&i| (std::cmp::Reverse(ps[i].piece.rank), ps[i].id()));
let lead = todo[0];
let mut samples = sampler.encrypted_units(
&ps[lead].piece,
&segments,
ev.container,
ev.detector,
run.halt,
MIN_SAMPLE_UNITS,
)?;
for &j in &todo[1..] {
if samples.len() >= MIN_SAMPLE_UNITS {
break;
}
if ps[j]
.piece
.titles
.iter()
.any(|t| ps[lead].piece.titles.contains(t))
{
let more = sampler.encrypted_units(
&ps[j].piece,
&segments,
ev.container,
ev.detector,
run.halt,
MIN_SAMPLE_UNITS - samples.len(),
)?;
samples.extend(more);
}
}
if samples.len() < MIN_SAMPLE_UNITS {
// Too little ciphertext to ask with (KU step 9.2): proven on arrival instead.
thin.push(lead);
continue;
}
budget -= 1;
for &i in &live {
run.check_halt()?;
let asked = run.ask(i, &samples, false)?;
if !matches!(asked, Asked::Skipped) && !tried.contains(&i) {
tried.push(i);
}
match asked {
Asked::Keys(k, _) => {
let who = run.sources[i].label();
run.add_keys(&k, who);
if opened(run, &ps[lead]).is_some() {
break;
}
}
Asked::Empty => run.dead[i] = true,
Asked::Failed | Asked::Skipped => {}
}
}
if opened(run, &ps[lead]).is_none() {
gave_up.push(lead);
}
}
// Every unit's sample was sent: a piece no held key opens has no key to find.
let whole_with_keys =
ev.scope == KeyScope::WholeDisc && !run.pool.is_empty() && run.first_failure.is_none();
for (idx, p) in ps.iter_mut().enumerate() {
if p.verdict == Verdict::Ask {
match opened(run, p) {
Some(slot) => p.verdict = Verdict::Keyed(slot),
None if thin.contains(&idx) => p.verdict = Verdict::Lazy(None),
// Not asked at all (too little ciphertext): the probed path decides.
None if tried.is_empty() => {
for &i in &tried {
run.dead[i] = true;
}
return Ok(None);
}
// A whole-disc copy blanks it while other pieces are usable (as the probed path).
None if whole_with_keys => {
tracing::warn!(target: "freemkv::keys", lba = p.id(), "no held key opens this stream file: it will be blanked in the image");
p.verdict = Verdict::Lazy(None);
}
None => {
let lba = p.id();
let failure = run.first_failure.take();
let err = failure.unwrap_or_else(|| missing_error(&ev.scope, disc_hash));
tracing::error!(target: "freemkv::keys", lba, code = err.code(), "a stream file in scope has no key; refusing before any output");
return Err(err);
}
}
}
}
// KU §2.7: an empty pool on an encrypted scope is the keyless case.
if run.pool.is_empty() && ps.iter().any(|p| matches!(p.verdict, Verdict::Lazy(_))) {
let failure = run.first_failure.take();
return Err(failure.unwrap_or_else(|| missing_error(&ev.scope, disc_hash)));
}
if whole_with_keys && !ps.iter().any(|p| matches!(p.verdict, Verdict::Keyed(_))) {
let failure = run.first_failure.take();
return Err(failure.unwrap_or_else(|| missing_error(&ev.scope, disc_hash)));
}
tracing::info!(
target: "freemkv::keys",
phase = "per_cps_unit",
declared = n_decl,
pieces = ps.len(),
"each piece keyed by the key that opens its one sampled unit"
);
let mut inner = aacs_inner(run);
inner.no_stream_files = ev.no_stream_files;
let forensic = match layout {
None => None,
Some(l) => Some(resolve_forensic(sampler, l, seed, run, ev.container)?),
};
build(&mut inner, &ps, layout, forensic, run, clip);
Ok(Some(inner))
}
// The one encryption decision, every AACS format and source (CSS makes it on its scramble
@@ -520,6 +858,17 @@ fn decide(
seed: Option<&KeyRing>,
run: &mut Run,
) -> Result<Inner> {
// BD/UHD, FMTS included: trusted as declared (segment units are never unit-key samples).
if ev.detector == Detector::Verified {
let trusted = match n_decl {
Some(1) => resolve_one_unit(ev, sampler, disc_hash, seed, run)?,
Some(n) if n > 1 => resolve_per_unit(ev, sampler, disc_hash, seed, run, n)?,
_ => None,
};
if let Some(inner) = trusted {
return Ok(inner);
}
}
let scoped = probe_scope(ev, sampler, run.halt)?;
if in_clear(&scoped, sampler, ev.container, run.halt)? {
tracing::info!(target: "freemkv::keys", pieces = scoped.ps.len(), "content in the clear: no key source asked");
@@ -584,7 +933,7 @@ fn resolve_hddvd(
.unwrap_or_default();
let mut origin = (!keys.is_empty()).then_some("seed");
if keys.is_empty() {
let samples = sampler.main_samples(ev.main.as_ref(), MIN_SAMPLE_UNITS);
let samples = sampler.main_samples(ev.main.as_ref(), MIN_SAMPLE_UNITS, &[]);
for i in 0..run.sources.len() {
if let Asked::Keys(k, idx) = run.ask(i, &samples, false)? {
let nums = idx.iter().map(|&i| title_key_number(ev, i));
+189 -119
View File
@@ -220,6 +220,8 @@ impl SectorSource for NoSeek {
struct Call {
who: &'static str,
samples: usize,
// The sample units the request carried.
units: Vec<Vec<u8>>,
vid: Option<[u8; 16]>,
forensic: bool,
at: Duration,
@@ -333,6 +335,7 @@ impl Fake {
self.spec.calls.0.lock().unwrap().push(Call {
who: self.spec.who,
samples: samples.len(),
units: samples.clone(),
vid: ctx.vid().map(|v| v.0),
forensic,
at,
@@ -580,11 +583,11 @@ fn single_declared_unit_wrong_key_refuses_e7013() {
assert_eq!(code(r), E7013);
}
/// LK4 (J22). KS-14: with one declared CPS unit, a key proven on one piece keys a Lazy
/// piece only when it opens every read probe of it. A piece with no readable probe stays
/// Lazy with that key as its candidate ("never guessed") and is proven on first read.
/// LK4. KS-14 [BD] §3.9.3: "Num_of_CPS_Unit field (16 bits) indicates the number of CPS Units
/// on the disc". One declared unit is trusted: the key that opens the main title keys every
/// piece, including one with no readable unit at resolve, which reads under it once healed.
#[test]
fn single_declared_unit_keys_lazy_pieces() {
fn single_declared_unit_keys_every_piece() {
let fx = fixture(
&[stream(1, 10, Some(K1)), stream(2, 10, Some(K1))],
1,
@@ -603,20 +606,14 @@ fn single_declared_unit_keys_lazy_pieces() {
&FakeClock::default(),
)
.unwrap();
assert_eq!(
set.lazy(),
&[(b, b + n)],
"no probe of B read: Lazy, never keyed blind"
assert!(
set.lazy().is_empty(),
"the declared unit keys B without reading it"
);
assert_eq!(set.status().keyed, 1);
assert_eq!(set.status().keyed, 2);
src.heal();
let mut r = set.title_reader(&fx.disc, 0, src).unwrap();
assert_eq!(read(&mut r, &fx, 1, 0, 10).unwrap(), fx.plain(b, 10));
assert_eq!(
set.proof_cache().get(b),
Some(Proof::Proven(0)),
"K1 first, proven on read"
);
}
/// LK5 (K-1). KS-14, KS-10: the count of held keys never decides coverage. Two declared,
@@ -782,7 +779,11 @@ fn small_piece_borrows_or_goes_lazy_never_missing() {
)
.unwrap();
assert_eq!(calls.of("online").len(), 1, "S and T ask once, together");
assert_eq!(calls.of("online")[0].samples, 9);
assert_eq!(
calls.of("online")[0].samples,
crate::keysource::MIN_SAMPLE_UNITS,
"exactly the minimum: no unit read beyond it"
);
let (u, n) = fx.file(3);
assert_eq!(
set.lazy(),
@@ -818,10 +819,10 @@ fn playlist_pieces_share_a_cps_unit_for_borrowing() {
assert_eq!(code(set), E7022);
}
/// J21 (OQ-L2-1) guard. A piece where exactly one probe read and opened is Lazy with that
/// key as its first candidate — never keyed from one probe — and is proven on read.
/// KS-10 [BD] §3.9.2: a stream file sits in one CPS unit, so with several declared units one
/// readable unit of a piece names its key: the piece is keyed by it and reads.
#[test]
fn one_opened_probe_is_lazy_never_keyed() {
fn one_sampled_unit_keys_its_piece_on_a_multi_unit_disc() {
let fx = fixture(
&[stream(1, 10, Some(K1)), stream(2, 10, Some(K2))],
2,
@@ -840,15 +841,15 @@ fn one_opened_probe_is_lazy_never_keyed() {
&FakeClock::default(),
)
.unwrap();
assert_eq!(set.lazy(), &[(b, b + n)]);
assert_eq!(set.status().keyed, 1);
assert!(set.lazy().is_empty());
assert_eq!(set.status().keyed, 2);
src.heal();
let mut r = set.title_reader(&fx.disc, 0, src).unwrap();
assert_eq!(read(&mut r, &fx, 1, 0, 10).unwrap(), fx.plain(b, 10));
}
/// LK22. A sample-independent source (a keydb) is asked once per resolve; an online source
/// once per unopened piece (N-KU10).
/// LK22. A sample-independent source (a keydb) is asked once per resolve, and an online
/// source once with one unit of every unopened piece: neither is asked again on a refusal.
#[test]
fn sample_independent_source_asked_once() {
let files: Vec<BdFile> = (1..=5).map(|i| stream(i, 10, Some(K2))).collect();
@@ -861,7 +862,7 @@ fn sample_independent_source_asked_once() {
);
assert_eq!(code(r), E7032);
assert_eq!(calls.of("keydb").len(), 1);
assert_eq!(calls.of("online").len(), 5);
assert_eq!(calls.of("online").len(), 1);
}
/// LK23a (J13, J15). A source that gives no answer (transport class) for 20 s of simulated
@@ -1252,7 +1253,8 @@ fn stop_during_resolve_builds_no_set() {
let fx = two_units();
let halt = Halt::new();
let mut src = fx.source();
src.halt_after = Some((halt.clone(), Arc::new(Mutex::new(40))));
// Stopped on the first read: before any source is asked.
src.halt_after = Some((halt.clone(), Arc::new(Mutex::new(1))));
let calls = Calls::default();
let opts = AcquireOptions {
..Default::default()
@@ -1808,12 +1810,17 @@ fn decrypting_reader_sits_below_the_prefetcher() {
// with an index-1 segment over units 0..16 and an index-2 segment over units 20..36. Our
// phase is Even (F1 / F2); odd segment units are the alternate variant (ALT).
fn fmts_fixture() -> Fx {
fmts_fixture_keyed(Some(K1), 2)
}
// `fmts_fixture` with file 0 under `key` (`None`: clear), declaring `declared` CPS units.
fn fmts_fixture_keyed(key: Option<[u8; 16]>, declared: usize) -> Fx {
let files = [
stream(1, 10, Some(K1)),
stream(1, 10, key),
BdFile::new("BDMV/STREAM/00002.fmts", 180, Some(K2)),
BdFile::new("AACS/IndividualSegment.tbl", 1, None),
];
let mut fx = fixture(&files, 2, &[&[0], &[1], &[0, 1]]);
let mut fx = fixture(&files, declared, &[&[0], &[1], &[0, 1]]);
fx.disc.format = DiscFormat::Fmts;
let segs = [(1u16, 0u32, 16u32), (2, 20, 36)];
let mut tbl = Vec::new();
@@ -1838,6 +1845,132 @@ fn fmts_fixture() -> Fx {
fx
}
/// Key evidence reads the filesystem as the scan does, batched with the metadata partition
/// prefetched: a few commands where the direct walk sent one per sector, and the same
/// pieces and forensic layout.
#[test]
fn evidence_reads_the_filesystem_in_batched_commands() {
let fx = fmts_fixture();
let ctx = crate::ctx::Ctx::new(Halt::new());
let mut batched = CountingSource::new(fx.source());
let log = batched.log();
let ev = KeyEvidence::from_disc(&fx.disc, &mut batched, KeyScope::WholeDisc, &ctx).unwrap();
// The direct walk the evidence used to make, on the raw source.
let mut direct = CountingSource::new(fx.source());
let direct_log = direct.log();
let fs = crate::udf::read_filesystem(&mut direct).unwrap();
let files = crate::whole_disc::content_files_in(&fs, &mut direct).unwrap();
let layout = super::fmts::layout(&fs, &mut direct)
.unwrap()
.expect("FMTS");
let all: Vec<usize> = (0..fx.disc.titles.len()).collect();
assert_eq!(
ev.pieces,
super::evidence::pieces(&fx.disc, &files, &all, true)
);
let fmts = ev.fmts.as_ref().expect("the forensic layout");
assert_eq!(fmts.ranges, layout.ranges);
assert_eq!(fmts.clip, layout.clip);
assert!(
direct_log.reads().iter().all(|&(_, n)| n == 1),
"the direct walk reads a sector per command"
);
assert!(
log.count() * 3 <= direct_log.count(),
"{} commands for the evidence, the direct walk {}",
log.count(),
direct_log.count()
);
}
/// KS-25, KS-26 (evidence): an FMTS segment unit carries a forensic key, not the unit key, so
/// the main title's samples skip the segments; the forensic clip's base units are sampled.
#[test]
fn main_samples_skip_forensic_segment_units() {
let fx = fmts_fixture();
let mut src = fx.source();
let fs = crate::udf::read_filesystem(&mut src).unwrap();
let layout = super::fmts::layout(&fs, &mut src).unwrap().expect("FMTS");
let segments: Vec<(u32, u32)> = layout.ranges.iter().map(|&(s, e, _)| (s, e)).collect();
let (c, n) = fx.file(1);
let main = super::evidence::MainTitle {
extents: vec![Extent {
start_lba: c,
sector_count: n,
}],
format: ContentFormat::BdTs,
};
let mut sampler = Sampler::new(&mut src);
let skipped = sampler.main_samples(Some(&main), 32, &segments);
assert!(skipped.len() >= crate::keysource::MIN_SAMPLE_UNITS);
assert!(
skipped.iter().all(|u| opens(u, &K2)),
"every sample is a base unit of the clip"
);
let all = sampler.main_samples(Some(&main), 32, &[]);
assert!(
all.iter().any(|u| !opens(u, &K2)),
"the clip's segments are sampled when not skipped"
);
}
/// KS-14 [BD] §3.9.3 on an FMTS disc: one declared unit is trusted, no piece probed unit by
/// unit. The main title's samples (its ciphertext is the forensic clip's), none from a
/// forensic segment, ask the source once; its key keys the pieces and the forensic set keys
/// the segments: the clip reads, the alternate phase still ciphertext.
#[test]
fn fmts_one_declared_unit_is_keyed_from_the_main_titles_base_units() {
let fx = fmts_fixture_keyed(None, 1);
let calls = Calls::default();
let mut src = CountingSource::new(fx.source());
let log = src.log();
let set = resolve_with(
&fx,
&mut src,
KeyScope::Titles(vec![2]),
&[fmts_online(&calls)],
AcquireOptions::default(),
&FakeClock::default(),
)
.unwrap();
// One-unit reads are the forensic anchor's and phase probes', all inside the segments.
let segments = [
(fx.unit(1, 0), fx.unit(1, 16)),
(fx.unit(1, 20), fx.unit(1, 36)),
];
assert!(
log.reads()
.iter()
.filter(|r| r.1 == 3)
.all(|&(lba, _)| segments.iter().any(|&(s, e)| (s..e).contains(&lba))),
"a piece was probed unit by unit: {:?}",
log.reads()
);
let unit_calls: Vec<Call> = calls
.of("online")
.into_iter()
.filter(|c| !c.forensic)
.collect();
assert_eq!(unit_calls.len(), 1, "one unit-key request");
assert!(unit_calls[0].samples >= crate::keysource::MIN_SAMPLE_UNITS);
assert!(
unit_calls[0].units.iter().all(|u| opens(u, &K2)),
"no forensic segment unit is a unit-key sample"
);
assert_eq!(set.status().keyed, 2);
assert_eq!(set.status().forensic, ForensicState::Resolved);
let mut r = set.title_reader(&fx.disc, 2, fx.source()).unwrap();
let got = read(&mut r, &fx, 1, 0, 60).unwrap();
let plain = fx.plain(fx.file(1).0, 60);
for u in 0..60usize {
let range = u * ALIGNED_UNIT_LEN..(u + 1) * ALIGNED_UNIT_LEN;
let alternate = (u < 16 || (20..36).contains(&u)) && u % 2 == 1;
assert_eq!(got[range.clone()] == plain[range], !alternate, "unit {u}");
}
}
/// A present but unparseable segment table is a refusal, never "not FMTS" (which would rip
/// forensic units as base content).
#[test]
@@ -2359,33 +2492,26 @@ fn whole_disc_status_counts_keyed_files_without_titles() {
assert_eq!((s.keyed, s.proven, s.lazy), (1, 1, 0), "{s:?}");
}
/// KU §2.3 step 10 (review item 4). KS-14 [BD] §3.9.3: "Num_of_CPS_Unit field (16 bits)
/// indicates the number of CPS Units on the disc". The n_decl == 1 rule keys only Lazy
/// pieces no other held key opened: a piece whose one readable probe another key opened
/// stays Lazy with that candidate, never keyed with the proven key over it.
/// KS-14 [BD] §3.9.3. One declared unit: the key that opens the main title is the disc's key,
/// so every piece is keyed by it and asked for once, whatever other keys a source returns.
#[test]
fn single_unit_rule_never_overrides_a_piece_another_key_opened() {
fn single_declared_unit_keys_every_piece_with_the_main_titles_key() {
let fx = fixture(
&[stream(1, 10, Some(K1)), stream(2, 10, Some(K2))],
&[stream(1, 10, Some(K1)), stream(2, 10, Some(K1))],
1,
&[&[0, 1]],
);
let (b, n) = fx.file(1);
let src = fx.source();
src.kill(b + 3, b + n); // only B's first unit reads, and K2 opens it
let (b, _) = fx.file(1);
let calls = Calls::default();
let set = resolve_with(
let set = resolve(
&fx,
&mut src.clone(),
KeyScope::Titles(vec![0]),
&[Spec::keydb(&[K1, K2], &calls)],
AcquireOptions::default(),
&FakeClock::default(),
&[Spec::keydb(&[K2, K1], &calls)],
)
.unwrap();
assert_eq!(set.lazy(), &[(b, b + n)], "B stays Lazy (candidate K2)");
src.heal();
let mut r = set.title_reader(&fx.disc, 0, src).unwrap();
assert_eq!((set.status().keyed, set.lazy()), (2, &[][..]));
assert_eq!(calls.of("keydb").len(), 1);
let mut r = set.title_reader(&fx.disc, 0, fx.source()).unwrap();
assert_eq!(read(&mut r, &fx, 1, 0, 10).unwrap(), fx.plain(b, 10));
}
@@ -2404,13 +2530,11 @@ fn unopenable_unit_stops_after_one_side_read() {
assert_eq!(reads.len(), 2, "then one side read");
}
/// KU §2.3 step 10 under invariant 2, "never guessed" (J22, review r2 M1). KS-14 [BD]
/// §3.9.3: "Num_of_CPS_Unit field (16 bits) indicates the number of CPS Units on the disc".
/// With one declared unit, a Lazy piece is keyed by the proven key only if that key opens
/// every `Enc` probe of it: (a) probes no held key opens, (b) probes opened by two keys.
/// KS-14 [BD] §3.9.3: "Num_of_CPS_Unit field (16 bits) indicates the number of CPS Units on
/// the disc". The declaration is trusted: with one unit no piece is probed, so even a piece
/// the disc puts under another key is keyed with the main title's key, the source asked once.
#[test]
fn single_unit_rule_keys_only_pieces_the_proven_key_opens() {
// (a) B's three units are under K2, which no source holds.
fn single_declared_unit_is_trusted_without_probing_each_piece() {
let fx = fixture(
&[stream(1, 10, Some(K1)), stream(2, 3, Some(K2))],
1,
@@ -2423,51 +2547,8 @@ fn single_unit_rule_keys_only_pieces_the_proven_key_opens() {
&[Spec::keydb(&[K1], &calls)],
)
.unwrap();
let (b, n) = fx.file(1);
assert_eq!(
(set.status().keyed, set.lazy()),
(1, &[(b, b + n)][..]),
"(a)"
);
let mut r = set.title_reader(&fx.disc, 0, fx.source()).unwrap();
assert_eq!(
code(read(&mut r, &fx, 1, 0, 3)),
E7022,
"(a) never K1 over K2's ciphertext"
);
// (b) B's only readable probes: unit 0 opens under K1, unit 5 under K2.
let mut fx = fixture(
&[stream(1, 10, Some(K1)), stream(2, 10, Some(K2))],
1,
&[&[0, 1]],
);
fx.reencrypt(1, 0, &K1);
let (b, n) = fx.file(1);
let src = fx.source();
src.kill(fx.unit(1, 1), fx.unit(1, 5));
src.kill(fx.unit(1, 6), b + n);
let set = resolve_with(
&fx,
&mut src.clone(),
KeyScope::Titles(vec![0]),
&[Spec::keydb(&[K1, K2], &calls)],
AcquireOptions::default(),
&FakeClock::default(),
)
.unwrap();
assert_eq!(
(set.status().keyed, set.lazy()),
(1, &[(b, b + n)][..]),
"(b)"
);
src.heal();
let mut r = set.title_reader(&fx.disc, 0, src).unwrap();
assert_eq!(
code(read(&mut r, &fx, 1, 0, 10)),
E7022,
"(b) two keys: a conflict"
);
assert_eq!((set.status().keyed, set.lazy()), (2, &[][..]));
assert_eq!(calls.of("keydb").len(), 1);
}
/// KU §3.1 (review r2 M3): a keyless set over a title whose extents overlap (one inside
@@ -2529,14 +2610,12 @@ fn keyless_set_over_overlapping_extents_stops_e7022() {
}
}
/// J22 (review r3). KS-14 [BD] §3.9.3: "Num_of_CPS_Unit field (16 bits) indicates the number
/// of CPS Units on the disc". A piece whose every probe faulted has no evidence: with one
/// declared unit it stays Lazy (the proven key its candidate), so a readable unit under a
/// key no source holds stops with E7022, never decrypted under the proven key.
/// KS-14 [BD] §3.9.3. A piece with no readable unit at resolve is keyed by the one declared
/// unit's key without being read, and reads under it once healed.
#[test]
fn single_unit_rule_never_keys_a_piece_with_no_readable_probe() {
fn single_declared_unit_keys_an_unreadable_piece_without_reading_it() {
let fx = fixture(
&[stream(1, 10, Some(K1)), stream(2, 3, Some(K2))],
&[stream(1, 10, Some(K1)), stream(2, 3, Some(K1))],
1,
&[&[0, 1]],
);
@@ -2553,14 +2632,10 @@ fn single_unit_rule_never_keys_a_piece_with_no_readable_probe() {
&FakeClock::default(),
)
.unwrap();
assert_eq!((set.status().keyed, set.lazy()), (1, &[(b, b + n)][..]));
assert_eq!((set.status().keyed, set.lazy()), (2, &[][..]));
src.heal();
let mut r = set.title_reader(&fx.disc, 0, src).unwrap();
assert_eq!(
code(read(&mut r, &fx, 1, 0, 3)),
E7022,
"never K1 over K2's ciphertext"
);
assert_eq!(read(&mut r, &fx, 1, 0, 3).unwrap(), fx.plain(b, 3));
}
/// KS-1 [BD] §3.10.1: "encryption is applied to every Aligned Unit in the file" — each clip
@@ -2609,12 +2684,11 @@ fn keyless_session_set_fits_a_disc_without_a_captured_hash() {
assert!(set.is_for(&fx.disc.media_id()));
}
/// J22 with J21 parity (review r4). KS-14 [BD] §3.9.3: "Num_of_CPS_Unit field (16 bits)
/// indicates the number of CPS Units on the disc". Step 10 keys on no less evidence than step
/// 9: one opened probe of a piece longer than one unit is not proof, so the piece stays Lazy
/// with the proven key as candidate; a one-unit piece's one opened probe keys it.
/// KS-14 [BD] §3.9.3: one declared unit keys every piece from the main title's samples: a
/// piece with one readable unit of ten, and a one-unit piece, are keyed alike, and the only
/// source is asked once.
#[test]
fn single_unit_rule_needs_two_probes_unless_one_unit_long() {
fn single_declared_unit_needs_no_probe_of_each_piece() {
let fx = fixture(
&[
stream(1, 10, Some(K1)),
@@ -2626,7 +2700,7 @@ fn single_unit_rule_needs_two_probes_unless_one_unit_long() {
);
let (b, n) = fx.file(1);
let src = fx.source();
src.kill(b + 3, b + n); // one of B's ten probes reads
src.kill(b + 3, b + n);
let calls = Calls::default();
let set = resolve_with(
&fx,
@@ -2637,16 +2711,12 @@ fn single_unit_rule_needs_two_probes_unless_one_unit_long() {
&FakeClock::default(),
)
.unwrap();
assert_eq!(
set.lazy(),
&[(b, b + n)],
"one probe of ten: Lazy, not keyed"
);
assert_eq!(set.status().keyed, 2, "A and the one-unit C");
assert!(set.lazy().is_empty());
assert_eq!(set.status().keyed, 3);
assert_eq!(calls.of("keydb").len(), 1);
src.heal();
let mut r = set.title_reader(&fx.disc, 0, src).unwrap();
assert_eq!(read(&mut r, &fx, 1, 0, 10).unwrap(), fx.plain(b, 10));
assert_eq!(set.proof_cache().get(b), Some(Proof::Proven(0)));
}
// ── J23: would the disc's VID help? ────────────────────────────────────────
+17
View File
@@ -386,6 +386,18 @@ pub(crate) fn encrypted_units_in(
extents: &[crate::disc::Extent],
format: crate::disc::ContentFormat,
n: usize,
) -> Vec<Vec<u8>> {
encrypted_units_outside(reader, extents, format, n, &[])
}
// `encrypted_units_in` taking no unit that touches a `skip` range `[start, end)` (FMTS
// forensic segments: their units carry a forensic key, not the unit key).
pub(crate) fn encrypted_units_outside(
reader: &mut dyn crate::sector::SectorSource,
extents: &[crate::disc::Extent],
format: crate::disc::ContentFormat,
n: usize,
skip: &[(u32, u32)],
) -> Vec<Vec<u8>> {
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
@@ -441,6 +453,11 @@ pub(crate) fn encrypted_units_in(
break;
}
let u = &buf[o..o + ALIGNED_UNIT_LEN];
let at = lba as u64 + (i as u64) * ALIGNED_UNIT_SECTORS as u64;
let end = at + ALIGNED_UNIT_SECTORS as u64;
if skip.iter().any(|&(s, e)| (s as u64) < end && at < e as u64) {
continue;
}
if aacs_unit_encrypted(u, format) {
out.push(u.to_vec());
if out.len() >= n {
+20
View File
@@ -1277,6 +1277,20 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
jar_file_paths(udf, filename).into_iter().next()
}
/// The files the labels read from a BDMV/JAR subdirectory (`read_jar_file`, Deluxe's
/// `config.xml`): the scan's metadata prefetch loads these and none of the subdirectory's
/// image assets. A name missing here still reads, unprefetched.
pub(crate) const JAR_DIR_FILES: &[&str] = &[
"config.xml",
"playlists.xml",
"language_streams.txt",
"menu_base.prop",
"bluray_project.bin",
"streamproperties.xml",
"playbackconfig.xml",
"dcx.xml",
];
/// Read a file from any BDMV/JAR subdirectory by filename; the first copy
/// that reads successfully and is non-empty wins.
pub(crate) fn read_jar_file(
@@ -1284,6 +1298,12 @@ pub(crate) fn read_jar_file(
udf: &UdfFs,
filename: &str,
) -> Option<Vec<u8>> {
debug_assert!(
JAR_DIR_FILES
.iter()
.any(|f| f.eq_ignore_ascii_case(filename)),
"{filename} is read by a label: list it in JAR_DIR_FILES so the scan prefetches it"
);
jar_file_paths(udf, filename)
.into_iter()
.find_map(|path| udf.read_file(reader, &path).ok().filter(|d| !d.is_empty()))
+28
View File
@@ -235,6 +235,14 @@ impl SgIoTransport {
/// Resolve /dev/sr* -> /dev/sg* via sysfs. If already sg, returns as-is.
/// Falls back to the original path if resolution fails.
fn resolve_to_sg(device: &Path) -> std::path::PathBuf {
// A bare node name (`sr0`, `sg3`) is that node under /dev, as `/dev/sr0` is.
let dev_path;
let device = if device.parent() == Some(Path::new("")) {
dev_path = Path::new("/dev").join(device);
dev_path.as_path()
} else {
device
};
let dev_name = match device.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return device.to_path_buf(),
@@ -560,6 +568,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
// probe time, stashed under `.../sgN/device/`. Survives even when
// the drive is wedged below the USB bridge and our INQUIRY times out.
let (sysfs_vendor, sysfs_model, sysfs_firmware) = sysfs_identity(&name);
let display_name = display_name(std::path::Path::new(SG_CLASS), &name, &path);
// INQUIRY-only probe — open transport, run INQUIRY, drop. No
// identify, no init, no firmware reset preamble's secondary
@@ -580,6 +589,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let info = match probed {
Ok(Ok(r)) => super::DriveInfo {
path: path.clone(),
display_name,
vendor: pick_identity(r.vendor_id, &sysfs_vendor),
model: pick_identity(r.model, &sysfs_model),
firmware: pick_identity(r.firmware, &sysfs_firmware),
@@ -588,6 +598,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
// listed from what sysfs knows, so autorip reports it, not an unplug.
_ => super::DriveInfo {
path: path.clone(),
display_name,
vendor: sysfs_vendor,
model: sysfs_model,
firmware: sysfs_firmware,
@@ -609,6 +620,23 @@ fn pick_identity(live: String, sysfs: &str) -> String {
}
}
const SG_CLASS: &str = "/sys/class/scsi_generic";
/// The `/dev/srN` users know for `sgN`, from `<sg_class>/sgN/device/block/`;
/// `path` when the node is already `srN` or sysfs has no block device for it.
fn display_name(sg_class: &std::path::Path, name: &str, path: &str) -> String {
if name.starts_with("sr") {
return path.to_string();
}
std::fs::read_dir(sg_class.join(name).join("device/block"))
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.find(|block| block.starts_with("sr"))
.map_or_else(|| path.to_string(), |sr| format!("/dev/{sr}"))
}
/// Read the kernel's cached INQUIRY identity strings for `sgN` from
/// `/sys/class/scsi_generic/sgN/device/{vendor,model,rev}`. Empty strings
/// when sysfs is unavailable (minimal container, non-Linux filesystem).
+17
View File
@@ -9,3 +9,20 @@ fn device_nodes_sort_numerically_not_lexically() {
assert_eq!(names, ["sg0", "sg2", "sg10", "sr0"]);
assert_eq!(node_number("sg"), u32::MAX);
}
#[test]
fn display_name_is_the_sr_block_device_behind_sg() {
let class = tempfile::tempdir().unwrap();
std::fs::create_dir_all(class.path().join("sg3/device/block/sr1")).unwrap();
assert_eq!(display_name(class.path(), "sg3", "/dev/sg3"), "/dev/sr1");
}
#[test]
fn display_name_falls_back_to_the_path() {
let class = tempfile::tempdir().unwrap();
// No sysfs entry for the node.
assert_eq!(display_name(class.path(), "sg3", "/dev/sg3"), "/dev/sg3");
// An `sr` node is already the name users know.
std::fs::create_dir_all(class.path().join("sr0/device/block/sr0")).unwrap();
assert_eq!(display_name(class.path(), "sr0", "/dev/sr0"), "/dev/sr0");
}
+16
View File
@@ -10,3 +10,19 @@ fn open_errno_maps_to_its_real_cause() {
assert!(matches!(map(libc::EMFILE), Error::IoError { .. }));
assert!(matches!(map(libc::EBUSY), Error::IoError { .. }));
}
#[test]
fn a_bare_node_name_opens_under_dev() {
assert_eq!(
SgIoTransport::resolve_to_sg(Path::new("sg3")),
Path::new("/dev/sg3")
);
assert_eq!(
SgIoTransport::resolve_to_sg(Path::new("/dev/sg3")),
Path::new("/dev/sg3")
);
assert_eq!(
SgIoTransport::resolve_to_sg(Path::new("./sg3")),
Path::new("./sg3")
);
}
+10 -1
View File
@@ -44,6 +44,7 @@ struct ShimDriveInfo {
vendor: [u8; 32],
model: [u8; 48],
firmware: [u8; 16],
bsd_name: [u8; 32],
}
// shim_open_exclusive's result when the cancel byte was set (`SHIM_CANCELLED` in the shim).
@@ -266,6 +267,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
bsd_name: [0; 32],
};
// The shim stops at the buffer size; a full buffer means there may be more.
let mut capacity = 16usize;
@@ -288,8 +290,15 @@ fn drive_info_from_shim(info: &ShimDriveInfo) -> Option<super::DriveInfo> {
if selector.is_empty() {
return None;
}
let path = device_path_for_selector(selector);
let bsd_name = cstr_to_str(&info.bsd_name);
Some(super::DriveInfo {
path: device_path_for_selector(selector),
display_name: if bsd_name.is_empty() {
path.clone()
} else {
bsd_name.to_string()
},
path,
vendor: cstr_to_str(&info.vendor).to_string(),
model: cstr_to_str(&info.model).to_string(),
firmware: cstr_to_str(&info.firmware).to_string(),
+9 -3
View File
@@ -87,7 +87,7 @@ extern char **environ;
#define SHIM_WAIT_SLICE_MS 20
// diskutil unmount budget; a wedged unmount is killed so open() still returns (§2.9).
#define SHIM_DISKUTIL_BUDGET_MS 20000
// Settle time after the unmount, and between ObtainExclusiveAccess retries.
// Wait between ObtainExclusiveAccess retries (the unmount not yet released).
#define SHIM_SETTLE_MS 500
// DiskArbitration claim wait, so a wedged DA can't hang open().
#define SHIM_DA_CLAIM_MS 5000
@@ -197,6 +197,7 @@ typedef struct {
char vendor[32];
char model[48];
char firmware[16];
char bsd_name[32];
} ShimDriveInfo;
// ── Global handle (single-drive, same as before) ──────────────────────────
@@ -698,14 +699,17 @@ int shim_open_exclusive(const char *selector, const volatile uint8_t *cancel) {
// BSD device name is a discrete argv element and never shell syntax. Only a
// name confirmed to belong to an optical drive above reaches this point. A
// killed unmount is fine: ObtainExclusiveAccess below is the real gate (-5).
// No settle sleep after it: a drive not yet released is waited for by the
// ObtainExclusiveAccess retry loop, so a prompt unmount costs no fixed delay.
if (bsd_name[0]) {
char *const argv[] = {
"diskutil", "unmountDisk", "force", bsd_name, NULL
};
// On cancel run_and_reap has killed and reaped diskutil (§2.9 M2).
// On cancel run_and_reap has killed and reaped diskutil (§2.9 M2); a cancel that
// lands as the unmount ends still stops the open before the drive is claimed.
if (run_and_reap("/usr/sbin/diskutil", argv, SHIM_DISKUTIL_BUDGET_MS, cancel, NULL)
== SHIM_CANCELLED
|| sliced_sleep(SHIM_SETTLE_MS, cancel) == SHIM_CANCELLED) {
|| shim_cancelled(cancel)) {
IOObjectRelease(svc);
pthread_mutex_unlock(&g_handle_lock);
return SHIM_CANCELLED;
@@ -940,6 +944,8 @@ static int visit_list(io_service_t svc, void *ctx) {
// optical service survives: always use its ID so a busy drive cannot be
// enumerated as a second, apparently empty drive after opening it.
registry_id_selector(svc, info->device_selector, sizeof(info->device_selector));
// Only present while a disc is mounted; the drive's user-visible name.
bdsvc_to_bsd_name(svc, info->bsd_name, sizeof(info->bsd_name));
if (info->device_selector[0]) lc->count++;
return lc->count >= lc->max ? 2 : 0;
}
+71 -33
View File
@@ -52,6 +52,7 @@ fn shim_drive(selector: &[u8], vendor: &[u8], model: &[u8], firmware: &[u8]) ->
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
bsd_name: [0; 32],
};
info.device_selector[..selector.len()].copy_from_slice(selector);
info.vendor[..vendor.len()].copy_from_slice(vendor);
@@ -80,11 +81,21 @@ fn shim_drive_record_keeps_empty_tray_registry_selector() {
);
let mapped = drive_info_from_shim(&info).expect("registry selector");
assert_eq!(mapped.path, "ioreg:18446744073709551615");
assert_eq!(mapped.display_name, "ioreg:18446744073709551615");
assert_eq!(mapped.vendor, "HL-DT-ST");
assert_eq!(mapped.model, "BD-RE BU40N");
assert_eq!(mapped.firmware, "1.03");
}
#[test]
fn shim_drive_record_shows_mounted_bsd_name_but_keeps_registry_selector() {
let mut info = shim_drive(b"ioreg:4295000001", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
info.bsd_name[..5].copy_from_slice(b"disk4");
let mapped = drive_info_from_shim(&info).expect("registry selector");
assert_eq!(mapped.path, "ioreg:4295000001");
assert_eq!(mapped.display_name, "disk4");
}
#[test]
fn shim_drive_record_with_empty_selector_is_skipped() {
let info = shim_drive(b"", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
@@ -134,8 +145,8 @@ fn presence_probe_does_not_open_a_transport() {
);
assert!(
elapsed < std::time::Duration::from_millis(250),
"probe took {elapsed:?}: the transport path's unconditional 500 ms \
post-unmount sleep means this budget can only be met without it"
"probe took {elapsed:?}: it must answer without opening the transport \
(force-unmount, exclusive-access retries)"
);
assert_eq!(
OPEN.load(Ordering::Acquire),
@@ -286,7 +297,7 @@ fn shim_selftest_wait_slice_is_the_library_wait_slice() {
assert_eq!(Duration::from_millis(slice as u64), WAIT_SLICE);
}
/// §2.9 M2: the settle and ObtainExclusiveAccess retry sleeps end within a slice of a cancel.
/// §2.9 M2: the ObtainExclusiveAccess retry sleep ends within a slice of a cancel.
#[test]
fn shim_selftest_cancel_ends_the_settle_sleep() {
let (rc, wake) = cancel_during(|c| unsafe { shim_selftest_sleep(LONG_MS, c) });
@@ -431,7 +442,7 @@ fn shim_selftest_cancelled_open_is_halted() {
}
/// A selector that is not an optical drive is refused before anything is unmounted: the
/// open fails at once (no 500 ms settle sleep, no diskutil) and releases the lock.
/// open fails at once (no diskutil) and releases the lock.
#[test]
fn open_of_a_non_optical_selector_is_refused_before_any_unmount() {
let _globals = shim_globals();
@@ -444,7 +455,7 @@ fn open_of_a_non_optical_selector_is_refused_before_any_unmount() {
);
assert!(
t0.elapsed() < Duration::from_millis(450),
"refusal took {:?}: the post-unmount settle sleep ran",
"refusal took {:?}: it waited on an unmount",
t0.elapsed()
);
assert!(
@@ -453,43 +464,26 @@ fn open_of_a_non_optical_selector_is_refused_before_any_unmount() {
);
}
/// §2.9 M2 end to end: a resolved drive's open hands its token to the unmount and 500 ms
/// settle waits. The unmount of a missing disk ends at once, so the cancel lands in the settle.
/// §2.9 M2 end to end: a resolved drive's open hands its token to the unmount, so a Stop
/// raised before the open ends it `Halted` before the drive is claimed (the unmount's wait,
/// or the check as it returns, sees the token).
#[test]
fn shim_selftest_cancel_mid_open_is_halted() {
struct FakeOptical;
impl Drop for FakeOptical {
fn drop(&mut self) {
unsafe { shim_selftest_fake_optical(0) };
}
}
let _globals = shim_globals();
unsafe { shim_selftest_fake_optical(1) };
let _fake = FakeOptical;
let (r, wake) = {
let halt = Halt::new();
let canceller = {
let halt = halt.clone();
thread::spawn(move || {
thread::sleep(CANCEL_AFTER);
let at = Instant::now();
halt.cancel();
at
})
};
let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &halt);
let returned = Instant::now();
let cancelled_at = canceller.join().expect("canceller thread");
(r, returned.saturating_duration_since(cancelled_at))
};
let _fake = FakeOptical::on();
let halt = Halt::new();
halt.cancel();
let t0 = Instant::now();
let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &halt);
assert!(
matches!(r, Err(Error::Halted)),
"expected Halted, got {:?}",
r.err()
);
assert!(
wake <= WAKE_BOUND,
"open returned {wake:?} after the cancel"
t0.elapsed() <= WAKE_BOUND,
"cancelled open took {:?}",
t0.elapsed()
);
assert!(
!OPEN.load(Ordering::Acquire),
@@ -497,6 +491,50 @@ fn shim_selftest_cancel_mid_open_is_halted() {
);
}
/// A resolved drive's open goes straight from the unmount to claiming the drive: no fixed
/// settle sleep after `diskutil` (a drive not yet released is waited for by the
/// ObtainExclusiveAccess retries). The fake optical service is no MMC device, so the open
/// fails at the plugin, well inside the 500 ms the settle sleep alone used to cost.
#[test]
fn shim_selftest_open_has_no_post_unmount_settle() {
let _globals = shim_globals();
let _fake = FakeOptical::on();
let t0 = Instant::now();
let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &Halt::new());
let took = t0.elapsed();
assert!(r.is_err(), "the fake optical service is no MMC device");
assert!(
!matches!(r, Err(Error::Halted)),
"nothing cancelled the open: {:?}",
r.err()
);
assert!(
took < Duration::from_millis(450),
"open took {took:?}: a settle sleep ran after the unmount"
);
assert!(
!OPEN.load(Ordering::Acquire),
"a failed open left OPEN held"
);
}
// The shim resolves any selector to a stand-in optical service while held; off on drop
// (panic included).
struct FakeOptical;
impl FakeOptical {
fn on() -> Self {
unsafe { shim_selftest_fake_optical(1) };
FakeOptical
}
}
impl Drop for FakeOptical {
fn drop(&mut self) {
unsafe { shim_selftest_fake_optical(0) };
}
}
/// §2.9 M1: "`timeout_ms` is passed through to the shim", reaching the task unchanged.
/// Apple SCSITaskLib.h SetTimeoutDuration: "The timeout duration is counted in milliseconds."
#[test]
+4
View File
@@ -443,6 +443,10 @@ pub struct DriveInfo {
/// `ioreg:<id>` selector (macOS), `\\.\CdRomN`
/// (Windows).
pub path: String,
/// The name the OS shows users: a drive letter like `E:` (Windows),
/// `/dev/srN` (Linux), `diskN` (macOS, only while a disc is mounted).
/// Equals [`path`](Self::path) when the OS has no name for the drive.
pub display_name: String,
/// SCSI INQUIRY vendor identifier (e.g. `"HL-DT-ST"`).
pub vendor: String,
/// SCSI INQUIRY product identifier (e.g. `"BD-RE BU40N"`).
+62 -12
View File
@@ -184,6 +184,10 @@ fn normalize_device_path(path: &str) -> String {
if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' {
return format!("\\\\.\\{}", trimmed);
}
// A bare drive letter (`E`) is that drive, as `E:` is.
if trimmed.len() == 1 && trimmed.as_bytes()[0].is_ascii_alphabetic() {
return format!("\\\\.\\{trimmed}:");
}
format!("\\\\.\\{}", path)
}
@@ -242,32 +246,77 @@ impl SptiTransport {
/// must answer INQUIRY as an optical peripheral (type 0x05).
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let names = cdrom_device_names();
let listed: Vec<u32> = names.iter().filter_map(|name| cdrom_number(name)).collect();
let mut candidates: Vec<String> = names
let letters: Vec<(char, Option<u32>)> = optical_drive_letters()
.into_iter()
.map(|name| format!("\\\\.\\{name}"))
.map(|letter| (letter, device_number(&format!("\\\\.\\{letter}:"))))
.collect();
for letter in optical_drive_letters() {
let path = format!("\\\\.\\{letter}:");
match device_number(&path) {
Some(n) if listed.contains(&n) => {}
_ => candidates.push(path),
}
}
let mut drives = Vec::new();
for path in candidates {
for path in candidate_paths(names, &letters) {
match probe(&path) {
Ok(Some(drive)) => drives.push(drive),
Ok(Some(mut drive)) => {
drive.display_name = display_name(&drive.path, &letters);
drives.push(drive);
}
Ok(None) => tracing::info!(path = %path, "skipping non-optical device"),
Err(e) => {
tracing::warn!(path = %path, error = %e, "skipping optical device that did not answer")
}
}
}
drives.sort_by_key(|drive| sort_key(&drive.path, &drive.display_name));
drives
}
/// Every `CdRomN` device, plus each optical letter whose `CdRomN` is not among them.
fn candidate_paths(names: Vec<String>, letters: &[(char, Option<u32>)]) -> Vec<String> {
let listed: Vec<u32> = names.iter().filter_map(|name| cdrom_number(name)).collect();
let mut candidates: Vec<String> = names
.into_iter()
.map(|name| format!("\\\\.\\{name}"))
.collect();
for (letter, number) in letters {
if !number.is_some_and(|n| listed.contains(&n)) {
candidates.push(format!("\\\\.\\{letter}:"));
}
}
candidates
}
/// The drive letter Explorer shows for `path` (`E:`), else `path` itself.
fn display_name(path: &str, letters: &[(char, Option<u32>)]) -> String {
let device = path.strip_prefix("\\\\.\\").unwrap_or(path);
let letter = match cdrom_number(device) {
Some(n) => letters
.iter()
.find(|(_, number)| *number == Some(n))
.map(|(letter, _)| *letter),
None => drive_letter(device),
};
letter.map_or_else(|| path.to_string(), |letter| format!("{letter}:"))
}
fn drive_letter(device: &str) -> Option<char> {
let mut chars = device.chars();
match (chars.next(), chars.next(), chars.next()) {
(Some(letter), Some(':'), None) if letter.is_ascii_alphabetic() => Some(letter),
_ => None,
}
}
/// Lettered drives in letter order, then the rest by `CdRomN`.
fn sort_key(path: &str, display_name: &str) -> (bool, u32, String) {
if display_name != path {
return (false, 0, display_name.to_string());
}
let device = path.strip_prefix("\\\\.\\").unwrap_or(path);
(
true,
cdrom_number(device).unwrap_or(u32::MAX),
path.to_string(),
)
}
fn probe(path: &str) -> Result<Option<super::DriveInfo>> {
let mut transport = crate::scsi::open(Path::new(path))?;
let r = match super::inquiry(transport.as_mut()) {
@@ -278,6 +327,7 @@ fn probe(path: &str) -> Result<Option<super::DriveInfo>> {
Ok(
super::is_optical_peripheral(&r.raw).then(|| super::DriveInfo {
path: normalize_device_path(path),
display_name: normalize_device_path(path),
vendor: r.vendor_id,
model: r.model,
firmware: r.firmware,
+41
View File
@@ -6,6 +6,8 @@ fn normalize_device_path_maps_drive_forms_to_the_volume_path() {
assert_eq!(normalize_device_path("D:"), r"\\.\D:");
assert_eq!(normalize_device_path(r"D:\"), r"\\.\D:");
assert_eq!(normalize_device_path(r"\\.\D:"), r"\\.\D:");
assert_eq!(normalize_device_path("E"), r"\\.\E:");
assert_eq!(normalize_device_path("e"), r"\\.\e:");
assert_eq!(normalize_device_path(r"\\.\CdRom0"), r"\\.\CdRom0");
assert_eq!(normalize_device_path("CdRom0"), r"\\.\CdRom0");
}
@@ -120,3 +122,42 @@ fn cdrom_names_come_from_the_dos_device_list_without_a_number_limit() {
assert_eq!(cdrom_number("CdRom"), None);
assert_eq!(cdrom_number("PhysicalDrive0"), None);
}
// Explorer's letter is the name; the `CdRomN` path stays the selector.
#[test]
fn display_name_is_the_drive_letter_for_its_cdrom_device() {
let letters = [('D', Some(0)), ('E', Some(3))];
assert_eq!(display_name(r"\\.\CdRom3", &letters), "E:");
assert_eq!(display_name(r"\\.\CdRom0", &letters), "D:");
// A letter-only candidate is opened by its letter.
assert_eq!(display_name(r"\\.\F:", &letters), "F:");
}
#[test]
fn display_name_without_a_letter_is_the_path() {
let letters = [('D', Some(0)), ('E', None)];
assert_eq!(display_name(r"\\.\CdRom2", &letters), r"\\.\CdRom2");
}
#[test]
fn candidates_add_only_letters_without_a_listed_cdrom() {
let names = vec!["CdRom0".to_string(), "CdRom3".to_string()];
let letters = [('D', Some(0)), ('E', Some(7)), ('F', None)];
assert_eq!(
candidate_paths(names, &letters),
[r"\\.\CdRom0", r"\\.\CdRom3", r"\\.\E:", r"\\.\F:"]
);
}
#[test]
fn drives_sort_by_letter_then_unlettered_cdrom_number() {
let mut drives = [
(r"\\.\CdRom10", r"\\.\CdRom10"),
(r"\\.\CdRom0", "F:"),
(r"\\.\CdRom2", r"\\.\CdRom2"),
(r"\\.\CdRom5", "D:"),
];
drives.sort_by_key(|(path, name)| sort_key(path, name));
let names: Vec<_> = drives.iter().map(|(_, name)| *name).collect();
assert_eq!(names, ["D:", "F:", r"\\.\CdRom2", r"\\.\CdRom10"]);
}
+10 -1
View File
@@ -12,7 +12,7 @@
//! - [`synthetic_bd_clip`]: a muxable clear BD-TS clip (MPEG-2 + two AC-3) for goldens.
//! - [`FakeTransport`]: a scripted drive with a state model for Stop tests (stop §5.0).
use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit};
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::mkb::AacsVersion;
use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES};
use crate::disc::AacsState;
@@ -412,6 +412,15 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
crate::aacs::content::decrypt_unit(unit, unit_key)
}
/// Encrypt one 6144-byte aligned unit in place with a CPS unit key — the exact
/// inverse of [`decrypt_unit`]. The test-side door for building ciphertext
/// fixtures, so no consumer test needs the internal one. Returns `false` if the
/// slice is too short to encrypt.
#[must_use]
pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
crate::aacs::content::encrypt_unit(unit, unit_key)
}
/// Overwrite the first sector of an aligned unit with garbage that keeps the encrypted flag
/// (CPI 11₂, KS-5) but not the seed's TS sync at byte 4 (KS-2, KS-4): read damage that no
/// key opens, as a drive returning the wrong bytes leaves it.
+77 -36
View File
@@ -345,6 +345,17 @@ impl UdfFs {
/// `STREAM` directories (case-insensitive) are not descended, and files over 50 MB are
/// skipped.
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
self.metadata_sector_ranges_for(reader, &|_| true)
}
/// [`Self::metadata_sector_ranges`] over the files `wanted` accepts, by absolute path
/// (e.g. `/BDMV/PLAYLIST/00000.mpls`): a file it refuses gets no range and its File Entry
/// is not read. The live scan's prefetch plan.
pub(crate) fn metadata_sector_ranges_for(
&self,
reader: &mut dyn SectorSource,
wanted: &dyn Fn(&str) -> bool,
) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure: sector 0 through end of metadata partition
@@ -363,7 +374,15 @@ impl UdfFs {
// Walk tree, collect ranges for each metadata file
let mut seen = HashSet::new();
self.collect_file_ranges(reader, &self.root, &mut ranges, &mut seen)?;
let mut path = String::new();
self.collect_file_ranges(
reader,
&self.root,
&mut path,
wanted,
&mut ranges,
&mut seen,
)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
@@ -442,52 +461,74 @@ impl UdfFs {
.collect()
}
// `path` is `entry`'s absolute path ("" for the root), restored on return.
fn collect_file_ranges(
&self,
reader: &mut dyn SectorSource,
entry: &DirEntry,
path: &mut String,
wanted: &dyn Fn(&str) -> bool,
ranges: &mut Vec<(u32, u32)>,
seen: &mut HashSet<u32>,
) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
// Only skip STREAM — those are the multi-GB video files
if child.name.eq_ignore_ascii_case("STREAM") {
continue;
}
self.collect_file_ranges(reader, child, ranges, seen)?;
} else {
// Include the ICB sector itself (in metadata partition)
ranges.push((self.meta_to_abs(child.meta_lba)?, 1));
let at = path.len();
path.push('/');
path.push_str(&child.name);
let r = self.collect_child_ranges(reader, child, path, wanted, ranges, seen);
path.truncate(at);
r?;
}
Ok(())
}
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
if child.size > MAX_CACHED_FILE_BYTES {
continue;
}
// Push every extent (a fragmented AACS cert / MPLS / CLPI spans several);
// entries can share one ICB, so read its extents once.
if !seen.insert(child.meta_lba) {
continue;
}
match self.read_icb_extents(reader, child.meta_lba) {
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => {}
Ok(extents) => {
for ext in extents {
// An unrecorded extent holds nothing to cache.
if !ext.recorded {
continue;
}
let Some(abs_start) = self.partition_start.checked_add(ext.lba) else {
continue;
};
let sector_count = (ext.len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
ranges.push((abs_start, sector_count));
}
compact_ranges(ranges, self.meta.start())?;
fn collect_child_ranges(
&self,
reader: &mut dyn SectorSource,
child: &DirEntry,
path: &mut String,
wanted: &dyn Fn(&str) -> bool,
ranges: &mut Vec<(u32, u32)>,
seen: &mut HashSet<u32>,
) -> Result<()> {
if child.is_dir {
// Only skip STREAM — those are the multi-GB video files
if child.name.eq_ignore_ascii_case("STREAM") {
return Ok(());
}
return self.collect_file_ranges(reader, child, path, wanted, ranges, seen);
}
// A file the scan never reads is not prefetched (nor its File Entry read).
if !wanted(path) {
return Ok(());
}
// Include the ICB sector itself (in metadata partition)
ranges.push((self.meta_to_abs(child.meta_lba)?, 1));
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
if child.size > MAX_CACHED_FILE_BYTES {
return Ok(());
}
// Push every extent (a fragmented AACS cert / MPLS / CLPI spans several);
// entries can share one ICB, so read its extents once.
if !seen.insert(child.meta_lba) {
return Ok(());
}
match self.read_icb_extents(reader, child.meta_lba) {
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => {}
Ok(extents) => {
for ext in extents {
// An unrecorded extent holds nothing to cache.
if !ext.recorded {
continue;
}
let Some(abs_start) = self.partition_start.checked_add(ext.lba) else {
continue;
};
let sector_count = (ext.len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
ranges.push((abs_start, sector_count));
}
compact_ranges(ranges, self.meta.start())?;
}
}
Ok(())
+62
View File
@@ -1792,6 +1792,68 @@ fn metadata_sector_ranges_covers_the_structure_and_each_small_files_extents() {
);
}
// `metadata_sector_ranges_for`: `wanted` sees each file's absolute path; a file it refuses gets no range, neither its
// data nor its File Entry, and its File Entry is never read.
#[test]
fn metadata_sector_ranges_leave_out_the_files_wanted_refuses() {
const PART: u32 = 1000;
let fs = UdfFs {
root: DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![DirEntry {
name: "BDMV".to_string(),
is_dir: true,
meta_lba: 1,
size: 0,
entries: vec![
file_entry("index.bdmv", 10, 2048),
DirEntry {
name: "BACKUP".to_string(),
is_dir: true,
meta_lba: 2,
size: 0,
entries: vec![file_entry("index.bdmv", 20, 2048)],
},
],
}],
},
volume_id: String::new(),
partition_start: PART,
meta: MetaMap::contiguous(PART),
metadata_sectors: 4,
};
struct Reads(MapReader, Vec<u32>);
impl SectorSource for Reads {
fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], r: bool) -> Result<usize> {
self.1.push(lba);
self.0.read_sectors(lba, n, buf, r)
}
}
let mut reader = Reads(MapReader::new(), Vec::new());
reader.0.put(PART + 10, build_efe(2048, &[(0, 2048, 500)]));
reader.0.put(PART + 20, build_efe(2048, &[(0, 2048, 600)]));
let seen = std::cell::RefCell::new(Vec::new());
let ranges = fs
.metadata_sector_ranges_for(&mut reader, &|p| {
seen.borrow_mut().push(p.to_string());
!p.contains("/BACKUP/")
})
.expect("the plan is built");
assert_eq!(
*seen.borrow(),
["/BDMV/index.bdmv", "/BDMV/BACKUP/index.bdmv"]
);
assert_eq!(ranges, vec![(0, 1004), (1010, 1), (1500, 1)]);
assert!(
!reader.1.contains(&(PART + 20)),
"the refused file's File Entry was read: {:?}",
reader.1
);
}
#[test]
fn prefetch_huge_count_is_capped() {
// A disc-controlled sector count far exceeding the 8192-sector cap must
+2 -8
View File
@@ -28,15 +28,9 @@ pub fn raw_whole_disc_reader<S: SectorSource>(reader: S) -> WholeDiscReader<S> {
)
}
// Every AACS content file's extents, one entry per file (contiguous extents joined):
// Every AACS content file's extents in `fs`, one entry per file (contiguous extents joined):
// `/BDMV/STREAM` (m2ts before SSIF, which re-lists them) or else HD DVD `/HVDVD_TS/*.EVO`.
// An unreadable UDF or unmappable file fails loud: it would otherwise ship as ciphertext.
pub(crate) fn content_files(reader: &mut dyn SectorSource) -> Result<Vec<Vec<(u32, u32)>>> {
let fs = crate::udf::read_filesystem(reader)?;
content_files_in(&fs, reader)
}
// `content_files` over an already-read filesystem.
// An unmappable file fails loud: it would otherwise ship as ciphertext.
pub(crate) fn content_files_in(
fs: &crate::udf::UdfFs,
reader: &mut dyn SectorSource,
@@ -10,18 +10,11 @@ copy-raw image = 8466432 bytes sha256:f0856c74bf5c9f7b6822186cafcaf1d6
extract-raw result = good=73728 unreadable=0 complete=true halted=false files=[("BDMV/STREAM/00001.m2ts", 36864, 0, true), ("BDMV/STREAM/00002.m2ts", 36864, 0, true)]
extract-raw out BDMV/STREAM/00001.m2ts = 36864 bytes sha256:375559f987f4f85e585d39abe6eb12fa
extract-raw out BDMV/STREAM/00002.m2ts = 36864 bytes sha256:2d895155c02331f792c4fca0bfb881ea
resolve-title status = KeySetStatus { proven: 1, declared: Some(2), keyed: 1, clear: 0, lazy: 1, forensic: None, origin: Some("keydb"), best_effort: false, requests: 1 }
title-read = 36864 bytes sha256:368a55d7eca3e25f6af377a6f1aec041
title-read blanked = 0
title-read failed = E7022
resolve-title = refused E7022
iso-mkv refused = E7022
iso-mkv out o.mkv = 21863 bytes sha256:47337f351b04ebb679c0da200a04e94a
iso-m2ts refused = E7022
iso-m2ts out o.m2ts = 31488 bytes sha256:3a40b2a87a7e62135df01ead62597601
iso-null refused = E7022
iso-mkv-raw refused = E6008
iso-mkv-selection refused = E7022
iso-mkv-selection out o.mkv = 15124 bytes sha256:120947d4cc4150b1531e918c11865049
live-mkv refused = E7022
live-mkv out o.mkv = 21863 bytes sha256:47337f351b04ebb679c0da200a04e94a
live-mkv-raw refused = E6008
@@ -1,6 +1,6 @@
phase0 status = KeySetStatus { proven: 2, declared: Some(2), keyed: 2, clear: 0, lazy: 0, forensic: Resolved, origin: Some("online"), best_effort: false, requests: 2 }
phase0 status = KeySetStatus { proven: 2, declared: Some(2), keyed: 2, clear: 0, lazy: 0, forensic: Resolved, origin: Some("online"), best_effort: false, requests: 3 }
phase0 forensic-clip = 98304 bytes sha256:2f40ac12464652e99ffb371dc0ed2af6
phase0 copy = refused E7013
phase1 status = KeySetStatus { proven: 2, declared: Some(2), keyed: 2, clear: 0, lazy: 0, forensic: Resolved, origin: Some("online"), best_effort: false, requests: 3 }
phase1 status = KeySetStatus { proven: 2, declared: Some(2), keyed: 2, clear: 0, lazy: 0, forensic: Resolved, origin: Some("online"), best_effort: false, requests: 4 }
phase1 forensic-clip = 98304 bytes sha256:5026938565e0cc00c6399f36f8fc1fa2
phase1 copy = refused E7013