scan, keys: read less on a live drive; FMTS discs on the trusted key path

- Key evidence reads the filesystem in batches with the metadata partition
  prefetched, not one command per sector (~1 s on a USB UHD).
- The live Blu-ray scan prefetches only the files it parses (~22 MB and
  three long seeks less on a UHD). The MKB is read from a 4 MiB prefix with
  its record headers walked; its bytes are unchanged.
- macOS: no fixed 500 ms sleep after unmounting before claiming the drive.
- FMTS discs use the trusted CPS path; unit-key samples skip forensic
  segment units. The parity_image_fmts_alternate_phase golden changes only
  in request count, on a fixture title under two keys (not a real disc).
This commit is contained in:
Matthew Jackson
2026-10-07 08:47:00 -07:00
parent af62d6d5d8
commit 15f7829468
16 changed files with 717 additions and 141 deletions
+5 -2
View File
@@ -2,8 +2,11 @@
## [Unreleased]
- Key acquisition trusts the disc's declared CPS units. With one declared unit, the main title's samples ask the key sources once and the key that opens them keys every stream file, with no per-file probing: 1.8.0 first read up to 32 units from every file (about 1,500 seeks, ~95 s on a 46-file UHD over USB; now ~3 s). With several declared units, one encrypted unit per file goes into a single request. A source is never asked twice, and when every unit's samples were sent and no key opens a file, it is refused at once (blanked in a whole-disc copy) instead of probed. A disc with an FMTS forensic layout in scope keeps the probed path.
- Key acquisition logs its timings: evidence read, per-piece probe reads (count, average and slowest), main-title samples, total.
- 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.
+22
View File
@@ -247,6 +247,28 @@ pub fn mkb_content_len(mkb: &[u8]) -> usize {
.unwrap_or(0)
}
/// How far a prefix `buf` of an MKB file shows its record stream, walking the record
/// headers (4 bytes: type + BE24 length) as [`mkb_content_len`] does. `Ok(end)`: the walk
/// stopped on a header inside `buf` (the `00 000000` end marker, padding, or a header that
/// frames no record), so the stream ends at `end`. `Err(need)`: a record, or the header
/// after it, runs past `buf`; the stream needs at least `need` bytes to be seen.
pub(crate) fn mkb_prefix_end(buf: &[u8]) -> std::result::Result<usize, usize> {
let mut pos = 0usize;
loop {
let Some(head) = buf.get(pos..pos + 4) else {
return Err(pos + 4);
};
let rec_len = ((head[1] as usize) << 16) | ((head[2] as usize) << 8) | (head[3] as usize);
if (head[0] == 0 && rec_len == 0) || rec_len < 4 {
return Ok(pos);
}
if pos + rec_len > buf.len() {
return Err(pos + rec_len + 4);
}
pos += rec_len;
}
}
/// Trim an MKB's trailing fixed-region padding to its real content length —
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
+29
View File
@@ -295,3 +295,32 @@ fn aacs_version_from_major_only_bd_is_v10() {
assert_eq!(AacsVersion::from_major(m).unit_key_stride(), 64);
}
}
// `mkb_prefix_end`: the stream ends inside the prefix only where the walk stops on a header
// the prefix holds; a record or header cut by the prefix asks for the bytes it needs.
#[test]
fn prefix_end_finds_the_end_marker_or_names_the_bytes_still_needed() {
let a = rec(0x10, &[0u8; 8]); // 12 bytes
let b = rec(0x04, &[0xAA; 100]); // 104 bytes
let mut mkb = [a.clone(), b.clone()].concat();
let content = mkb.len();
mkb.extend_from_slice(&[0, 0, 0, 0]);
mkb.resize(content + 64, 0);
// Whole stream and its end marker seen: the end, equal to `mkb_content_len`.
assert_eq!(mkb_prefix_end(&mkb), Ok(content));
assert_eq!(mkb_content_len(&mkb), content);
// The prefix cuts record `b`: its end plus the next header are needed. The record
// walk alone stops early here, at the end of `a`.
let cut = &mkb[..a.len() + 50];
assert_eq!(mkb_prefix_end(cut), Err(content + 4));
assert_eq!(mkb_content_len(cut), a.len());
// The prefix ends exactly after `b`, or inside the header after it: that header.
assert_eq!(mkb_prefix_end(&mkb[..content]), Err(content + 4));
assert_eq!(mkb_prefix_end(&mkb[..content + 2]), Err(content + 4));
// A header that frames no record ends the stream where it stands.
let mut bad = a.clone();
bad.extend_from_slice(&[0x04, 0, 0, 2]);
assert_eq!(mkb_prefix_end(&bad), Ok(a.len()));
assert_eq!(mkb_prefix_end(&[0x04, 0, 0, 2]), Ok(0));
assert_eq!(mkb_prefix_end(&[]), Err(4));
}
+70 -14
View File
@@ -1778,6 +1778,56 @@ impl DiscId {
}
}
// Whether a live scan parses the file at `path` (absolute, any case), so the metadata
// prefetch bulk-loads it. On a BD tree only what the scan reads: the nav and clip files,
// BD-J objects, jar archives and the config files the labels read beside them, META XML,
// and the AACS files the capture reads first. Not /BDMV/BACKUP, BD-J image assets,
// /AACS/DUPLICATE or the other AACS files: a fallback read of one still works, unprefetched.
// Any other tree (DVD, HD DVD) keeps every file.
pub(crate) fn scan_parses(bd: bool, path: &str) -> bool {
if !bd {
return true;
}
let p = path.to_ascii_uppercase();
let in_dir = |dir: &str| {
p.strip_prefix(dir)
.and_then(|rest| rest.strip_prefix('/'))
.filter(|name| !name.contains('/'))
};
if let Some(name) = in_dir("/BDMV") {
return matches!(name, "INDEX.BDMV" | "MOVIEOBJECT.BDMV");
}
if ["/BDMV/PLAYLIST", "/BDMV/CLIPINF", "/BDMV/BDJO"]
.iter()
.any(|d| in_dir(d).is_some())
{
return true;
}
if p.starts_with("/BDMV/META/") {
return p.ends_with(".XML");
}
if let Some(name) = in_dir("/BDMV/JAR") {
return name.ends_with(".JAR");
}
if let Some(rest) = p.strip_prefix("/BDMV/JAR/") {
// A jar subdirectory's own files: only those a label reads.
return rest.split_once('/').is_some_and(|(_, name)| {
!name.contains('/')
&& crate::labels::JAR_DIR_FILES
.iter()
.any(|f| f.eq_ignore_ascii_case(name))
});
}
[
crate::aacs::PATH_UNIT_KEY_RO,
crate::aacs::PATH_CONTENT_CERT,
crate::aacs::PATH_CONTENT_CERT_ALT,
crate::aacs::PATH_MKB_RO,
]
.iter()
.any(|f| f.eq_ignore_ascii_case(path))
}
impl Disc {
/// Fast disc identification — reads only UDF metadata for name and format.
/// No AACS handshake, no playlist parsing, no CLPI, no labels.
@@ -1957,8 +2007,10 @@ impl Disc {
tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read");
// Pre-read small files (AACS, MPLS, CLPI, META, *.bdmv): one command each otherwise.
match udf_fs.metadata_sector_ranges(&mut buffered) {
// Pre-read the small files the scan parses (AACS, MPLS, CLPI, META, *.bdmv): one
// command each otherwise.
let bd = udf_fs.find_dir("/BDMV").is_some();
match udf_fs.metadata_sector_ranges_for(&mut buffered, &|p| scan_parses(bd, p)) {
Ok(ranges) => buffered.prefetch_ranges(&ranges)?,
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => {} // prefetch is optional
@@ -2287,11 +2339,12 @@ impl Disc {
Ok(crate::aacs::mkb::resolve_aacs_version(cert_major, mkb, index).major())
}
// Reads the AACS MKB's real record stream — NOT its ~128 MiB zero padding. Reads a bounded,
// growing prefix instead, avoiding both the padding read and the read_file MAX_FILE_BYTES
// cap.
// Reads the AACS MKB's real record stream — NOT its ~128 MiB zero padding. Reads a bounded
// prefix (4 MiB holds a real MKB, ~4 MB on a UHD) and walks the record headers: a stream
// the prefix cuts is re-read with room for the record it cuts, never returned short. Also
// avoids the read_file MAX_FILE_BYTES cap.
fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
const START_BYTES: usize = 16 * 1024 * 1024;
const START_BYTES: usize = 4 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
@@ -2299,17 +2352,20 @@ impl Disc {
&crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::Mkb),
|p| udf_fs.read_file_prefix(reader, p, want),
)?;
let n = crate::aacs::mkb::mkb_content_len(&buf);
// `n` strictly inside `buf` or `buf` shorter than `want` means the
// whole content is captured; otherwise records may run past the
// prefix — grow and retry, bounded by MAX_BYTES.
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
// The stream ends inside `buf`, or `buf` is the whole file, or the cap is
// reached: done. An unparseable first record (end 0) grows as before.
let need = match crate::aacs::mkb::mkb_prefix_end(&buf) {
Ok(n) if n > 0 => None,
Ok(_) => Some(0),
Err(need) => Some(need),
};
let Some(need) = need.filter(|_| buf.len() >= want && want < MAX_BYTES) else {
// The prefix buffer is sized for the padded file; release the unused capacity.
let mut mkb = crate::aacs::mkb::trim_mkb(buf);
mkb.shrink_to_fit();
return Ok(mkb);
}
want = (want * 2).min(MAX_BYTES);
};
want = (want * 2).max(need).min(MAX_BYTES);
}
}
@@ -3336,7 +3392,7 @@ fn random_u64() -> u64 {
}
const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
pub(crate) const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192;
const MIN_BATCH_SECTORS: u16 = 3;
+130
View File
@@ -1337,6 +1337,86 @@ fn read_mkb_content_grows_prefix_past_16mib_when_records_run_longer() {
);
}
// An MKB_RO.inf holding `mkb` (zero-padded to `file_len`) at LBA 1000 of a bare UDF tree.
fn mkb_disc(mut mkb: Vec<u8>, file_len: usize) -> (crate::udf::fixture::MemDisc, udf::UdfFs) {
use crate::udf::fixture::*;
mkb.resize(file_len, 0);
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "AACS".into(),
icb_lba: 12,
dir_data_lba: 13,
files: vec![file_with("MKB_RO.inf", 14, 1000, mkb, true)],
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
(disc, udf)
}
// An MKB record stream: `lens` records (header included) of filler, then the end marker.
fn mkb_stream(lens: &[usize]) -> Vec<u8> {
let mut mkb = Vec::new();
for &len in lens {
mkb.extend_from_slice(&[0x04, (len >> 16) as u8, (len >> 8) as u8, len as u8]);
mkb.resize(mkb.len() + len - 4, 0xAA);
}
mkb.extend_from_slice(&[0, 0, 0, 0]);
mkb
}
// Counts the sectors read off the wrapped source.
struct SectorCount<'a>(&'a mut crate::udf::fixture::MemDisc, u64);
impl SectorSource for SectorCount<'_> {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], r: bool) -> Result<usize> {
self.1 += u64::from(count);
self.0.read_sectors(lba, count, buf, r)
}
}
// A real-sized MKB (3,809,272 bytes of records on a live UHD, in a ~128 MiB padded file) is
// read from a 4 MiB prefix, not 16 MiB, and returned byte for byte.
#[test]
fn read_mkb_content_reads_a_4mib_prefix_for_a_real_sized_mkb() {
let records = [12, 3_809_272 - 12 - 4];
let stream = mkb_stream(&records);
assert_eq!(stream.len(), 3_809_272);
let (mut disc, udf) = mkb_disc(stream.clone(), 20 * 1024 * 1024);
let mut src = SectorCount(&mut disc, 0);
let got = Disc::read_mkb_content(&mut src, &udf).expect("mkb");
assert_eq!(
got,
stream[..stream.len() - 4],
"the record stream, byte for byte"
);
let data_sectors = (4 * 1024 * 1024 / 2048) as u64;
assert!(
src.1 <= data_sectors + 4,
"read {} sectors: more than the 4 MiB prefix and the File Entry",
src.1
);
}
// A record the 4 MiB prefix cuts is read whole: the walk asks for the bytes it needs and the
// prefix grows, rather than returning the stream up to the cut record.
#[test]
fn read_mkb_content_rereads_a_record_the_prefix_cuts() {
const MIB: usize = 1024 * 1024;
let stream = mkb_stream(&[3 * MIB, 3 * MIB]);
let (mut disc, udf) = mkb_disc(stream.clone(), 20 * MIB);
let got = Disc::read_mkb_content(&mut disc, &udf).expect("mkb");
assert_eq!(got.len(), 6 * MIB, "both records, not the first alone");
assert_eq!(got, stream[..6 * MIB]);
}
// ── identify(): AACS-directory encrypted gate (finding 6) ──────────────
// A ScsiTransport serving a synthetic UDF image through real READ(10),
// so Disc::identify is exercised end-to-end, not mocked itself.
@@ -5786,3 +5866,53 @@ fn read_capacity_rejects_a_short_transfer_instead_of_reporting_one_sector() {
Err(crate::error::Error::DiscCapacityMalformed)
));
}
// The scan's metadata prefetch on a BD tree loads only what the scan parses: nav, clip,
// BD-J object, jar, META XML, the label config files beside the jars and the primary AACS
// files. Backups, duplicates, image assets and unread AACS files stay on the disc (a
// fallback read of one still works, unprefetched). Other trees keep every file.
#[test]
fn scan_prefetches_only_the_files_a_bd_scan_parses() {
for p in [
"/BDMV/index.bdmv",
"/BDMV/MovieObject.bdmv",
"/BDMV/PLAYLIST/00800.mpls",
"/BDMV/CLIPINF/00055.clpi",
"/BDMV/BDJO/00000.bdjo",
"/BDMV/META/DL/bdmt_eng.xml",
"/BDMV/JAR/00000.jar",
"/BDMV/JAR/00001/config.xml",
"/BDMV/JAR/00002/DCX.XML",
"/AACS/Unit_Key_RO.inf",
"/AACS/Content000.cer",
"/AACS/Content001.cer",
"/AACS/MKB_RO.inf",
"/aacs/unit_key_ro.inf",
] {
assert!(scan_parses(true, p), "{p} is parsed: prefetch it");
}
for p in [
"/BDMV/BACKUP/index.bdmv",
"/BDMV/BACKUP/PLAYLIST/00800.mpls",
"/BDMV/JAR/00001/menu_bg.png",
"/BDMV/JAR/00001/sub/config.xml",
"/BDMV/META/DL/bdmt_eng_thumb.jpg",
"/BDMV/AUXDATA/sound.bdmv",
"/BDMV/PLAYLIST/x/00800.mpls",
"/AACS/DUPLICATE/Unit_Key_RO.inf",
"/AACS/DUPLICATE/Content000.cer",
"/AACS/ContentHash000.tbl",
"/AACS/ContentRevocation.lst",
"/AACS/CPSUnit00001.cci",
"/CERTIFICATE/id.bdmv",
] {
assert!(!scan_parses(true, p), "{p} is not parsed: leave it");
}
for p in [
"/VIDEO_TS/VIDEO_TS.IFO",
"/HVDVD_TS/FEATURE.MAP",
"/AACS/DUPLICATE/x",
] {
assert!(scan_parses(false, p), "{p}: a non-BD tree keeps every file");
}
}
+43 -24
View File
@@ -232,24 +232,13 @@ impl KeyEvidence {
volume_label: inputs.volume_label,
});
let whole = ev.scope == KeyScope::WholeDisc;
if ev.detector == Detector::PerPack {
// Listed whatever the declared count: acquisition judges a copy in the clear from
// its pieces before it refuses a multi-key HD DVD.
let files = match crate::whole_disc::content_files(reader) {
Ok(f) => f,
Err(e) if whole => return Err(e),
Err(e) => {
tracing::warn!(target: "freemkv::keys", error = %e, "no HD DVD file list: keying title extents");
Vec::new()
}
};
ev.no_stream_files = files.is_empty();
ev.pieces = pieces(disc, &files, &sel, whole);
return Ok(ev);
}
// The filesystem the scan already read, re-read as the scan reads it: batched, with
// the metadata partition prefetched, so the tree walk and every stream file's File
// Entry cost a few commands, not one per sector.
let mut buffered = crate::udf::BufferedSectorReader::new(reader, FS_BATCH_SECTORS);
// An unreadable filesystem fails a whole-disc copy (it would otherwise ship
// ciphertext); a title rip keys its title extents instead.
let fs = match crate::udf::read_filesystem(reader) {
let fs = match buffered_filesystem(&mut buffered) {
Ok(fs) => Some(fs),
Err(e) if whole => return Err(e),
Err(e) => {
@@ -258,7 +247,7 @@ impl KeyEvidence {
}
};
let files = match &fs {
Some(fs) => match crate::whole_disc::content_files_in(fs, reader) {
Some(fs) => match crate::whole_disc::content_files_in(fs, &mut buffered) {
Ok(f) => f,
Err(e) if whole => return Err(e),
Err(e) => {
@@ -270,14 +259,30 @@ impl KeyEvidence {
};
ev.no_stream_files = files.is_empty();
ev.pieces = pieces(disc, &files, &sel, whole);
ev.fmts = match &fs {
Some(fs) => super::fmts::layout(fs, reader)?,
None => None,
};
// HD DVD: listed whatever the declared count (acquisition judges a copy in the clear
// from its pieces before it refuses a multi-key HD DVD); it has no forensic layout.
if ev.detector != Detector::PerPack {
ev.fmts = match &fs {
Some(fs) => super::fmts::layout(fs, &mut buffered)?,
None => None,
};
}
Ok(ev)
}
}
/// Sectors per batched read of the filesystem: the optical default the scan uses.
const FS_BATCH_SECTORS: u16 = crate::disc::DEFAULT_BATCH_SECTORS_OPTICAL;
// The UDF tree, then the metadata partition (every directory and File Entry) prefetched.
fn buffered_filesystem<S: SectorSource + ?Sized>(
reader: &mut crate::udf::BufferedSectorReader<'_, S>,
) -> Result<crate::udf::UdfFs> {
let fs = crate::udf::read_filesystem(reader)?;
reader.prefetch(fs.metadata_start(), fs.metadata_sectors())?;
Ok(fs)
}
pub(crate) fn sorted_ranges(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
v.sort_unstable();
let mut out: Vec<(u32, u32)> = Vec::with_capacity(v.len());
@@ -504,11 +509,25 @@ impl<'a> Sampler<'a> {
Ok(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>> {
// 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_in(self.reader, &m.extents, m.format, n))
.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
+36 -17
View File
@@ -488,9 +488,7 @@ struct Scoped<'e> {
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: Vec<(u32, u32)> = layout
.map(|l| sorted_ranges(l.ranges.iter().map(|&(s, e, _)| (s, e)).collect()))
.unwrap_or_default();
let segments = layout_segments(layout);
let t0 = std::time::Instant::now();
let before = sampler.stats();
for p in &mut ps {
@@ -527,6 +525,13 @@ fn probe_scope<'e>(ev: &'e KeyEvidence, sampler: &mut Sampler, halt: &Halt) -> R
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();
@@ -549,8 +554,9 @@ fn scope_layout(ev: &KeyEvidence) -> (Option<&super::fmts::Layout>, Vec<(u32, u3
// 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). `None` (no ciphertext found, or no key opens it) falls back to
// the probed path, which decides clear content and refusals.
// 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,
@@ -558,7 +564,9 @@ fn resolve_one_unit(
seed: Option<&KeyRing>,
run: &mut Run,
) -> Result<Option<Inner>> {
let samples = sampler.main_samples(ev.main.as_ref(), MIN_SAMPLE_UNITS);
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);
}
@@ -603,7 +611,6 @@ fn resolve_one_unit(
}
return Ok(None);
};
let (layout, clip) = scope_layout(ev);
let ps: Vec<Probed> = ev
.pieces
.iter()
@@ -637,9 +644,11 @@ fn resolve_one_unit(
}
// 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) 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.
// `None` (a piece unreadable, or one no key opens) falls back to the probed path.
// 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,
@@ -649,9 +658,7 @@ fn resolve_per_unit(
n_decl: usize,
) -> Result<Option<Inner>> {
let (layout, clip) = scope_layout(ev);
let segments: Vec<(u32, u32)> = layout
.map(|l| sorted_ranges(l.ranges.iter().map(|&(s, e, _)| (s, e)).collect()))
.unwrap_or_default();
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());
@@ -667,6 +674,18 @@ fn resolve_per_unit(
}
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()
@@ -680,7 +699,7 @@ fn resolve_per_unit(
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);
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;
@@ -840,8 +859,8 @@ fn decide(
seed: Option<&KeyRing>,
run: &mut Run,
) -> Result<Inner> {
// A forensic layout in scope keeps the probed path: its segments follow their own rules.
if ev.detector == Detector::Verified && scope_layout(ev).0.is_none() {
// 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)?,
@@ -915,7 +934,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));
+136 -2
View File
@@ -220,6 +220,8 @@ impl SectorSource for NoSeek {
struct Call {
who: &'static str,
samples: usize,
// The sample units the request carried.
units: Vec<Vec<u8>>,
vid: Option<[u8; 16]>,
forensic: bool,
at: Duration,
@@ -333,6 +335,7 @@ impl Fake {
self.spec.calls.0.lock().unwrap().push(Call {
who: self.spec.who,
samples: samples.len(),
units: samples.clone(),
vid: ctx.vid().map(|v| v.0),
forensic,
at,
@@ -1807,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();
@@ -1837,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]
+17
View File
@@ -386,6 +386,18 @@ pub(crate) fn encrypted_units_in(
extents: &[crate::disc::Extent],
format: crate::disc::ContentFormat,
n: usize,
) -> Vec<Vec<u8>> {
encrypted_units_outside(reader, extents, format, n, &[])
}
// `encrypted_units_in` taking no unit that touches a `skip` range `[start, end)` (FMTS
// forensic segments: their units carry a forensic key, not the unit key).
pub(crate) fn encrypted_units_outside(
reader: &mut dyn crate::sector::SectorSource,
extents: &[crate::disc::Extent],
format: crate::disc::ContentFormat,
n: usize,
skip: &[(u32, u32)],
) -> Vec<Vec<u8>> {
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
@@ -441,6 +453,11 @@ pub(crate) fn encrypted_units_in(
break;
}
let u = &buf[o..o + ALIGNED_UNIT_LEN];
let at = lba as u64 + (i as u64) * ALIGNED_UNIT_SECTORS as u64;
let end = at + ALIGNED_UNIT_SECTORS as u64;
if skip.iter().any(|&(s, e)| (s as u64) < end && at < e as u64) {
continue;
}
if aacs_unit_encrypted(u, format) {
out.push(u.to_vec());
if out.len() >= n {
+20
View File
@@ -1277,6 +1277,20 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
jar_file_paths(udf, filename).into_iter().next()
}
/// The files the labels read from a BDMV/JAR subdirectory (`read_jar_file`, Deluxe's
/// `config.xml`): the scan's metadata prefetch loads these and none of the subdirectory's
/// image assets. A name missing here still reads, unprefetched.
pub(crate) const JAR_DIR_FILES: &[&str] = &[
"config.xml",
"playlists.xml",
"language_streams.txt",
"menu_base.prop",
"bluray_project.bin",
"streamproperties.xml",
"playbackconfig.xml",
"dcx.xml",
];
/// Read a file from any BDMV/JAR subdirectory by filename; the first copy
/// that reads successfully and is non-empty wins.
pub(crate) fn read_jar_file(
@@ -1284,6 +1298,12 @@ pub(crate) fn read_jar_file(
udf: &UdfFs,
filename: &str,
) -> Option<Vec<u8>> {
debug_assert!(
JAR_DIR_FILES
.iter()
.any(|f| f.eq_ignore_ascii_case(filename)),
"{filename} is read by a label: list it in JAR_DIR_FILES so the scan prefetches it"
);
jar_file_paths(udf, filename)
.into_iter()
.find_map(|path| udf.read_file(reader, &path).ok().filter(|d| !d.is_empty()))
+6 -3
View File
@@ -87,7 +87,7 @@ extern char **environ;
#define SHIM_WAIT_SLICE_MS 20
// diskutil unmount budget; a wedged unmount is killed so open() still returns (§2.9).
#define SHIM_DISKUTIL_BUDGET_MS 20000
// Settle time after the unmount, and between ObtainExclusiveAccess retries.
// Wait between ObtainExclusiveAccess retries (the unmount not yet released).
#define SHIM_SETTLE_MS 500
// DiskArbitration claim wait, so a wedged DA can't hang open().
#define SHIM_DA_CLAIM_MS 5000
@@ -699,14 +699,17 @@ int shim_open_exclusive(const char *selector, const volatile uint8_t *cancel) {
// BSD device name is a discrete argv element and never shell syntax. Only a
// name confirmed to belong to an optical drive above reaches this point. A
// killed unmount is fine: ObtainExclusiveAccess below is the real gate (-5).
// No settle sleep after it: a drive not yet released is waited for by the
// ObtainExclusiveAccess retry loop, so a prompt unmount costs no fixed delay.
if (bsd_name[0]) {
char *const argv[] = {
"diskutil", "unmountDisk", "force", bsd_name, NULL
};
// On cancel run_and_reap has killed and reaped diskutil (§2.9 M2).
// On cancel run_and_reap has killed and reaped diskutil (§2.9 M2); a cancel that
// lands as the unmount ends still stops the open before the drive is claimed.
if (run_and_reap("/usr/sbin/diskutil", argv, SHIM_DISKUTIL_BUDGET_MS, cancel, NULL)
== SHIM_CANCELLED
|| sliced_sleep(SHIM_SETTLE_MS, cancel) == SHIM_CANCELLED) {
|| shim_cancelled(cancel)) {
IOObjectRelease(svc);
pthread_mutex_unlock(&g_handle_lock);
return SHIM_CANCELLED;
+60 -33
View File
@@ -145,8 +145,8 @@ fn presence_probe_does_not_open_a_transport() {
);
assert!(
elapsed < std::time::Duration::from_millis(250),
"probe took {elapsed:?}: the transport path's unconditional 500 ms \
post-unmount sleep means this budget can only be met without it"
"probe took {elapsed:?}: it must answer without opening the transport \
(force-unmount, exclusive-access retries)"
);
assert_eq!(
OPEN.load(Ordering::Acquire),
@@ -297,7 +297,7 @@ fn shim_selftest_wait_slice_is_the_library_wait_slice() {
assert_eq!(Duration::from_millis(slice as u64), WAIT_SLICE);
}
/// §2.9 M2: the settle and ObtainExclusiveAccess retry sleeps end within a slice of a cancel.
/// §2.9 M2: the ObtainExclusiveAccess retry sleep ends within a slice of a cancel.
#[test]
fn shim_selftest_cancel_ends_the_settle_sleep() {
let (rc, wake) = cancel_during(|c| unsafe { shim_selftest_sleep(LONG_MS, c) });
@@ -442,7 +442,7 @@ fn shim_selftest_cancelled_open_is_halted() {
}
/// A selector that is not an optical drive is refused before anything is unmounted: the
/// open fails at once (no 500 ms settle sleep, no diskutil) and releases the lock.
/// open fails at once (no diskutil) and releases the lock.
#[test]
fn open_of_a_non_optical_selector_is_refused_before_any_unmount() {
let _globals = shim_globals();
@@ -455,7 +455,7 @@ fn open_of_a_non_optical_selector_is_refused_before_any_unmount() {
);
assert!(
t0.elapsed() < Duration::from_millis(450),
"refusal took {:?}: the post-unmount settle sleep ran",
"refusal took {:?}: it waited on an unmount",
t0.elapsed()
);
assert!(
@@ -464,43 +464,26 @@ fn open_of_a_non_optical_selector_is_refused_before_any_unmount() {
);
}
/// §2.9 M2 end to end: a resolved drive's open hands its token to the unmount and 500 ms
/// settle waits. The unmount of a missing disk ends at once, so the cancel lands in the settle.
/// §2.9 M2 end to end: a resolved drive's open hands its token to the unmount, so a Stop
/// raised before the open ends it `Halted` before the drive is claimed (the unmount's wait,
/// or the check as it returns, sees the token).
#[test]
fn shim_selftest_cancel_mid_open_is_halted() {
struct FakeOptical;
impl Drop for FakeOptical {
fn drop(&mut self) {
unsafe { shim_selftest_fake_optical(0) };
}
}
let _globals = shim_globals();
unsafe { shim_selftest_fake_optical(1) };
let _fake = FakeOptical;
let (r, wake) = {
let halt = Halt::new();
let canceller = {
let halt = halt.clone();
thread::spawn(move || {
thread::sleep(CANCEL_AFTER);
let at = Instant::now();
halt.cancel();
at
})
};
let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &halt);
let returned = Instant::now();
let cancelled_at = canceller.join().expect("canceller thread");
(r, returned.saturating_duration_since(cancelled_at))
};
let _fake = FakeOptical::on();
let halt = Halt::new();
halt.cancel();
let t0 = Instant::now();
let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &halt);
assert!(
matches!(r, Err(Error::Halted)),
"expected Halted, got {:?}",
r.err()
);
assert!(
wake <= WAKE_BOUND,
"open returned {wake:?} after the cancel"
t0.elapsed() <= WAKE_BOUND,
"cancelled open took {:?}",
t0.elapsed()
);
assert!(
!OPEN.load(Ordering::Acquire),
@@ -508,6 +491,50 @@ fn shim_selftest_cancel_mid_open_is_halted() {
);
}
/// A resolved drive's open goes straight from the unmount to claiming the drive: no fixed
/// settle sleep after `diskutil` (a drive not yet released is waited for by the
/// ObtainExclusiveAccess retries). The fake optical service is no MMC device, so the open
/// fails at the plugin, well inside the 500 ms the settle sleep alone used to cost.
#[test]
fn shim_selftest_open_has_no_post_unmount_settle() {
let _globals = shim_globals();
let _fake = FakeOptical::on();
let t0 = Instant::now();
let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &Halt::new());
let took = t0.elapsed();
assert!(r.is_err(), "the fake optical service is no MMC device");
assert!(
!matches!(r, Err(Error::Halted)),
"nothing cancelled the open: {:?}",
r.err()
);
assert!(
took < Duration::from_millis(450),
"open took {took:?}: a settle sleep ran after the unmount"
);
assert!(
!OPEN.load(Ordering::Acquire),
"a failed open left OPEN held"
);
}
// The shim resolves any selector to a stand-in optical service while held; off on drop
// (panic included).
struct FakeOptical;
impl FakeOptical {
fn on() -> Self {
unsafe { shim_selftest_fake_optical(1) };
FakeOptical
}
}
impl Drop for FakeOptical {
fn drop(&mut self) {
unsafe { shim_selftest_fake_optical(0) };
}
}
/// §2.9 M1: "`timeout_ms` is passed through to the shim", reaching the task unchanged.
/// Apple SCSITaskLib.h SetTimeoutDuration: "The timeout duration is counted in milliseconds."
#[test]
+77 -36
View File
@@ -345,6 +345,17 @@ impl UdfFs {
/// `STREAM` directories (case-insensitive) are not descended, and files over 50 MB are
/// skipped.
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
self.metadata_sector_ranges_for(reader, &|_| true)
}
/// [`Self::metadata_sector_ranges`] over the files `wanted` accepts, by absolute path
/// (e.g. `/BDMV/PLAYLIST/00000.mpls`): a file it refuses gets no range and its File Entry
/// is not read. The live scan's prefetch plan.
pub(crate) fn metadata_sector_ranges_for(
&self,
reader: &mut dyn SectorSource,
wanted: &dyn Fn(&str) -> bool,
) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure: sector 0 through end of metadata partition
@@ -363,7 +374,15 @@ impl UdfFs {
// Walk tree, collect ranges for each metadata file
let mut seen = HashSet::new();
self.collect_file_ranges(reader, &self.root, &mut ranges, &mut seen)?;
let mut path = String::new();
self.collect_file_ranges(
reader,
&self.root,
&mut path,
wanted,
&mut ranges,
&mut seen,
)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
@@ -442,52 +461,74 @@ impl UdfFs {
.collect()
}
// `path` is `entry`'s absolute path ("" for the root), restored on return.
fn collect_file_ranges(
&self,
reader: &mut dyn SectorSource,
entry: &DirEntry,
path: &mut String,
wanted: &dyn Fn(&str) -> bool,
ranges: &mut Vec<(u32, u32)>,
seen: &mut HashSet<u32>,
) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
// Only skip STREAM — those are the multi-GB video files
if child.name.eq_ignore_ascii_case("STREAM") {
continue;
}
self.collect_file_ranges(reader, child, ranges, seen)?;
} else {
// Include the ICB sector itself (in metadata partition)
ranges.push((self.meta_to_abs(child.meta_lba)?, 1));
let at = path.len();
path.push('/');
path.push_str(&child.name);
let r = self.collect_child_ranges(reader, child, path, wanted, ranges, seen);
path.truncate(at);
r?;
}
Ok(())
}
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
if child.size > MAX_CACHED_FILE_BYTES {
continue;
}
// Push every extent (a fragmented AACS cert / MPLS / CLPI spans several);
// entries can share one ICB, so read its extents once.
if !seen.insert(child.meta_lba) {
continue;
}
match self.read_icb_extents(reader, child.meta_lba) {
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => {}
Ok(extents) => {
for ext in extents {
// An unrecorded extent holds nothing to cache.
if !ext.recorded {
continue;
}
let Some(abs_start) = self.partition_start.checked_add(ext.lba) else {
continue;
};
let sector_count = (ext.len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
ranges.push((abs_start, sector_count));
}
compact_ranges(ranges, self.meta.start())?;
fn collect_child_ranges(
&self,
reader: &mut dyn SectorSource,
child: &DirEntry,
path: &mut String,
wanted: &dyn Fn(&str) -> bool,
ranges: &mut Vec<(u32, u32)>,
seen: &mut HashSet<u32>,
) -> Result<()> {
if child.is_dir {
// Only skip STREAM — those are the multi-GB video files
if child.name.eq_ignore_ascii_case("STREAM") {
return Ok(());
}
return self.collect_file_ranges(reader, child, path, wanted, ranges, seen);
}
// A file the scan never reads is not prefetched (nor its File Entry read).
if !wanted(path) {
return Ok(());
}
// Include the ICB sector itself (in metadata partition)
ranges.push((self.meta_to_abs(child.meta_lba)?, 1));
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
if child.size > MAX_CACHED_FILE_BYTES {
return Ok(());
}
// Push every extent (a fragmented AACS cert / MPLS / CLPI spans several);
// entries can share one ICB, so read its extents once.
if !seen.insert(child.meta_lba) {
return Ok(());
}
match self.read_icb_extents(reader, child.meta_lba) {
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => {}
Ok(extents) => {
for ext in extents {
// An unrecorded extent holds nothing to cache.
if !ext.recorded {
continue;
}
let Some(abs_start) = self.partition_start.checked_add(ext.lba) else {
continue;
};
let sector_count = (ext.len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
ranges.push((abs_start, sector_count));
}
compact_ranges(ranges, self.meta.start())?;
}
}
Ok(())
+62
View File
@@ -1792,6 +1792,68 @@ fn metadata_sector_ranges_covers_the_structure_and_each_small_files_extents() {
);
}
// `metadata_sector_ranges_for`: `wanted` sees each file's absolute path; a file it refuses gets no range, neither its
// data nor its File Entry, and its File Entry is never read.
#[test]
fn metadata_sector_ranges_leave_out_the_files_wanted_refuses() {
const PART: u32 = 1000;
let fs = UdfFs {
root: DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![DirEntry {
name: "BDMV".to_string(),
is_dir: true,
meta_lba: 1,
size: 0,
entries: vec![
file_entry("index.bdmv", 10, 2048),
DirEntry {
name: "BACKUP".to_string(),
is_dir: true,
meta_lba: 2,
size: 0,
entries: vec![file_entry("index.bdmv", 20, 2048)],
},
],
}],
},
volume_id: String::new(),
partition_start: PART,
meta: MetaMap::contiguous(PART),
metadata_sectors: 4,
};
struct Reads(MapReader, Vec<u32>);
impl SectorSource for Reads {
fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], r: bool) -> Result<usize> {
self.1.push(lba);
self.0.read_sectors(lba, n, buf, r)
}
}
let mut reader = Reads(MapReader::new(), Vec::new());
reader.0.put(PART + 10, build_efe(2048, &[(0, 2048, 500)]));
reader.0.put(PART + 20, build_efe(2048, &[(0, 2048, 600)]));
let seen = std::cell::RefCell::new(Vec::new());
let ranges = fs
.metadata_sector_ranges_for(&mut reader, &|p| {
seen.borrow_mut().push(p.to_string());
!p.contains("/BACKUP/")
})
.expect("the plan is built");
assert_eq!(
*seen.borrow(),
["/BDMV/index.bdmv", "/BDMV/BACKUP/index.bdmv"]
);
assert_eq!(ranges, vec![(0, 1004), (1010, 1), (1500, 1)]);
assert!(
!reader.1.contains(&(PART + 20)),
"the refused file's File Entry was read: {:?}",
reader.1
);
}
#[test]
fn prefetch_huge_count_is_capped() {
// A disc-controlled sector count far exceeding the 8192-sector cap must
+2 -8
View File
@@ -28,15 +28,9 @@ pub fn raw_whole_disc_reader<S: SectorSource>(reader: S) -> WholeDiscReader<S> {
)
}
// Every AACS content file's extents, one entry per file (contiguous extents joined):
// Every AACS content file's extents in `fs`, one entry per file (contiguous extents joined):
// `/BDMV/STREAM` (m2ts before SSIF, which re-lists them) or else HD DVD `/HVDVD_TS/*.EVO`.
// An unreadable UDF or unmappable file fails loud: it would otherwise ship as ciphertext.
pub(crate) fn content_files(reader: &mut dyn SectorSource) -> Result<Vec<Vec<(u32, u32)>>> {
let fs = crate::udf::read_filesystem(reader)?;
content_files_in(&fs, reader)
}
// `content_files` over an already-read filesystem.
// An unmappable file fails loud: it would otherwise ship as ciphertext.
pub(crate) fn content_files_in(
fs: &crate::udf::UdfFs,
reader: &mut dyn SectorSource,
@@ -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