Compare commits

...
10 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
30 changed files with 1520 additions and 262 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
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [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`.
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",
+6
View File
@@ -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 }
+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()))
+8
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(),
+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")
);
}
+6 -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
@@ -699,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;
+60 -33
View File
@@ -145,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),
@@ -297,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) });
@@ -442,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();
@@ -455,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!(
@@ -464,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),
@@ -508,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
@@ -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)
}
+2
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");
}
+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