diff --git a/docs/disc-to-rip.md b/docs/disc-to-rip.md new file mode 100644 index 0000000..fbc9628 --- /dev/null +++ b/docs/disc-to-rip.md @@ -0,0 +1,114 @@ +# Disc to Rip: End-to-End Flow + +How libfreemkv goes from a disc in the drive to decrypted content ready for backup. +This is the starting point for understanding the library. + +## The Pipeline + +``` +Insert disc + │ + ▼ +1. Open drive (drive.rs) + │ INQUIRY → identify drive + │ Match bundled profile → chipset, unlock parameters + │ + ▼ +2. AACS handshake (aacs_handshake.rs) — optional, separate transport + │ Allocate AGID + │ Exchange certificates + nonces (ECDH) + │ Derive bus key + │ Read Volume ID + read_data_key + │ (fails gracefully if drive doesn't support AACS for this disc) + │ + ▼ +3. Unlock drive (drive.rs → platform/mt1959.rs) + │ Vendor-specific command activates raw read mode + │ Required — drive firmware blocks all reads without it + │ + ▼ +4. Read UDF filesystem (udf.rs) + │ Sector 256: AVDP → find Volume Descriptor Sequence + │ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start) + │ Metadata partition → File Set Descriptor → Root directory + │ Walk directory tree: BDMV/, AACS/, CERTIFICATE/ + │ → docs/udf.md + │ + ▼ +5. Read AACS files from disc (aacs.rs) + │ AACS/Unit_Key_RO.inf → SHA1 = disc hash + │ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag + │ MKB via SCSI → for key derivation fallback + │ + ▼ +6. Resolve AACS keys (aacs.rs → resolve_keys) + │ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs) + │ Path 2: KEYDB media key + Volume ID → VUK + │ Path 3: MKB + processing keys → media key → VUK + │ Path 4: MKB + device keys → subset-difference tree → VUK + │ VUK → decrypt unit keys from Unit_Key_RO.inf + │ → docs/aacs.md + │ + ▼ +7. Parse playlists (mpls.rs) + │ BDMV/PLAYLIST/*.mpls → titles with play items + │ Each play item: clip ID, in/out timestamps + │ STN table: video, audio, subtitle streams with codec + language + │ → docs/mpls.md + │ + ▼ +8. Parse clip info (clpi.rs) + │ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping) + │ Coarse + fine entries → full PTS and SPN + │ SPN → byte offset → sector extents for reading + │ → docs/clpi.md + │ + ▼ +9. Parse BD-J labels (jar.rs) — optional + │ BDMV/JAR/*.jar → Java class constant pool strings + │ Audio track labels: "English Descriptive Audio", "French 5.1", etc. + │ + ▼ +10. Read + decrypt content (disc.rs → ContentReader) + │ For each aligned unit (6144 bytes = 3 sectors): + │ Read 3 sectors from disc + │ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC) + │ If encrypted: unit decrypt (per-unit key derivation + AES-CBC) + │ Output decrypted content + │ + ▼ + Decrypted m2ts stream → ready for muxing/backup +``` + +## API Summary + +```rust +// Steps 1 + 3 (open + unlock) +let mut session = DriveSession::open(Path::new("/dev/sr0"))?; + +// Steps 2 + 4-9 (AACS + scan) +let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?; + +// Step 10 (read + decrypt) +let mut reader = disc.open_title(&mut session, 0)?; +while let Some(unit) = reader.read_unit()? { + output.write_all(&unit)?; +} +``` + +Three lines. Everything else is internal. + +## Module Reference + +| Module | Doc | Purpose | +|--------|-----|---------| +| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read | +| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport | +| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem | +| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams | +| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map | +| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt | +| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication | +| disc.rs | -- | High-level scan + read API | +| jar.rs | -- | BD-J audio track labels | +| error.rs | -- | Error codes (E1xxx-E7xxx) | diff --git a/src/aacs.rs b/src/aacs.rs index 731ff84..e69c989 100644 --- a/src/aacs.rs +++ b/src/aacs.rs @@ -753,17 +753,21 @@ pub fn derive_media_key_from_dk( None } +/// MKB disc structure format code. +const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83; +/// MKB pack buffer size. +const MKB_PACK_SIZE: usize = 32772; + /// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). /// Returns the concatenated MKB data from all packs. pub fn read_mkb_from_drive(session: &mut crate::drive::DriveSession) -> crate::error::Result> { - use crate::scsi::DataDirection; + use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; - // First pack: get pack count and initial data let cdb = [ - 0xAD, 0x01, // REPORT DISC STRUCTURE, Blu-ray - 0x00, 0x00, 0x00, 0x00, // address = 0 (pack 0) - 0x00, 0x83, // format = 0x83 (MKB) - 0x80, 0x04, // allocation length = 32772 + SCSI_READ_DISC_STRUCTURE, 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x00, MKB_DISC_STRUCTURE_FORMAT, + (MKB_PACK_SIZE >> 8) as u8, (MKB_PACK_SIZE & 0xFF) as u8, 0x00, 0x00, ]; let mut buf = vec![0u8; 32772]; @@ -782,10 +786,10 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::DriveSession) -> crate::e // Read remaining packs for pack in 1..num_packs { let mut cdb = [ - 0xAD, 0x01, + SCSI_READ_DISC_STRUCTURE, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x83, - 0x80, 0x04, + 0x00, MKB_DISC_STRUCTURE_FORMAT, + (MKB_PACK_SIZE >> 8) as u8, (MKB_PACK_SIZE & 0xFF) as u8, 0x00, 0x00, ]; // Pack number goes in address field diff --git a/src/aacs_handshake.rs b/src/aacs_handshake.rs index d2489c6..7a1ec5b 100644 --- a/src/aacs_handshake.rs +++ b/src/aacs_handshake.rs @@ -459,8 +459,8 @@ fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] { /// Build REPORT KEY CDB (0xA4). fn cdb_report_key(agid: u8, format: u8, len: u16) -> [u8; 12] { let mut cdb = [0u8; 12]; - cdb[0] = 0xA4; - cdb[7] = 0x02; // key class = AACS + cdb[0] = crate::scsi::SCSI_REPORT_KEY; + cdb[7] = crate::scsi::AACS_KEY_CLASS; cdb[8] = (len >> 8) as u8; cdb[9] = (len & 0xFF) as u8; cdb[10] = (agid << 6) | (format & 0x3F); @@ -470,8 +470,8 @@ fn cdb_report_key(agid: u8, format: u8, len: u16) -> [u8; 12] { /// Build SEND KEY CDB (0xA3). fn cdb_send_key(agid: u8, format: u8, len: u16) -> [u8; 12] { let mut cdb = [0u8; 12]; - cdb[0] = 0xA3; - cdb[7] = 0x02; // key class = AACS + cdb[0] = crate::scsi::SCSI_SEND_KEY; + cdb[7] = crate::scsi::AACS_KEY_CLASS; cdb[8] = (len >> 8) as u8; cdb[9] = (len & 0xFF) as u8; cdb[10] = (agid << 6) | (format & 0x3F); @@ -481,7 +481,7 @@ fn cdb_send_key(agid: u8, format: u8, len: u16) -> [u8; 12] { /// Build REPORT DISC STRUCTURE CDB (0xAD). fn cdb_report_disc_structure(agid: u8, format: u8, len: u16) -> [u8; 12] { let mut cdb = [0u8; 12]; - cdb[0] = 0xAD; + cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE; cdb[1] = 0x01; // Blu-ray cdb[7] = format; cdb[8] = (len >> 8) as u8; diff --git a/src/disc.rs b/src/disc.rs index 4e54996..8305d66 100644 --- a/src/disc.rs +++ b/src/disc.rs @@ -691,7 +691,7 @@ impl<'a> ContentReader<'a> { fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> { let cdb = [ - 0x28, 0x00, + crate::scsi::SCSI_READ_10, 0x00, (lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8, 0x00, 0x00, 0x01, 0x00, ]; diff --git a/src/drive.rs b/src/drive.rs index a88de78..ea49527 100644 --- a/src/drive.rs +++ b/src/drive.rs @@ -133,7 +133,7 @@ impl DriveSession { /// Standard SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI). pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result { let cdb = [ - 0x28, 0x00, + crate::scsi::SCSI_READ_10, 0x00, (lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8, 0x00, (count >> 8) as u8, count as u8, diff --git a/src/error.rs b/src/error.rs index e3ef205..ed7ec8d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,102 +1,120 @@ -/// libfreemkv error codes. -/// -/// The library returns structured error codes with context data. -/// Applications are responsible for formatting user-facing messages. -/// This keeps the library locale-independent and testable. +//! 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. +//! +//! # Error Code Ranges +//! +//! | Range | Category | +//! |-------|----------| +//! | E1xxx | Device errors | +//! | E2xxx | Profile errors | +//! | E3xxx | Unlock errors | +//! | E4xxx | SCSI errors | +//! | E5xxx | I/O errors | +//! | E6xxx | Disc format errors | +//! | E7xxx | AACS errors | -/// Error code table. -/// -/// | Code | Name | Meaning | -/// |------|------|---------| -/// | 1000 | DeviceNotFound | Device path doesn't exist or can't be opened | -/// | 1001 | DevicePermission | Device exists but permission denied | -/// | 2000 | UnsupportedDrive | Drive not in profile database | -/// | 2001 | ProfileNotFound | Specific firmware version not in database | -/// | 2002 | ProfileParse | Profile database is malformed | -/// | 3000 | UnlockFailed | Drive rejected unlock command | -/// | 3001 | SignatureMismatch | Wrong signature returned by drive | -/// | 3002 | NotUnlocked | Raw read attempted before unlock | -/// | 3003 | NotCalibrated | Raw read attempted before calibrate | -/// | 4000 | ScsiError | SCSI command failed | -/// | 4001 | ScsiTimeout | SCSI command timed out | -/// | 5000 | IoError | OS-level I/O error | +// ── Error codes (single source of truth) ──────────────────────────────────── + +pub const E_DEVICE_NOT_FOUND: u16 = 1000; +pub const E_DEVICE_PERMISSION: u16 = 1001; +pub const E_UNSUPPORTED_DRIVE: u16 = 2000; +pub const E_PROFILE_NOT_FOUND: u16 = 2001; +pub const E_PROFILE_PARSE: u16 = 2002; +pub const E_UNLOCK_FAILED: u16 = 3000; +pub const E_SIGNATURE_MISMATCH: u16 = 3001; +pub const E_NOT_UNLOCKED: u16 = 3002; +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; + +// ── Error enum ────────────────────────────────────────────────────────────── + +/// Structured error with numeric code and context data. #[derive(Debug)] pub enum Error { - // 1xxx — Device errors DeviceNotFound { path: String }, DevicePermission { path: String }, - - // 2xxx — Profile errors UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String }, ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String }, ProfileParse { detail: String }, - - // 3xxx — Unlock errors UnlockFailed { detail: String }, SignatureMismatch { expected: [u8; 4], got: [u8; 4] }, NotUnlocked, NotCalibrated, - - // 4xxx — SCSI errors ScsiError { opcode: u8, status: u8, sense_key: u8 }, ScsiTimeout { opcode: u8 }, - - // 5xxx — I/O errors IoError { source: std::io::Error }, - - // 6xxx — Disc format errors DiscError { detail: String }, - - // 7xxx — AACS errors AacsError { detail: String }, } impl Error { - /// Numeric error code for programmatic handling. + /// Numeric error code. pub fn code(&self) -> u16 { match self { - Error::DeviceNotFound { .. } => 1000, - Error::DevicePermission { .. } => 1001, - Error::UnsupportedDrive { .. } => 2000, - Error::ProfileNotFound { .. } => 2001, - Error::ProfileParse { .. } => 2002, - Error::UnlockFailed { .. } => 3000, - Error::SignatureMismatch { .. } => 3001, - Error::NotUnlocked => 3002, - Error::NotCalibrated => 3003, - Error::ScsiError { .. } => 4000, - Error::ScsiTimeout { .. } => 4001, - Error::IoError { .. } => 5000, - Error::DiscError { .. } => 6000, - Error::AacsError { .. } => 7000, + 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::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, } } } -/// Default Display — terse, for logs. Applications should format their own messages. +/// Display format: "E{code}: {context}" — terse, for logs. +/// Applications should format their own user-facing messages using code() and fields. impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Error::DeviceNotFound { path } => write!(f, "E1000: device not found: {path}"), - Error::DevicePermission { path } => write!(f, "E1001: permission denied: {path}"), + Error::DeviceNotFound { path } => + write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path), + Error::DevicePermission { path } => + write!(f, "E{}: {}", E_DEVICE_PERMISSION, path), Error::UnsupportedDrive { vendor_id, product_id, product_revision } => - write!(f, "E2000: unsupported drive: {} {} {}", vendor_id.trim(), product_id.trim(), product_revision.trim()), + write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE, + vendor_id.trim(), product_id.trim(), product_revision.trim()), Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } => - write!(f, "E2001: no profile: {} {} {}", vendor_id.trim(), product_revision.trim(), vendor_specific.trim()), - Error::ProfileParse { detail } => write!(f, "E2002: profile parse: {detail}"), - Error::UnlockFailed { detail } => write!(f, "E3000: unlock failed: {detail}"), + write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND, + 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, "E3001: signature mismatch: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}", + write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}", + E_SIGNATURE_MISMATCH, expected[0], expected[1], expected[2], expected[3], got[0], got[1], got[2], got[3]), - Error::NotUnlocked => write!(f, "E3002: not unlocked"), - Error::NotCalibrated => write!(f, "E3003: not calibrated"), + Error::NotUnlocked => + write!(f, "E{}", E_NOT_UNLOCKED), + Error::NotCalibrated => + write!(f, "E{}", E_NOT_CALIBRATED), Error::ScsiError { opcode, status, sense_key } => - write!(f, "E4000: SCSI 0x{opcode:02x} failed: status=0x{status:02x} sense=0x{sense_key:02x}"), - Error::ScsiTimeout { opcode } => write!(f, "E4001: SCSI 0x{opcode:02x} timeout"), - Error::IoError { source } => write!(f, "E5000: {source}"), - Error::DiscError { detail } => write!(f, "E6000: disc: {detail}"), - Error::AacsError { detail } => write!(f, "E7000: AACS: {detail}"), + write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}", + E_SCSI_ERROR, opcode, status, sense_key), + Error::ScsiTimeout { opcode } => + write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, 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), } } } diff --git a/src/scsi.rs b/src/scsi.rs index 79c28a3..cd9b45c 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -8,6 +8,22 @@ use crate::error::{Error, Result}; use std::path::Path; +// ── SCSI opcodes (SPC-4, MMC-6) ──────────────────────────────────────────── + +pub const SCSI_INQUIRY: u8 = 0x12; +pub const SCSI_READ_CAPACITY: u8 = 0x25; +pub const SCSI_READ_10: u8 = 0x28; +pub const SCSI_READ_BUFFER: u8 = 0x3C; +pub const SCSI_READ_TOC: u8 = 0x43; +pub const SCSI_GET_CONFIGURATION: u8 = 0x46; +pub const SCSI_SEND_KEY: u8 = 0xA3; +pub const SCSI_REPORT_KEY: u8 = 0xA4; +pub const SCSI_READ_12: u8 = 0xA8; +pub const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD; + +/// AACS key class for REPORT KEY / SEND KEY commands. +pub const AACS_KEY_CLASS: u8 = 0x02; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum DataDirection { None,