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:
@@ -0,0 +1,681 @@
|
||||
//! Tests for the synthetic-image source.
|
||||
//!
|
||||
//! Round-tripping through `udf::read_filesystem` is a REGRESSION NET, not an
|
||||
//! oracle: it proves `parse(write(x)) == x`, which any assumption shared by
|
||||
//! writer and parser (tag checksum convention, AD stride, descriptor
|
||||
//! placement) is invisible to. The external check — writing the image to a
|
||||
//! file and asking the operating system to mount it — is the part that can
|
||||
//! fail independently, and it is `write_and_mount_externally` below (ignored
|
||||
//! by default: it shells out and needs a mountable host).
|
||||
|
||||
use super::*;
|
||||
use crate::udf;
|
||||
use std::io::Write;
|
||||
|
||||
/// A scratch directory that removes itself.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Self {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"freemkv-dirimage-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
fn file(&self, rel: &str, bytes: &[u8]) {
|
||||
let p = self.0.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
let mut f = std::fs::File::create(&p).unwrap();
|
||||
f.write_all(bytes).unwrap();
|
||||
}
|
||||
fn dir(&self, rel: &str) {
|
||||
std::fs::create_dir_all(self.0.join(rel)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic filler so a mis-offset read is visible as wrong CONTENT, not
|
||||
/// just a wrong length.
|
||||
fn pattern(seed: u8, len: usize) -> Vec<u8> {
|
||||
(0..len)
|
||||
.map(|i| (i as u32).wrapping_mul(31).wrapping_add(seed as u32) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A minimal but structurally real Blu-ray folder.
|
||||
fn bdmv_scratch() -> (Scratch, Vec<u8>, Vec<u8>) {
|
||||
let s = Scratch::new("bdmv");
|
||||
let index = pattern(1, 100);
|
||||
let clip = pattern(7, 5000);
|
||||
s.file("BDMV/index.bdmv", &index);
|
||||
s.file("BDMV/PLAYLIST/00000.mpls", &pattern(3, 300));
|
||||
s.file("BDMV/CLIPINF/00000.clpi", &pattern(5, 700));
|
||||
s.file("BDMV/STREAM/00000.m2ts", &clip);
|
||||
(s, index, clip)
|
||||
}
|
||||
|
||||
// ── The de-risking spike ────────────────────────────────────────────────────
|
||||
|
||||
/// THE load-bearing assertion of the whole design: metadata synthesized here
|
||||
/// must be parseable by the PRODUCTION `read_filesystem`, unmodified. If this
|
||||
/// fails, nothing above the sector layer can consume a `dir://` source and the
|
||||
/// approach is wrong.
|
||||
#[test]
|
||||
fn production_parser_reads_the_synthesized_tree() {
|
||||
let (s, index, clip) = bdmv_scratch();
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs =
|
||||
udf::read_filesystem(&mut img).expect("read_filesystem must mount the synthetic image");
|
||||
|
||||
assert!(fs.find_dir("/BDMV").is_some(), "BDMV must be a directory");
|
||||
assert!(fs.find_dir("/BDMV/PLAYLIST").is_some());
|
||||
assert!(fs.find_dir("/BDMV/CLIPINF").is_some());
|
||||
assert!(fs.find_dir("/BDMV/STREAM").is_some());
|
||||
|
||||
let stream = fs.find_dir("/BDMV/STREAM").unwrap();
|
||||
let names: Vec<&str> = stream.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["00000.m2ts"]);
|
||||
assert_eq!(stream.entries[0].size, clip.len() as u64);
|
||||
|
||||
// Bytes, not just shape: a wrong AD offset or a wrong partition base would
|
||||
// still produce a plausible tree.
|
||||
assert_eq!(
|
||||
fs.read_file(&mut img, "/BDMV/index.bdmv").unwrap(),
|
||||
index,
|
||||
"read_file must return the host file's bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
fs.read_file(&mut img, "/BDMV/STREAM/00000.m2ts").unwrap(),
|
||||
clip
|
||||
);
|
||||
}
|
||||
|
||||
/// `file_extents` is what the rip pipeline actually reads a title through, so
|
||||
/// the extents must be absolute, in range, and cover the file exactly.
|
||||
#[test]
|
||||
fn file_extents_are_absolute_and_readable() {
|
||||
let (s, _, clip) = bdmv_scratch();
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs = udf::read_filesystem(&mut img).unwrap();
|
||||
|
||||
let exts = fs
|
||||
.file_extents(&mut img, "/BDMV/STREAM/00000.m2ts")
|
||||
.unwrap();
|
||||
assert_eq!(exts.len(), 1);
|
||||
let (lba, sectors) = exts[0];
|
||||
assert!(lba > 0, "bluray.rs:137 drops any extent at LBA 0");
|
||||
assert_eq!(sectors as usize, clip.len().div_ceil(SECTOR));
|
||||
assert!(lba + sectors <= img.capacity_sectors());
|
||||
|
||||
// Read them the way the mux does and compare against the file.
|
||||
let mut buf = vec![0u8; sectors as usize * SECTOR];
|
||||
img.read_sectors(lba, sectors as u16, &mut buf, false)
|
||||
.unwrap();
|
||||
assert_eq!(&buf[..clip.len()], &clip[..]);
|
||||
assert!(
|
||||
buf[clip.len()..].iter().all(|&b| b == 0),
|
||||
"the tail sector must be zero-padded"
|
||||
);
|
||||
|
||||
// And `file_start_lba` — the term `ifo.rs` adds its IFO offsets to — must
|
||||
// agree with the first extent.
|
||||
assert_eq!(
|
||||
fs.file_start_lba(&mut img, "/BDMV/STREAM/00000.m2ts")
|
||||
.unwrap(),
|
||||
lba
|
||||
);
|
||||
}
|
||||
|
||||
/// A file past the 30-bit allocation-descriptor ceiling comes back as MULTIPLE
|
||||
/// extents whose lengths sum to the file size — the multi-extent case a real
|
||||
/// dual-layer disc produces, and the one the single-AD fixture in `udf.rs`
|
||||
/// never covered. Uses a sparse file so the test costs no real disk space.
|
||||
#[test]
|
||||
fn a_file_past_the_ad_ceiling_reads_back_as_multiple_extents() {
|
||||
let s = Scratch::new("big");
|
||||
s.file("BDMV/index.bdmv", &pattern(1, 16));
|
||||
s.dir("BDMV/STREAM");
|
||||
let big = s.path().join("BDMV/STREAM/00000.m2ts");
|
||||
let size = super::layout::MAX_AD_BYTES + 3 * SECTOR as u64;
|
||||
let f = std::fs::File::create(&big).unwrap();
|
||||
f.set_len(size).unwrap();
|
||||
drop(f);
|
||||
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs = udf::read_filesystem(&mut img).unwrap();
|
||||
let entry = fs
|
||||
.find_dir("/BDMV/STREAM")
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == "00000.m2ts")
|
||||
.unwrap();
|
||||
assert_eq!(entry.size, size, "declared size survives the split");
|
||||
|
||||
let exts = fs
|
||||
.file_extents(&mut img, "/BDMV/STREAM/00000.m2ts")
|
||||
.unwrap();
|
||||
assert_eq!(exts.len(), 2, "one AD cannot hold a 1 GiB+ file");
|
||||
assert_eq!(
|
||||
exts.iter().map(|(_, s)| *s as u64).sum::<u64>(),
|
||||
size.div_ceil(SECTOR as u64),
|
||||
"the extents must cover the whole file"
|
||||
);
|
||||
assert_eq!(
|
||||
exts[1].0,
|
||||
exts[0].0 + exts[0].1,
|
||||
"the second extent starts where the first ends"
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads outside any planned extent are zeros, not errors — a real image has
|
||||
/// unrecorded sectors too, and `read_filesystem` probes fixed LBAs (256, the
|
||||
/// VDS window) before it knows what is there.
|
||||
#[test]
|
||||
fn gaps_and_out_of_range_reads_are_zero_filled() {
|
||||
let (s, _, _) = bdmv_scratch();
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let cap = img.capacity_sectors();
|
||||
let mut buf = [0xAAu8; SECTOR * 2];
|
||||
let n = img.read_sectors(cap + 10, 2, &mut buf, false).unwrap();
|
||||
assert_eq!(n, SECTOR * 2);
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
/// Two runs over the same unchanged folder must produce the same image, byte
|
||||
/// for byte. Non-determinism here would make `dir:// -> iso://` output differ
|
||||
/// run to run for no reason, and would make every golden test flaky.
|
||||
#[test]
|
||||
fn the_same_folder_synthesizes_the_same_image() {
|
||||
let (s, _, _) = bdmv_scratch();
|
||||
let mut a = DirImage::open(s.path()).unwrap();
|
||||
let mut b = DirImage::open(s.path()).unwrap();
|
||||
assert_eq!(a.capacity_sectors(), b.capacity_sectors());
|
||||
let mut buf_a = vec![0u8; SECTOR * 64];
|
||||
let mut buf_b = vec![0u8; SECTOR * 64];
|
||||
for start in [0u32, 256, 320, 4096] {
|
||||
a.read_sectors(start, 64, &mut buf_a, false).unwrap();
|
||||
b.read_sectors(start, 64, &mut buf_b, false).unwrap();
|
||||
assert_eq!(buf_a, buf_b, "sectors from {start} differ between runs");
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory with enough children that its FID list spans several 2048-byte
|
||||
/// blocks, read back through the production parser.
|
||||
///
|
||||
/// This is the case the block-alignment question turns on: FIDs are packed
|
||||
/// contiguously and a descriptor may straddle a block boundary, because
|
||||
/// `read_directory` (`udf.rs:1312`) walks the directory extent as one flat byte
|
||||
/// run and BREAKS at the first non-257 tag. Padding each block would truncate
|
||||
/// the directory at the first pad. 200 entries also exceeds the 16-handle LRU
|
||||
/// several times over, so it exercises handle eviction on the read path.
|
||||
#[test]
|
||||
fn a_directory_spanning_many_blocks_reads_back_whole() {
|
||||
const N: usize = 200;
|
||||
let s = Scratch::new("wide");
|
||||
s.file("BDMV/index.bdmv", &pattern(1, 16));
|
||||
for i in 0..N {
|
||||
s.file(
|
||||
&format!("BDMV/STREAM/{i:05}.m2ts"),
|
||||
&pattern(i as u8, 1000 + i),
|
||||
);
|
||||
}
|
||||
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs = udf::read_filesystem(&mut img).unwrap();
|
||||
let stream = fs.find_dir("/BDMV/STREAM").unwrap();
|
||||
assert_eq!(
|
||||
stream.entries.len(),
|
||||
N,
|
||||
"every FID must survive the block boundaries"
|
||||
);
|
||||
assert!(
|
||||
stream.size > SECTOR as u64,
|
||||
"the fixture must actually span blocks, or it proves nothing"
|
||||
);
|
||||
|
||||
// Read every file back, in an order that thrashes the handle cache.
|
||||
for i in (0..N).rev() {
|
||||
let path = format!("/BDMV/STREAM/{i:05}.m2ts");
|
||||
assert_eq!(
|
||||
fs.read_file(&mut img, &path).unwrap(),
|
||||
pattern(i as u8, 1000 + i),
|
||||
"{path} came back wrong"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rejection gates ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A 3D folder must be REFUSED, not planned. `bluray.rs:127` sets `is_3d` the
|
||||
/// moment an `.ssif` resolves, and this planner has no extent aliasing, so a
|
||||
/// planned 3D image would rip the wrong bytes and report success.
|
||||
#[test]
|
||||
fn a_3d_folder_is_rejected_rather_than_mis_planned() {
|
||||
let s = Scratch::new("ssif");
|
||||
s.file("BDMV/index.bdmv", &pattern(1, 16));
|
||||
s.file("BDMV/STREAM/00000.m2ts", &pattern(2, 4096));
|
||||
s.file("BDMV/STREAM/SSIF/00000.ssif", &pattern(3, 8192));
|
||||
let err = DirImage::open(s.path()).unwrap_err();
|
||||
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_SSIF_UNSUPPORTED);
|
||||
}
|
||||
|
||||
/// A folder with no disc structure at all is not an image.
|
||||
#[test]
|
||||
fn a_folder_with_no_disc_structure_is_rejected() {
|
||||
let s = Scratch::new("empty");
|
||||
s.file("readme.txt", b"not a disc");
|
||||
let err = DirImage::open(s.path()).unwrap_err();
|
||||
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_UNSUPPORTED_TREE);
|
||||
}
|
||||
|
||||
/// A file that shrinks between planning and reading is an ERROR. Zero-filling
|
||||
/// the difference would produce a truncated rip that exits 0.
|
||||
#[test]
|
||||
fn a_file_that_shrinks_after_planning_fails_the_read() {
|
||||
let (s, _, clip) = bdmv_scratch();
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs = udf::read_filesystem(&mut img).unwrap();
|
||||
let (lba, sectors) = fs
|
||||
.file_extents(&mut img, "/BDMV/STREAM/00000.m2ts")
|
||||
.unwrap()[0];
|
||||
assert!(clip.len() > SECTOR);
|
||||
|
||||
// Truncate the backing file behind the image's back.
|
||||
let f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(s.path().join("BDMV/STREAM/00000.m2ts"))
|
||||
.unwrap();
|
||||
f.set_len(16).unwrap();
|
||||
drop(f);
|
||||
|
||||
let mut buf = vec![0u8; sectors as usize * SECTOR];
|
||||
let err = img
|
||||
.read_sectors(lba, sectors as u16, &mut buf, false)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED);
|
||||
}
|
||||
|
||||
// ── DVD placement ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a `VTS_01_0.IFO` body whose VOB pointers are the given sector
|
||||
/// offsets. Only the fields the planner and `ifo.rs` read are filled.
|
||||
fn vts_ifo(len: usize, vtsm_vobs: u32, vtstt_vobs: u32) -> Vec<u8> {
|
||||
let mut v = vec![0u8; len.max(0xC8)];
|
||||
v[0..12].copy_from_slice(b"DVDVIDEO-VTS");
|
||||
v[0xC0..0xC4].copy_from_slice(&vtsm_vobs.to_be_bytes());
|
||||
v[0xC4..0xC8].copy_from_slice(&vtstt_vobs.to_be_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
/// THE DVD invariant. `ifo.rs:554-556` computes
|
||||
/// `vob_start_sector = file_start_lba(VTS_01_0.IFO) + vtstt_vobs`, and the
|
||||
/// title extents are built on top of that, so the planner must place
|
||||
/// `VTS_01_1.VOB` at exactly that sector. Anything else rips the wrong bytes
|
||||
/// with no error anywhere.
|
||||
#[test]
|
||||
fn vtstt_vobs_lands_on_the_first_sector_of_the_title_vob() {
|
||||
let s = Scratch::new("dvd");
|
||||
// IFO is 2 sectors; menu VOB 3 sectors; so the natural, gap-free layout
|
||||
// puts the title VOB 5 sectors past the IFO. Declare exactly that.
|
||||
let ifo_sectors = 2u32;
|
||||
let menu_sectors = 3u32;
|
||||
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
|
||||
s.file(
|
||||
"VIDEO_TS/VTS_01_0.IFO",
|
||||
&vts_ifo(
|
||||
ifo_sectors as usize * SECTOR,
|
||||
ifo_sectors,
|
||||
ifo_sectors + menu_sectors,
|
||||
),
|
||||
);
|
||||
s.file(
|
||||
"VIDEO_TS/VTS_01_0.VOB",
|
||||
&pattern(9, menu_sectors as usize * SECTOR),
|
||||
);
|
||||
let title = pattern(11, 4 * SECTOR);
|
||||
s.file("VIDEO_TS/VTS_01_1.VOB", &title);
|
||||
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs = udf::read_filesystem(&mut img).unwrap();
|
||||
|
||||
let ifo_lba = fs
|
||||
.file_start_lba(&mut img, "/VIDEO_TS/VTS_01_0.IFO")
|
||||
.unwrap();
|
||||
let vob_lba = fs
|
||||
.file_start_lba(&mut img, "/VIDEO_TS/VTS_01_1.VOB")
|
||||
.unwrap();
|
||||
let menu_lba = fs
|
||||
.file_start_lba(&mut img, "/VIDEO_TS/VTS_01_0.VOB")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ifo_lba + ifo_sectors + menu_sectors,
|
||||
vob_lba,
|
||||
"vtstt_vobs must resolve to VTS_01_1.VOB's first sector"
|
||||
);
|
||||
assert_eq!(
|
||||
ifo_lba + ifo_sectors,
|
||||
menu_lba,
|
||||
"vtsm_vobs must resolve to VTS_01_0.VOB's first sector"
|
||||
);
|
||||
|
||||
// And the bytes at that sector really are the title VOB's.
|
||||
let mut buf = vec![0u8; SECTOR];
|
||||
img.read_sectors(vob_lba, 1, &mut buf, false).unwrap();
|
||||
assert_eq!(buf, title[..SECTOR]);
|
||||
}
|
||||
|
||||
/// Continuation VOBs are one logical stream split at the 1 GB file limit, and
|
||||
/// cell sector addresses run continuously across the split, so they must be
|
||||
/// back-to-back with ZERO gap.
|
||||
#[test]
|
||||
fn continuation_vobs_are_contiguous() {
|
||||
let s = Scratch::new("dvdmulti");
|
||||
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
|
||||
s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1));
|
||||
s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, 3 * SECTOR));
|
||||
s.file("VIDEO_TS/VTS_01_2.VOB", &pattern(2, 2 * SECTOR));
|
||||
s.file("VIDEO_TS/VTS_01_3.VOB", &pattern(3, SECTOR));
|
||||
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs = udf::read_filesystem(&mut img).unwrap();
|
||||
let at = |img: &mut DirImage, n: u32| {
|
||||
fs.file_start_lba(img, &format!("/VIDEO_TS/VTS_01_{n}.VOB"))
|
||||
.unwrap()
|
||||
};
|
||||
let (a, b, c) = (at(&mut img, 1), at(&mut img, 2), at(&mut img, 3));
|
||||
assert_eq!(b, a + 3, "VTS_01_2.VOB immediately follows _1");
|
||||
assert_eq!(c, b + 2, "VTS_01_3.VOB immediately follows _2");
|
||||
}
|
||||
|
||||
/// The negative case: an offset that cannot be satisfied must be a typed
|
||||
/// error naming the file, NOT a silent misplacement. Here `vtstt_vobs` is 1,
|
||||
/// which would put the title VOB inside the 2-sector IFO.
|
||||
#[test]
|
||||
fn an_unsatisfiable_vob_offset_errors_instead_of_misplacing() {
|
||||
let s = Scratch::new("dvdbad");
|
||||
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
|
||||
s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(2 * SECTOR, 0, 1));
|
||||
s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR));
|
||||
|
||||
let err = DirImage::open(s.path()).unwrap_err();
|
||||
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_PLACEMENT);
|
||||
assert!(
|
||||
err.to_string().contains("VTS_01_1.VOB"),
|
||||
"the error must name the file it could not place, got {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A gap-inducing offset (bigger than the natural packing) is legal: the
|
||||
/// planner leaves a hole rather than failing. Real discs pad between title
|
||||
/// sets.
|
||||
#[test]
|
||||
fn an_oversized_vob_offset_leaves_a_gap_rather_than_failing() {
|
||||
let s = Scratch::new("dvdgap");
|
||||
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
|
||||
s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 64));
|
||||
s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR));
|
||||
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let fs = udf::read_filesystem(&mut img).unwrap();
|
||||
let ifo = fs
|
||||
.file_start_lba(&mut img, "/VIDEO_TS/VTS_01_0.IFO")
|
||||
.unwrap();
|
||||
let vob = fs
|
||||
.file_start_lba(&mut img, "/VIDEO_TS/VTS_01_1.VOB")
|
||||
.unwrap();
|
||||
assert_eq!(vob, ifo + 64);
|
||||
// The hole reads as zeros.
|
||||
let mut buf = vec![0u8; SECTOR];
|
||||
img.read_sectors(ifo + 10, 1, &mut buf, false).unwrap();
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
/// End to end through the real scanner: a DVD folder must enumerate titles the
|
||||
/// same way an ISO of the same disc would, which is the whole point of
|
||||
/// synthesizing a real filesystem rather than faking a tree.
|
||||
#[test]
|
||||
fn scan_image_enumerates_a_bdmv_folder() {
|
||||
let (s, _, _) = bdmv_scratch();
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let cap = img.capacity_sectors();
|
||||
// The playlist here is filler, so no titles are expected — what is being
|
||||
// asserted is that the scan reaches the BD enumerator at all (it would
|
||||
// return `UdfNotFilesystem` if the synthesized volume did not parse) and
|
||||
// reports the structure it found.
|
||||
let disc = crate::disc::Disc::scan_image(&mut img, cap, &crate::disc::ScanOptions::default())
|
||||
.expect("scan_image must accept a synthesized BDMV image");
|
||||
assert!(!disc.encrypted, "a decrypted folder has no AACS directory");
|
||||
assert_eq!(disc.content_format, crate::disc::ContentFormat::BdTs);
|
||||
assert_eq!(disc.capacity_sectors, cap);
|
||||
}
|
||||
|
||||
// ── Folder-level encryption verdict (`session::scan_dir`) ───────────────────
|
||||
|
||||
/// A one-PlayItem MPLS long enough that `parse_playlist` keeps it (it drops
|
||||
/// anything under 30 s as a menu stub).
|
||||
fn one_item_mpls(clip_id: &[u8; 5]) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(b"MPLS0200");
|
||||
buf.extend_from_slice(&40u32.to_be_bytes()); // playlist_start
|
||||
buf.extend_from_slice(&[0u8; 28]); // mark_start placeholder + pad to 40
|
||||
|
||||
let pl = buf.len();
|
||||
buf.extend_from_slice(&[0u8; 4]); // length placeholder
|
||||
buf.extend_from_slice(&[0u8; 2]); // reserved
|
||||
buf.extend_from_slice(&1u16.to_be_bytes()); // num_play_items
|
||||
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
|
||||
|
||||
let mut item = Vec::new();
|
||||
item.extend_from_slice(clip_id);
|
||||
item.extend_from_slice(b"M2TS");
|
||||
item.push(0); // connection condition
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
item.extend_from_slice(&0u32.to_be_bytes()); // in_time
|
||||
item.extend_from_slice(&(45_000u32 * 120).to_be_bytes()); // out_time: 2 min
|
||||
item.extend_from_slice(&[0u8; 8]); // UO mask
|
||||
item.push(0);
|
||||
item.push(0);
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
// Empty STN table: length, reserved, eight zero counts, reserved.
|
||||
item.extend_from_slice(&16u16.to_be_bytes());
|
||||
item.extend_from_slice(&[0u8; 16]);
|
||||
buf.extend_from_slice(&(item.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(&item);
|
||||
|
||||
let pl_len = (buf.len() - pl - 4) as u32;
|
||||
buf[pl..pl + 4].copy_from_slice(&pl_len.to_be_bytes());
|
||||
|
||||
let mark_start = buf.len() as u32;
|
||||
buf[12..16].copy_from_slice(&mark_start.to_be_bytes());
|
||||
buf.extend_from_slice(&2u32.to_be_bytes());
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // no marks
|
||||
buf
|
||||
}
|
||||
|
||||
/// A CLPI with only the fields `clpi::parse` needs: magic, zeroed section
|
||||
/// starts, and the source packet count at 56.
|
||||
fn minimal_clpi(source_packets: u32) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 60];
|
||||
d[0..4].copy_from_slice(b"HDMV");
|
||||
d[4..8].copy_from_slice(b"0200");
|
||||
d[56..60].copy_from_slice(&source_packets.to_be_bytes());
|
||||
d
|
||||
}
|
||||
|
||||
/// A BD folder that really enumerates a title, so the AACS content probe has
|
||||
/// an extent to sample.
|
||||
///
|
||||
/// The `.m2ts` is built as real 192-byte BD source packets, because that is
|
||||
/// what the probe judges: byte 0 carries the AACS Copy Permission Indicator in
|
||||
/// its top two bits, and byte 4 is the MPEG-TS sync. `scrambled` sets the CPI
|
||||
/// and withholds the sync — "flagged and not structurally clean", which is
|
||||
/// exactly `aacs_unit_needs_decrypt`. An all-zero payload would prove nothing
|
||||
/// either way: `is_clean_ts` skips zero payloads as padding.
|
||||
fn playable_bdmv(tag: &str, scrambled: bool) -> Scratch {
|
||||
let s = Scratch::new(tag);
|
||||
let packets = 4096u32;
|
||||
let mut m2ts = vec![0x5Au8; packets as usize * 192];
|
||||
for p in m2ts.chunks_mut(192) {
|
||||
p[0] = if scrambled { 0xC0 } else { 0x00 };
|
||||
p[4] = if scrambled { 0xAB } else { 0x47 };
|
||||
}
|
||||
s.file("BDMV/index.bdmv", &pattern(1, 64));
|
||||
s.file("BDMV/PLAYLIST/00000.mpls", &one_item_mpls(b"00000"));
|
||||
s.file("BDMV/CLIPINF/00000.clpi", &minimal_clpi(packets));
|
||||
s.file("BDMV/STREAM/00000.m2ts", &m2ts);
|
||||
s
|
||||
}
|
||||
|
||||
/// The folder must enumerate a title through the real BD scanner — the whole
|
||||
/// reason for synthesizing a filesystem instead of faking a tree.
|
||||
#[test]
|
||||
fn a_bd_folder_enumerates_its_title_through_scan_dir() {
|
||||
let s = playable_bdmv("play", false);
|
||||
let (disc, _reader) =
|
||||
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
|
||||
assert_eq!(disc.titles.len(), 1, "the playlist must produce one title");
|
||||
assert!(!disc.titles[0].extents.is_empty(), "with real extents");
|
||||
assert!(!disc.encrypted);
|
||||
}
|
||||
|
||||
/// A folder that kept its `AACS/` directory but whose content is in the clear
|
||||
/// must be treated as DECRYPTED. Tree shape claims encryption
|
||||
/// (`disc/mod.rs:1992`); the content is the evidence that overrides it.
|
||||
#[test]
|
||||
fn an_aacs_directory_over_clear_content_is_treated_as_decrypted() {
|
||||
let s = playable_bdmv("aacsclear", false);
|
||||
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
|
||||
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
|
||||
|
||||
// Without the probe this is what the scan alone concludes.
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
let cap = img.capacity_sectors();
|
||||
let raw =
|
||||
crate::disc::Disc::scan_image(&mut img, cap, &crate::disc::ScanOptions::default()).unwrap();
|
||||
assert!(raw.encrypted, "tree shape alone says encrypted");
|
||||
|
||||
let (disc, _reader) =
|
||||
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
|
||||
assert!(
|
||||
!disc.encrypted,
|
||||
"sampled content units are clear, so the folder is decrypted"
|
||||
);
|
||||
assert!(disc.aacs_error.is_none(), "and no key is demanded");
|
||||
}
|
||||
|
||||
/// The other verdict: a folder whose content units really are flagged and
|
||||
/// scrambled is a raw encrypted copy, which `dir://` does not support. It must
|
||||
/// be a typed error, not a rip that emits garbage.
|
||||
#[test]
|
||||
fn an_aacs_folder_with_scrambled_content_is_rejected() {
|
||||
let s = playable_bdmv("aacsenc", true);
|
||||
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
|
||||
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
|
||||
let err = match crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()) {
|
||||
Ok(_) => panic!("a scrambled folder must not scan clean"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED);
|
||||
}
|
||||
|
||||
// ── External oracle ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Write a synthesized image to a real file and ask the OS to mount it.
|
||||
///
|
||||
/// This is the only check here that is not circular: `read_filesystem` shares
|
||||
/// every assumption with the encoder, an operating system's UDF driver shares
|
||||
/// none of them. Ignored by default because it shells out to `hdiutil` and
|
||||
/// needs a host that can attach an image; run with
|
||||
/// `cargo test -- --ignored write_and_mount_externally --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "external: shells out to hdiutil/mount"]
|
||||
fn write_and_mount_externally() {
|
||||
let s = Scratch::new("mount");
|
||||
s.file("BDMV/index.bdmv", &pattern(1, 100));
|
||||
s.file("BDMV/PLAYLIST/00000.mpls", &pattern(3, 300));
|
||||
s.file("BDMV/STREAM/00000.m2ts", &pattern(7, 5000));
|
||||
|
||||
let mut img = DirImage::open(s.path()).unwrap();
|
||||
// `.iso`, not `.udf`: hdiutil dispatches on the extension and answers
|
||||
// "image not recognized" for a raw sector image it has no handler for —
|
||||
// which is a statement about the filename, not about the volume.
|
||||
let out = s
|
||||
.path()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(format!("freemkv-dirimage-{}.iso", std::process::id()));
|
||||
let mut f = std::fs::File::create(&out).unwrap();
|
||||
let cap = img.capacity_sectors();
|
||||
let mut buf = vec![0u8; SECTOR * 64];
|
||||
let mut lba = 0u32;
|
||||
while lba < cap {
|
||||
let n = (cap - lba).min(64) as u16;
|
||||
img.read_sectors(lba, n, &mut buf, false).unwrap();
|
||||
f.write_all(&buf[..n as usize * SECTOR]).unwrap();
|
||||
lba += n as u32;
|
||||
}
|
||||
f.sync_all().unwrap();
|
||||
drop(f);
|
||||
|
||||
println!("image written to {}", out.display());
|
||||
let attach = std::process::Command::new("hdiutil")
|
||||
.args(["attach", "-nobrowse", "-readonly", "-noverify"])
|
||||
.arg(&out)
|
||||
.output()
|
||||
.expect("hdiutil must be runnable");
|
||||
println!(
|
||||
"hdiutil attach status={} stdout={} stderr={}",
|
||||
attach.status,
|
||||
String::from_utf8_lossy(&attach.stdout),
|
||||
String::from_utf8_lossy(&attach.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&attach.stdout).into_owned();
|
||||
let mount = stdout
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.split_whitespace()
|
||||
.last()
|
||||
.filter(|p| p.starts_with("/Volumes/"))
|
||||
})
|
||||
.map(PathBuf::from);
|
||||
// Content, not just "it mounted": the OS's own UDF driver must hand back
|
||||
// the same bytes the host file holds, which is the assertion that shares
|
||||
// nothing with this crate's parser.
|
||||
let same = mount
|
||||
.as_ref()
|
||||
.map(|m| std::fs::read(m.join("BDMV/STREAM/00000.m2ts")).ok() == Some(pattern(7, 5000)));
|
||||
if attach.status.success()
|
||||
&& let Some(dev) = stdout.split_whitespace().next()
|
||||
{
|
||||
let _ = std::process::Command::new("hdiutil")
|
||||
.args(["detach", dev])
|
||||
.output();
|
||||
}
|
||||
let _ = std::fs::remove_file(&out);
|
||||
assert!(
|
||||
attach.status.success(),
|
||||
"the OS refused to mount the synthesized image"
|
||||
);
|
||||
assert_eq!(
|
||||
same,
|
||||
Some(true),
|
||||
"the OS mounted the image but read back different bytes"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user