v0.7.1: SectorReader trait, IsoStream, StdioStream, resolve_encryption

- SectorReader trait decouples disc scanning from SCSI
- Disc::scan_image() for ISO and any sector source
- resolve_encryption() handles AACS 1.0/2.0/none in one path
- IsoStream: full UDF/MPLS/CLPI/labels pipeline from ISO files
- StdioStream: stdin/stdout pipe
- Strict scheme:// URL format with validation
- Labels module refactored to SectorReader
- 7 stream types total
This commit is contained in:
MattJackson
2026-04-11 15:49:29 +00:00
parent 168005ef34
commit dc4ebd7d9b
14 changed files with 346 additions and 264 deletions
+97 -59
View File
@@ -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<crate::error::Error>,
}
/// 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<Self> {
// 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> {
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<HandshakeResult>,
opts: &ScanOptions,
) -> Result<Self> {
// 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<AacsState> {
/// 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<HandshakeResult> {
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<AacsState> {
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<String> {
fn read_meta_title(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Option<String> {
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("<di:name>") {
let s = start + "<di:name>".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 {
+7
View File
@@ -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<usize> {
self.read_disc(lba, count, buf)
}
}
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
for i in 0..16 {
+4 -4
View File
@@ -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<Vec<StreamLabel>> {
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
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<Vec<StreamLabel>
// Stream number mapping from playbackconfig.xml
let mut stream_map: HashMap<String, u16> = 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);
}
+8 -8
View File
@@ -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<Vec<StreamLabel>> {
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
// 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<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
// ── language_streams.txt parser ────────────────────────────────────────────
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
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<Vec
// ── menu_base.prop parser ──────────────────────────────────────────────────
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
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
+9 -9
View File
@@ -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<Vec<StreamLabel>>`
//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 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<Vec<StreamLabel>>;
type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option<Vec<StreamLabel>>;
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<StreamLabel> {
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
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<String> {
}
/// Read a file from any BDMV/JAR subdirectory by filename.
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
pub(crate) fn read_jar_file(reader: &mut dyn SectorReader, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
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())
}
+3 -3
View File
@@ -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<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "playlists.xml")?;
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
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"
+3 -3
View File
@@ -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<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
let strings = extract_strings(&data);
let mut labels = Vec::new();
+2
View File
@@ -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,
+124 -141
View File
@@ -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<Self> {
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<usize> {
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<Disc>,
reader: Option<IsoSectorReader>,
writer: Option<io::BufWriter<File>>,
/// 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<usize>) -> io::Result<Self> {
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<usize>, opts: &ScanOptions) -> io::Result<Self> {
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<Self> {
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<usize>) -> 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<Vec<u8>> {
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<Vec<(DiscTitle, Vec<(u64, u64)>)>> {
// 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<u64> = 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<bool> {
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<usize> {
Err(io::Error::new(io::ErrorKind::Unsupported,
"iso:// is read-only"))
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
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(()) }
}
+7 -3
View File
@@ -141,7 +141,11 @@ pub fn open_input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn IOStream
}
"iso" => {
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<Box<dyn IOStream>>
"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)))
+14
View File
@@ -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<usize>;
}
+33 -33
View File
@@ -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<u32> {
pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result<u32> {
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<Vec<u8>> {
pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<u8>> {
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<Vec<(u32, u32)>> {
pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> {
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<Vec<(u32, u32)>> {
fn read_icb_extents(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
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<Vec<(u32, u32)>> {
pub fn file_extents(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<(u32, u32)>> {
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<UdfFs> {
pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
// 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<UdfFs> {
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<UdfFs> {
// 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<UdfFs> {
// 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<UdfFs> {
// 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<UdfFs> {
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<UdfFs> {
/// 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<DirEntry> {
// 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<u64> {
fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> {
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(())
}