diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index 653837b..8998244 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -1635,7 +1635,7 @@ pub fn run_cert_handshake( // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::scsi::mock::{MockTransport, Reply}; @@ -1649,7 +1649,7 @@ mod tests { /// A host cert good enough to reach the SCSI steps (the crypto is exercised /// by the math tests; these tests are about the transport contract). - fn dummy_cert() -> crate::HostCert { + pub(crate) fn dummy_cert() -> crate::HostCert { crate::HostCert { private_key: [0x11u8; 20], certificate: vec![0u8; 92], @@ -1749,6 +1749,29 @@ mod tests { assert_eq!(err, crate::UnlockError::HandshakeRejected); } + /// A drive that rejects the FIRST cert (a plain cert-level rejection, not a + /// wedge or a dead bus) must roll on to the second cert — and the roll-on + /// is gated by the per-cert backoff sleep (`idx > 0`), which fires only on + /// the second and later attempts. Pins both: the loop actually retries + /// with a second host cert (not just re-reporting the first failure), and + /// the backoff genuinely elapses real time (catches deleting the sleep or + /// gating it on the wrong condition). + #[test] + fn a_rejected_first_cert_backs_off_and_tries_the_second() { + let mut t = MockTransport::always(Reply::illegal_request()); + let certs = vec![dummy_cert(), dummy_cert()]; + let started = std::time::Instant::now(); + let err = run_cert_handshake(&mut t, &certs).expect_err("both certs rejected"); + assert_eq!(err, crate::UnlockError::HandshakeRejected); + assert!( + started.elapsed() >= std::time::Duration::from_millis(900), + "must have slept the per-cert backoff before the second attempt" + ); + // Each attempt issues 4 invalidations + 1 AGID alloc (which is where + // the rejection fires) = 5 CDBs; two attempts = 10. + assert_eq!(t.calls(), 10, "must have actually issued a second attempt"); + } + /// Script a full, successful AACS 1.0 mutual auth against the mock. /// /// The drive presents a type-0x11 (AACS 2.0) certificate, which the @@ -1895,7 +1918,7 @@ mod tests { /// answer the Volume ID read with a MAC that actually verifies — the only /// way to drive `run_cert_handshake` past the VID gate and into the /// read-data-keys arm with the crate's static mocks unable to. - struct DriveEmu { + pub(crate) struct DriveEmu { drive_priv: [u8; 20], drive_x: [u8; 20], drive_y: [u8; 20], @@ -1904,11 +1927,11 @@ mod tests { /// When set, format-0x84 (read-data-keys) is served a genuine non-zero /// response instead of the default dead-bus behaviour, letting /// `run_cert_handshake` run all the way to its `Ok` return. - serve_data_keys: bool, + pub(crate) serve_data_keys: bool, } impl DriveEmu { - fn new() -> Self { + pub(crate) fn new() -> Self { let (drive_priv, drive_x, drive_y) = generate_host_key_pair(); DriveEmu { drive_priv, diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 947ee98..6eed789 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -140,6 +140,42 @@ mod tests { } } + /// `Default` must delegate to `new()` (there is only one way to build an + /// `AacsCert`, so this pins the two constructors never drift apart). + #[test] + #[allow(clippy::default_constructed_unit_structs)] + fn default_matches_new() { + let _ = AacsCert::default(); + let _ = AacsCert::new(); + } + + /// THE full success path through `Unlocker::unlock_bus` itself: a + /// self-consistent AACS 1.0 drive emulator carries auth + VID + data-key + /// reads all the way to `Ok(Unlocked { .. })`, so the crate's public entry + /// point — not just `run_cert_handshake` underneath it — is proven to wire + /// `volume_id` and `read_data_key` into `Unlocked` correctly. + #[test] + fn unlock_bus_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" + ); + } + + fn host_cert() -> crate::HostCert { + handshake::tests::dummy_cert() + } + /// With no host certs there is nothing to authenticate with → NoUsableHostCert, /// and the transport is never touched. #[test] diff --git a/src/css/mod.rs b/src/css/mod.rs index 0b9c3a8..b09daa9 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -1021,6 +1021,15 @@ mod tests { assert_eq!(DvdUnlocker::new().name(), "DVD"); } + /// `Default` must delegate to `new()` — there is only one way to build a + /// `DvdUnlocker` (it is a unit struct), so this pins the two never drift. + #[test] + #[allow(clippy::default_constructed_unit_structs)] + fn default_matches_new() { + let _ = DvdUnlocker::default(); + let _ = DvdUnlocker::new(); + } + /// DvdUnlocker provides bus removal only — it never provides drive features. #[test] fn dvd_unlocker_provides_no_features() { @@ -1270,6 +1279,70 @@ mod tests { ); } + /// Host challenge + Key1 both succeed, but the drive challenge REPORT KEY + /// (step 3) is refused. Distinct from `key1_report_failure_fails_the_auth` + /// (step 2) and `key2_send_failure_fails_the_auth` (step 4) below — each + /// pins a different `css_scsi` call site's `?`. + #[test] + fn drive_challenge_report_failure_fails_the_auth() { + struct FailAtDriveChallenge(FakeDvdDrive); + impl ScsiTransport for FailAtDriveChallenge { + fn execute( + &mut self, + cdb: &[u8], + dir: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> crate::scsi::Result { + if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 { + return Ok(crate::scsi::ScsiResult { + status: 0x02, + bytes_transferred: 0, + sense: [0u8; 32], + }); + } + self.0.execute(cdb, dir, data, timeout_ms) + } + } + let mut t = FailAtDriveChallenge(FakeDvdDrive { + variant: 4, + host_challenge: [0u8; 10], + }); + let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge"); + assert!(matches!(e, Error::CssAuthFailed)); + } + + /// Host challenge, Key1, and the drive challenge all succeed, but the + /// Key2 SEND KEY (step 4, the last command in the handshake) is refused. + #[test] + fn key2_send_failure_fails_the_auth() { + struct FailAtKey2Send(FakeDvdDrive); + impl ScsiTransport for FailAtKey2Send { + fn execute( + &mut self, + cdb: &[u8], + dir: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> crate::scsi::Result { + if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 { + return Ok(crate::scsi::ScsiResult { + status: 0x02, + bytes_transferred: 0, + sense: [0u8; 32], + }); + } + self.0.execute(cdb, dir, data, timeout_ms) + } + } + let mut t = FailAtKey2Send(FakeDvdDrive { + variant: 4, + host_challenge: [0u8; 10], + }); + let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send"); + assert!(matches!(e, Error::CssAuthFailed)); + } + // ── read_disc_key ──────────────────────────────────────────────────────── /// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must @@ -1422,6 +1495,41 @@ mod tests { unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed"); } + /// A drive whose bus-auth succeeds but refuses the best-effort disc-key + /// REPORT KEY (CHECK CONDITION). `unlock_css_reads_inner` must swallow + /// that failure — the read barrier is already open from bus-auth, so the + /// whole unlock must still report `Ok`, not propagate the disc-key error. + struct DiscKeyRefusingDrive(FakeDvdDrive); + + impl ScsiTransport for DiscKeyRefusingDrive { + fn execute( + &mut self, + cdb: &[u8], + dir: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> crate::scsi::Result { + if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE { + return Ok(crate::scsi::ScsiResult { + status: 0x02, + bytes_transferred: 0, + sense: [0u8; 32], + }); + } + self.0.execute(cdb, dir, data, timeout_ms) + } + } + + #[test] + fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() { + let mut t = DiscKeyRefusingDrive(FakeDvdDrive { + variant: 5, + host_challenge: [0u8; 10], + }); + unlock_css_reads(&mut t, 0) + .expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it"); + } + /// `crypt_key`'s `key_type == 2` arm (`PERM_VARIANT[1]`) has no /// production caller (only key_type 0, the variant brute-force, and 1, /// the drive-challenge Key2, are ever invoked from `authenticate_with_agid`) diff --git a/src/ld/error.rs b/src/ld/error.rs index 7ec3a66..e00162d 100644 --- a/src/ld/error.rs +++ b/src/ld/error.rs @@ -136,6 +136,31 @@ mod tests { } } + /// `Display` is what shows up in logs — every variant must render without + /// panicking and the SCSI variant must carry the opcode/status through. + #[test] + fn display_renders_every_variant() { + assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error"); + assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed"); + assert_eq!( + Error::SignatureMismatch { + expected: [0; 4], + got: [1; 4], + } + .to_string(), + "signature mismatch" + ); + assert_eq!( + Error::Scsi { + opcode: 0x3C, + status: 0x02, + sense: None, + } + .to_string(), + "SCSI error (opcode 0x3c, status 0x02)" + ); + } + /// `From` must carry the status AND sense across the seam — /// collapsing either one destroys the transport-vs-rejection distinction the /// whole abort/fall-through split rests on. diff --git a/src/ld/mod.rs b/src/ld/mod.rs index bb33149..fb8adee 100644 --- a/src/ld/mod.rs +++ b/src/ld/mod.rs @@ -397,6 +397,15 @@ mod tests { assert_eq!(got, None); } + /// Public catalog accessors: `profiles()` returns the bundled catalog and + /// `profile()` is `profiles().and_then(get)` for a known drive. + #[test] + fn public_catalog_accessors_find_the_bundled_fixture_drive() { + assert!(super::profiles().is_some(), "bundled catalog must parse"); + let m = super::profile(&known_vid_drive_id()).expect("known fixture drive matches"); + assert!(m.profile.read_vid_cdb.is_some()); + } + /// `unlock_features` on a drive with no matching profile → `NotApplicable` /// (fall through), short-circuiting before any firmware handshake. #[test] @@ -525,6 +534,83 @@ mod tests { assert_eq!(err, UnlockError::Transport); } + /// A drive-sense (not a dead bus) rejecting the speed probe is a genuine + /// calibration miss, not a transport fault — `firmware_unlock` must + /// warn-and-continue on the drive's default speed table, still reporting a + /// full unlock. The non-transport sibling of + /// `transport_fault_during_probe_disc_is_transport_not_a_successful_unlock`. + #[test] + fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() { + 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]); // primary marker + resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker + + struct ProbeSenseDrive { + resp: Vec, + } + impl ScsiTransport for ProbeSenseDrive { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::scsi::Result { + // READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe. + if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) { + return Ok(ScsiResult { + status: 0x02, + bytes_transferred: 0, + sense: [0u8; 32], + }); + } + let n = self.resp.len().min(data.len()); + data[..n].copy_from_slice(&self.resp[..n]); + Ok(ScsiResult { + status: 0, + bytes_transferred: n, + sense: [0u8; 32], + }) + } + } + let mut t = ProbeSenseDrive { resp }; + let u = LibreDrive::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 = LibreDrive::new() + .unlock_bus(&mut t, &ctx(&id)) + .expect("unlock_bus runs the same firmware handshake"); + assert!(u.drive_unlocked); + } + /// Catches classifying a dead bus as "not this unlocker's drive": the very /// first unlock command faulting at the transport layer must abort the /// consumer (`Transport`), not fall through to the next unlocker. diff --git a/src/ld/platform/mod.rs b/src/ld/platform/mod.rs index 50a51a0..336fbca 100644 --- a/src/ld/platform/mod.rs +++ b/src/ld/platform/mod.rs @@ -25,3 +25,41 @@ pub(crate) trait PlatformDriver: Send { false } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::scsi::mock::{MockTransport, Reply}; + + /// A minimal driver that takes every `PlatformDriver` default (`is_unlocked`) + /// so the shim's dispatch and the default's `false` are both exercised — + /// every real driver (`Mt1959`) overrides `is_unlocked`, so without this the + /// default body is dead in coverage even though it is reachable code. + struct StubDriver; + + impl PlatformDriver for StubDriver { + fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> { + scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?; + Ok(()) + } + + fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> { + scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?; + Ok(()) + } + + fn is_ready(&self) -> bool { + true + } + } + + #[test] + fn default_is_unlocked_is_false_and_trait_methods_dispatch() { + let mut drv = StubDriver; + let mut scsi = MockTransport::always(Reply::good(vec![])); + assert!(drv.init(&mut scsi).is_ok()); + assert!(drv.probe_disc(&mut scsi).is_ok()); + assert!(drv.is_ready()); + assert!(!drv.is_unlocked()); + } +} diff --git a/src/ld/platform/mt1959/mod.rs b/src/ld/platform/mt1959/mod.rs index af5c39e..791b4c1 100644 --- a/src/ld/platform/mt1959/mod.rs +++ b/src/ld/platform/mt1959/mod.rs @@ -823,6 +823,49 @@ mod tests { } } + /// A drive SENSE (not a dead bus) during the coarse speed probe is the + /// drive's OWN failure, not a transport fault — `run_probe` must report + /// `UnlockFailed`, not `Transport`. The sibling of + /// `transport_fault_during_pass1_probe_stays_a_transport_failure`, which + /// only exercises the `is_transport_failure()` arm. + #[test] + fn drive_sense_during_pass1_probe_is_unlock_failed_not_transport() { + struct SenseAtFirstProbe; + impl ScsiTransport for SenseAtFirstProbe { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::scsi::Result { + if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_PROBE) { + return Ok(ScsiResult { + status: 0x02, + bytes_transferred: 0, + sense: [0u8; 32], + }); + } + for b in data.iter_mut() { + *b = 0; + } + Ok(ScsiResult { + status: 0, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } + } + let mut t = SenseAtFirstProbe; + let mut mt = Mt1959::new(fixture_profile([0; 4]), false); + mt.init_complete = true; + let e = mt + .run_probe(&mut t) + .expect_err("drive-refused pass-1 probe"); + assert!(!e.is_transport_failure(), "a sense is not a dead bus"); + assert!(matches!(e, Error::UnlockFailed)); + } + /// THE probe pass-1 dead-bus test. A bus that dies during the coarse speed /// probe must keep its transport classification out of `run_probe` so the /// caller (`firmware_unlock`) aborts with `Transport` rather than reporting a @@ -917,6 +960,48 @@ mod tests { ); } + /// A firmware blob larger than WRITE_BUFFER's 24-bit CDB length field + /// cannot be uploaded — encoding it would silently disagree with the + /// bytes actually sent. Must reject before issuing any CDB. + #[test] + fn variant_a_refuses_firmware_over_the_24bit_write_buffer_length() { + let mut profile = fixture_profile([0; 4]); + profile.firmware = vec![0u8; 0x0100_0000]; // one past WRITE_BUFFER_MAX_LEN + let mut mt = Mt1959::new(profile, false); + let mut t = RecordingTransport { cdbs: Vec::new() }; + let e = variant_a::load_firmware(&mut mt, &mut t).expect_err("blob too large to encode"); + assert!(matches!(e, Error::UnlockFailed)); + assert!(t.cdbs.is_empty(), "no CDB may reach the drive"); + } + + /// THE full success path: WRITE_BUFFER upload, a fully-good verify read + /// (exercising the `Ok(r) if status==0 && bytes_transferred==len` arm no + /// other variant_a test reaches), then BOTH `do_unlock` calls succeed — + /// the first fatally-gated, the second best-effort. Every other + /// variant_a test above deliberately stops short (empty firmware, a + /// dead bus, or `do_unlock` failing against `RecordingTransport`'s empty + /// response) — this is the only one that reaches `load_firmware`'s final + /// `Ok(())`. + #[test] + fn variant_a_load_firmware_succeeds_end_to_end() { + let sig = [0x11, 0x22, 0x33, 0x44]; + let mut profile = fixture_profile(sig); + profile.firmware = vec![0u8; 64]; + let mut mt = Mt1959::new(profile, false); + let unlock_response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG); + let mut t = crate::scsi::mock::MockTransport::scripted( + vec![ + crate::scsi::mock::Reply::good(vec![]), // WRITE_BUFFER upload + crate::scsi::mock::Reply::good(vec![0u8; VALIDATE_RESPONSE_SIZE as usize]), // verify read, full + crate::scsi::mock::Reply::good(unlock_response.clone()), // do_unlock #1 + crate::scsi::mock::Reply::good(unlock_response), // do_unlock #2 (best-effort) + ], + crate::scsi::mock::Reply::TransportFault, + ); + variant_a::load_firmware(&mut mt, &mut t).expect("upload + verify + double unlock all ok"); + assert!(mt.init_complete, "do_unlock #1 must have set init_complete"); + } + /// variant_b had no transport-abort test. A firmware-upload STEP that hits a /// dead bus (here the metadata read) must abort via `trace_step`, not be /// swallowed and carried into the unlock retries. @@ -1083,6 +1168,28 @@ mod tests { ); } + /// Same shape as `run_init_exhausts_after_three_generic_failures`, but for + /// a MODE_B (`is_variant_b = true`) drive — pins that `run_init`'s mode + /// dispatch (`self.mode == MODE_A`) actually reaches `variant_b::load_firmware` + /// and not just its `variant_a` sibling, which every other `run_init` test + /// above exercises exclusively. + #[test] + fn run_init_exhausts_after_three_generic_failures_mode_b() { + let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short( + vec![0u8; 64], + 10, + )); + let mut mt = Mt1959::new(fixture_profile([0; 4]), true); + let e = mt.init(&mut t).expect_err("never validates, never uploads"); + assert!(matches!(e, Error::UnlockFailed)); + assert!(!e.is_transport_failure()); + assert_eq!( + t.calls(), + 3, + "one do_unlock call per attempt, three attempts, no firmware CDBs" + ); + } + /// Same generic `do_unlock` failure, but this time the firmware-reload /// fallback itself hits a dead bus. That must abort `run_init` /// immediately (transport classification preserved), not be folded into