diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 35aeae6..2240534 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -12,7 +12,7 @@ use aes::Aes128; use aes::cipher::{Array, BlockCipherDecrypt, KeyInit}; use crate::scsi::ScsiTransport; -use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker}; +use crate::{DiscKind, HostCert, UnlockCtx, UnlockError, Unlocked, Unlocker}; /// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key / /// read_data_key the drive returns after the handshake. @@ -25,53 +25,47 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { out } -/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc -/// (`DiscKind::Aacs`) and runs the cert handshake against the host certs the -/// consumer collected (via [`UnlockCtx::host_certs`]), learning the Volume ID -/// and — on AACS 2.0 — the bus key. -pub struct AacsCert; +/// The AACS host-certificate unlocker — the fallback for a drive with NO vendor +/// unlock (can't be raw-read-enabled), so the bus is removed by a real host-cert +/// AKE + bus key rather than a CDB. The host certs are injected at CONSTRUCTION +/// (the one place certs enter the system). Owns its AKE crypto — no reach into +/// libfreemkv. +pub struct AacsUnlocker { + host_certs: Vec, +} -impl AacsCert { - pub fn new() -> Self { - AacsCert +impl AacsUnlocker { + pub fn new(host_certs: Vec) -> Self { + AacsUnlocker { host_certs } } } -impl Default for AacsCert { - fn default() -> Self { - Self::new() - } -} - -impl Unlocker for AacsCert { +impl Unlocker for AacsUnlocker { fn name(&self) -> &'static str { "AACS" } - /// AACS removes BUS encryption via the host-cert handshake; it provides no - /// drive features. Self-guards on the disc kind (the consumer iterates every - /// unlocker's `unlock_bus`, so a non-AACS disc must decline here). - fn unlock_bus( + /// Remove AACS bus encryption via the host-cert handshake. Like every other + /// unlocker this DOES unlock the drive — just with a cert + AKE instead of a + /// vendor CDB. `Some` on a successful handshake (VID + bus key learned); + /// `None` on a non-AACS disc, no usable cert, or a rejected handshake (fall + /// through); `Err(Transport)` on a dead bus. + fn unlock( &self, scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx, - ) -> std::result::Result { - if ctx.kind != DiscKind::Aacs { - return Err(UnlockError::NotApplicable); + ) -> std::result::Result, UnlockError> { + if ctx.kind != DiscKind::Aacs || self.host_certs.is_empty() { + // Wrong disc kind, or no host cert to authenticate with — the loop + // falls through to a VID-less / keysource path. + return Ok(None); } - if ctx.host_certs.is_empty() { - // No host cert to authenticate with — the consumer falls back to a - // VID-less / keysource path. - return Err(UnlockError::NoUsableHostCert); - } - let h = handshake::run_cert_handshake(scsi, ctx.host_certs)?; - Ok(Unlocked { - vid: Some(h.volume_id), - // Host-cert AKE path: bus removal depends on the bus key, not a - // firmware unlock. - bus_key: h.read_data_key, - drive_unlocked: false, - }) + crate::fallthrough( + handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| Unlocked { + vid: Some(h.volume_id), + bus_key: h.read_data_key, + }), + ) } } @@ -115,11 +109,11 @@ mod tests { crate::DriveId::default() } - /// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines - /// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a - /// CSS/unknown disc is safe. + /// Self-guards on the disc kind: on a non-AACS disc `unlock()` declines + /// (`Ok(false)`) WITHOUT touching the transport, even WITH a cert present — + /// so the reason is the kind, not a missing cert. #[test] - fn unlock_bus_declines_non_aacs_kinds() { + fn declines_non_aacs_kinds() { struct DeadTransport; impl ScsiTransport for DeadTransport { fn execute( @@ -135,49 +129,41 @@ mod tests { let id = id(); let mut t = DeadTransport; for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] { - let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, k, &[])); - assert_eq!(r.unwrap_err(), UnlockError::NotApplicable, "declines {k:?}"); + assert!( + AacsUnlocker::new(vec![host_cert()]) + .unlock(&mut t, &UnlockCtx::new(&id, k)) + .expect("declines") + .is_none(), + "declines {k:?}" + ); } } - /// `Default` must delegate to `new()` (there is only one way to build an - /// `AacsCert`, so this pins the two constructors never drift apart). + // Full success path through `Unlocker::unlock`: a self-consistent AACS 1.0 + // emulator proves the entry point learns `vid`/`bus_key` — and that the cert + // route reports `unlock() == true` (it DOES unlock the drive, via the cert). #[test] - #[allow(clippy::default_constructed_unit_structs)] - fn default_matches_new() { - let _ = AacsCert::default(); - let _ = AacsCert::new(); - } - - // Full success path through `Unlocker::unlock_bus`: a self-consistent - // AACS 1.0 emulator proves the public entry point (not just - // `run_cert_handshake`) wires `volume_id`/`read_data_key` into `Unlocked`. - #[test] - fn unlock_bus_succeeds_end_to_end() { + fn unlock_succeeds_end_to_end() { let mut t = handshake::tests::DriveEmu::new(); t.serve_data_keys = true; let id = id(); - let certs = [host_cert()]; - let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &certs); - let unlocked = AacsCert::new() - .unlock_bus(&mut t, &ctx) - .expect("auth + VID + data-key reads all succeed"); - assert_eq!(unlocked.vid, Some([0x5Au8; 16])); - assert!(unlocked.bus_key.is_some()); - assert!( - !unlocked.drive_unlocked, - "cert path never sets drive_unlocked" - ); + let ctx = UnlockCtx::new(&id, DiscKind::Aacs); + let out = AacsUnlocker::new(vec![host_cert()]) + .unlock(&mut t, &ctx) + .expect("auth + VID + data-key reads all succeed") + .expect("the cert route unlocks the drive"); + assert_eq!(out.vid, Some([0x5Au8; 16])); + assert!(out.bus_key.is_some()); } fn host_cert() -> crate::HostCert { handshake::tests::dummy_cert() } - /// With no host certs there is nothing to authenticate with → NoUsableHostCert, + /// With no host certs there is nothing to authenticate with → `Ok(false)`, /// and the transport is never touched. #[test] - fn no_host_certs_is_no_usable_host_cert() { + fn no_host_certs_declines() { struct DeadTransport; impl ScsiTransport for DeadTransport { fn execute( @@ -192,7 +178,11 @@ mod tests { } let id = id(); let mut t = DeadTransport; - let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[])); - assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert); + assert!( + AacsUnlocker::new(vec![]) + .unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs)) + .expect("no certs declines") + .is_none() + ); } } diff --git a/src/css/mod.rs b/src/css/mod.rs index 3954f9f..0a32b5b 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -206,30 +206,35 @@ impl crate::Unlocker for DvdUnlocker { "DVD" } - /// CSS removes the scrambled-sector barrier (a bus-level concern); it - /// provides no drive features. Self-guards against the hardware (below), so - /// it declines cleanly when the consumer iterates it on a non-DVD. - fn unlock_bus( + /// CSS removes the scrambled-sector barrier via bus-auth. Self-guards + /// against the hardware (below), so it declines cleanly (`Ok(None)`) when the + /// consumer iterates it on a non-DVD. `Some` once the bus-auth runs; + /// `Err(Transport)` on a dead bus. Yields no VID and no bus key (descrambling + /// is keyless — the returned `Unlocked` is empty). + fn unlock( &self, scsi: &mut dyn ScsiTransport, _ctx: &crate::UnlockCtx, - ) -> std::result::Result { + ) -> std::result::Result, crate::UnlockError> { // Self-guard against the hardware, not just the caller-declared - // DiscKind: refuse (NotApplicable) without issuing any CSS CDB when - // the drive doesn't report a DVD profile. + // DiscKind: decline without issuing any CSS CDB when the drive doesn't + // report a DVD profile. if !mounted_disc_is_dvd(scsi)? { tracing::debug!( target: "freemkv::css", phase = "dvd_unlocker_not_dvd", - "DvdUnlocker invoked on a non-DVD profile; refusing (NotApplicable)" + "DvdUnlocker invoked on a non-DVD profile; declining" ); - return Err(crate::UnlockError::NotApplicable); + return Ok(None); } - // The bus-auth handshake is what unlocks scrambled-sector reads; the lba - // is not consumed by the unlock primitive (the disc-key REPORT KEY is - // best-effort). CSS yields neither a Volume ID nor an AACS bus key. - unlock_css_reads(scsi, 0)?; - Ok(crate::Unlocked::default()) + // Bus-auth unlocks scrambled-sector reads (the lba isn't consumed here; + // the disc-key REPORT KEY is best-effort). A refused handshake falls + // through (`fallthrough` → `Ok(None)`); only a dead bus is an `Err`. + crate::fallthrough( + unlock_css_reads(scsi, 0) + .map(|()| crate::Unlocked::default()) + .map_err(crate::UnlockError::from), + ) } } @@ -925,36 +930,13 @@ mod tests { } /// DvdUnlocker provides bus removal only — it never provides drive features. - #[test] - fn dvd_unlocker_provides_no_features() { - use crate::scsi::{DataDirection, ScsiResult}; - use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker}; - struct DeadTransport; - impl ScsiTransport for DeadTransport { - fn execute( - &mut self, - _cdb: &[u8], - _dir: DataDirection, - _data: &mut [u8], - _timeout_ms: u32, - ) -> crate::scsi::Result { - panic!("unlock_features must not touch the transport"); - } - } - let id = DriveId::default(); - let mut t = DeadTransport; - let r = - DvdUnlocker::new().unlock_features(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[])); - assert_eq!(r.unwrap_err(), UnlockError::NotApplicable); - } - // Defense in depth: even when the caller declares `DiscKind::Css`, // DvdUnlocker self-verifies against GET CONFIGURATION; a BD profile // must yield NotApplicable with no CSS CDB issued. #[test] fn dvd_unlocker_self_guards_against_non_dvd() { use crate::scsi::{DataDirection, ScsiResult}; - use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker}; + use crate::{DiscKind, DriveId, UnlockCtx, Unlocker}; /// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any /// other CDB (i.e. CSS bus-auth activity). @@ -995,10 +977,9 @@ mod tests { }; let mut t = BdTransport { non_config_cdbs: 0 }; - let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[])); - assert_eq!( - r.unwrap_err(), - UnlockError::NotApplicable, + let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); + assert!( + r.expect("BD profile declines, not a hard error").is_none(), "a BD-profile drive must be refused" ); assert_eq!( @@ -1018,7 +999,7 @@ mod tests { fn transport_fault_probing_for_a_dvd_aborts() { let id = DriveId::default(); let mut t = MockTransport::always(Reply::TransportFault); - let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[])); + let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); assert_eq!(r.unwrap_err(), UnlockError::Transport); assert_eq!( t.calls(), @@ -1034,8 +1015,8 @@ mod tests { fn check_condition_probing_for_a_dvd_declines() { let id = DriveId::default(); let mut t = MockTransport::always(Reply::illegal_request()); - let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[])); - assert_eq!(r.unwrap_err(), UnlockError::NotApplicable); + let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); + assert!(r.expect("inconclusive probe declines").is_none()); } // Defect-2 regression: a DVD is mounted, then the bus dies mid @@ -1049,7 +1030,7 @@ mod tests { config[6] = 0x00; config[7] = 0x10; let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault); - let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[])); + let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); assert_eq!(r.unwrap_err(), UnlockError::Transport); } @@ -1061,8 +1042,8 @@ mod tests { let mut config = vec![0u8; 8]; config[7] = 0x10; let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::illegal_request()); - let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[])); - assert_eq!(r.unwrap_err(), UnlockError::NotApplicable); + let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); + assert!(r.expect("a refused bus-auth declines").is_none()); } // A CHECK CONDITION on AGID allocation must not let the handshake carry @@ -1339,9 +1320,9 @@ mod tests { read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds"); } - // The same happy path through the public `unlock_bus` entry point, - // proving it returns `Ok(Unlocked::default())` on success — the path - // every failure-injection test above deliberately avoids. + // The same happy path through the public `unlock` entry point, proving it + // returns `Some(Unlocked::default())` on success — the path every + // failure-injection test above deliberately avoids. #[test] fn dvd_unlocker_succeeds_end_to_end() { let id = DriveId::default(); @@ -1349,14 +1330,12 @@ mod tests { variant: 3, host_challenge: [0u8; 10], }; - let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[])); - let unlocked = r.expect("full unlock succeeds"); + let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); + let unlocked = r + .expect("full unlock succeeds") + .expect("DVD bus-auth unlocks the drive"); assert!(unlocked.vid.is_none(), "CSS yields no Volume ID"); assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key"); - assert!( - !unlocked.drive_unlocked, - "CSS is a bus-auth unlock, not a firmware drive-unlock" - ); } /// `unlock_css_reads` (the crate-level public entry point, distinct from diff --git a/src/freemkv/mod.rs b/src/freemkv/mod.rs index a550411..08d4329 100644 --- a/src/freemkv/mod.rs +++ b/src/freemkv/mod.rs @@ -75,16 +75,6 @@ const IDENTITY_RESPONSE_LEN: usize = 32; // transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md. const KNOCK_RESP_LEN: usize = 64; -/// The 16-byte AACS Volume ID. -const VID_LEN: usize = 16; - -/// AACS `READ DISC STRUCTURE` (`0xAD`) format for the Volume ID. -const DISC_STRUCT_FMT_VID: u8 = 0x80; -/// Response length requested for the format-0x80 VID structure: a 4-byte header, -/// the 16-byte VID, and a 16-byte MAC. On the bare-read path (no AKE → no bus -/// key) the MAC cannot be verified and is ignored; only `buf[4..20]` matters. -const VID_STRUCT_LEN: u16 = 36; - /// Bytes returned by one DumpAll (subfn 0x09) diagnostic read — the firmware /// always commits a fixed 64-byte window. const MEMREAD_LEN: usize = 64; @@ -126,20 +116,6 @@ fn build_memread_cdb(addr: u32) -> [u8; 10] { ] } -// Build the STANDARD AACS READ DISC STRUCTURE (0xAD) CDB for the Volume ID -// (format 0x80, Blu-ray, AGID 0) — NOT a vendor knock; valid only once Raw -// Read is on. Mirrors libfreemkv's read_volume_id. -fn build_read_disc_structure_vid_cdb() -> [u8; 12] { - let mut cdb = [0u8; 12]; - cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE; - cdb[1] = 0x01; // Blu-ray - cdb[7] = DISC_STRUCT_FMT_VID; - cdb[8] = (VID_STRUCT_LEN >> 8) as u8; - cdb[9] = (VID_STRUCT_LEN & 0xFF) as u8; - // cdb[10] = agid << 6; AGID 0 on the bare path (no AKE) → 0. - cdb -} - /// Whether a transport error is a genuine dead bus (a senseless /// transport-failure status) rather than a drive rejection surfaced through a /// non-conforming transport (`Err` carrying a sense). @@ -147,13 +123,14 @@ fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool { e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() } -// The freemkv custom-firmware unlocker; pub(crate) so clients reach it only via -// crate::all_unlockers. Detection: subfn-01 Identity, checking the response starts -// "freemkv" — no bundled profile catalog needed, unlike crate::ld::Mt1959Unlocker. -pub(crate) struct FreemkvUnlocker; +// The freemkv custom-firmware unlocker. Detection: subfn-01 Identity, checking +// the response starts "freemkv" — no bundled profile catalog needed, unlike +// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned. +#[derive(Default)] +pub struct FreemkvUnlocker; impl FreemkvUnlocker { - pub(crate) fn new() -> Self { + pub fn new() -> Self { FreemkvUnlocker } @@ -274,71 +251,6 @@ impl FreemkvUnlocker { self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID) } - // Read the AACS VID with a BARE 0xAD fmt 0x80 (valid only after set_raw_read): - // no AKE/cert/bus key needed; VID comes back clear at response[4..20], MAC at - // [20..36] is unverifiable and ignored. Ok/Err semantics: see docs/freemkv-abi.md. - fn read_vid( - &self, - scsi: &mut dyn ScsiTransport, - ) -> std::result::Result<[u8; VID_LEN], UnlockError> { - let cdb = build_read_disc_structure_vid_cdb(); - let mut buf = [0u8; VID_STRUCT_LEN as usize]; - let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) { - Ok(r) => r, - Err(e) => { - if is_dead_bus(&e) { - return Err(UnlockError::Transport); - } - tracing::debug!( - target: "freemkv::disc", - phase = "freemkv_vid_rejected_as_err", - status = e.status, - "bare VID read rejected (via Err); no Volume ID" - ); - return Err(UnlockError::VidUnavailable); - } - }; - // A drive sense arrives as Ok with a non-zero status; without this check - // a CHECK CONDITION's zero-filled buffer would parse as a VID. - if result.status != 0 { - tracing::debug!( - target: "freemkv::disc", - phase = "freemkv_vid_check_condition", - status = result.status, - "bare VID read returned a drive sense (no medium / Raw Read not honoured)" - ); - return Err(UnlockError::VidUnavailable); - } - // Need the 4-byte header + the 16-byte VID. - if result.bytes_transferred < 4 + VID_LEN { - tracing::debug!( - target: "freemkv::disc", - phase = "freemkv_vid_short_response", - bytes_transferred = result.bytes_transferred, - "bare VID response too short" - ); - return Err(UnlockError::VidUnavailable); - } - let mut vid = [0u8; VID_LEN]; - vid.copy_from_slice(&buf[4..4 + VID_LEN]); - // An all-zero VID is what a permissive stub or an unfilled response - // leaves behind — reject it rather than pass a bogus key downstream. - if vid.iter().all(|&b| b == 0) { - tracing::debug!( - target: "freemkv::disc", - phase = "freemkv_vid_all_zero", - "bare VID read returned an all-zero Volume ID" - ); - return Err(UnlockError::VidUnavailable); - } - tracing::debug!( - target: "freemkv::disc", - phase = "freemkv_vid_ok", - "Volume ID retrieved via bare 0xAD read" - ); - Ok(vid) - } - /// DumpAll diagnostic RAM read (subfn 0x09): return the 64-byte window at /// `addr`. A host-side diagnostic path only — not used by the unlock flow. #[allow(dead_code)] @@ -409,18 +321,17 @@ impl FreemkvUnlocker { return Err(UnlockError::VidUnavailable); } } - // Bare 0xAD VID read (LOAD-BEARING). No VID ⇒ fail (propagates). - let vid = self.read_vid(scsi)?; + // Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the + // LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss + // must not discard it: only a dead bus propagates (`?`), else `None`. + let vid = crate::vid::read_aacs_vid(scsi)?; tracing::debug!( target: "freemkv::disc", phase = "freemkv_unlocked", - "freemkv drive unlocked (Raw Read on, VID read)" + has_vid = vid.is_some(), + "freemkv drive unlocked (Raw Read on)" ); - Ok(Unlocked { - vid: Some(vid), - bus_key: None, - drive_unlocked: true, - }) + Ok(Unlocked { vid, bus_key: None }) } } @@ -429,26 +340,18 @@ impl Unlocker for FreemkvUnlocker { "freemkv" } - /// Unlock a freemkv drive at drive-prep: recognise it, lift riplock and - /// region (best-effort), turn on Raw Read, and read the Volume ID with a - /// bare `0xAD`. One pass turns every capability on and returns the VID. - fn unlock_features( + /// Recognise the drive by its Identity knock, lift riplock/region + /// (best-effort), turn on Raw Read (the actual unlock), and read the Volume + /// ID with a bare `0xAD` (best-effort). `Some` when Raw Read succeeded — the + /// drive is unlocked whether or not the VID read did; `None` if it isn't a + /// freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this + /// unlocker self-identifies rather than matching on drive identity. + fn unlock( &self, scsi: &mut dyn ScsiTransport, _ctx: &UnlockCtx, - ) -> std::result::Result { - self.full_unlock(scsi) - } - - /// Same full sequence as [`Unlocker::unlock_features`]: a freemkv drive is - /// unlocked in one pass, removing bus encryption (Raw Read on) and returning - /// the Volume ID. - fn unlock_bus( - &self, - scsi: &mut dyn ScsiTransport, - _ctx: &UnlockCtx, - ) -> std::result::Result { - self.full_unlock(scsi) + ) -> std::result::Result, UnlockError> { + crate::fallthrough(self.full_unlock(scsi)) } } @@ -460,7 +363,7 @@ mod tests { use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport}; fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> { - UnlockCtx::new(id, DiscKind::Unknown, &[]) + UnlockCtx::new(id, DiscKind::Unknown) } /// Serves a fixed payload with a GOOD status (used for the Identity probe). @@ -493,9 +396,10 @@ mod tests { } /// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at - /// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). + /// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). 36 = + /// the fixed `0xAD` fmt-0x80 response length (see `crate::vid`). fn vid_ds_response(vid: [u8; 16]) -> Vec { - let mut p = vec![0u8; VID_STRUCT_LEN as usize]; + let mut p = vec![0u8; 36]; p[4..20].copy_from_slice(&vid); p } @@ -569,18 +473,6 @@ mod tests { ); } - /// The bare VID read is the STANDARD 0xAD READ DISC STRUCTURE (format 0x80, - /// Blu-ray, AGID 0, len 36) — NOT a vendor knock. - #[test] - fn build_read_disc_structure_vid_cdb_is_standard_0xad_fmt_80() { - let cdb = build_read_disc_structure_vid_cdb(); - assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE); - assert_eq!(cdb[1], 0x01); // Blu-ray - assert_eq!(cdb[7], 0x80); // AACS Volume ID - assert_eq!([cdb[8], cdb[9]], [0x00, 0x24]); // 36, BE16 - assert_eq!(cdb[10], 0x00); // AGID 0 (no AKE) - } - // ── Detection ──────────────────────────────────────────────────────── #[test] @@ -690,75 +582,9 @@ mod tests { assert_eq!(err, UnlockError::Transport); } - // ── Bare 0xAD VID read ─────────────────────────────────────────────── - - /// read_vid issues the STANDARD 0xAD READ DISC STRUCTURE and lifts the VID - /// from response[4..20]. - #[test] - fn read_vid_issues_standard_0xad_and_parses_offset_4() { - let vid = [0x5Au8; 16]; - let mut t = MockTransport::always(Reply::good(vid_ds_response(vid))); - let got = FreemkvUnlocker::new().read_vid(&mut t).expect("parse ok"); - assert_eq!(got, vid); - assert_eq!(t.cdbs.len(), 1, "exactly one command"); - assert_eq!(t.cdbs[0][0], crate::scsi::SCSI_READ_DISC_STRUCTURE); - assert_eq!(t.cdbs[0][7], 0x80); - } - - /// The MAC region (bytes 20..36) is ignored on the bare path — a zero MAC - /// still yields the VID. - #[test] - fn read_vid_ignores_the_unverifiable_mac() { - let vid: [u8; 16] = [ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, - 0xEE, 0xFF, - ]; - let resp = vid_ds_response(vid); // MAC left zero - let mut t = MockTransport::always(Reply::good(resp)); - assert_eq!(FreemkvUnlocker::new().read_vid(&mut t).expect("ok"), vid); - } - - /// A response too short to carry header + VID → VidUnavailable. - #[test] - fn read_vid_short_response_is_vid_unavailable() { - let mut t = MockTransport::always(Reply::short(vid_ds_response([0x11; 16]), 19)); - assert_eq!( - FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(), - UnlockError::VidUnavailable - ); - } - - /// A CHECK CONDITION (no medium / Raw Read not honoured) → VidUnavailable, - /// never a VID parsed from the zero fill. - #[test] - fn read_vid_check_condition_is_vid_unavailable() { - let mut t = MockTransport::always(Reply::illegal_request()); - assert_eq!( - FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(), - UnlockError::VidUnavailable - ); - } - - /// An all-zero VID (permissive stub / unfilled response) is rejected. - #[test] - fn read_vid_all_zero_is_vid_unavailable() { - let mut t = MockTransport::always(Reply::good(vid_ds_response([0u8; 16]))); - assert_eq!( - FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(), - UnlockError::VidUnavailable - ); - } - - #[test] - fn read_vid_transport_fault_propagates() { - let mut t = MockTransport::always(Reply::TransportFault); - assert_eq!( - FreemkvUnlocker::new().read_vid(&mut t).unwrap_err(), - UnlockError::Transport - ); - } - - // ── full_unlock / unlock_features / unlock_bus ─────────────────────── + // ── full_unlock / unlock ───────────────────────────────────────────── + // The bare 0xAD VID read itself is tested in crate::vid; here we cover the + // full unlock sequence end-to-end, including the best-effort VID handling. /// The full unlock runs `01→03→02→04→AD` in order: identity, region, speed, /// raw-read, bare VID read. This is the load-bearing sequence test. @@ -776,11 +602,11 @@ mod tests { Reply::TransportFault, ); let id = crate::DriveId::default(); - let u = FreemkvUnlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .expect("recognized"); - assert!(u.drive_unlocked, "Raw Read on ⇒ drive unlocked"); - assert_eq!(u.vid, Some(vid)); + let out = FreemkvUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("no fault") + .expect("Raw Read on ⇒ unlocked"); + assert_eq!(out.vid, Some(vid)); // Four vendor knocks then the standard 0xAD read. assert_eq!(t.cdbs.len(), 5); let vendor_subfns: Vec = t.cdbs[..4].iter().map(|c| c[4]).collect(); @@ -804,17 +630,18 @@ mod tests { Reply::TransportFault, ); let id = crate::DriveId::default(); - let u = FreemkvUnlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .expect("recognized"); - assert_eq!(u.vid, Some(vid)); - assert!(u.drive_unlocked); + let out = FreemkvUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("no fault") + .expect("unlocked"); + assert_eq!(out.vid, Some(vid)); } - /// Raw Read is LOAD-BEARING: if the drive rejects it, the unlock FAILS with - /// VidUnavailable — no fallback. + /// Raw Read is LOAD-BEARING: if the drive rejects it, this isn't an + /// unlockable freemkv drive, so `unlock()` declines (`Ok(false)`) and falls + /// through — never a hard error. #[test] - fn full_unlock_fails_when_raw_read_rejected() { + fn declines_when_raw_read_rejected() { let mut t = MockTransport::scripted( vec![ Reply::good(freemkv_identity_payload()), // identity @@ -825,16 +652,20 @@ mod tests { Reply::TransportFault, ); let id = crate::DriveId::default(); - let err = FreemkvUnlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .unwrap_err(); - assert_eq!(err, UnlockError::VidUnavailable); + assert!( + FreemkvUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("no fault") + .is_none() + ); } - /// No VID (Raw Read accepted but the bare read yields nothing) is a FAIL, - /// not an Ok with vid: None. + /// Best-effort VID: Raw Read succeeded (drive unlocked), but the bare VID + /// read was rejected — `unlock()` still returns `true`, with `vid() == None`. + /// This is the fix: the old load-bearing VID aborted the whole unlock here, + /// dropping a genuinely-unlocked drive to the cert path. #[test] - fn full_unlock_fails_when_no_vid() { + fn unlocks_without_vid_when_bare_read_fails() { let mut t = MockTransport::scripted( vec![ Reply::good(freemkv_identity_payload()), // identity @@ -846,62 +677,35 @@ mod tests { Reply::TransportFault, ); let id = crate::DriveId::default(); - let err = FreemkvUnlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .unwrap_err(); - assert_eq!(err, UnlockError::VidUnavailable); + let out = FreemkvUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("no fault") + .expect("unlocked despite no VID"); + assert_eq!(out.vid, None); } #[test] - fn unlock_features_not_applicable_on_non_freemkv_drive() { + fn declines_non_freemkv_drive() { let mut t = MockTransport::always(Reply::illegal_request()); let id = crate::DriveId::default(); - let err = FreemkvUnlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .unwrap_err(); - assert_eq!(err, UnlockError::NotApplicable); + assert!( + FreemkvUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("no fault") + .is_none() + ); } #[test] - fn unlock_features_transport_fault_propagates() { + fn transport_fault_propagates() { let mut t = MockTransport::always(Reply::TransportFault); let id = crate::DriveId::default(); - let err = FreemkvUnlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .unwrap_err(); - assert_eq!(err, UnlockError::Transport); - } - - /// unlock_bus runs the same full sequence and reports the VID + unlocked. - #[test] - fn unlock_bus_runs_full_sequence_and_reports_unlocked() { - let vid = [0x9Au8; 16]; - let mut t = MockTransport::scripted( - vec![ - Reply::good(freemkv_identity_payload()), - Reply::good(vec![]), - Reply::good(vec![]), - Reply::good(vec![]), - Reply::good(vid_ds_response(vid)), - ], - Reply::TransportFault, + assert_eq!( + FreemkvUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .unwrap_err(), + UnlockError::Transport ); - let id = crate::DriveId::default(); - let u = FreemkvUnlocker::new() - .unlock_bus(&mut t, &ctx(&id)) - .expect("bus removed"); - assert!(u.drive_unlocked); - assert_eq!(u.vid, Some(vid)); - } - - #[test] - fn unlock_bus_not_applicable_on_non_freemkv_drive() { - let mut t = MockTransport::always(Reply::illegal_request()); - let id = crate::DriveId::default(); - let err = FreemkvUnlocker::new() - .unlock_bus(&mut t, &ctx(&id)) - .unwrap_err(); - assert_eq!(err, UnlockError::NotApplicable); } // ── DumpAll (subfn 0x09) ───────────────────────────────────────────── diff --git a/src/ld/cdb.rs b/src/ld/cdb.rs index e9feb3e..a64b893 100644 --- a/src/ld/cdb.rs +++ b/src/ld/cdb.rs @@ -1,7 +1,7 @@ //! Public unlock-CDB seam. //! //! These items expose the *minimum* an external consumer (e.g. the bdemu -//! drive emulator) needs to recognise and answer the Mt1959Unlocker unlock +//! drive emulator) needs to recognise and answer the LdUnlocker unlock //! READ_BUFFER handshake, without that consumer open-coding the //! handshake internals. The concrete CDB shapes and the verification //! marker are unlock-handshake details and must live ONLY in this crate. @@ -15,7 +15,7 @@ pub const UNLOCK_MARKER: &[u8] = b"MMkv"; /// Returns `true` when a READ_BUFFER (`0x3C`) CDB with the given mode /// (`cdb[1] & 0x1F`) and buffer id (`cdb[2]`) is an unlock-handshake -/// read — i.e. one of the Mt1959Unlocker unlock variants. +/// read — i.e. one of the LdUnlocker unlock variants. /// /// This is the single source of truth for the unlock READ_BUFFER CDB /// shapes; consumers must call it rather than hardcoding the mode / diff --git a/src/ld/mod.rs b/src/ld/mod.rs index eafa519..88dc552 100644 --- a/src/ld/mod.rs +++ b/src/ld/mod.rs @@ -46,14 +46,14 @@ pub fn profile(drive_id: &DriveId) -> Option { #[cfg(feature = "emulation")] pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer}; -// The MT1959 unlocker, reached only through `crate::all_unlockers` (never by -// name). Matches a drive against the bundled profile database and runs the -// firmware-unlock + disc-speed-calibration handshake over raw SCSI. -pub(crate) struct Mt1959Unlocker; +// The MT1959 unlocker. Matches a drive against the bundled profile database and +// runs the firmware-unlock + disc-speed-calibration handshake over raw SCSI. +// Stateless: `unlock()` returns what it learned. +pub struct LdUnlocker; -impl Mt1959Unlocker { - pub(crate) fn new() -> Self { - Mt1959Unlocker +impl LdUnlocker { + pub fn new() -> Self { + LdUnlocker } } @@ -61,10 +61,10 @@ impl Mt1959Unlocker { /// drive-info "is this drive supported?" display), or `None`. A pure profile /// lookup — does NOT touch the drive or unlock anything. pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> { - profile::find_bundled(id).map(|_| "MT1959") + profile::find_bundled(id).map(|_| "LD") } -impl Mt1959Unlocker { +impl LdUnlocker { // Read the OEM Volume ID via the matched profile's vendor CDB (profile passed // in to avoid a redundant 206-entry catalog scan). Ok(Some) on a well-formed // response; Ok(None) if unreadable; Err only on a transport fault. @@ -118,7 +118,7 @@ impl Mt1959Unlocker { } } -impl Mt1959Unlocker { +impl LdUnlocker { // The MediaTek firmware unlock. Since it removes AACS at the drive (clear // content), this one op satisfies both features and bus-removal, so both // trait methods delegate here. See docs/ld-mod.md — firmware_unlock contract. @@ -175,38 +175,26 @@ impl Mt1959Unlocker { ); } let vid = self.read_oem_vid(scsi, &m.profile)?; - Ok(Unlocked { - vid, - bus_key: None, - drive_unlocked: true, - }) + Ok(Unlocked { vid, bus_key: None }) } } -impl Unlocker for Mt1959Unlocker { +impl Unlocker for LdUnlocker { fn name(&self) -> &'static str { - "MT1959" + "LD" } - /// Mt1959Unlocker provides drive features (riplock/speed, OEM VID) — and, because - /// its firmware unlock serves clear content, bus removal comes free with it. - fn unlock_features( + /// Match the drive against the bundled profile database and run the firmware + /// unlock — one op that removes bus encryption at the drive (clear content) + /// and reads the OEM Volume ID (best-effort). `Some` when the firmware + /// handshake reached the extended-access state; `None` for an unknown drive + /// or an incomplete handshake; `Err(Transport)` on a dead bus. + fn unlock( &self, scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx, - ) -> std::result::Result { - self.firmware_unlock(scsi, ctx) - } - - /// Same firmware code as [`unlock_features`]: Mt1959Unlocker removes the bus at - /// the drive. In practice the consumer skips this because drive-prep already - /// set `drive_unlocked`; it's here for completeness / a bus-first call order. - fn unlock_bus( - &self, - scsi: &mut dyn ScsiTransport, - ctx: &UnlockCtx, - ) -> std::result::Result { - self.firmware_unlock(scsi, ctx) + ) -> std::result::Result, UnlockError> { + crate::fallthrough(self.firmware_unlock(scsi, ctx)) } } @@ -219,7 +207,7 @@ mod tests { /// Unlock context for a fake drive id (kind/host-certs irrelevant to the /// firmware unlocker — it keys off the drive identity). fn ctx(id: &DriveId) -> UnlockCtx<'_> { - UnlockCtx::new(id, DiscKind::Unknown, &[]) + UnlockCtx::new(id, DiscKind::Unknown) } /// A fake transport that fills the response buffer from a fixed payload and @@ -285,7 +273,7 @@ mod tests { payload, bytes_transferred: 36, }; - let got = Mt1959Unlocker::new() + let got = LdUnlocker::new() .read_oem_vid(&mut t, &known_vid_profile()) .expect("parse ok"); assert_eq!(got, Some(vid), "VID parsed from [4..20]"); @@ -298,7 +286,7 @@ mod tests { payload: vec![0u8; 36], bytes_transferred: 20, }; - let got = Mt1959Unlocker::new() + let got = LdUnlocker::new() .read_oem_vid(&mut t, &known_vid_profile()) .expect("short response is Ok(None)"); assert_eq!(got, None); @@ -313,7 +301,7 @@ mod tests { payload, bytes_transferred: 36, }; - let got = Mt1959Unlocker::new() + let got = LdUnlocker::new() .read_oem_vid(&mut t, &known_vid_profile()) .expect("bad header is Ok(None)"); assert_eq!(got, None); @@ -326,7 +314,7 @@ mod tests { fn read_oem_vid_check_condition_is_none_not_a_vid() { use crate::scsi::mock::{MockTransport, Reply}; let mut t = MockTransport::always(Reply::illegal_request()); - let got = Mt1959Unlocker::new() + let got = LdUnlocker::new() .read_oem_vid(&mut t, &known_vid_profile()) .expect("a drive sense is not a transport fault"); assert_eq!(got, None, "a CHECK CONDITION must never yield a VID"); @@ -338,7 +326,7 @@ mod tests { fn read_oem_vid_transport_fault_propagates() { use crate::scsi::mock::{MockTransport, Reply}; let mut t = MockTransport::always(Reply::TransportFault); - let err = Mt1959Unlocker::new() + let err = LdUnlocker::new() .read_oem_vid(&mut t, &known_vid_profile()) .expect_err("a dead bus must not be Ok(None)"); assert!(err.is_transport_failure()); @@ -354,7 +342,7 @@ mod tests { payload: vec![0u8; 36], bytes_transferred: 36, }; - let got = Mt1959Unlocker::new() + let got = LdUnlocker::new() .read_oem_vid(&mut t, &p) .expect("no CDB is Ok(None)"); assert_eq!(got, None); @@ -377,13 +365,13 @@ mod tests { payload: vec![0u8; 36], bytes_transferred: 36, }; - let err = Mt1959Unlocker::new() - .unlock_features( + let unlocked = LdUnlocker::new() + .unlock( &mut t, &ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")), ) - .expect_err("no profile → NotApplicable"); - assert_eq!(err, UnlockError::NotApplicable); + .expect("no profile → declines, not a hard error"); + assert!(unlocked.is_none()); } // THE defect-1 test: response carries the signature + primary marker but not @@ -405,17 +393,20 @@ mod tests { resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); let mut t = MockTransport::always(Reply::good(resp)); - let err = Mt1959Unlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .expect_err("a half-unlocked drive must fall through to cert-auth"); - assert_eq!(err, UnlockError::NotApplicable); + let unlocked = LdUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("a half-unlock declines, not a hard error"); + assert!( + unlocked.is_none(), + "a half-unlocked drive must fall through to cert-auth" + ); } - /// The whole-unlock happy path still reports `drive_unlocked` when BOTH - /// firmware markers are present — the `is_unlocked()` gate must not have - /// made a genuinely unlocked drive fall through. + /// The whole-unlock happy path still reports unlocked when BOTH firmware + /// markers are present — the `is_unlocked()` gate must not have made a + /// genuinely unlocked drive fall through. #[test] - fn fully_unlocked_drive_reports_drive_unlocked() { + fn fully_unlocked_drive_reports_unlocked() { use crate::scsi::mock::{MockTransport, Reply}; let id = known_vid_drive_id(); let sig = profile::find_bundled(&id) @@ -429,10 +420,13 @@ mod tests { resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); let mut t = MockTransport::always(Reply::good(resp)); - let u = Mt1959Unlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .expect("both markers → unlocked"); - assert!(u.drive_unlocked); + assert!( + LdUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("no fault") + .is_some(), + "both markers → unlocked" + ); } // THE probe-disc dead-bus test: bus dies during speed calibration after a @@ -481,8 +475,8 @@ mod tests { resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker let mut t = ProbeFaultsDrive { resp }; - let err = Mt1959Unlocker::new() - .unlock_features(&mut t, &ctx(&id)) + let err = LdUnlocker::new() + .unlock(&mut t, &ctx(&id)) .expect_err("a dead bus during probe must abort, not report success"); assert_eq!(err, UnlockError::Transport); } @@ -532,34 +526,12 @@ mod tests { } } let mut t = ProbeSenseDrive { resp }; - let u = Mt1959Unlocker::new() - .unlock_features(&mut t, &ctx(&id)) - .expect("a calibration miss must not fail the whole unlock"); - assert!(u.drive_unlocked); - } - - /// `unlock_bus` is the same firmware code as `unlock_features` (bus removal - /// comes free with the drive unlock) — pins that the trait method actually - /// delegates rather than being an accidental no-op stub. - #[test] - fn unlock_bus_delegates_to_the_same_firmware_unlock() { - use crate::scsi::mock::{MockTransport, Reply}; - let id = known_vid_drive_id(); - let sig = profile::find_bundled(&id) - .expect("profile") - .profile - .signature; - - let mut resp = vec![0u8; 64]; - resp[0..4].copy_from_slice(&sig); - resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); - resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); - - let mut t = MockTransport::always(Reply::good(resp)); - let u = Mt1959Unlocker::new() - .unlock_bus(&mut t, &ctx(&id)) - .expect("unlock_bus runs the same firmware handshake"); - assert!(u.drive_unlocked); + assert!( + LdUnlocker::new() + .unlock(&mut t, &ctx(&id)) + .expect("a calibration miss must not fail the whole unlock") + .is_some() + ); } /// Catches classifying a dead bus as "not this unlocker's drive": the very @@ -569,8 +541,8 @@ mod tests { fn transport_fault_during_unlock_is_transport_not_not_applicable() { use crate::scsi::mock::{MockTransport, Reply}; let mut t = MockTransport::always(Reply::TransportFault); - let err = Mt1959Unlocker::new() - .unlock_features(&mut t, &ctx(&known_vid_drive_id())) + let err = LdUnlocker::new() + .unlock(&mut t, &ctx(&known_vid_drive_id())) .expect_err("dead bus"); assert_eq!(err, UnlockError::Transport); } diff --git a/src/ld/platform/mod.rs b/src/ld/platform/mod.rs index 1084d3a..9a27d05 100644 --- a/src/ld/platform/mod.rs +++ b/src/ld/platform/mod.rs @@ -1,4 +1,4 @@ -//! Platform-specific drive unlock + disc probing (Mt1959Unlocker internals). +//! Platform-specific drive unlock + disc probing (LdUnlocker internals). pub mod mt1959; @@ -18,7 +18,7 @@ pub(crate) trait PlatformDriver: Send { fn is_ready(&self) -> bool; /// True if the drive is currently in the extended-access state. THE gate - /// `Mt1959Unlocker::firmware_unlock` reports `drive_unlocked` from — `init()` + /// `LdUnlocker::firmware_unlock` reports `drive_unlocked` from — `init()` /// returning `Ok` only means the handshake completed, not that the drive /// serves clear content. fn is_unlocked(&self) -> bool { diff --git a/src/ld/profile.rs b/src/ld/profile.rs index 1dbb2d5..a5e1501 100644 --- a/src/ld/profile.rs +++ b/src/ld/profile.rs @@ -3,7 +3,7 @@ use crate::ld::error::{Error, Result}; use serde::Deserialize; -/// The Mt1959Unlocker profile catalog — the set of optical drives the firmware +/// The LdUnlocker profile catalog — the set of optical drives the firmware /// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled /// JSON; the public entry point is [`crate::ld::profiles`]. #[derive(Debug, Deserialize)] @@ -286,7 +286,7 @@ pub fn bundled() -> Option<&'static Profiles> { target: "freemkv::disc", phase = "bundled_profiles_parse_failed", error_code = e.code(), - "bundled Mt1959Unlocker profile catalog failed to parse; no drive can match" + "bundled LdUnlocker profile catalog failed to parse; no drive can match" ); None } diff --git a/src/lib.rs b/src/lib.rs index 12e2b9e..dbfc31e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,13 +6,16 @@ //! //! This crate defines the [`Unlocker`] contract + the SCSI transport //! contract, and holds the self-contained unlocker modules (firmware / AACS -//! cert / CSS). libfreemkv depends on this crate and dispatches via -//! [`all_unlockers`]; it never names an individual unlocker directly. +//! cert / CSS). The consumer (libfreemkv) assembles its own dispatch list from +//! the exported unlocker types and drives them all through the same trait. pub mod scsi; mod aacs; mod css; +// Shared best-effort AACS Volume ID read, used by every route that opens the +// drive to raw reads (freemkv / MT1959 / Renesas). See `vid`. +mod vid; // `ld` is public only for its drive-profile catalog + (under `emulation`) the // handshake wire format bdemu needs; the unlocker impl stays `pub(crate)`. // See docs/module-visibility.md — module visibility rationale. @@ -27,6 +30,15 @@ mod freemkv; use scsi::ScsiTransport; +// The five unlockers, exposed as concrete types so the consumer assembles its +// own dispatch list and injects each one's deps at construction (certs → AACS) +// — no central factory to thread another unlocker's config through. +pub use aacs::AacsUnlocker; +pub use css::DvdUnlocker; +pub use freemkv::FreemkvUnlocker; +pub use ld::LdUnlocker; +pub use renesas::Renesas; + /// Drive identity an unlocker matches against — four raw INQUIRY-derived fields, /// filled by the consumer (this crate parses no INQUIRY itself). #[derive(Debug, Clone, Default)] @@ -61,32 +73,31 @@ pub struct HostCert { pub certificate_v2: Option>, } -/// Context handed to an unlocker: drive identity, disc kind, and (for the cert -/// route) the host certs the consumer collected. +/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the +/// drive identity and the mounted disc's kind. These are the shared facts the +/// dispatch loop knows; anything an individual unlocker needs beyond them (the +/// AACS cert route's host certs) is injected into THAT unlocker at construction, +/// so the loop and the other unlockers never see it. See docs/module-visibility.md. pub struct UnlockCtx<'a> { pub drive_id: &'a DriveId, pub kind: DiscKind, - pub host_certs: &'a [HostCert], } impl<'a> UnlockCtx<'a> { - pub fn new(drive_id: &'a DriveId, kind: DiscKind, host_certs: &'a [HostCert]) -> Self { - Self { - drive_id, - kind, - host_certs, - } + pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self { + Self { drive_id, kind } } } -/// What removing bus encryption yielded. `drive_unlocked` means the drive now -/// serves clear content (firmware route) — equivalent, for the gate, to a cert -/// `bus_key`. +/// What a successful unlock captured: the Volume ID (best-effort — may be `None` +/// even on success) and, for the cert route, the bus key the read path applies +/// to de-bus content. Returned inside the `Some` of [`Unlocker::unlock`]; there +/// is no `drive_unlocked` flag — "unlocked" is simply `unlock()` returning +/// `Some`, uniformly for every route. #[derive(Clone, Default)] pub struct Unlocked { pub vid: Option<[u8; 16]>, pub bus_key: Option<[u8; 16]>, - pub drive_unlocked: bool, } // Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must @@ -97,7 +108,6 @@ impl std::fmt::Debug for Unlocked { f.debug_struct("Unlocked") .field("vid", &self.vid.map(|_| "[redacted]")) .field("bus_key", &self.bus_key.map(|_| "[redacted]")) - .field("drive_unlocked", &self.drive_unlocked) .finish() } } @@ -120,47 +130,46 @@ pub enum UnlockError { } /// An unlocker removes a drive-level bus-encryption barrier. Implementors are -/// the self-contained modules in this crate; the consumer only ever sees the -/// trait, via [`all_unlockers`]. (Each module owns its own conversion from its -/// internal error to [`UnlockError`].) +/// the self-contained modules in this crate; the consumer drives them all +/// through this trait. (Each module owns its own conversion from its internal +/// error to [`UnlockError`].) /// /// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT /// here — that is the consumer's concern, not bus removal. pub trait Unlocker: Send + Sync { - /// Short, stable identifier for this unlocker (e.g. "MT1959", "AACS", - /// "DVD", "Renesas"). The ONE place a name lives — apps render the unlocker - /// report from [`all_unlockers`], never hardcoding names, so adding/removing + /// Short, stable identifier for this unlocker (e.g. "freemkv", "LD", + /// "AACS", "DVD", "Renesas"). The ONE place a name lives — apps render the + /// unlocker report from `name()`, never hardcoding names, so adding/removing /// an unlocker updates every report with no app change. fn name(&self) -> &'static str; - /// Unlock DRIVE FEATURES — riplock/speed, OEM Volume ID, OEM extended-access - /// reads. The consumer runs this at drive-prep, trying each unlocker until - /// one handles the drive. `Ok(_)` = this unlocker handled it (Mt1959Unlocker also - /// removes bus encryption at the drive, so its result carries - /// `drive_unlocked: true`); `Err(NotApplicable)` = not this unlocker's drive; - /// `Err(Transport)` = dead bus (the consumer aborts). Default: not provided. - fn unlock_features( + /// Attempt to unlock the drive, whatever the mechanism (vendor CDB, profile + /// firmware handshake, AACS cert AKE, CSS auth): + /// `Ok(Some(unlocked))` = this one unlocked it (read `vid`/`bus_key`, stop); + /// `Ok(None)` = not this unlocker's drive (try the next); `Err(Transport)` = + /// dead bus (abort). Only a dead bus is `Err` — a missing VID, rejected + /// handshake, missing cert, or wrong disc kind all fall through as `Ok(None)` + /// (the VID inside `Unlocked` is best-effort). `&self`: stateless, returns + /// what it learned rather than stashing it. + fn unlock( &self, scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx, - ) -> std::result::Result { - let _ = (scsi, ctx); - Err(UnlockError::NotApplicable) - } + ) -> std::result::Result, UnlockError>; +} - /// Remove BUS ENCRYPTION for the mounted disc (AACS host-cert handshake, or - /// CSS scrambled-sector auth). The consumer runs this only when the bus isn't - /// already clear, trying each unlocker until one handles it. Same `Ok` / - /// `Err(NotApplicable)` / `Err(Transport)` contract as - /// [`Unlocker::unlock_features`]. - /// Default: not provided. - fn unlock_bus( - &self, - scsi: &mut dyn ScsiTransport, - ctx: &UnlockCtx, - ) -> std::result::Result { - let _ = (scsi, ctx); - Err(UnlockError::NotApplicable) +/// The shared "only a dead bus is fatal" rule every unlocker uses to turn its +/// mechanism's `Result` into the trait's outcome: success → `Some`, a +/// dead bus → `Err(Transport)`, any other failure → `None` (fall through to the +/// next unlocker). Written once here so the five unlockers stay identical in +/// error handling and differ only in their CDBs and checks. +pub(crate) fn fallthrough( + r: std::result::Result, +) -> std::result::Result, UnlockError> { + match r { + Ok(u) => Ok(Some(u)), + Err(UnlockError::Transport) => Err(UnlockError::Transport), + Err(_) => Ok(None), } } @@ -173,75 +182,46 @@ pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> { ld::firmware_name(drive_id) } -/// Every unlocker, in dispatch order (freemkv → firmware → cert → css). This -/// is the ONLY place an unlocker is named. Remove one = delete its line here + -/// its module dir; the consumer never changes. -/// -/// `FreemkvUnlocker` runs FIRST: its detection is a single, unambiguous -/// self-identification probe (the drive's own firmware answers `"freemkv"`) -/// rather than a profile-catalog match, so it is the cheapest and most -/// certain check to run before falling back to the MT1959 profile lookup or -/// the Renesas/cert/css routes. -pub fn all_unlockers() -> Vec> { - vec![ - Box::new(freemkv::FreemkvUnlocker::new()), - Box::new(ld::Mt1959Unlocker::new()), - Box::new(renesas::Renesas::new()), - Box::new(aacs::AacsCert::new()), - Box::new(css::DvdUnlocker::new()), - ] -} - #[cfg(test)] mod tests { use super::*; - // Dispatch order is a documented contract (`firmware → cert → css`); the - // consumer stops at the first unlocker that doesn't decline, so reordering - // this list silently changes which unlocker claims a drive. - #[test] - fn all_unlockers_dispatch_order_is_firmware_then_cert_then_css() { - let names: Vec<&'static str> = all_unlockers().iter().map(|u| u.name()).collect(); - assert_eq!(names, vec!["freemkv", "MT1959", "Renesas", "AACS", "DVD"]); + // The canonical dispatch order the consumer assembles (freemkv → firmware → + // Renesas → cert → css). It lives in the consumer now (no factory), but the + // types are ours, so pin their names here so a rename is caught crate-local. + fn canonical_unlockers() -> Vec> { + vec![ + Box::new(FreemkvUnlocker::new()), + Box::new(LdUnlocker::new()), + Box::new(Renesas::new()), + Box::new(AacsUnlocker::new(Vec::new())), + Box::new(DvdUnlocker::new()), + ] } - /// Every unlocker declines by default rather than claiming a capability it - /// does not provide — and declines WITHOUT touching the transport, so the - /// consumer can iterate the whole registry on any drive. #[test] - fn unlockers_decline_capabilities_they_do_not_provide() { - struct DeadTransport; - impl scsi::ScsiTransport for DeadTransport { - fn execute( - &mut self, - _cdb: &[u8], - _dir: scsi::DataDirection, - _data: &mut [u8], - _timeout_ms: u32, - ) -> scsi::Result { - panic!("a declining unlocker must not issue a CDB"); - } - } - let id = DriveId::default(); // matches no bundled profile - let ctx = UnlockCtx::new(&id, DiscKind::Unencrypted, &[]); - let mut t = DeadTransport; - for u in all_unlockers() { - // AACS/DVD provide no drive features; Mt1959Unlocker/Renesas are the - // feature unlockers but decline an unknown drive identity. + fn unlocker_names_are_stable() { + let names: Vec<&'static str> = canonical_unlockers().iter().map(|u| u.name()).collect(); + assert_eq!(names, vec!["freemkv", "LD", "Renesas", "AACS", "DVD"]); + } + + /// The uniform contract every unlocker obeys, whatever its mechanism: on a + /// DEAD BUS, `unlock()` either declined before touching it (`Ok(false)`) or + /// engaged it and reported the fault (`Err(Transport)`) — but NEVER claims a + /// dead bus as unlocked, and NEVER surfaces a non-`Transport` hard error. + /// This is the guardrail that was missing: it holds the next new unlocker to + /// the same discipline the loop relies on. + #[test] + fn no_unlocker_claims_a_dead_bus_or_hard_errors() { + let id = DriveId::default(); + let ctx = UnlockCtx::new(&id, DiscKind::Aacs); + for u in canonical_unlockers() { let name = u.name(); - if name == "AACS" || name == "DVD" { - assert_eq!( - u.unlock_features(&mut t, &ctx).unwrap_err(), - UnlockError::NotApplicable, - "{name} must not claim drive features" - ); - } - if name == "Renesas" { - assert_eq!( - u.unlock_bus(&mut t, &ctx).unwrap_err(), - UnlockError::NotApplicable, - "{name} leaves bus removal to the cert unlocker" - ); + let mut t = scsi::mock::MockTransport::always(scsi::mock::Reply::TransportFault); + match u.unlock(&mut t, &ctx) { + Ok(None) => {} // declined before touching — fine + Err(UnlockError::Transport) => {} // engaged, dead bus — fine + other => panic!("{name} violated the dead-bus contract: {other:?}"), } } } @@ -261,7 +241,6 @@ mod tests { let u = Unlocked { vid: Some([0xAB; 16]), bus_key: Some([0xCD; 16]), - drive_unlocked: true, }; let s = format!("{u:?}"); // A derived Debug renders `[171, 171, ...]` (0xAB) / `[205, ...]` (0xCD). @@ -270,6 +249,5 @@ mod tests { assert!(s.contains("[redacted]"), "must mark redaction: {s}"); // Presence (Some/None) stays observable. assert!(s.contains("Some"), "must still show a key WAS present: {s}"); - assert!(s.contains("drive_unlocked: true")); } } diff --git a/src/renesas/mod.rs b/src/renesas/mod.rs index 631612c..d41cb24 100644 --- a/src/renesas/mod.rs +++ b/src/renesas/mod.rs @@ -57,12 +57,15 @@ pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result Self { + pub fn new() -> Self { Renesas } @@ -119,16 +122,19 @@ impl Unlocker for Renesas { "Renesas" } - // Gate on the `0xF1` SAT identity, then the vendor open read `0xB0@0x04`; - // `drive_unlocked: false` since AACS bus decryption is the cert's job. - // See docs/renesas-mod.md for the full MakeMKV-parity rationale. - fn unlock_features( + /// Gate on the `0xF1` SAT identity, then the vendor open read `0xB0@0x04` + /// (LOAD-BEARING — it's the unlock), then read the Volume ID with the shared + /// best-effort `0xAD` reader. `Some` once the open succeeds — the drive + /// serves clear content whether or not the bonus VID read did; `None` if it + /// isn't a Renesas drive or the open is refused; `Err(Transport)` on a dead + /// bus. See docs/renesas-mod.md for the MakeMKV-parity rationale. + fn unlock( &self, scsi: &mut dyn ScsiTransport, _ctx: &UnlockCtx, - ) -> std::result::Result { + ) -> std::result::Result, UnlockError> { if !is_renesas(scsi)? { - return Err(UnlockError::NotApplicable); + return Ok(None); } if !Self::vendor_open(scsi)? { tracing::debug!( @@ -136,18 +142,19 @@ impl Unlocker for Renesas { phase = "renesas_open_rejected", "Renesas drive recognized but RB 0xB0@0x04 refused; deferring to next unlocker" ); - return Err(UnlockError::NotApplicable); + return Ok(None); } + // The open enabled raw reads (extended-access) — read the VID exactly as + // the firmware routes do. Best-effort: a VID miss must not discard the + // unlock (only a dead bus propagates via `?`). + let vid = crate::vid::read_aacs_vid(scsi)?; tracing::debug!( target: "freemkv::disc", phase = "renesas_opened", - "Renesas drive opened (RB 0xB0@0x04 GOOD); bus handled by cert" + has_vid = vid.is_some(), + "Renesas drive opened (RB 0xB0@0x04 GOOD, raw reads enabled)" ); - Ok(Unlocked { - vid: None, - bus_key: None, - drive_unlocked: false, - }) + Ok(Some(Unlocked { vid, bus_key: None })) } } @@ -240,33 +247,35 @@ mod tests { } #[test] - fn features_report_match_without_bus_removal_on_renesas() { + fn opened_renesas_reports_unlocked_and_reads_vid() { let mut t = RenesasTransport { payload: renesas_payload(), }; let id = crate::DriveId::default(); - let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]); - // Recognized → Ok, but a feature-only unlock: bus NOT removed, no VID. - let u = Renesas::new() - .unlock_features(&mut t, &ctx) - .expect("renesas → Ok"); - assert!( - !u.drive_unlocked, - "renesas does not remove the bus (cert does)" - ); - assert_eq!(u.vid, None); - assert_eq!(u.bus_key, None); + let ctx = UnlockCtx::new(&id, DiscKind::Unknown); + // Recognized + vendor-open GOOD → unlocked; the open enables raw reads, + // so the standard 0xAD VID read returns a (non-zero) VID best-effort. + let out = Renesas::new() + .unlock(&mut t, &ctx) + .expect("no fault") + .expect("renesas → unlocked"); + assert!(out.vid.is_some(), "opened drive yields a VID via 0xAD"); + assert_eq!(out.bus_key, None, "raw-read route needs no bus key"); } /// A drive REJECTION (ILLEGAL REQUEST, with a sense) is "not a Renesas - /// drive" → fall through to the next unlocker. + /// drive" → `Ok(false)`, fall through to the next unlocker. #[test] - fn features_not_applicable_on_non_renesas() { + fn declines_non_renesas() { let mut t = RejectingTransport; let id = crate::DriveId::default(); - let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]); - let err = Renesas::new().unlock_features(&mut t, &ctx).unwrap_err(); - assert_eq!(err, UnlockError::NotApplicable); + let ctx = UnlockCtx::new(&id, DiscKind::Unknown); + assert!( + Renesas::new() + .unlock(&mut t, &ctx) + .expect("non-renesas declines") + .is_none() + ); } // Same rejection via a CONFORMING transport (`Ok` + CHECK CONDITION); @@ -289,9 +298,9 @@ mod tests { let mut t = MockTransport::always(Reply::TransportFault); let id = crate::DriveId::default(); - let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]); + let ctx = UnlockCtx::new(&id, DiscKind::Unknown); assert_eq!( - Renesas::new().unlock_features(&mut t, &ctx).unwrap_err(), + Renesas::new().unlock(&mut t, &ctx).unwrap_err(), UnlockError::Transport ); } @@ -333,22 +342,13 @@ mod tests { } let mut t = GateOkOpenRefused; let id = crate::DriveId::default(); - let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]); - assert_eq!( - Renesas::new().unlock_features(&mut t, &ctx).unwrap_err(), - UnlockError::NotApplicable + let ctx = UnlockCtx::new(&id, DiscKind::Unknown); + assert!( + Renesas::new() + .unlock(&mut t, &ctx) + .expect("open refused → declines, not a hard error") + .is_none(), + "a refused vendor open must defer to the next unlocker" ); } - - #[test] - fn does_not_provide_bus_removal() { - // Renesas leaves bus encryption to the cert: unlock_bus is the default. - let mut t = RenesasTransport { - payload: renesas_payload(), - }; - let id = crate::DriveId::default(); - let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &[]); - let err = Renesas::new().unlock_bus(&mut t, &ctx).unwrap_err(); - assert_eq!(err, UnlockError::NotApplicable); - } } diff --git a/src/vid.rs b/src/vid.rs new file mode 100644 index 0000000..7855781 --- /dev/null +++ b/src/vid.rs @@ -0,0 +1,181 @@ +//! Shared AACS Volume ID read — the standard `READ DISC STRUCTURE` (`0xAD`, +//! format `0x80`) that returns the VID on a drive whose host-auth / bus has +//! already been opened by a firmware or vendor CDB unlock (freemkv Raw Read, +//! MT1959, or a Pioneer/Renesas vendor open). Because those routes put the +//! drive in the same raw-read state, the VID read is identical across them — so +//! it lives here once. +//! +//! BEST-EFFORT: only a dead bus is an `Err(Transport)`. A CHECK CONDITION, a +//! short response, or an all-zero VID all yield `Ok(None)` — a VID miss must +//! never discard an unlock that already removed the bus (a key source can still +//! supply the key). See docs/freemkv-abi.md for the fixed-length reply rules. + +use crate::UnlockError; +use crate::scsi::{DataDirection, ScsiTransport}; + +/// The 16-byte AACS Volume ID. +const VID_LEN: usize = 16; +/// `READ DISC STRUCTURE` format for the Volume ID. +const DISC_STRUCT_FMT_VID: u8 = 0x80; +/// Response length: a 4-byte header, the 16-byte VID, and a 16-byte MAC. On the +/// bare-read path (no bus key) the MAC can't be verified and is ignored. +const VID_STRUCT_LEN: u16 = 36; + +/// The standard `0xAD` fmt-`0x80` (Blu-ray, AGID 0) Volume ID CDB — NOT a vendor +/// knock; valid once the drive's bus/host-auth is open. Mirrors libfreemkv's +/// `read_volume_id`. +pub(crate) fn build_vid_cdb() -> [u8; 12] { + let mut cdb = [0u8; 12]; + cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE; + cdb[1] = 0x01; // Blu-ray + cdb[7] = DISC_STRUCT_FMT_VID; + cdb[8] = (VID_STRUCT_LEN >> 8) as u8; + cdb[9] = (VID_STRUCT_LEN & 0xFF) as u8; + // cdb[10] = agid << 6; AGID 0 on the bare path (no AKE) → 0. + cdb +} + +/// A senseless transport-failure status is a genuine dead bus, not a drive +/// rejection surfaced through a non-conforming transport (`Err` with a sense). +fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool { + e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() +} + +/// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive +/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for +/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on +/// a dead bus. +pub(crate) fn read_aacs_vid( + scsi: &mut dyn ScsiTransport, +) -> std::result::Result, UnlockError> { + let cdb = build_vid_cdb(); + let mut buf = [0u8; VID_STRUCT_LEN as usize]; + let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) { + Ok(r) => r, + Err(e) => { + if is_dead_bus(&e) { + return Err(UnlockError::Transport); + } + tracing::debug!( + target: "freemkv::disc", + phase = "vid_rejected_as_err", + status = e.status, + "bare VID read rejected (via Err); no Volume ID" + ); + return Ok(None); + } + }; + // A drive sense arrives as Ok with a non-zero status; without this check a + // CHECK CONDITION's zero-filled buffer would parse as a VID. + if result.status != 0 { + tracing::debug!( + target: "freemkv::disc", + phase = "vid_check_condition", + status = result.status, + "bare VID read returned a drive sense (no medium / drive not open)" + ); + return Ok(None); + } + // Need the 4-byte header + the 16-byte VID. + if result.bytes_transferred < 4 + VID_LEN { + tracing::debug!( + target: "freemkv::disc", + phase = "vid_short_response", + bytes_transferred = result.bytes_transferred, + "bare VID response too short" + ); + return Ok(None); + } + let mut vid = [0u8; VID_LEN]; + vid.copy_from_slice(&buf[4..4 + VID_LEN]); + // An all-zero VID is what a permissive stub or an unfilled response leaves + // behind — reject it rather than pass a bogus key downstream. + if vid.iter().all(|&b| b == 0) { + tracing::debug!( + target: "freemkv::disc", + phase = "vid_all_zero", + "bare VID read returned an all-zero Volume ID" + ); + return Ok(None); + } + tracing::debug!(target: "freemkv::disc", phase = "vid_ok", "Volume ID retrieved via bare 0xAD read"); + Ok(Some(vid)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scsi::mock::{MockTransport, Reply}; + + /// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at + /// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). + fn vid_ds_response(vid: [u8; 16]) -> Vec { + let mut p = vec![0u8; VID_STRUCT_LEN as usize]; + p[4..20].copy_from_slice(&vid); + p + } + + /// The bare VID read is the STANDARD 0xAD READ DISC STRUCTURE (format 0x80, + /// Blu-ray, AGID 0, len 36) — NOT a vendor knock. + #[test] + fn build_vid_cdb_is_standard_0xad_fmt_80() { + let cdb = build_vid_cdb(); + assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE); + assert_eq!(cdb[1], 0x01); // Blu-ray + assert_eq!(cdb[7], 0x80); // AACS Volume ID + assert_eq!([cdb[8], cdb[9]], [0x00, 0x24]); // 36, BE16 + assert_eq!(cdb[10], 0x00); // AGID 0 (no AKE) + } + + /// Parses the VID from response[4..20] and issues exactly one 0xAD read. + #[test] + fn reads_and_parses_offset_4() { + let vid = [0x5Au8; 16]; + let mut t = MockTransport::always(Reply::good(vid_ds_response(vid))); + let got = read_aacs_vid(&mut t).expect("no fault"); + assert_eq!(got, Some(vid)); + assert_eq!(t.cdbs.len(), 1, "exactly one command"); + assert_eq!(t.cdbs[0][0], crate::scsi::SCSI_READ_DISC_STRUCTURE); + assert_eq!(t.cdbs[0][7], 0x80); + } + + /// The MAC region (bytes 20..36) is ignored on the bare path. + #[test] + fn ignores_the_unverifiable_mac() { + let vid: [u8; 16] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, + 0xEE, 0xFF, + ]; + let mut t = MockTransport::always(Reply::good(vid_ds_response(vid))); + assert_eq!(read_aacs_vid(&mut t).expect("no fault"), Some(vid)); + } + + /// A response too short to carry header + VID → best-effort `Ok(None)`. + #[test] + fn short_response_is_none() { + let mut t = MockTransport::always(Reply::short(vid_ds_response([0x11; 16]), 19)); + assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None); + } + + /// A CHECK CONDITION (no medium / drive not open) → `Ok(None)`, never a VID + /// parsed from the zero fill. + #[test] + fn check_condition_is_none() { + let mut t = MockTransport::always(Reply::illegal_request()); + assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None); + } + + /// An all-zero VID (permissive stub / unfilled response) → `Ok(None)`. + #[test] + fn all_zero_is_none() { + let mut t = MockTransport::always(Reply::good(vid_ds_response([0u8; 16]))); + assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None); + } + + /// Only a dead bus is an error. + #[test] + fn transport_fault_propagates() { + let mut t = MockTransport::always(Reply::TransportFault); + assert_eq!(read_aacs_vid(&mut t).unwrap_err(), UnlockError::Transport); + } +}