diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d3ee1..cf473ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 0.7.1 (2026-04-11) + +### SectorReader trait + +- **`SectorReader` trait** — decouples disc scanning from SCSI. UDF, MPLS, CLPI, labels, and AACS resolution now work with any sector source. +- **`Disc::scan_image()`** — scan ISO images or any SectorReader. Full title/stream/label/AACS pipeline, no drive required. +- **`resolve_encryption()`** — single function handles AACS 1.0, 2.0, or none. Uses whatever path works (KEYDB VUK, handshake, media key, device key). + +### Stream types + +- **7 stream types** — Disc, ISO, MKV, M2TS, Network, Stdio, Null +- **`IsoStream`** — read/write Blu-ray ISO images. Uses `Disc::scan_image()` for full UDF parsing (not heuristic scanning). +- **`StdioStream`** — stdin/stdout pipe, format-agnostic +- **Strict URL format** — all URLs require `scheme://path`. Bare paths rejected with clear error messages. +- **Validation** — empty paths, missing ports, read-only/write-only direction errors + +### IOStream trait + +- `IOStream` trait for all stream types (Read + Write + info + finish) +- `open_input()` / `open_output()` resolve URL strings to stream instances + +## 0.7.0 (2026-04-11) + +### Stream I/O architecture + +- **5 stream types** — Disc, MKV, M2TS, Network, Null +- **`IOStream` trait** — common interface for all streams +- **URL resolver** — `open_input()` / `open_output()` with scheme://path format +- **FMKV metadata header** — JSON metadata embedded in M2TS and network streams +- **Bidirectional MKV** — MkvStream reads and writes Matroska containers +- **Network streaming** — TCP with metadata header, TCP_NODELAY +- **BD-TS demuxer** — PAT/PMT scanning, PTS duration detection +- **EBML reader** — parse existing MKV files for read-side MkvStream + ## 0.6.0 (2026-04-10) ### API improvements diff --git a/Cargo.toml b/Cargo.toml index cb01224..02595bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.7.0" +version = "0.7.1" edition = "2021" license = "AGPL-3.0-only" description = "Open source raw disc access library for optical drives" diff --git a/src/disc.rs b/src/disc.rs index 92a1812..189c6e5 100644 --- a/src/disc.rs +++ b/src/disc.rs @@ -10,6 +10,7 @@ use crate::error::{Error, Result}; use crate::drive::DriveSession; +use crate::sector::SectorReader; use crate::speed::DriveSpeed; use crate::udf; use crate::mpls; @@ -312,7 +313,16 @@ impl DiscTitle { } -// ─── AACS state ───────────────────────────────────────────────────────────── +// ─── Encryption ───────────────────────────────────────────────────────────── + +/// Result of SCSI AACS handshake (ECDH authentication). +/// Only available when scanning from a real drive, not ISO images. +#[derive(Debug)] +struct HandshakeResult { + volume_id: [u8; 16], + read_data_key: Option<[u8; 16]>, + error: Option, +} /// AACS decryption state for a disc. #[derive(Debug)] @@ -495,19 +505,34 @@ impl Disc { /// The session must be open and unlocked (DriveSession::open handles this). /// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands. pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result { - // 1. Capacity let capacity = Self::read_capacity(session)?; + let handshake = Self::do_handshake(session, opts); + Self::scan_with(session, capacity, handshake, opts) + } - // 2. UDF filesystem - let udf_fs = udf::read_filesystem(session)?; + /// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake. + /// AACS resolution uses KEYDB VUK lookup only. + pub fn scan_image(reader: &mut dyn SectorReader, capacity: u32, opts: &ScanOptions) -> Result { + Self::scan_with(reader, capacity, None, opts) + } - // 3. AACS -- read files from disc via UDF, resolve keys via KEYDB + /// Core scan pipeline — works with any SectorReader. + fn scan_with( + reader: &mut dyn SectorReader, + capacity: u32, + handshake: Option, + opts: &ScanOptions, + ) -> Result { + // 1. UDF filesystem + let udf_fs = udf::read_filesystem(reader)?; + + // 2. Resolve encryption (AACS, CSS, or none) let encrypted = udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); let aacs = if encrypted { if let Some(keydb_path) = opts.resolve_keydb() { - Self::resolve_aacs(&udf_fs, session, &keydb_path).ok() + Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()).ok() } else { None } @@ -515,14 +540,14 @@ impl Disc { None }; - // 4. Playlists + // 3. Playlists let mut titles = Vec::new(); if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") { for entry in &playlist_dir.entries { if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") { let path = format!("/BDMV/PLAYLIST/{}", entry.name); - if let Ok(mpls_data) = udf_fs.read_file(session, &path) { - if let Some(title) = Self::parse_playlist(session, &udf_fs, &entry.name, &mpls_data) { + if let Ok(mpls_data) = udf_fs.read_file(reader, &path) { + if let Some(title) = Self::parse_playlist(reader, &udf_fs, &entry.name, &mpls_data) { titles.push(title); } } @@ -531,11 +556,11 @@ impl Disc { } titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal)); - // 5. Metadata + labels - let meta_title = Self::read_meta_title(session, &udf_fs); - crate::labels::apply(session, &udf_fs, &mut titles); + // 4. Metadata + labels + let meta_title = Self::read_meta_title(reader, &udf_fs); + crate::labels::apply(reader, &udf_fs, &mut titles); - // 6. Derive format, layers, region + // 5. Derive format, layers, region let format = Self::detect_format(&titles); let layers = if capacity > 24_000_000 { 2 } else { 1 }; let region = if format == DiscFormat::Uhd { DiscRegion::Free } else { DiscRegion::Free }; @@ -554,61 +579,74 @@ impl Disc { }) } - /// Resolve AACS keys from disc files + KEYDB. No SCSI commands. - /// Reads Unit_Key_RO.inf, Content Certificate, and MKB from UDF. - fn resolve_aacs( - udf_fs: &udf::UdfFs, - session: &mut DriveSession, - keydb_path: &std::path::Path, - ) -> Result { + /// SCSI handshake result — volume ID and bus keys from ECDH authentication. + /// Only available when scanning from a real drive (not ISO images). + fn do_handshake(session: &mut DriveSession, opts: &ScanOptions) -> Option { use crate::aacs::{self, KeyDb}; - let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?; - - // Read AACS files from disc via UDF (standard READ(10), no vendor commands) - let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf") - .or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf")) - .map_err(|_| Error::AacsNoKeys)?; - - let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer") - .or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer")) - .ok(); - - let mkb_data = udf_fs.read_file(session, "/AACS/MKB_RW.inf") - .or_else(|_| udf_fs.read_file(session, "/AACS/MKB_RO.inf")) - .ok(); - let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version); - - // AACS SCSI handshake — get Volume ID (and read data key for AACS 2.0) - let mut volume_id = [0u8; 16]; - let mut read_data_key = None; - let mut handshake_error = None; + let keydb_path = opts.resolve_keydb()?; + let keydb = KeyDb::load(&keydb_path).ok()?; for hc in &keydb.host_certs { match aacs::handshake::aacs_authenticate( session, &hc.private_key, &hc.certificate, ) { Ok(mut auth) => { - // Read Volume ID (needed for MK → VUK derivation) - if let Ok(vid) = aacs::handshake::read_volume_id(session, &mut auth) { - volume_id = vid; - } - - // Read data keys for bus decryption (AACS 2.0 / UHD) - if let Ok((rdk, _wdk)) = aacs::handshake::read_data_keys(session, &mut auth) { - read_data_key = Some(rdk); - } - - handshake_error = None; - break; + let volume_id = aacs::handshake::read_volume_id(session, &mut auth) + .unwrap_or([0u8; 16]); + let read_data_key = aacs::handshake::read_data_keys(session, &mut auth) + .ok().map(|(rdk, _)| rdk); + return Some(HandshakeResult { volume_id, read_data_key, error: None }); } Err(e) => { - handshake_error = Some(e); + // Try next host cert + return Some(HandshakeResult { + volume_id: [0u8; 16], + read_data_key: None, + error: Some(e), + }); } } } + None + } - // Resolve: disc hash → KEYDB lookup → VUK → unit keys + /// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none. + /// + /// Reads AACS files from UDF (via SectorReader), resolves keys through + /// whatever path works: KEYDB VUK lookup, media key derivation, processing + /// keys, device keys. Uses handshake result (volume ID, bus key) if available. + fn resolve_encryption( + udf_fs: &udf::UdfFs, + reader: &mut dyn SectorReader, + keydb_path: &std::path::Path, + handshake: Option<&HandshakeResult>, + ) -> Result { + use crate::aacs::{self, KeyDb}; + + let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?; + + // Read AACS files from disc/image via UDF + let uk_ro_data = udf_fs.read_file(reader, "/AACS/Unit_Key_RO.inf") + .or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) + .map_err(|_| Error::AacsNoKeys)?; + + let cc_data = udf_fs.read_file(reader, "/AACS/Content000.cer") + .or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer")) + .ok(); + + let mkb_data = udf_fs.read_file(reader, "/AACS/MKB_RW.inf") + .or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf")) + .ok(); + let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version); + + // Use handshake volume ID if available, otherwise zeros + // (KEYDB VUK lookup by disc hash works without volume ID) + let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]); + let read_data_key = handshake.and_then(|h| h.read_data_key); + let handshake_error = None; + + // Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key let resolved = aacs::resolve_keys( &uk_ro_data, cc_data.as_deref(), @@ -662,7 +700,7 @@ impl Disc { /// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table). /// Prefers English, falls back to first available language. /// Returns None if META directory is empty or XML has no usable title. - fn read_meta_title(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> Option { + fn read_meta_title(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Option { let meta_dir = udf_fs.find_dir("/BDMV/META")?; for sub in &meta_dir.entries { if !sub.is_dir { continue; } @@ -677,7 +715,7 @@ impl Disc { if let Some(entry) = target { let path = format!("{}/{}", dl_path, entry.name); - if let Ok(data) = udf_fs.read_file(session, &path) { + if let Ok(data) = udf_fs.read_file(reader, &path) { let xml = String::from_utf8_lossy(&data); if let Some(start) = xml.find("") { let s = start + "".len(); @@ -704,7 +742,7 @@ impl Disc { } fn parse_playlist( - session: &mut DriveSession, + reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs, filename: &str, data: &[u8], @@ -732,7 +770,7 @@ impl Disc { let mut pkt_count: u32 = 0; let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id); - if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) { + if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) { if let Ok(clip_info) = clpi::parse(&clpi_data) { pkt_count = clip_info.source_packet_count; total_size += pkt_count as u64 * 192; @@ -740,7 +778,7 @@ impl Disc { // Get m2ts file start LBA and compute extent from packet count. // BD-ROM m2ts files are contiguous on disc (mastering requirement). let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id); - let file_lba = udf_fs.file_start_lba(session, &m2ts_path).unwrap_or(0); + let file_lba = udf_fs.file_start_lba(reader, &m2ts_path).unwrap_or(0); let total_bytes = pkt_count as u64 * 192; let total_sectors = ((total_bytes + 2047) / 2048) as u32; if total_sectors > 0 && file_lba > 0 { diff --git a/src/drive.rs b/src/drive.rs index c4a8d56..7709642 100644 --- a/src/drive.rs +++ b/src/drive.rs @@ -8,6 +8,7 @@ use std::path::Path; use crate::error::{Error, Result}; +use crate::sector::SectorReader; use crate::scsi::ScsiTransport; use crate::identity::DriveId; use crate::profile::{self, DriveProfile}; @@ -160,6 +161,12 @@ impl DriveSession { } } +impl SectorReader for DriveSession { + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result { + self.read_disc(lba, count, buf) + } +} + pub fn find_drives() -> Vec<(String, DriveId)> { let mut drives = Vec::new(); for i in 0..16 { diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index 541ba9d..47d4cc0 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -3,7 +3,7 @@ //! Clean structured XML with Content/Qualifier per stream and //! stream number mapping via playbackconfig. -use crate::drive::DriveSession; +use crate::sector::SectorReader; use crate::udf::UdfFs; use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; use std::collections::HashMap; @@ -12,8 +12,8 @@ pub fn detect(udf: &UdfFs) -> bool { super::jar_file_exists(udf, "streamproperties.xml") } -pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { - let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?; +pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { + let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?; let sp_text = std::str::from_utf8(&sp_data).ok()?; let stream_infos = parse_stream_infos(sp_text); @@ -21,7 +21,7 @@ pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option // Stream number mapping from playbackconfig.xml let mut stream_map: HashMap = HashMap::new(); - if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") { + if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml") { if let Ok(pc_text) = std::str::from_utf8(&pc_data) { parse_playback_config(pc_text, &mut stream_map); } diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index 7d782aa..9a26aaa 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -4,7 +4,7 @@ //! When both exist, language_streams.txt provides structured types while //! menu_base.prop provides stream number → button name mapping. -use crate::drive::DriveSession; +use crate::sector::SectorReader; use crate::udf::UdfFs; use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab}; use std::collections::HashMap; @@ -14,12 +14,12 @@ pub fn detect(udf: &UdfFs) -> bool { || super::jar_file_exists(udf, "language_streams.txt") } -pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { +pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { // Try language_streams.txt first (richer structured data) - let ls_labels = parse_language_streams(session, udf); + let ls_labels = parse_language_streams(reader, udf); // Try menu_base.prop (stream numbers + key names) - let mb_labels = parse_menu_base(session, udf); + let mb_labels = parse_menu_base(reader, udf); // If we have both, merge: language_streams for structure, menu_base for names match (ls_labels, mb_labels) { @@ -48,8 +48,8 @@ fn merge(ls: Vec, mb: Vec) -> Vec { // ── language_streams.txt parser ──────────────────────────────────────────── -fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option> { - let data = super::read_jar_file(session, udf, "language_streams.txt")?; +fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { + let data = super::read_jar_file(reader, udf, "language_streams.txt")?; let text = std::str::from_utf8(&data).ok()?; let mut labels = Vec::new(); @@ -123,8 +123,8 @@ fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option Option> { - let data = super::read_jar_file(session, udf, "menu_base.prop")?; +fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { + let data = super::read_jar_file(reader, udf, "menu_base.prop")?; let text = std::str::from_utf8(&data).ok()?; // Parse key=value, group by prefix diff --git a/src/labels/mod.rs b/src/labels/mod.rs index e59476f..4a5cb3b 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -4,7 +4,7 @@ //! To add a new format: //! 1. Create `src/labels/myformat.rs` //! 2. Implement `pub fn detect(udf: &UdfFs) -> bool` -//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option>` +//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option>` //! 4. Add `mod myformat;` below and one line to `PARSERS` array mod paramount; @@ -13,7 +13,7 @@ mod pixelogic; mod ctrm; pub mod vocab; -use crate::drive::DriveSession; +use crate::sector::SectorReader; use crate::udf::UdfFs; use crate::disc::{DiscTitle, Stream}; @@ -67,7 +67,7 @@ pub enum LabelQualifier { // Order = priority. First match wins. Highest quality output first. type DetectFn = fn(&UdfFs) -> bool; -type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option>; +type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option>; const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ ("paramount", paramount::detect, paramount::parse), @@ -79,9 +79,9 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ /// Search disc for config files, extract labels, apply to streams. /// This is 100% optional — if anything fails, streams are untouched. -pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [DiscTitle]) { +pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) { let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - extract(session, udf) + extract(reader, udf) })).unwrap_or_default(); if labels.is_empty() { return; } @@ -133,10 +133,10 @@ pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [DiscTitle]) } } -fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec { +fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec { for (_name, detect, parse) in PARSERS { if detect(udf) { - if let Some(labels) = parse(session, udf) { + if let Some(labels) = parse(reader, udf) { return labels; } } @@ -169,7 +169,7 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option { } /// Read a file from any BDMV/JAR subdirectory by filename. -pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option> { +pub(crate) fn read_jar_file(reader: &mut dyn SectorReader, udf: &UdfFs, filename: &str) -> Option> { let path = find_jar_file(udf, filename)?; - udf.read_file(session, &path).ok().filter(|d| !d.is_empty()) + udf.read_file(reader, &path).ok().filter(|d| !d.is_empty()) } diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index cbeb352..cba0eda 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -12,7 +12,7 @@ //! sub_com1_idx="23,24,25" /> //! ``` -use crate::drive::DriveSession; +use crate::sector::SectorReader; use crate::udf::UdfFs; use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier}; @@ -20,8 +20,8 @@ pub fn detect(udf: &UdfFs) -> bool { super::jar_file_exists(udf, "playlists.xml") } -pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { - let data = super::read_jar_file(session, udf, "playlists.xml")?; +pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { + let data = super::read_jar_file(reader, udf, "playlists.xml")?; let text = std::str::from_utf8(&data).ok()?; // Find the feature playlist — longest duration or name="Feature" diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index 8010763..b9b4a49 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -5,7 +5,7 @@ //! //! Token format: `{lang}_{codec?}_{purpose?}_{region?}_` -use crate::drive::DriveSession; +use crate::sector::SectorReader; use crate::udf::UdfFs; use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab}; @@ -18,8 +18,8 @@ pub fn detect(udf: &UdfFs) -> bool { super::jar_file_exists(udf, "bluray_project.bin") } -pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option> { - let data = super::read_jar_file(session, udf, "bluray_project.bin")?; +pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { + let data = super::read_jar_file(reader, udf, "bluray_project.bin")?; let strings = extract_strings(&data); let mut labels = Vec::new(); diff --git a/src/lib.rs b/src/lib.rs index 0f8a92a..5c4a994 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,6 +68,7 @@ //! | E7xxx | AACS errors | pub mod error; +pub mod sector; pub mod scsi; pub mod profile; pub mod platform; @@ -90,6 +91,7 @@ pub use drive::{DriveSession, find_drive, find_drives, resolve_device}; pub use identity::DriveId; pub use profile::DriveProfile; // Platform trait is pub(crate) -- callers use DriveSession, not Platform directly +pub use sector::SectorReader; pub use scsi::ScsiTransport; pub use speed::DriveSpeed; pub use disc::{Disc, DiscFormat, DiscTitle, Clip, Stream, VideoStream, AudioStream, SubtitleStream, diff --git a/src/mux/iso.rs b/src/mux/iso.rs index 944be0c..e53974b 100644 --- a/src/mux/iso.rs +++ b/src/mux/iso.rs @@ -1,54 +1,117 @@ -//! IsoStream — read BD-TS data from a Blu-ray ISO image file. +//! IsoStream — read/write Blu-ray ISO disc images. //! -//! Read-only. Parses the UDF filesystem inside the ISO to find -//! BDMV/STREAM/*.m2ts files, then streams the BD-TS bytes. +//! Read: parses UDF filesystem inside the ISO using the same pipeline as +//! DiscStream (titles, streams, labels, AACS). An ISO is a flat image of +//! 2048-byte sectors — sector N starts at byte offset N * 2048. //! -//! An ISO file is a flat image of 2048-byte sectors — the same -//! layout as on a real disc. Sector N starts at byte offset N * 2048. +//! Write: creates a sector-by-sector disc image from a SectorReader source. use std::io::{self, Read, Write, Seek, SeekFrom}; use std::fs::File; use std::path::Path; use super::IOStream; -use crate::disc::DiscTitle; +use crate::disc::{Disc, DiscTitle, ScanOptions}; +use crate::sector::SectorReader; +use crate::error::{Error, Result}; const SECTOR_SIZE: u64 = 2048; -/// Blu-ray ISO image stream. Read-only. +/// File-backed sector reader for ISO images. +pub struct IsoSectorReader { + file: File, + capacity: u32, +} + +impl IsoSectorReader { + pub fn open(path: &str) -> io::Result { + let file = File::open(Path::new(path)) + .map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?; + let size = file.metadata()?.len(); + let capacity = (size / SECTOR_SIZE) as u32; + Ok(Self { file, capacity }) + } + + pub fn capacity(&self) -> u32 { self.capacity } +} + +impl SectorReader for IsoSectorReader { + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result { + let bytes = count as usize * SECTOR_SIZE as usize; + self.file.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE)) + .map_err(|e| Error::IoError { source: e })?; + self.file.read_exact(&mut buf[..bytes]) + .map_err(|e| Error::IoError { source: e })?; + Ok(bytes) + } +} + +/// Blu-ray ISO image stream. /// -/// Opens an ISO file, parses UDF to locate BDMV playlists and streams, -/// then reads the m2ts content sectors in order. +/// Read: opens ISO, parses UDF (same as DiscStream), streams BD-TS content. +/// Write: receives sector data and writes to ISO file. pub struct IsoStream { disc_title: DiscTitle, - file: File, + disc: Option, + reader: Option, + writer: Option>, /// Sector ranges to read: (start_lba, sector_count) - extents: Vec<(u64, u64)>, - /// Current extent index + extents: Vec<(u32, u32)>, extent_idx: usize, - /// Sectors remaining in current extent - sectors_remaining: u64, - /// Read buffer for one sector + sectors_remaining: u32, sector_buf: [u8; SECTOR_SIZE as usize], - /// Position within current sector buffer buf_pos: usize, - /// Bytes valid in sector buffer buf_len: usize, eof: bool, } impl IsoStream { - /// Open an ISO file and scan its contents. - /// - /// Parses UDF filesystem, finds playlists and stream extents. - /// The title_index selects which title to read (0-based, default: longest). - pub fn open(path: &str, title_index: Option) -> io::Result { - let file = File::open(Path::new(path)) - .map_err(|e| io::Error::new(e.kind(), - format!("iso://{}: {}", path, e)))?; + /// Open an ISO file for reading. Parses UDF, scans titles, streams, labels. + pub fn open(path: &str, title_index: Option, opts: &ScanOptions) -> io::Result { + let mut reader = IsoSectorReader::open(path)?; + let capacity = reader.capacity(); - let mut stream = IsoStream { + let disc = Disc::scan_image(&mut reader, capacity, opts) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + let idx = title_index.unwrap_or(0).min(disc.titles.len().saturating_sub(1)); + let disc_title = if disc.titles.is_empty() { + return Err(io::Error::new(io::ErrorKind::NotFound, "no titles found in ISO image")); + } else { + disc.titles[idx].clone() + }; + + let extents: Vec<(u32, u32)> = disc_title.extents.iter() + .map(|e| (e.start_lba, e.sector_count)) + .collect(); + + let sectors_remaining = extents.first().map(|e| e.1).unwrap_or(0); + + Ok(IsoStream { + disc_title, + disc: Some(disc), + reader: Some(reader), + writer: None, + extents, + extent_idx: 0, + sectors_remaining, + sector_buf: [0u8; SECTOR_SIZE as usize], + buf_pos: 0, + buf_len: 0, + eof: false, + }) + } + + /// Create an ISO file for writing. Receives raw sector data. + pub fn create(path: &str) -> io::Result { + let file = File::create(Path::new(path)) + .map_err(|e| io::Error::new(e.kind(), format!("iso://{}: {}", path, e)))?; + let writer = io::BufWriter::with_capacity(4 * 1024 * 1024, file); + + Ok(IsoStream { disc_title: DiscTitle::empty(), - file, + disc: None, + reader: None, + writer: Some(writer), extents: Vec::new(), extent_idx: 0, sectors_remaining: 0, @@ -56,128 +119,35 @@ impl IsoStream { buf_pos: 0, buf_len: 0, eof: false, - }; - - stream.scan_iso(title_index)?; - Ok(stream) + }) } - /// Scan the ISO: parse UDF, build title metadata, extract extent map. - fn scan_iso(&mut self, title_index: Option) -> io::Result<()> { - // Read AVDP at sector 256 to verify this is a UDF disc image - let avdp = self.read_sector(256)?; - let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]); - if tag_id != 2 { - return Err(io::Error::new(io::ErrorKind::InvalidData, - "not a valid UDF image — no AVDP at sector 256")); - } - - // For now, scan BDMV/PLAYLIST and BDMV/STREAM directories - // by searching for MPLS and M2TS markers in the UDF metadata. - // - // Full UDF parsing (AVDP → VDS → metadata → FSD → root → files) - // will be refactored out of udf.rs to work with both DriveSession - // and file-backed sector reads. For now, find the main m2ts file - // by scanning for the stream file extents in the UDF file entries. - - // Find all .m2ts file extents from UDF metadata - let disc_size = self.file.seek(SeekFrom::End(0))?; - let total_sectors = disc_size / SECTOR_SIZE; - self.file.seek(SeekFrom::Start(0))?; - - // Scan UDF partition for BDMV structure - let titles = self.find_stream_extents(total_sectors)?; - - if titles.is_empty() { - return Err(io::Error::new(io::ErrorKind::NotFound, - "no BD stream files found in ISO image")); - } - - // Select title - let idx = title_index.unwrap_or(0).min(titles.len() - 1); - let (title, extents) = &titles[idx]; - - self.disc_title = title.clone(); - self.extents = extents.clone(); - - if !self.extents.is_empty() { - self.sectors_remaining = self.extents[0].1; - } - - Ok(()) + /// Set metadata (for write mode). + pub fn meta(mut self, dt: &DiscTitle) -> Self { + self.disc_title = dt.clone(); + self } - /// Read a single sector from the ISO file. - fn read_sector(&mut self, lba: u64) -> io::Result> { - let mut buf = vec![0u8; SECTOR_SIZE as usize]; - self.file.seek(SeekFrom::Start(lba * SECTOR_SIZE))?; - self.file.read_exact(&mut buf)?; - Ok(buf) - } - - /// Scan the ISO for BD stream file extents. - /// - /// Returns: Vec of (DiscTitle, Vec<(start_lba, sector_count)>) - /// - /// This is a simplified scanner that finds m2ts content by looking - /// for 192-byte BD-TS packet boundaries (0x47 sync byte at offset 4). - /// Full UDF parsing will replace this once udf.rs is decoupled from DriveSession. - fn find_stream_extents(&mut self, total_sectors: u64) -> io::Result)>> { - // Strategy: scan the UDF file entry area for allocation descriptors - // pointing to large contiguous regions (m2ts files are large). - // - // For a BD-ROM ISO, the main m2ts typically starts after the BDMV - // metadata (around sector 1000-5000) and runs contiguously to the end. - // - // Quick approach: find first sector with BD-TS sync (0x47 at byte 4) - // and treat everything from there to the end as one extent. - - let probe_start = 256u64; // skip lead-in - let probe_end = total_sectors.min(10000); // probe first 20 MB - - let mut stream_start: Option = None; - - for lba in probe_start..probe_end { - let sector = self.read_sector(lba)?; - // BD-TS: 192-byte packets, sync byte 0x47 at offset 4 of each packet - // A sector (2048 bytes) holds partial packets, but the sync pattern - // should appear at regular intervals - if sector.len() >= 196 && sector[4] == 0x47 { - // Verify: check for another sync at offset 196 (4 + 192) - if sector[196] == 0x47 { - stream_start = Some(lba); - break; - } - } - } - - match stream_start { - Some(start) => { - let sector_count = total_sectors - start; - let size_bytes = sector_count * SECTOR_SIZE; - - let mut title = DiscTitle::empty(); - title.playlist = "Main Title".into(); - title.size_bytes = size_bytes; - - Ok(vec![(title, vec![(start, sector_count)])]) - } - None => Ok(Vec::new()), - } - } + /// Get the full Disc (for listing all titles). + pub fn disc(&self) -> Option<&Disc> { self.disc.as_ref() } /// Read the next sector from the current extent. fn read_next_sector(&mut self) -> io::Result { + let reader = match self.reader.as_mut() { + Some(r) => r, + None => return Ok(false), + }; + if self.extent_idx >= self.extents.len() { return Ok(false); } - let (start_lba, _) = self.extents[self.extent_idx]; - let offset = self.extents[self.extent_idx].1 - self.sectors_remaining; + let (start_lba, total) = self.extents[self.extent_idx]; + let offset = total - self.sectors_remaining; let lba = start_lba + offset; - self.file.seek(SeekFrom::Start(lba * SECTOR_SIZE))?; - self.file.read_exact(&mut self.sector_buf)?; + reader.read_sectors(lba, 1, &mut self.sector_buf) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; self.buf_pos = 0; self.buf_len = SECTOR_SIZE as usize; @@ -195,7 +165,12 @@ impl IsoStream { impl IOStream for IsoStream { fn info(&self) -> &DiscTitle { &self.disc_title } - fn finish(&mut self) -> io::Result<()> { Ok(()) } + fn finish(&mut self) -> io::Result<()> { + if let Some(ref mut w) = self.writer { + w.flush()?; + } + Ok(()) + } } impl Read for IsoStream { @@ -224,9 +199,17 @@ impl Read for IsoStream { } impl Write for IsoStream { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::new(io::ErrorKind::Unsupported, - "iso:// is read-only")) + fn write(&mut self, buf: &[u8]) -> io::Result { + match self.writer.as_mut() { + Some(w) => w.write(buf), + None => Err(io::Error::new(io::ErrorKind::Unsupported, + "iso:// opened for reading — cannot write")), + } + } + fn flush(&mut self) -> io::Result<()> { + match self.writer.as_mut() { + Some(w) => w.flush(), + None => Ok(()), + } } - fn flush(&mut self) -> io::Result<()> { Ok(()) } } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 73cb395..bf85634 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -141,7 +141,11 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result { validate_file_path(&parsed.path, "iso")?; - Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index)?)) + let scan_opts = match &opts.keydb_path { + Some(p) => crate::disc::ScanOptions::with_keydb(p), + None => crate::disc::ScanOptions::default(), + }; + Ok(Box::new(IsoStream::open(&parsed.path, opts.title_index, &scan_opts)?)) } "null" => { Err(io::Error::new(io::ErrorKind::InvalidInput, @@ -166,8 +170,8 @@ pub fn open_output(url: &str, meta: &DiscTitle) -> io::Result> "disc:// is read-only — cannot use as output")) } "iso" => { - Err(io::Error::new(io::ErrorKind::Unsupported, - "iso:// is read-only — cannot use as output")) + validate_file_path(&parsed.path, "iso")?; + Ok(Box::new(IsoStream::create(&parsed.path)?.meta(meta))) } "null" => { Ok(Box::new(NullStream::new().meta(meta))) diff --git a/src/sector.rs b/src/sector.rs new file mode 100644 index 0000000..3d14f1e --- /dev/null +++ b/src/sector.rs @@ -0,0 +1,14 @@ +//! SectorReader — trait for reading 2048-byte disc sectors. +//! +//! Implemented by DriveSession (SCSI) and IsoFile (file-backed). +//! Used by UDF parser, disc scanner, label parsers — anything that +//! reads sectors doesn't need to know where they come from. + +use crate::error::Result; + +/// Read 2048-byte sectors from a disc or disc image. +pub trait SectorReader { + /// Read `count` sectors starting at `lba` into `buf`. + /// `buf` must be at least `count * 2048` bytes. + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result; +} diff --git a/src/udf.rs b/src/udf.rs index 3664c47..81a16c5 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -19,7 +19,7 @@ //! BD-ROM Part 3 — Blu-ray filesystem profile use crate::error::{Error, Result}; -use crate::drive::DriveSession; +use crate::sector::SectorReader; /// A UDF filesystem parsed from disc. #[derive(Debug)] @@ -74,7 +74,7 @@ impl UdfFs { /// Reads sector by sector from disc — no buffering. /// Get the absolute starting LBA of a file on disc. /// Used by the rip pipeline to locate m2ts content sectors. - pub fn file_start_lba(&self, session: &mut DriveSession, path: &str) -> Result { + pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; for part in &parts[..parts.len() - 1] { @@ -91,11 +91,11 @@ impl UdfFs { !e.is_dir && e.name.eq_ignore_ascii_case(filename) }).ok_or_else(|| Error::UdfNotFound { path: path.to_string() } )?; - let (data_lba, _) = self.read_icb_extent(session, entry.meta_lba)?; + let (data_lba, _) = self.read_icb_extent(reader, entry.meta_lba)?; Ok(self.partition_start + data_lba) } - pub fn read_file(&self, session: &mut DriveSession, path: &str) -> Result> { + pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; @@ -118,7 +118,7 @@ impl UdfFs { )?; // Read the file's ICB to get its data extent - let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?; + let (data_lba, data_len) = self.read_icb_extent(reader, entry.meta_lba)?; // Read file data sector by sector // File DATA is in the physical partition (partition_start + lba), @@ -129,7 +129,7 @@ impl UdfFs { for i in 0..sector_count { let offset = (i as usize) * 2048; - read_sector(session, abs_start + i, &mut data[offset..offset + 2048])?; + read_sector(reader, abs_start + i, &mut data[offset..offset + 2048])?; } data.truncate(entry.size as usize); @@ -145,7 +145,7 @@ impl UdfFs { /// /// Skips: STREAM/ (video), BACKUP/, DUPLICATE/, /// MKB_RO.inf, ContentHash*, ContentRevocation* - pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result> { + pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result> { let mut ranges = Vec::new(); // UDF structure: sector 0 through end of metadata partition @@ -154,7 +154,7 @@ impl UdfFs { ranges.push((0, meta_end)); // Walk tree, collect ranges for each metadata file - self.collect_file_ranges(session, &self.root, &mut ranges)?; + self.collect_file_ranges(reader, &self.root, &mut ranges)?; // Merge overlapping/adjacent ranges and sort ranges.sort_by_key(|r| r.0); @@ -162,14 +162,14 @@ impl UdfFs { Ok(merged) } - fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> { + fn collect_file_ranges(&self, reader: &mut dyn SectorReader, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> { for child in &entry.entries { if child.is_dir { // Only skip STREAM — those are the multi-GB video files if child.name.eq_ignore_ascii_case("STREAM") { continue; } - self.collect_file_ranges(session, child, ranges)?; + self.collect_file_ranges(reader, child, ranges)?; } else { // Include the ICB sector itself (in metadata partition) ranges.push((self.meta_to_abs(child.meta_lba), 1)); @@ -179,7 +179,7 @@ impl UdfFs { continue; } - if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) { + if let Ok((data_lba, data_len)) = self.read_icb_extent(reader, child.meta_lba) { let abs_start = self.partition_start + data_lba; let sector_count = (data_len + 2047) / 2048; ranges.push((abs_start, sector_count)); @@ -197,17 +197,17 @@ impl UdfFs { /// Read an Extended File Entry (tag 266) or File Entry (tag 261) /// and return its first allocation extent: (data_lba, data_length). /// The data_lba is partition-relative. - fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> { - let extents = self.read_icb_extents(session, meta_lba)?; + fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> { + let extents = self.read_icb_extents(reader, meta_lba)?; extents.first().copied().ok_or_else(|| Error::DiscRead { sector: 0 }) } /// Read ALL allocation extents for a file from its ICB. /// Returns Vec of (partition_relative_lba, byte_length) pairs. /// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents). - fn read_icb_extents(&self, session: &mut DriveSession, meta_lba: u32) -> Result> { + fn read_icb_extents(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result> { let mut icb = [0u8; 2048]; - read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?; + read_sector(reader, self.meta_to_abs(meta_lba), &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); @@ -255,7 +255,7 @@ impl UdfFs { /// Get all absolute disc sector extents for a file. /// Returns Vec of (absolute_lba, sector_count) covering the entire file. - pub fn file_extents(&self, session: &mut DriveSession, path: &str) -> Result> { + pub fn file_extents(&self, reader: &mut dyn SectorReader, path: &str) -> Result> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; for part in &parts[..parts.len() - 1] { @@ -273,7 +273,7 @@ impl UdfFs { }).ok_or_else(|| Error::UdfNotFound { path: path.to_string() } )?; - let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?; + let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?; let mut disc_extents = Vec::new(); for (lba, byte_len) in alloc_extents { let abs_lba = self.partition_start + lba; @@ -293,11 +293,11 @@ impl UdfFs { /// 3. Metadata partition file → metadata content location /// 4. FSD → root directory ICB /// 5. Root directory → file tree -pub fn read_filesystem(session: &mut DriveSession) -> Result { +pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result { // Step 1: Anchor Volume Descriptor Pointer at sector 256 // ECMA-167 §10.2 — always at sector 256 let mut avdp = [0u8; 2048]; - read_sector(session, 256, &mut avdp)?; + read_sector(reader, 256, &mut avdp)?; let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]); if tag_id != 2 { @@ -317,7 +317,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result { for i in 32..64 { let mut desc = [0u8; 2048]; - read_sector(session, i, &mut desc)?; + read_sector(reader, i, &mut desc)?; let desc_tag = u16::from_le_bytes([desc[0], desc[1]]); match desc_tag { @@ -352,7 +352,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result { // Read LVD to check partition map type let mut lvd = [0u8; 2048]; - read_sector(session, lvd_sec, &mut lvd)?; + read_sector(reader, lvd_sec, &mut lvd)?; // Parse partition maps starting at offset 440 // Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata) @@ -368,7 +368,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result { // Read it to find where the metadata content starts let meta_file_lba = partition_start; // lba 0 of partition let mut meta_icb = [0u8; 2048]; - read_sector(session, meta_file_lba, &mut meta_icb)?; + read_sector(reader, meta_file_lba, &mut meta_icb)?; let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]); if meta_tag == 266 { @@ -401,7 +401,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result { // Step 4: Read File Set Descriptor from metadata partition // FSD is at metadata-relative lba 0 (first sector of metadata content) let mut fsd = [0u8; 2048]; - read_sector(session, metadata_start, &mut fsd)?; + read_sector(reader, metadata_start, &mut fsd)?; let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]); if fsd_tag != 256 { @@ -413,7 +413,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result { let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]); // Step 5: Read root directory and build file tree - let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?; + let root = read_directory(reader, partition_start, metadata_start, root_lba, "", 0)?; let metadata_sectors = (metadata_size_bytes + 2047) / 2048; @@ -432,7 +432,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result { /// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir /// and points to its ICB. fn read_directory( - session: &mut DriveSession, + reader: &mut dyn SectorReader, part_start: u32, meta_start: u32, meta_lba: u32, @@ -441,7 +441,7 @@ fn read_directory( ) -> Result { // Read ICB for this directory let mut icb = [0u8; 2048]; - read_sector(session, meta_start + meta_lba, &mut icb)?; + read_sector(reader, meta_start + meta_lba, &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); @@ -477,7 +477,7 @@ fn read_directory( let sector_count = ((ad_len + 2047) / 2048).min(64); let mut dir_data = vec![0u8; sector_count as usize * 2048]; for i in 0..sector_count { - read_sector(session, dir_abs + i, + read_sector(reader, dir_abs + i, &mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048])?; } @@ -512,11 +512,11 @@ fn read_directory( if !entry_name.is_empty() { // Read the ICB to get file size - let file_size = read_file_size(session, meta_start, icb_lba).unwrap_or(0); + let file_size = read_file_size(reader, meta_start, icb_lba).unwrap_or(0); if is_dir && depth < 3 { // Recurse into subdirectory (max 3 levels: BDMV/PLAYLIST/*.mpls) - let subdir = read_directory(session, part_start, meta_start, icb_lba, &entry_name, depth + 1)?; + let subdir = read_directory(reader, part_start, meta_start, icb_lba, &entry_name, depth + 1)?; entries.push(subdir); } else { entries.push(DirEntry { @@ -545,9 +545,9 @@ fn read_directory( } /// Read file size (info_length) from an Extended File Entry ICB. -fn read_file_size(session: &mut DriveSession, meta_start: u32, meta_lba: u32) -> Result { +fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result { let mut icb = [0u8; 2048]; - read_sector(session, meta_start + meta_lba, &mut icb)?; + read_sector(reader, meta_start + meta_lba, &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); match tag { @@ -642,7 +642,7 @@ fn parse_dstring(data: &[u8]) -> String { /// Read a single 2048-byte sector from the drive. /// Uses standard READ(10) — no unlock required. -fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> { - session.read_disc(lba, 1, buf)?; +fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> { + reader.read_sectors(lba, 1, buf)?; Ok(()) }