Read a disc folder as an input: dir:// becomes a source

Users keep discs as extracted folders — a DVD VIDEO_TS or a Blu-ray BDMV,
usually a backup that is already decrypted. dir:// could only ever be a
destination, so those folders could be produced and never read back.

Everything above the sector layer wants a UdfFs over a SectorSource, and
every UdfFs read re-reads the ICB off that source at call time, so a
folder has to present itself as sectors. It does: dirimage plans a block
layout over the real files, encodes a UDF 1.02 filesystem for the
metadata, and serves data straight from disk. read_filesystem then parses
it exactly as it parses a disc, so nothing above changes — and the
iso:// arm of input() is now shared rather than duplicated, so dir://
inherits its decrypt gates, title selection and stream pruning.

The encoder is validated by more than its own reader: macOS mounts the
synthesized image and the mounted files compare byte-identical to the
originals. A round-trip through our own parser could not have shown that
— the tag CRC seeds at zero, and a wrong seed would satisfy us and no
real driver.

DVD placement is not free packing: a VTS IFO records where its title
VOBS begins relative to itself, so the VOB has to land exactly there.
Unsatisfiable marks fail loudly rather than misplace the file. 3D folders
are refused for now: the scanner detects SSIF and the planner cannot
alias its extents yet, so accepting them would produce quiet nonsense.

Left for later: metadata capture, HD-DVD, FMTS, encrypted folders.
This commit is contained in:
Matthew Jackson
2026-08-05 15:24:18 -07:00
parent dc7c3a7db5
commit bd2ba08bb7
9 changed files with 2812 additions and 185 deletions
+95
View File
@@ -439,6 +439,101 @@ pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorS
Ok((disc, Box::new(reader)))
}
/// Sampled 6144-byte aligned units when deciding whether a folder that still
/// carries `AACS/` actually holds encrypted content. Enough to survive a clip
/// whose opening units happen to be unflagged (a clear leader), few enough to
/// stay a handful of reads.
const AACS_PROBE_UNITS: usize = 8;
/// [`Disc`] together with a [`SectorSource`] over a synthesized image of an
/// extracted disc FOLDER — the `dir://` counterpart to [`scan_iso`].
///
/// The extra step over `scan_iso` is the encryption verdict.
/// `Disc::scan_with` decides `encrypted` STRUCTURALLY, from the presence of an
/// `/AACS` or `/BDMV/AACS` directory (`disc/mod.rs:1992-1993`). For the common
/// case — a MakeMKV-style backup, which strips `AACS/` — that already gives the
/// right answer, and `DecryptKeys::None` is a pass-through. But a folder copied
/// verbatim from a decrypted disc keeps `AACS/`, and the tree shape then claims
/// encryption over content that is already in the clear: the rip would fail
/// asking for a key it does not need.
///
/// So for a folder, tree shape is not the evidence — CONTENT is. Several
/// aligned units at the largest title's start are sampled and judged by
/// `aacs_unit_needs_decrypt`, the same authority the mux read path uses:
///
/// * none need decryption → the folder is decrypted; `encrypted` is forced
/// false and the reason is logged.
/// * any unit does → the folder is a raw encrypted copy, which `dir://` does
/// not support; [`Error::DirImageEncrypted`].
///
/// This lives HERE and not in `Disc::scan_image`, which is shared with the ISO
/// and drive paths: an ISO that carries `AACS/` and clear content is a
/// different situation (it may be mid-decrypt, or `--raw` output), and the
/// verdict must not change underneath those callers.
pub fn scan_dir(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> {
let mut reader = crate::dirimage::DirImage::open(path)?;
let capacity = reader.capacity_sectors();
let mut disc = Disc::scan_image(&mut reader, capacity, &opts)?;
// `css.is_some()` is the DVD path, and that verdict came from actually
// cracking scrambled sectors — real evidence about content, not tree shape.
// Only the AACS-by-tree-shape verdict is re-judged here.
if disc.encrypted && disc.css.is_none() && disc.css_error.is_none() {
match probe_folder_encryption(&mut reader, &disc)? {
true => return Err(Error::DirImageEncrypted),
false => {
tracing::warn!(
target: "freemkv::scan",
phase = "scan_dir",
"folder carries an AACS directory but its sampled content units \
are already in the clear; treating it as decrypted"
);
disc.encrypted = false;
disc.aacs = None;
disc.aacs_error = None;
}
}
}
Ok((disc, Box::new(reader)))
}
/// `true` when any sampled content unit still needs decryption.
///
/// Anchored at the largest title's first extent, because AACS unit alignment
/// is measured from the clip FILE's start (`aacs::content::is_unit_aligned`),
/// not from an absolute `lba % 3` — sampling off a boundary would mis-judge a
/// perfectly clear unit.
fn probe_folder_encryption(reader: &mut dyn SectorSource, disc: &Disc) -> Result<bool> {
use crate::aacs::content::{aacs_unit_needs_decrypt, is_unit_aligned};
use crate::consts::SECTOR_BYTES;
const UNIT_SECTORS: u32 = 3;
let Some(extent) = disc
.titles
.iter()
.flat_map(|t| t.extents.iter())
.max_by_key(|e| e.sector_count)
else {
// No content to judge. A folder with an AACS directory and no titles
// has nothing to rip either way; leave the structural verdict alone.
return Ok(true);
};
let base = extent.start_lba;
let mut unit = vec![0u8; UNIT_SECTORS as usize * SECTOR_BYTES];
for i in 0..AACS_PROBE_UNITS as u32 {
let lba = base + i * UNIT_SECTORS;
if lba + UNIT_SECTORS > base + extent.sector_count {
break;
}
debug_assert!(is_unit_aligned(lba, base));
reader.read_sectors(lba, UNIT_SECTORS as u16, &mut unit, false)?;
if aacs_unit_needs_decrypt(&unit, disc.content_format) {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;