mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1810161256 | ||
|
|
1a17ad6c14 | ||
|
|
33155eb9bf | ||
|
|
c0be981300 | ||
|
|
7aa28ba019 | ||
|
|
61b9c31c86 | ||
|
|
15f7829468 | ||
|
|
af62d6d5d8 | ||
|
|
6acc55b246 | ||
|
|
959800adec |
@@ -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
|
||||
|
||||
@@ -22,3 +22,4 @@ CLAUDE.md
|
||||
# cargo-mutants working output: large, machine-specific, never committed
|
||||
mutants.out/
|
||||
mutants.out.old/
|
||||
.cargo/config.toml
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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? ────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user