sector: generic recovery seam; FMTS forensic segments as decrypt loss

Replace the AACS-specific inline key-fetch in the decrypt decorator with
a scheme-neutral recovery seam: the input stream (L3) installs a Recover
closure (none / AACS key-fetch) and the decorator (L2) runs it at the
single decrypt-miss point. FMTS (AACS 2.1) forensic-segment units that no
key opens are just undecryptable units, concealed and counted as ordinary
decrypt loss with no FMTS-specific branch ("a loss is a loss"), so the
separate bytes_undecryptable bucket collapses into one loss count.

- sector/recovery.rs: the seam (MissOutcome, none/key_fetch factories),
  naming no encryption scheme in its type.
- FMTS: segment routing primitives + BYPASS_FMTS_KEY, and an upfront
  ensure_forensic_segments_decryptable gate (Error::FmtsKeyMissing) in
  the mux input path, parallel to the unit-key gate.
- CSS descramble/rekey moves from decrypt_sectors into
  css::descramble_region: CSS self-recovers from the data itself, so it
  stays OFF the seam (which is only for external inputs).
- disc/mod.rs also: main-title selection aligned to largest physical
  size; is_regular read from the open file handle, not metadata(path),
  fixing a swallowed sync_all on a fresh-rip ISO. decrypt_threads()
  resolved once via OnceLock off the per-buffer hot path.
This commit is contained in:
Matthew Jackson
2026-07-08 14:44:03 -07:00
parent 45c12fc5ce
commit 67aba17173
12 changed files with 742 additions and 251 deletions
+46 -12
View File
@@ -2041,10 +2041,10 @@ impl Disc {
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
/// composites. The capacity check is a hard "physically
/// possible data on this disc" gate.
/// 2. Among real titles, fewer clips first. A 1-clip playlist is
/// the canonical main feature; multi-clip playlists are either
/// chapter-stitched (small count) or virtual composites
/// (large count). Fewer wins.
/// 2. Among real titles, LARGEST physical size first — the main
/// feature is the biggest real title on the disc. (This replaced
/// the old clip-count ordering, which mis-ranked chapter-per-clip
/// discs like Fast & Furious.)
/// 3. Tiebreak on longer duration first.
///
/// **Effect on non-branching discs:** unchanged — the main movie
@@ -2616,6 +2616,28 @@ impl Disc {
self.ensure_decryptable_keys(raw, keys)
}
/// Upfront FMTS (AACS 2.1) key gate, parallel to
/// [`ensure_title_decryptable`](Self::ensure_title_decryptable). A 2.1 disc
/// carries forensic variant segments that need segment (variant) keys the
/// unit-key path cannot provide. When
/// [`BYPASS_FMTS_KEY`](crate::aacs::segment::BYPASS_FMTS_KEY) is `false`,
/// their absence is a hard upfront failure ([`Error::FmtsKeyMissing`]) — the
/// same policy as a missing unit key, so a forensic-holed rip is refused, not
/// produced. When `true` (the default today) the segments are skipped as
/// expected loss and this passes. `raw` mode and non-FMTS discs always pass.
pub fn ensure_forensic_segments_decryptable(&self, raw: bool) -> Result<()> {
if raw || crate::aacs::segment::BYPASS_FMTS_KEY {
return Ok(());
}
// A 2.1 (FMTS) disc carries forensic variant segments with no segment-key
// source (none exists yet), so its variant segments cannot be opened.
// Refuse upfront rather than emit a forensic-holed rip.
if self.format == DiscFormat::Fmts {
return Err(Error::FmtsKeyMissing);
}
Ok(())
}
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
@@ -3231,25 +3253,37 @@ impl Disc {
// ISO file: if resuming and mapfile has Finished ranges, open existing;
// otherwise create fresh and pre-size to total_bytes (sparse holes for
// non-tried regions).
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
let file = if resume
//
// `is_regular` MUST be read from the OPEN file handle, not from
// `metadata(path)` — on a fresh rip the path does not exist yet, so a
// pre-create `metadata(path)` always fails (is_regular=false), which both
// skips the pre-size AND makes `SweepSink::close` swallow a real
// `sync_all()` failure on the just-written ISO as if it were /dev/null.
let (file, is_regular) = if resume
&& std::fs::metadata(path)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
std::fs::OpenOptions::new()
let f = std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| Error::IoError { source: e })?
.map_err(|e| Error::IoError { source: e })?;
let reg = f
.metadata()
.map(|m| m.file_type().is_file())
.unwrap_or(false);
(f, reg)
} else {
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
if is_regular {
let reg = f
.metadata()
.map(|m| m.file_type().is_file())
.unwrap_or(false);
if reg {
f.set_len(total_bytes)
.map_err(|e| Error::IoError { source: e })?;
}
f
(f, reg)
};
// Wrap the raw `File` in our bounded-cache `WritebackFile`