diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index 6641e95..d170181 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -203,7 +203,9 @@ fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint { (p - (&p1.x - &p2.x) % p) % p }; - let dx_inv = mod_inv(&dx, p).unwrap(); + // Safety: mod_inv only returns None if dx == 0 (points identical), + // which is prevented by the caller using ec_double for that case. + let dx_inv = mod_inv(&dx, p).expect("ec_add: dx has no inverse"); let lam = (&dy * &dx_inv) % p; // x3 = λ² - x1 - x2 mod p @@ -247,7 +249,9 @@ fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint { let numerator = (&three * &pt.x * &pt.x + a) % p; let denominator = (&two * &pt.y) % p; - let denom_inv = mod_inv(&denominator, p).unwrap(); + // Safety: mod_inv only returns None if 2*y == 0 (point at infinity), + // which shouldn't occur with valid curve points. + let denom_inv = mod_inv(&denominator, p).expect("ec_double: denominator has no inverse"); let lam = (&numerator * &denom_inv) % p; // x3 = λ² - 2x mod p @@ -715,7 +719,7 @@ pub fn aacs_authenticate( host_cert: &[u8], ) -> Result { if host_cert.len() < 92 { - return Err(Error::AacsError { detail: "host certificate too short".into() }); + return Err(Error::AacsCertShort); } // Step 1: Invalidate all AGIDs @@ -727,7 +731,7 @@ pub fn aacs_authenticate( // Step 2: Allocate AGID let cdb = cdb_report_key(0, 0x00, 8); let response = scsi_read(session, &cdb, 8) - .map_err(|e| Error::AacsError { detail: format!("failed to allocate AGID: {}", e) })?; + .map_err(|_| Error::AacsAgidAlloc)?; let agid = (response[7] >> 6) & 0x03; // Step 3: Generate host nonce and ephemeral key pair @@ -744,12 +748,12 @@ pub fn aacs_authenticate( let cdb = cdb_send_key(agid, 0x01, 116); scsi_write(session, &cdb, &send_buf) - .map_err(|_| Error::AacsError { detail: "drive rejected host certificate".into() })?; + .map_err(|_| Error::AacsCertRejected)?; // Step 5: Read drive certificate + nonce (REPORT KEY format 0x01) let cdb = cdb_report_key(agid, 0x01, 116); let response = scsi_read(session, &cdb, 116) - .map_err(|_| Error::AacsError { detail: "failed to read drive certificate".into() })?; + .map_err(|_| Error::AacsCertRead)?; let mut drive_nonce = [0u8; 20]; let mut drive_cert = [0u8; 92]; @@ -760,7 +764,7 @@ pub fn aacs_authenticate( if drive_cert[0] == 0x01 { // AACS 1.0 certificate if !verify_cert(&drive_cert) { - return Err(Error::AacsError { detail: "drive certificate verification failed".into() }); + return Err(Error::AacsCertVerify); } } else if drive_cert[0] == 0x11 { // AACS 2.0 certificate — verify with P-256 LA key @@ -771,7 +775,7 @@ pub fn aacs_authenticate( // Step 6: Read drive key point + signature (REPORT KEY format 0x02) let cdb = cdb_report_key(agid, 0x02, 84); let response = scsi_read(session, &cdb, 84) - .map_err(|_| Error::AacsError { detail: "failed to read drive key".into() })?; + .map_err(|_| Error::AacsKeyRead)?; let mut drive_key_point = [0u8; 40]; // x(20) + y(20) let mut drive_key_sig = [0u8; 40]; // r(20) + s(20) @@ -790,7 +794,7 @@ pub fn aacs_authenticate( sig_s.copy_from_slice(&drive_key_sig[20..40]); if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) { - return Err(Error::AacsError { detail: "drive key signature verification failed".into() }); + return Err(Error::AacsKeyVerify); } // Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point) @@ -811,7 +815,7 @@ pub fn aacs_authenticate( let cdb = cdb_send_key(agid, 0x02, 84); scsi_write(session, &cdb, &send_buf) - .map_err(|_| Error::AacsError { detail: "drive rejected host key".into() })?; + .map_err(|_| Error::AacsKeyRejected)?; // Step 9: Compute bus key via ECDH let mut dkp_x = [0u8; 20]; @@ -851,12 +855,8 @@ pub fn aacs2_authenticate( } // AACS 2.0 native P-256 handshake - let host_priv_v2 = host_priv_key_v2.ok_or_else(|| Error::AacsError { - detail: "AACS 2.0 host credentials required but not available".into(), - })?; - let host_cert_v2 = host_cert_v2.ok_or_else(|| Error::AacsError { - detail: "AACS 2.0 host certificate required but not available".into(), - })?; + let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsCertShort)?; + let host_cert_v2 = host_cert_v2.ok_or(Error::AacsCertShort)?; aacs2_authenticate_p256(session, host_priv_v2, host_cert_v2) } @@ -869,7 +869,7 @@ fn aacs2_authenticate_p256( host_cert: &[u8], ) -> Result { if host_cert.len() < 132 { - return Err(Error::AacsError { detail: "AACS 2.0 host cert too short".into() }); + return Err(Error::AacsCertShort); } // Step 1: Invalidate all AGIDs @@ -881,7 +881,7 @@ fn aacs2_authenticate_p256( // Step 2: Allocate AGID let cdb = cdb_report_key(0, 0x00, 8); let response = scsi_read(session, &cdb, 8) - .map_err(|_| Error::AacsError { detail: "AGID allocation failed".into() })?; + .map_err(|_| Error::AacsAgidAlloc)?; let agid = (response[7] >> 6) & 0x03; // Step 3: Generate host nonce + P-256 ephemeral key pair @@ -899,13 +899,13 @@ fn aacs2_authenticate_p256( let cdb = cdb_send_key(agid, 0x01, 156); scsi_write(session, &cdb, &send_buf) - .map_err(|_| Error::AacsError { detail: "drive rejected AACS 2.0 host cert".into() })?; + .map_err(|_| Error::AacsCertRejected)?; // Step 5: Read drive certificate + nonce // AACS 2.0 drive cert is also 132 bytes let cdb = cdb_report_key(agid, 0x01, 156); let response = scsi_read(session, &cdb, 156) - .map_err(|_| Error::AacsError { detail: "failed to read AACS 2.0 drive cert".into() })?; + .map_err(|_| Error::AacsCertRead)?; let mut drive_nonce = [0u8; 20]; drive_nonce.copy_from_slice(&response[4..24]); @@ -919,7 +919,7 @@ fn aacs2_authenticate_p256( // Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes) let cdb = cdb_report_key(agid, 0x02, 132); let response = scsi_read(session, &cdb, 132) - .map_err(|_| Error::AacsError { detail: "failed to read AACS 2.0 drive key".into() })?; + .map_err(|_| Error::AacsKeyRead)?; let drive_key_x = &response[4..36]; let drive_key_y = &response[36..68]; @@ -934,7 +934,7 @@ fn aacs2_authenticate_p256( verify_data.extend_from_slice(drive_key_y); if !ecdsa_verify_p256(&drive_pub_x, &drive_pub_y, drive_sig_r, drive_sig_s, &verify_data) { - return Err(Error::AacsError { detail: "AACS 2.0 drive key verification failed".into() }); + return Err(Error::AacsKeyVerify); } // Step 7: Sign host key point @@ -955,7 +955,7 @@ fn aacs2_authenticate_p256( let cdb = cdb_send_key(agid, 0x02, 132); scsi_write(session, &cdb, &send_buf) - .map_err(|_| Error::AacsError { detail: "drive rejected AACS 2.0 host key".into() })?; + .map_err(|_| Error::AacsKeyRejected)?; // Step 9: Compute bus key via P-256 ECDH let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y); @@ -978,7 +978,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result // REPORT DISC STRUCTURE format 0x80 let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36); let response = scsi_read(session, &cdb, 36) - .map_err(|_| Error::AacsError { detail: "failed to read Volume ID".into() })?; + .map_err(|_| Error::AacsVidRead)?; let mut vid = [0u8; 16]; let mut mac = [0u8; 16]; @@ -988,7 +988,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result // Verify MAC: AES-CMAC(VID, bus_key) should equal mac let calc_mac = aes_cmac_16(&vid, &auth.bus_key); if calc_mac != mac { - return Err(Error::AacsError { detail: "VID MAC verification failed".into() }); + return Err(Error::AacsVidMac); } auth.volume_id = Some(vid); @@ -1000,7 +1000,7 @@ pub fn read_data_keys(session: &mut DriveSession, auth: &mut AacsAuth) -> Result // REPORT DISC STRUCTURE format 0x84 let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36); let response = scsi_read(session, &cdb, 36) - .map_err(|_| Error::AacsError { detail: "failed to read data keys".into() })?; + .map_err(|_| Error::AacsDataKey)?; let mut enc_rdk = [0u8; 16]; let mut enc_wdk = [0u8; 16]; diff --git a/src/clpi.rs b/src/clpi.rs index 7a83140..8dac016 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -114,11 +114,11 @@ impl ClipInfo { /// Parse a CLPI file from raw bytes. pub fn parse(data: &[u8]) -> Result { if data.len() < 40 { - return Err(Error::DiscError { detail: "CLPI too short".into() }); + return Err(Error::ClpiParse); } if &data[0..4] != b"HDMV" { - return Err(Error::DiscError { detail: "not a CLPI file".into() }); + return Err(Error::ClpiParse); } let version = String::from_utf8_lossy(&data[4..8]).to_string(); diff --git a/src/disc.rs b/src/disc.rs index b825ae9..f9e68d6 100644 --- a/src/disc.rs +++ b/src/disc.rs @@ -1,4 +1,4 @@ -//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc. +//! Disc structure -- scan titles, streams, and sector ranges from a Blu-ray disc. //! //! This is the high-level API for disc content. The CLI calls this, //! never parses MPLS/CLPI/UDF directly. @@ -37,7 +37,7 @@ pub struct Disc { pub titles: Vec, /// Disc region pub region: DiscRegion, - /// AACS state — None if disc is unencrypted or keys unavailable + /// AACS state -- None if disc is unencrypted or keys unavailable pub aacs: Option<AacsState>, /// Whether this disc requires AACS decryption pub encrypted: bool, @@ -70,11 +70,11 @@ pub enum DiscRegion { /// Blu-ray region codes. #[derive(Debug, Clone, Copy, PartialEq)] pub enum BdRegion { - /// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia) + /// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia) A, - /// Region B/2 — Europe, Africa, Australia, Middle East + /// Region B/2 -- Europe, Africa, Australia, Middle East B, - /// Region C/3 — Central/South Asia, China, Russia + /// Region C/3 -- Central/South Asia, China, Russia C, } @@ -310,7 +310,7 @@ pub struct AacsState { pub bus_encryption: bool, /// MKB version from disc (e.g. 68, 77) pub mkb_version: Option<u32>, - /// Disc hash (SHA1 of Unit_Key_RO.inf) — hex string with 0x prefix + /// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix pub disc_hash: String, /// How keys were resolved pub key_source: KeySource, @@ -318,9 +318,9 @@ pub struct AacsState { pub vuk: [u8; 16], /// Decrypted unit keys (CPS unit number, key) pub unit_keys: Vec<(u32, [u8; 16])>, - /// Read data key for AACS 2.0 bus decryption — None for AACS 1.0 + /// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0 pub read_data_key: Option<[u8; 16]>, - /// Volume ID (16 bytes) — from SCSI handshake + /// Volume ID (16 bytes) -- from SCSI handshake pub volume_id: [u8; 16], } @@ -392,30 +392,84 @@ impl ScanOptions { } } +/// A disc with an active drive session -- the main API. +/// +/// Owns both the disc metadata and the drive connection. +/// Created by `Disc::open()`. Provides `rip()` to read title data. +pub struct OpenDisc { + pub disc: Disc, + pub session: DriveSession, +} + +impl OpenDisc { + /// Open a drive, wait for disc, initialize, probe, and scan. + /// This is the single entry point -- one call does everything. + /// + pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> { + use std::path::Path; + + let mut session = DriveSession::open(Path::new(device))?; + session.wait_ready()?; + + // Init (unlock + firmware) -- non-fatal if fails + let _ = session.init(); + let _ = session.probe_disc(); + + let opts = if let Some(kp) = keydb_path { + ScanOptions::with_keydb(kp) + } else { + ScanOptions::default() + }; + + let disc = Disc::scan(&mut session, &opts)?; + Ok(Self { disc, session }) + } + + /// Rip a title to any output stream. + /// + /// Reads sectors from disc, decrypts AACS, handles errors/retries, + /// and writes decrypted BD-TS bytes to the output. + /// Knows nothing about the output format -- just calls `write_all()`. + /// + pub fn rip(&mut self, title_idx: usize, mut output: impl std::io::Write) -> Result<()> { + let mut reader = self.disc.open_title(&mut self.session, title_idx)?; + + loop { + match reader.read_batch() { + Ok(Some(batch)) => { + output.write_all(batch).map_err(|_| Error::WriteError)?; + } + Ok(None) => break, + Err(_) => { + // ContentReader handles retries internally + } + } + } + + Ok(()) + } + + /// Total bytes for a title (for progress tracking). + pub fn title_size(&self, title_idx: usize) -> u64 { + self.disc.titles.get(title_idx) + .map(|t| t.size_bytes) + .unwrap_or(0) + } +} + impl Disc { /// Disc capacity in GB pub fn capacity_gb(&self) -> f64 { self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0) } - /// Scan a disc — parse filesystem, playlists, streams, and set up AACS decryption. + /// Scan a disc -- parse filesystem, playlists, streams, and set up AACS decryption. /// /// This is the main entry point. After scan(), the Disc is ready: /// - titles are populated with streams /// - AACS keys are derived (if KEYDB available) /// - content can be read and decrypted transparently /// - /// ```no_run - /// use libfreemkv::{DriveSession, Disc}; - /// use libfreemkv::disc::ScanOptions; - /// use std::path::Path; - /// - /// let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap(); - /// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap(); - /// for title in &disc.titles { - /// println!("{} — {} streams", title.duration_display(), title.streams.len()); - /// } - /// ``` /// Scan a disc. One pipeline, one order: /// 1. Read capacity /// 2. Read UDF filesystem @@ -424,7 +478,7 @@ impl Disc { /// 5. Apply labels /// /// The session must be open and unlocked (DriveSession::open handles this). - /// All disc reads use standard READ(10) via UDF — no vendor SCSI commands. + /// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands. pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> { use crate::aacs::{self, KeyDb}; @@ -434,7 +488,7 @@ impl Disc { // 2. UDF filesystem let udf_fs = udf::read_filesystem(session)?; - // 3. AACS — read files from disc via UDF, resolve keys via KEYDB + // 3. AACS -- read files from disc via UDF, resolve keys via KEYDB let encrypted = udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); @@ -496,16 +550,12 @@ impl Disc { ) -> Result<AacsState> { use crate::aacs::{self, KeyDb}; - let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError { - detail: format!("failed to load KEYDB: {}", e), - })?; + 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::AacsError { - detail: "Unit_Key_RO.inf not found on disc".into(), - })?; + .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")) @@ -524,9 +574,7 @@ impl Disc { &vid_zero, &keydb, mkb_data.as_deref(), - ).ok_or_else(|| Error::AacsError { - detail: "disc not in KEYDB".into(), - })?; + ).ok_or_else(|| Error::AacsNoKeys)?; Ok(AacsState { version: if resolved.aacs2 { 2 } else { 1 }, @@ -606,7 +654,7 @@ impl Disc { } fn read_capacity(session: &mut DriveSession) -> Result<u32> { - let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let cdb = [crate::scsi::SCSI_READ_CAPACITY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; let mut buf = [0u8; 8]; session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?; let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); @@ -700,7 +748,7 @@ impl Disc { })), 2 | 5 => { // Guard: if coding_type is a subtitle codec (PGS 0x90/0x91), - // this is a misaligned stream — treat as subtitle, not audio + // this is a misaligned stream -- treat as subtitle, not audio if matches!(codec, Codec::Pgs) { Some(Stream::Subtitle(SubtitleStream { pid: s.pid, @@ -726,7 +774,7 @@ impl Disc { language: s.language.clone(), forced: false, })), - // Stream type 4 = IG, unknown types — skip + // Stream type 4 = IG, unknown types -- skip _ => None, } }).collect(); @@ -781,25 +829,12 @@ pub struct ContentReader<'a> { } impl Disc { - /// Open a title for reading. Decryption is automatic — if the disc + /// Open a title for reading. Decryption is automatic -- if the disc /// is encrypted and keys were found during scan(), content is decrypted /// on the fly. Unencrypted discs pass through unchanged. /// - /// ```no_run - /// # use libfreemkv::{DriveSession, Disc}; - /// # use libfreemkv::disc::ScanOptions; - /// # use std::path::Path; - /// # let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap(); - /// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap(); - /// let mut reader = disc.open_title(&mut session, 0).unwrap(); - /// while let Some(unit) = reader.read_unit().unwrap() { - /// // unit is 6144 bytes of decrypted content - /// } - /// ``` pub fn open_title<'a>(&'a self, session: &'a mut DriveSession, title_idx: usize) -> Result<ContentReader<'a>> { - let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscError { - detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()), - })?; + let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscTitleRange { index: title_idx, count: self.titles.len() })?; // Let the drive manage its own read speed after init. // SET_CD_SPEED is only used reactively by the error handler to slow @@ -905,7 +940,7 @@ impl<'a> ContentReader<'a> { /// Read the next batch of aligned units, decrypted in-place. /// Returns the decrypted data as a single contiguous slice. - /// More efficient than read_unit() — one write_all() per batch instead of per unit. + /// More efficient than read_unit() -- one write_all() per batch instead of per unit. /// Returns None when all extents are exhausted. pub fn read_batch(&mut self) -> Result<Option<&[u8]>> { if !self.fill_buffer()? { @@ -1037,7 +1072,7 @@ impl<'a> ContentReader<'a> { self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS); std::thread::sleep(std::time::Duration::from_millis(100)); } else { - // At minimum batch — retry once with longer pause + // At minimum batch -- retry once with longer pause std::thread::sleep(std::time::Duration::from_millis(500)); self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0); if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() { @@ -1051,7 +1086,7 @@ impl<'a> ContentReader<'a> { } return Ok(true); } - // Still failing — skip this unit (zero-fill) + // Still failing -- skip this unit (zero-fill) self.current_offset += 3; if self.current_offset >= ext_sectors { self.current_extent += 1; diff --git a/src/error.rs b/src/error.rs index d511ed8..fd822cf 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,8 +1,7 @@ //! Error types for libfreemkv. //! -//! Every error carries a numeric code for programmatic handling. -//! No user-facing English text — applications format their own messages. -//! This keeps the library locale-independent and testable. +//! Every error is a code with structured data. No English text. +//! Applications map codes to localized messages. //! //! # Error Code Ranges //! @@ -15,8 +14,10 @@ //! | E5xxx | I/O errors | //! | E6xxx | Disc format errors | //! | E7xxx | AACS errors | +//! | E8xxx | Keydb errors | +//! | E9xxx | Mux errors | -// ── Error codes (single source of truth) ──────────────────────────────────── +// ── Error codes ───────────────────────────────────────────────────────────── pub const E_DEVICE_NOT_FOUND: u16 = 1000; pub const E_DEVICE_PERMISSION: u16 = 1001; @@ -30,116 +31,194 @@ pub const E_NOT_CALIBRATED: u16 = 3003; pub const E_SCSI_ERROR: u16 = 4000; pub const E_SCSI_TIMEOUT: u16 = 4001; pub const E_IO_ERROR: u16 = 5000; -pub const E_DISC_ERROR: u16 = 6000; -pub const E_AACS_ERROR: u16 = 7000; +pub const E_WRITE_ERROR: u16 = 5001; +// Disc format (6xxx) +pub const E_DISC_READ: u16 = 6000; +pub const E_MPLS_PARSE: u16 = 6001; +pub const E_CLPI_PARSE: u16 = 6002; +pub const E_UDF_NOT_FOUND: u16 = 6003; +pub const E_DISC_NO_TITLES: u16 = 6004; +pub const E_DISC_TITLE_RANGE: u16 = 6005; +pub const E_DISC_NO_EXTENTS: u16 = 6006; +// AACS (7xxx) +pub const E_AACS_NO_KEYS: u16 = 7000; +pub const E_AACS_CERT_SHORT: u16 = 7001; +pub const E_AACS_AGID_ALLOC: u16 = 7002; +pub const E_AACS_CERT_REJECTED: u16 = 7003; +pub const E_AACS_CERT_READ: u16 = 7004; +pub const E_AACS_CERT_VERIFY: u16 = 7005; +pub const E_AACS_KEY_READ: u16 = 7006; +pub const E_AACS_KEY_REJECTED: u16 = 7007; +pub const E_AACS_KEY_VERIFY: u16 = 7008; +pub const E_AACS_VID_READ: u16 = 7009; +pub const E_AACS_VID_MAC: u16 = 7010; +pub const E_AACS_DATA_KEY: u16 = 7011; +pub const E_AACS_VUK_DERIVE: u16 = 7012; +// Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; pub const E_KEYDB_HTTP: u16 = 8001; pub const E_KEYDB_INVALID: u16 = 8002; pub const E_KEYDB_WRITE: u16 = 8003; pub const E_KEYDB_PARSE: u16 = 8004; +pub const E_KEYDB_LOAD: u16 = 8005; +// Mux (9xxx) +pub const E_MUX_LOOKAHEAD: u16 = 9000; +pub const E_MUX_WRITE: u16 = 9001; // ── Error enum ────────────────────────────────────────────────────────────── -/// Structured error with numeric code and context data. +/// Structured error with numeric code and context data. No English text. #[derive(Debug)] pub enum Error { + // Device DeviceNotFound { path: String }, DevicePermission { path: String }, + + // Profile UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String }, ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String }, - ProfileParse { detail: String }, - UnlockFailed { detail: String }, + ProfileParse, + + // Unlock + UnlockFailed, SignatureMismatch { expected: [u8; 4], got: [u8; 4] }, NotUnlocked, NotCalibrated, + + // SCSI ScsiError { opcode: u8, status: u8, sense_key: u8 }, ScsiTimeout { opcode: u8 }, + + // I/O IoError { source: std::io::Error }, - DiscError { detail: String }, - AacsError { detail: String }, + WriteError, + + // Disc format + DiscRead { sector: u64 }, + MplsParse, + ClpiParse, + UdfNotFound { path: String }, + DiscNoTitles, + DiscTitleRange { index: usize, count: usize }, + DiscNoExtents, + + // AACS + AacsNoKeys, + AacsCertShort, + AacsAgidAlloc, + AacsCertRejected, + AacsCertRead, + AacsCertVerify, + AacsKeyRead, + AacsKeyRejected, + AacsKeyVerify, + AacsVidRead, + AacsVidMac, + AacsDataKey, + AacsVukDerive, + + // Keydb KeydbConnect { host: String }, KeydbHttp { status: u16 }, KeydbInvalid, KeydbWrite { path: String }, KeydbParse, + KeydbLoad { path: String }, + + // Mux + MuxLookahead, + MuxWrite, } impl Error { - /// Numeric error code. pub fn code(&self) -> u16 { match self { Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND, Error::DevicePermission { .. } => E_DEVICE_PERMISSION, Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE, Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND, - Error::ProfileParse { .. } => E_PROFILE_PARSE, - Error::UnlockFailed { .. } => E_UNLOCK_FAILED, + Error::ProfileParse => E_PROFILE_PARSE, + Error::UnlockFailed => E_UNLOCK_FAILED, Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH, Error::NotUnlocked => E_NOT_UNLOCKED, Error::NotCalibrated => E_NOT_CALIBRATED, Error::ScsiError { .. } => E_SCSI_ERROR, Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT, Error::IoError { .. } => E_IO_ERROR, - Error::DiscError { .. } => E_DISC_ERROR, - Error::AacsError { .. } => E_AACS_ERROR, + Error::WriteError => E_WRITE_ERROR, + Error::DiscRead { .. } => E_DISC_READ, + Error::MplsParse => E_MPLS_PARSE, + Error::ClpiParse => E_CLPI_PARSE, + Error::UdfNotFound { .. } => E_UDF_NOT_FOUND, + Error::DiscNoTitles => E_DISC_NO_TITLES, + Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE, + Error::DiscNoExtents => E_DISC_NO_EXTENTS, + Error::AacsNoKeys => E_AACS_NO_KEYS, + Error::AacsCertShort => E_AACS_CERT_SHORT, + Error::AacsAgidAlloc => E_AACS_AGID_ALLOC, + Error::AacsCertRejected => E_AACS_CERT_REJECTED, + Error::AacsCertRead => E_AACS_CERT_READ, + Error::AacsCertVerify => E_AACS_CERT_VERIFY, + Error::AacsKeyRead => E_AACS_KEY_READ, + Error::AacsKeyRejected => E_AACS_KEY_REJECTED, + Error::AacsKeyVerify => E_AACS_KEY_VERIFY, + Error::AacsVidRead => E_AACS_VID_READ, + Error::AacsVidMac => E_AACS_VID_MAC, + Error::AacsDataKey => E_AACS_DATA_KEY, + Error::AacsVukDerive => E_AACS_VUK_DERIVE, Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbHttp { .. } => E_KEYDB_HTTP, Error::KeydbInvalid => E_KEYDB_INVALID, Error::KeydbWrite { .. } => E_KEYDB_WRITE, Error::KeydbParse => E_KEYDB_PARSE, + Error::KeydbLoad { .. } => E_KEYDB_LOAD, + Error::MuxLookahead => E_MUX_LOOKAHEAD, + Error::MuxWrite => E_MUX_WRITE, } } } -/// Display format: "E{code}: {context}" — terse, for logs. -/// Applications should format their own user-facing messages using code() and fields. +/// Display: "E{code}" with structured data. No English words. impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Error::DeviceNotFound { path } => - write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path), + write!(f, "E{}: {}", self.code(), path), Error::DevicePermission { path } => - write!(f, "E{}: {}", E_DEVICE_PERMISSION, path), + write!(f, "E{}: {}", self.code(), path), Error::UnsupportedDrive { vendor_id, product_id, product_revision } => - write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE, + write!(f, "E{}: {} {} {}", self.code(), vendor_id.trim(), product_id.trim(), product_revision.trim()), Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } => - write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND, + write!(f, "E{}: {} {} {}", self.code(), vendor_id.trim(), product_revision.trim(), vendor_specific.trim()), - Error::ProfileParse { detail } => - write!(f, "E{}: {}", E_PROFILE_PARSE, detail), - Error::UnlockFailed { detail } => - write!(f, "E{}: {}", E_UNLOCK_FAILED, detail), Error::SignatureMismatch { expected, got } => - write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}", - E_SIGNATURE_MISMATCH, + write!(f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}", + self.code(), expected[0], expected[1], expected[2], expected[3], got[0], got[1], got[2], got[3]), - Error::NotUnlocked => - write!(f, "E{}", E_NOT_UNLOCKED), - Error::NotCalibrated => - write!(f, "E{}", E_NOT_CALIBRATED), Error::ScsiError { opcode, status, sense_key } => - write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}", - E_SCSI_ERROR, opcode, status, sense_key), + write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key), Error::ScsiTimeout { opcode } => - write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, opcode), + write!(f, "E{}: 0x{:02x}", self.code(), opcode), Error::IoError { source } => - write!(f, "E{}: {}", E_IO_ERROR, source), - Error::DiscError { detail } => - write!(f, "E{}: {}", E_DISC_ERROR, detail), - Error::AacsError { detail } => - write!(f, "E{}: {}", E_AACS_ERROR, detail), + write!(f, "E{}: {}", self.code(), source), + Error::DiscRead { sector } => + write!(f, "E{}: {}", self.code(), sector), + Error::UdfNotFound { path } => + write!(f, "E{}: {}", self.code(), path), + Error::DiscTitleRange { index, count } => + write!(f, "E{}: {}/{}", self.code(), index, count), Error::KeydbConnect { host } => - write!(f, "E{}: {}", E_KEYDB_CONNECT, host), + write!(f, "E{}: {}", self.code(), host), Error::KeydbHttp { status } => - write!(f, "E{}: {}", E_KEYDB_HTTP, status), - Error::KeydbInvalid => - write!(f, "E{}", E_KEYDB_INVALID), + write!(f, "E{}: {}", self.code(), status), Error::KeydbWrite { path } => - write!(f, "E{}: {}", E_KEYDB_WRITE, path), - Error::KeydbParse => - write!(f, "E{}", E_KEYDB_PARSE), + write!(f, "E{}: {}", self.code(), path), + Error::KeydbLoad { path } => + write!(f, "E{}: {}", self.code(), path), + // Simple codes — no extra data + _ => write!(f, "E{}", self.code()), } } } diff --git a/src/lib.rs b/src/lib.rs index a71b4ce..a8614dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! libfreemkv — Open source optical drive library for 4K UHD / Blu-ray / DVD. +//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD. //! //! Handles drive access, disc structure parsing, AACS decryption, and raw //! sector reading. 206 bundled drive profiles. No external files needed. @@ -14,7 +14,7 @@ //! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap(); //! //! for title in &disc.titles { -//! println!("{} — {} streams", title.duration_display(), title.streams.len()); +//! println!("{} -- {} streams", title.duration_display(), title.streams.len()); //! } //! //! // Read content (decrypted automatically if AACS keys available) @@ -27,21 +27,21 @@ //! # Architecture //! //! ```text -//! DriveSession — open, identify, unlock, read sectors -//! ├── ScsiTransport — SG_IO (Linux), IOKit (macOS) -//! ├── DriveProfile — per-drive unlock parameters (206 bundled) -//! ├── DriveId — INQUIRY + GET_CONFIG identification +//! DriveSession -- open, identify, unlock, read sectors +//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS) +//! ├── DriveProfile -- per-drive unlock parameters (206 bundled) +//! ├── DriveId -- INQUIRY + GET_CONFIG identification //! └── Platform -//! └── Mt1959 — MediaTek unlock/read (Renesas planned) +//! └── Mt1959 -- MediaTek unlock/read (Renesas planned) //! -//! Disc — scan titles, streams, AACS state -//! ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions -//! ├── MPLS parser — playlists → titles + clips + STN streams -//! ├── CLPI parser — clip info → EP map → sector extents -//! ├── JAR parser — BD-J audio track labels -//! └── AACS — encryption: key resolution + content decrypt -//! ├── aacs — KEYDB, VUK, MKB, unit decrypt -//! └── handshake — SCSI auth, ECDH, bus key +//! Disc -- scan titles, streams, AACS state +//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions +//! ├── MPLS parser -- playlists → titles + clips + STN streams +//! ├── CLPI parser -- clip info → EP map → sector extents +//! ├── JAR parser -- BD-J audio track labels +//! └── AACS -- encryption: key resolution + content decrypt +//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt +//! └── handshake -- SCSI auth, ECDH, bus key //! ``` //! //! # AACS Encryption @@ -55,7 +55,7 @@ //! # Error Codes //! //! All errors are structured with numeric codes. No user-facing English -//! text — applications format their own messages. +//! text -- applications format their own messages. //! //! | Range | Category | //! |-------|----------| @@ -81,14 +81,18 @@ pub mod disc; pub mod aacs; pub mod labels; pub mod keydb; +pub mod event; +pub mod mux; pub use error::{Error, Result}; +pub use event::{Event, EventKind}; 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 +// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly pub use scsi::ScsiTransport; pub use speed::DriveSpeed; pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream, Codec, HdrFormat, ColorSpace, Extent, ContentReader, AacsState, KeySource, ScanOptions}; +pub use mux::MkvStream; diff --git a/src/mpls.rs b/src/mpls.rs index b5c115c..8d6f26c 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -62,17 +62,17 @@ pub struct StreamEntry { /// Parse an MPLS file from raw bytes. pub fn parse(data: &[u8]) -> Result<Playlist> { if data.len() < 40 { - return Err(Error::DiscError { detail: "MPLS too short".into() }); + return Err(Error::MplsParse); } if &data[0..4] != b"MPLS" { - return Err(Error::DiscError { detail: "not an MPLS file".into() }); + return Err(Error::MplsParse); } let version = String::from_utf8_lossy(&data[4..8]).to_string(); let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize; if playlist_start + 10 > data.len() { - return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() }); + return Err(Error::MplsParse); } let pl = &data[playlist_start..]; diff --git a/src/platform/mt1959/mod.rs b/src/platform/mt1959/mod.rs index 9a24336..e0e50f0 100644 --- a/src/platform/mt1959/mod.rs +++ b/src/platform/mt1959/mod.rs @@ -113,12 +113,7 @@ impl Mt1959 { if response.len() >= FIRMWARE_ACTIVE_OFFSET + 4 && response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG { - return Err(Error::UnlockFailed { - detail: format!( - "mode not active: {:02x}{:02x}{:02x}{:02x}", - response[12], response[13], response[14], response[15] - ), - }); + return Err(Error::UnlockFailed); } self.unlocked = true; @@ -148,9 +143,7 @@ impl Mt1959 { match self.do_unlock(scsi) { Ok(_) => { unlocked = true; break; } Err(Error::SignatureMismatch { .. }) => { - return Err(Error::UnlockFailed { - detail: "signature mismatch — wrong profile for this drive".into(), - }); + return Err(Error::UnlockFailed); } Err(_) => { let ok = if self.mode == MODE_A { @@ -163,7 +156,7 @@ impl Mt1959 { } } if !unlocked { - return Err(Error::UnlockFailed { detail: "failed after 6 attempts".into() }); + return Err(Error::UnlockFailed); } Ok(()) } diff --git a/src/platform/mt1959/variant_a.rs b/src/platform/mt1959/variant_a.rs index bfe205d..53676c4 100644 --- a/src/platform/mt1959/variant_a.rs +++ b/src/platform/mt1959/variant_a.rs @@ -12,9 +12,7 @@ const VERIFY_BUFFER_ID: u8 = 0x45; pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> { let firmware = &mt.profile.firmware; if firmware.is_empty() { - return Err(crate::error::Error::UnlockFailed { - detail: "no firmware in profile".into(), - }); + return Err(crate::error::Error::UnlockFailed); } // Upload firmware via WRITE_BUFFER diff --git a/src/platform/mt1959/variant_b.rs b/src/platform/mt1959/variant_b.rs index 119e696..457205a 100644 --- a/src/platform/mt1959/variant_b.rs +++ b/src/platform/mt1959/variant_b.rs @@ -16,9 +16,7 @@ const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> { let firmware = &mt.profile.firmware; if firmware.is_empty() { - return Err(crate::error::Error::UnlockFailed { - detail: "no firmware in profile".into(), - }); + return Err(crate::error::Error::UnlockFailed); } // Step 1: Upload firmware via MODE SELECT diff --git a/src/udf.rs b/src/udf.rs index 9d0dcbf..3664c47 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -80,16 +80,17 @@ impl UdfFs { for part in &parts[..parts.len() - 1] { current = current.entries.iter().find(|e| { e.is_dir && e.name.eq_ignore_ascii_case(part) - }).ok_or_else(|| Error::DiscError { - detail: format!("directory not found: {}", part), - })?; + }).ok_or_else(|| Error::UdfNotFound { path: part.to_string() } +)?; } - let filename = parts.last().unwrap(); + let filename = match parts.last() { + Some(f) => f, + None => return Err(Error::UdfNotFound { path: path.to_string() }), + }; let entry = current.entries.iter().find(|e| { !e.is_dir && e.name.eq_ignore_ascii_case(filename) - }).ok_or_else(|| Error::DiscError { - detail: format!("file not found: {}", path), - })?; + }).ok_or_else(|| Error::UdfNotFound { path: path.to_string() } +)?; let (data_lba, _) = self.read_icb_extent(session, entry.meta_lba)?; Ok(self.partition_start + data_lba) } @@ -102,18 +103,19 @@ impl UdfFs { for part in &parts[..parts.len() - 1] { current = current.entries.iter().find(|e| { e.is_dir && e.name.eq_ignore_ascii_case(part) - }).ok_or_else(|| Error::DiscError { - detail: format!("directory not found: {}", part), - })?; + }).ok_or_else(|| Error::UdfNotFound { path: part.to_string() } +)?; } // Find the file - let filename = parts.last().unwrap(); + let filename = match parts.last() { + Some(f) => f, + None => return Err(Error::UdfNotFound { path: path.to_string() }), + }; let entry = current.entries.iter().find(|e| { !e.is_dir && e.name.eq_ignore_ascii_case(filename) - }).ok_or_else(|| Error::DiscError { - detail: format!("file not found: {}", path), - })?; + }).ok_or_else(|| Error::UdfNotFound { path: path.to_string() } +)?; // Read the file's ICB to get its data extent let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?; @@ -197,9 +199,7 @@ impl UdfFs { /// 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)?; - extents.first().copied().ok_or_else(|| Error::DiscError { - detail: "no allocation descriptors in ICB".into(), - }) + extents.first().copied().ok_or_else(|| Error::DiscRead { sector: 0 }) } /// Read ALL allocation extents for a file from its ICB. @@ -225,9 +225,7 @@ impl UdfFs { let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize; (176 + l_ea, l_ad) } - _ => return Err(Error::DiscError { - detail: format!("unexpected ICB tag {} at meta_lba {}", tag, meta_lba), - }), + _ => return Err(Error::DiscRead { sector: 0 }), }; let mut extents = Vec::new(); @@ -235,8 +233,8 @@ impl UdfFs { for i in 0..num_descriptors { let off = ad_offset + i * 8; - if off + 8 > 2048 { - break; // TODO: follow Allocation Extent Descriptors (tag 258) for overflow + if off + 8 > icb.len() { + break; } let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]); @@ -263,16 +261,17 @@ impl UdfFs { for part in &parts[..parts.len() - 1] { current = current.entries.iter().find(|e| { e.is_dir && e.name.eq_ignore_ascii_case(part) - }).ok_or_else(|| Error::DiscError { - detail: format!("directory not found: {}", part), - })?; + }).ok_or_else(|| Error::UdfNotFound { path: part.to_string() } +)?; } - let filename = parts.last().unwrap(); + let filename = match parts.last() { + Some(f) => f, + None => return Err(Error::UdfNotFound { path: path.to_string() }), + }; let entry = current.entries.iter().find(|e| { !e.is_dir && e.name.eq_ignore_ascii_case(filename) - }).ok_or_else(|| Error::DiscError { - detail: format!("file not found: {}", path), - })?; + }).ok_or_else(|| Error::UdfNotFound { path: path.to_string() } +)?; let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?; let mut disc_extents = Vec::new(); @@ -302,9 +301,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> { let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]); if tag_id != 2 { - return Err(Error::DiscError { - detail: format!("AVDP: expected tag 2, got {} at sector 256", tag_id), - }); + return Err(Error::DiscRead { sector: 0 }); } // Main VDS extent location: bytes [16:20] = LBA, [20:24] = length @@ -344,16 +341,14 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> { } if partition_start == 0 { - return Err(Error::DiscError { detail: "UDF: no Partition Descriptor found".into() }); + return Err(Error::DiscRead { sector: 0 }); } // Step 3: Parse partition maps from LVD to find metadata partition // BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition") // The metadata file is stored at lba=0 of the physical partition let metadata_start = if num_partition_maps >= 2 { - let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscError { - detail: "UDF: no LVD found".into(), - })?; + let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscRead { sector: 0 })?; // Read LVD to check partition map type let mut lvd = [0u8; 2048]; @@ -410,9 +405,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> { let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]); if fsd_tag != 256 { - return Err(Error::DiscError { - detail: format!("FSD: expected tag 256, got {} at sector {}", fsd_tag, metadata_start), - }); + return Err(Error::DiscRead { sector: 0 }); } // Root Directory ICB: long_ad at FSD offset 400