From 6f53767e8bfa3807147e0267e360e2ca846e226d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:48:50 -0700 Subject: [PATCH] Add scan_iso entry point for file-backed ISO scans Introduce libfreemkv::scan_iso(path, opts) -> (Disc, Box), the file-backed counterpart to DiscSession::scan. It is the single place that opens a FileSectorSource, reads its capacity, and runs Disc::scan_image, returning the scanned Disc plus a reusable reader over the same image so consumers stop hand-rolling that triple. Add an integration test that materialises a minimal synthetic UDF image to a real file, asserts scan_iso matches the manual open+scan_image composition, and confirms the returned reader is still usable (capacity + sector read). Also covers open-failure and scan-failure error propagation. --- src/lib.rs | 2 +- src/session.rs | 27 ++++++- tests/scan_iso.rs | 187 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/scan_iso.rs diff --git a/src/lib.rs b/src/lib.rs index 532f84d..4dd17a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -146,7 +146,7 @@ pub use drive::{Drive, DriveStatus, find_drive}; // stop hand-rolling `open → wait_ready → init → probe_disc → identify → scan`. // Owns the `Drive` by value; forwards consumer-built key material into // `ScanOptions` (the library derives no certs — see `KeySpec`). -pub use session::{DeviceTarget, DiscSession, KeySpec}; +pub use session::{DeviceTarget, DiscSession, KeySpec, scan_iso}; // ─── Errors ───────────────────────────────────────────────────────────────── // diff --git a/src/session.rs b/src/session.rs index 93d5325..aa7195b 100644 --- a/src/session.rs +++ b/src/session.rs @@ -17,8 +17,8 @@ use crate::disc::{Disc, DiscId, DriveCredentials, ScanOptions}; use crate::drive::{Drive, find_drive}; use crate::error::{Error, Result}; use crate::keysource::KeySource; -use crate::sector::SectorSource; -use std::path::PathBuf; +use crate::sector::{FileSectorSource, SectorSource}; +use std::path::{Path, PathBuf}; /// Which optical device a [`DiscSession`] should open. pub enum DeviceTarget { @@ -190,6 +190,29 @@ impl DiscSession { } } +/// Scan an ISO image's structure from a file path, returning the scanned +/// [`Disc`] together with a reusable [`SectorSource`] over the same file. +/// +/// This is the file-backed counterpart to [`DiscSession::scan`]: it is the one +/// place that opens a [`FileSectorSource`], reads its capacity, and runs +/// [`Disc::scan_image`], so consumers (CLI, autorip) stop hand-rolling that +/// triple and stop constructing the low-level reader themselves. No SCSI, no +/// handshake, no key resolution — AACS resolution during the scan uses only +/// whatever `opts` already carries (mirroring how `Disc::scan_image` forwards +/// `ScanOptions`). +/// +/// The returned reader is a fresh handle positioned at the start of the image; +/// callers that need to sample ciphertext (key resolution) or feed a mux can +/// reuse it directly rather than re-opening the file. `Disc::scan_image` reads +/// only through the same reader, and all reads are LBA-addressed, so the +/// handle is fully reusable afterward. +pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box)> { + let mut reader = FileSectorSource::open(path)?; + let capacity = reader.capacity_sectors(); + let disc = Disc::scan_image(&mut reader, capacity, &opts)?; + Ok((disc, Box::new(reader))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/scan_iso.rs b/tests/scan_iso.rs new file mode 100644 index 0000000..c4a8e98 --- /dev/null +++ b/tests/scan_iso.rs @@ -0,0 +1,187 @@ +//! Tests for the `libfreemkv::scan_iso` entry point — the file-backed scan seam +//! that replaced consumers hand-rolling `FileSectorSource::open` + +//! `capacity_sectors` + `Disc::scan_image`. +//! +//! Uses a minimal synthetic UDF image (the same byte-level fixture the +//! `disc_tests.rs` `scan_image` tests build, but materialised to a real file on +//! disk so the file-backed `FileSectorSource` path is exercised end to end). + +use libfreemkv::{Disc, ScanOptions, SectorSource}; +use std::collections::BTreeMap; +use std::io::Write; + +const SECTOR_SIZE: usize = 2048; + +// ── Minimal UDF sector builders (mirrors disc_tests.rs) ───────────────────── + +fn make_avdp_sector(vds_lba: u32) -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&2u16.to_le_bytes()); + s[16..20].copy_from_slice(&vds_lba.to_le_bytes()); + s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes()); + s +} + +fn make_pvd_sector(volume_id: &str) -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&1u16.to_le_bytes()); + if !volume_id.is_empty() { + let id_bytes = volume_id.as_bytes(); + s[24] = 8; + let copy_len = id_bytes.len().min(30); + s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]); + s[55] = (1 + copy_len) as u8; + } + s +} + +fn make_partition_desc(partition_start: u32) -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&5u16.to_le_bytes()); + s[188..192].copy_from_slice(&partition_start.to_le_bytes()); + s +} + +fn make_lvd_sector_simple() -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&6u16.to_le_bytes()); + s[268..272].copy_from_slice(&1u32.to_le_bytes()); + s +} + +fn make_terminator() -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&8u16.to_le_bytes()); + s +} + +fn make_fsd_sector(root_meta_lba: u32) -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&256u16.to_le_bytes()); + s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); + s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes()); + s +} + +fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&266u16.to_le_bytes()); + s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes()); + s[208..212].copy_from_slice(&0u32.to_le_bytes()); + s[212..216].copy_from_slice(&8u32.to_le_bytes()); + s[216..220].copy_from_slice(&data_len.to_le_bytes()); + s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes()); + s +} + +fn make_parent_fid() -> Vec { + let fid_len = (38 + 3) & !3; + let mut fid = vec![0u8; fid_len]; + fid[0..2].copy_from_slice(&257u16.to_le_bytes()); + fid[18] = 0x08; + fid[19] = 0; + fid +} + +/// Build the minimal UDF image as an LBA→sector map (empty root directory). +fn minimal_udf_sectors() -> BTreeMap> { + let partition_start: u32 = 512; + let mut sectors: BTreeMap> = BTreeMap::new(); + sectors.insert(256, make_avdp_sector(32)); + sectors.insert(32, make_pvd_sector("TEST_DISC")); + sectors.insert(33, make_partition_desc(partition_start)); + sectors.insert(34, make_lvd_sector_simple()); + sectors.insert(35, make_terminator()); + sectors.insert(partition_start, make_fsd_sector(1)); + + let parent_fid = make_parent_fid(); + let dir_data_len = parent_fid.len() as u32; + sectors.insert(partition_start + 1, make_dir_icb(2, dir_data_len)); + let mut sector = vec![0u8; SECTOR_SIZE]; + sector[..parent_fid.len()].copy_from_slice(&parent_fid); + sectors.insert(partition_start + 2, sector); + sectors +} + +/// Materialise an LBA→sector map to a real ISO file (zero-filled gaps) and +/// return its path (kept alive by the returned tempfile handle). +fn write_iso(sectors: &BTreeMap>) -> tempfile::NamedTempFile { + let max_lba = *sectors.keys().max().unwrap(); + let mut image = vec![0u8; (max_lba as usize + 1) * SECTOR_SIZE]; + for (&lba, data) in sectors { + let off = lba as usize * SECTOR_SIZE; + image[off..off + SECTOR_SIZE].copy_from_slice(data); + } + let mut tmp = tempfile::Builder::new() + .suffix(".iso") + .tempfile() + .expect("tempfile create"); + tmp.write_all(&image).expect("write iso"); + tmp.flush().expect("flush iso"); + tmp +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[test] +fn scan_iso_matches_manual_scan_image_path() { + let sectors = minimal_udf_sectors(); + let expected_capacity = *sectors.keys().max().unwrap() + 1; + let tmp = write_iso(§ors); + + // The new entry point. + let (disc, mut reader) = + libfreemkv::scan_iso(tmp.path(), ScanOptions::default()).expect("scan_iso succeeds"); + + // Parity with the old hand-rolled triple: open a fresh reader and run the + // exact composition scan_iso encapsulates. The resulting Disc must match. + let mut manual_reader = libfreemkv::FileSectorSource::open(tmp.path()).expect("manual open"); + let manual_capacity = manual_reader.capacity_sectors(); + let manual = Disc::scan_image(&mut manual_reader, manual_capacity, &ScanOptions::default()) + .expect("manual scan_image succeeds"); + + assert_eq!(disc.capacity_sectors, manual.capacity_sectors, "capacity"); + assert_eq!(disc.capacity_sectors, expected_capacity, "capacity value"); + assert_eq!(disc.titles.len(), manual.titles.len(), "title count"); + assert_eq!(disc.encrypted, manual.encrypted, "encrypted flag"); + assert_eq!(disc.format, manual.format, "disc format"); + assert!(!disc.encrypted, "minimal UDF (no /AACS) is not encrypted"); + + // The returned reader is usable: correct capacity and a real read of sector + // 256 (the AVDP) returns the bytes we wrote — proves it is not consumed / + // exhausted by the scan. + assert_eq!( + reader.capacity_sectors(), + expected_capacity, + "reader capacity" + ); + let mut buf = vec![0u8; SECTOR_SIZE]; + let n = reader + .read_sectors(256, 1, &mut buf, false) + .expect("read AVDP sector"); + assert_eq!(n, SECTOR_SIZE); + assert_eq!(&buf[..], §ors[&256][..], "AVDP sector bytes round-trip"); +} + +#[test] +fn scan_iso_propagates_open_error() { + // A path that does not exist must surface an Err (not a panic) — kills a + // mutant that ignores the open failure. + let missing = std::path::Path::new("/nonexistent/does-not-exist.iso"); + let result = libfreemkv::scan_iso(missing, ScanOptions::default()); + assert!(result.is_err(), "missing file must error"); +} + +#[test] +fn scan_iso_propagates_scan_error() { + // A readable file with no valid UDF (no AVDP at sector 256) must surface the + // scan failure — kills a mutant that swallows the scan_image error. + let mut tmp = tempfile::Builder::new() + .suffix(".iso") + .tempfile() + .expect("tempfile create"); + tmp.write_all(&vec![0u8; 8 * SECTOR_SIZE]).expect("write"); + tmp.flush().expect("flush"); + let result = libfreemkv::scan_iso(tmp.path(), ScanOptions::default()); + assert!(result.is_err(), "non-UDF image must error"); +}