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:
+1
-1
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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(§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");
|
||||
}
|
||||
Reference in New Issue
Block a user