Add scan_iso entry point for file-backed ISO scans

Introduce libfreemkv::scan_iso(path, opts) -> (Disc, Box<dyn SectorSource>),
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.
This commit is contained in:
Matthew Jackson
2026-07-23 23:48:50 -07:00
parent 7ed798e386
commit 6f53767e8b
3 changed files with 213 additions and 3 deletions
+1 -1
View File
@@ -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 ─────────────────────────────────────────────────────────────────
//
+25 -2
View File
@@ -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<dyn SectorSource>)> {
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::*;
+187
View File
@@ -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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u32, Vec<u8>> {
let partition_start: u32 = 512;
let mut sectors: BTreeMap<u32, Vec<u8>> = 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<u32, Vec<u8>>) -> 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(&sectors);
// 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[..], &sectors[&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");
}