From 2ee8c0b0a6377092e2defe5cad1c1095fafcd60d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:42:29 -0700 Subject: [PATCH] stop: Stop-aware unlock operations ScsiTransport gains Stop methods; AGIDs are handed off by AgidGuard, CSS is defused on Stop and MT1959 critical spans are guarded, and the post-upload readiness poll waits via pause so a Stop ends it. A test guards the op paths against bare sleeps. --- src/aacs/handshake.rs | 402 ++++++++++++++++++++++++---- src/aacs/mod.rs | 46 ++++ src/css/mod.rs | 172 ++++++++++-- src/ld/platform/mt1959/mod.rs | 139 +++++++++- src/ld/platform/mt1959/variant_a.rs | 11 +- src/ld/platform/mt1959/variant_b.rs | 14 +- src/scsi.rs | 399 +++++++++++++++++++++++++++ tests/op_path_sleep_guard.rs | 144 ++++++++++ 8 files changed, 1240 insertions(+), 87 deletions(-) create mode 100644 tests/op_path_sleep_guard.rs diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index 61c8742..b1975a2 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -7,7 +7,7 @@ //! //! Supports AACS 1.0 and AACS 2.0 cert chains. use crate::aacs::error::{Error, Result}; -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{AgidGuard, DataDirection, ScsiTransport}; use num_bigint::BigUint; use num_traits::{One, Zero}; use sha1::{Digest, Sha1}; @@ -69,12 +69,11 @@ fn cert_host_id_hex(cert: &[u8]) -> String { cert[4..10].iter().map(|b| format!("{b:02x}")).collect() } -// Release an AGID (REPORT KEY format 0x3F). A drive has only four, so avoid -// leaving one held between attempts. Best-effort: a release failure isn't a -// failure of the operation that's already failing. -fn release_agid(session: &mut dyn ScsiTransport, agid: u8) { - let cdb = cdb_report_key(agid, 0x3F, 2); - let _ = scsi_read(session, &cdb, 2); +// Hold an allocated AGID. A drive has only four, so its drop releases it (REPORT KEY +// format 0x3F) via `execute_cleanup`, so even a Stop frees it: one release per +// allocation (SS-7, evidence: libaacs mmc.c AGID invalidation; stop-design-v5 §2.3). +fn agid_guard<'a>(session: &'a mut (dyn ScsiTransport + 'a), agid: u8) -> AgidGuard<'a> { + AgidGuard::new(session, agid, cdb_report_key(agid, 0x3F, 2)) } // ── AACS 1.0 elliptic curve parameters (160-bit) ─────────────────────────── @@ -982,7 +981,14 @@ fn aacs_authenticate_with_anchor( la_x: &[u8; 20], la_y: &[u8; 20], ) -> Result { - aacs1_ake(session, host_priv_key, host_cert, la_x, la_y).map_err(V1Fail::into_error) + // AKE-only callers read the VID themselves under `auth.agid`, so the AGID is + // handed over still held, exactly as before the guard existed. + aacs1_ake(session, host_priv_key, host_cert, la_x, la_y) + .map(|(auth, guard)| { + guard.defuse(); + auth + }) + .map_err(V1Fail::into_error) } /// Why the 1.0 AKE failed. `DriveCert`: the drive presented a recognised cert @@ -1008,14 +1014,16 @@ impl V1Fail { } } -/// [`aacs_authenticate_with_anchor`] keeping the [`V1Fail`] distinction. -fn aacs1_ake( - session: &mut dyn ScsiTransport, +/// [`aacs_authenticate_with_anchor`] keeping the [`V1Fail`] distinction. On +/// success the AGID comes back still held in its [`AgidGuard`], which the caller +/// hands to [`finish_auth`]; on any failure the guard has already released it. +fn aacs1_ake<'a>( + session: &'a mut (dyn ScsiTransport + 'a), host_priv_key: &[u8; 20], host_cert: &[u8], la_x: &[u8; 20], la_y: &[u8; 20], -) -> std::result::Result { +) -> std::result::Result<(AacsAuth, AgidGuard<'a>), V1Fail> { if host_cert.len() < 92 { return Err(Error::AacsCertShort.into()); } @@ -1032,18 +1040,15 @@ fn aacs1_ake( scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?; let agid = (response[7] >> 6) & 0x03; - // From here on we HOLD the AGID. Every failure below used to abandon it - // (see [`release_agid`]); release it on the way out instead. - let r = aacs_authenticate_with_agid(session, agid, host_priv_key, host_cert, la_x, la_y); - if r.is_err() { - release_agid(session, agid); - } - r + // From here on we HOLD the AGID: an error below drops the guard, releasing it. + let mut guard = agid_guard(session, agid); + let auth = + aacs_authenticate_with_agid(&mut *guard, agid, host_priv_key, host_cert, la_x, la_y)?; + Ok((auth, guard)) } -/// Steps 3-9 of [`aacs_authenticate`], with the AGID already allocated. Split -/// out so the single caller can release the AGID on ANY failure without a Drop -/// guard or a release call at each of the seven early returns. +/// Steps 3-9 of [`aacs_authenticate`], with the AGID already allocated and held +/// by the caller's [`AgidGuard`], which releases it on any of the early returns. fn aacs_authenticate_with_agid( session: &mut dyn ScsiTransport, agid: u8, @@ -1183,16 +1188,16 @@ fn aacs_authenticate_with_agid( }) } -// Native AACS 2.0 handshake (P-256/SHA-256); LA anchor is a parameter so tests drive the full -// AKE under a test keypair. Its 2.0 cert offsets are PROVISIONAL, so run_cert_handshake gates -// it off (see AACS2_P256_EXPERIMENTAL). -fn aacs2_authenticate_p256_with_anchor( - session: &mut dyn ScsiTransport, +// Native AACS 2.0 handshake (P-256/SHA-256), returning the AGID still held in its guard like +// `aacs1_ake`. LA anchor is a parameter so tests drive the full AKE; its 2.0 cert offsets are +// PROVISIONAL, so run_cert_handshake gates it off (see AACS2_P256_EXPERIMENTAL). +fn aacs2_authenticate_p256_with_anchor<'a>( + session: &'a mut (dyn ScsiTransport + 'a), host_priv_key: &[u8; 32], host_cert: &[u8], la_x: &[u8; 32], la_y: &[u8; 32], -) -> Result { +) -> Result<(AacsAuth, AgidGuard<'a>)> { if host_cert.len() < 132 { return Err(Error::AacsCertShort); } @@ -1209,18 +1214,15 @@ fn aacs2_authenticate_p256_with_anchor( scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?; let agid = (response[7] >> 6) & 0x03; - // From here we HOLD the AGID; release on ANY failure below, mirroring - // the leak fix (`975315d`) applied to the AACS 1.0 twin. - let r = aacs2_authenticate_p256_with_agid(session, agid, host_priv_key, host_cert, la_x, la_y); - if r.is_err() { - release_agid(session, agid); - } - r + // From here we HOLD the AGID: an error below drops the guard, releasing it. + let mut guard = agid_guard(session, agid); + let auth = + aacs2_authenticate_p256_with_agid(&mut *guard, agid, host_priv_key, host_cert, la_x, la_y)?; + Ok((auth, guard)) } -/// Steps 3-9 of [`aacs2_authenticate_p256_with_anchor`] with the AGID already allocated, -/// split out so the single caller can release the AGID on any of the seven -/// fallible exits without a per-return release call or a Drop guard. +/// Steps 3-9 of [`aacs2_authenticate_p256_with_anchor`] with the AGID already +/// allocated and held by the caller's [`AgidGuard`]. fn aacs2_authenticate_p256_with_agid( session: &mut dyn ScsiTransport, agid: u8, @@ -1483,15 +1485,17 @@ enum FinishErr { } /// Read the Volume ID + data keys for a completed auth and assemble the finished -/// [`CertHandshake`]. Releases the AGID on EVERY exit — including the -/// fully-successful one (previously leaked, slowly draining the drive's 4-AGID -/// pool across discs), since nothing downstream needs the AGID once the VID and -/// data keys are read. `idx` is for log correlation only. +/// [`CertHandshake`]. Takes the AGID's guard and so releases it on EVERY exit, +/// including success (nothing downstream needs the AGID once the VID and data +/// keys are read) and before the caller's P-256 fall-through. `idx` is for log +/// correlation only. fn finish_auth( - scsi: &mut dyn ScsiTransport, + mut guard: AgidGuard<'_>, mut auth: AacsAuth, idx: usize, ) -> std::result::Result { + debug_assert_eq!(guard.agid(), auth.agid, "the guard holds this auth's AGID"); + let scsi: &mut dyn ScsiTransport = &mut *guard; let volume_id = match read_volume_id(scsi, &mut auth) { Ok(vid) => vid, Err(e) => { @@ -1505,10 +1509,7 @@ fn finish_auth( transport_failure = transport, "auth ok but volume ID read failed" ); - // We authenticated, so we hold an AGID; release it before giving up - // rather than leaving the drive one short until the next attempt - // invalidates all four. - release_agid(scsi, auth.agid); + // The guard releases the AGID on this return. // A dead bus is NOT "the drive has no Volume ID" — that told the // consumer to fall through and keep working a transport that is gone. return Err(if transport { @@ -1542,9 +1543,8 @@ fn finish_auth( ); // A dead bus here is NOT "no data key served" — left unclassified it // returned a successful-looking unlock with read_data_key: None. - // Release AGID, abort like VID. + // Abort like VID (the guard releases the AGID). if transport { - release_agid(scsi, auth.agid); return Err(FinishErr::Transport); } (None, Some(e.code())) @@ -1559,10 +1559,9 @@ fn finish_auth( has_read_data_key = read_data_key.is_some(), "AACS bus-auth handshake complete" ); - // Release the AGID on the fully-successful path too: the VID and data keys - // are already read, so nothing downstream needs it, and holding it slowly - // leaks the drive's 4-AGID pool across discs. - release_agid(scsi, auth.agid); + // Release the AGID on the fully-successful path too: nothing downstream needs + // it, and holding it slowly leaks the drive's 4-AGID pool across discs. + drop(guard); Ok(CertHandshake { volume_id, read_data_key, @@ -1613,7 +1612,9 @@ fn attempt_one_cert( let mut v1_drive_dead = false; if try_v1 { match aacs1_ake(scsi, &hc.private_key, &hc.certificate, la.0, la.1) { - Ok(auth) => match finish_auth(scsi, auth, idx) { + // `finish_auth` drops the guard (releasing the 1.0 AGID) on every + // return, so it is free before the P-256 fall-through below. + Ok((auth, guard)) => match finish_auth(guard, auth, idx) { Ok(ch) => return CertOutcome::Ok(ch), Err(FinishErr::Transport) => return CertOutcome::Transport, // Post-auth VID/MAC failure with v2 creds available: fall through to @@ -1659,7 +1660,7 @@ fn attempt_one_cert( return CertOutcome::VidUnavailable; }; match aacs2_authenticate_p256_with_anchor(scsi, k, c, la.2, la.3) { - Ok(auth) => match finish_auth(scsi, auth, idx) { + Ok((auth, guard)) => match finish_auth(guard, auth, idx) { Ok(ch) => CertOutcome::Ok(ch), Err(FinishErr::Transport) => CertOutcome::Transport, // A VID failure on the P-256 path is terminal for this cert. @@ -1742,8 +1743,20 @@ pub(crate) fn run_cert_handshake_with_anchors( ); continue; } - if drive_attempts > 0 { - std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS)); + // Halt-aware (stop-design-v5 §2.3 "The per-cert backoff … uses `pause`"): a + // Stop ends the wait at once, surfacing as the transport abort it is. + if drive_attempts > 0 + && scsi + .pause(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS)) + .is_err() + { + tracing::debug!( + target: "freemkv::disc", + phase = "cert_backoff_interrupted", + cert_index = idx, + "per-cert backoff interrupted (operation stopped); aborting" + ); + return Err(UnlockError::Transport); } drive_attempts += 1; match attempt_one_cert(scsi, hc, idx, la, v1_ok && !v1_drive_dead, has_v2) { @@ -3447,9 +3460,11 @@ pub(crate) mod tests { let host_cert = p256_synth_cert(0x11, &host_x, &host_y, &la_priv); let mut emu = DriveEmuP256::new(drive_lt_priv, cert); - let auth = + let (auth, guard) = aacs2_authenticate_p256_with_anchor(&mut emu, &host_priv, &host_cert, &la_x, &la_y) .expect("a genuine LA-signed 2.0 cert must complete the AKE"); + assert_eq!(guard.agid(), auth.agid, "the AGID comes back still held"); + drop(guard); assert_ne!(auth.bus_key, [0u8; 16], "a bus key must be derived"); assert_eq!( Some(auth.bus_key), @@ -4899,4 +4914,275 @@ pub(crate) mod tests { "SEND KEY format 0x02 must not have been sent" ); } + + // ── Stop (stop-design-v5 §2.3, ST-U1): AGID guard + halt-aware backoff ── + + use crate::scsi::mock::{Ev, StopFake, agid_ledger, is_agid_release}; + + /// (case, setup, cancel after this CDB, expected outcome). + type AkeCase = (&'static str, S, Option bool>, &'static str); + + fn outcome_name(o: &CertOutcome) -> &'static str { + match o { + CertOutcome::Ok(_) => "Ok", + CertOutcome::Transport => "Transport", + CertOutcome::VidUnavailable => "VidUnavailable", + CertOutcome::Reject { .. } => "Reject", + } + } + + fn send_key_fmt(fmt: u8) -> fn(&[u8]) -> bool { + match fmt { + 0x01 => |c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01, + _ => |c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x02, + } + } + + fn is_vid_read(c: &[u8]) -> bool { + c[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE && c[7] == 0x80 + } + + /// Asserts one allocation, released exactly once through `execute_cleanup`. + fn assert_released_once(case: &str, log: &[Ev]) { + let (allocs, held) = agid_ledger(log); + assert_eq!(allocs, 1, "{case}: one allocation"); + assert!(!held, "{case}: the AGID is released on the way out"); + let rel = log + .iter() + .filter(|e| matches!(e, Ev::Cleanup(c) if is_agid_release(c))); + assert_eq!( + rel.count(), + 1, + "{case}: exactly one release, via execute_cleanup" + ); + } + + // UT2 (stop-design-v5 §5.2) per evidence SS-7: "exactly one 0x3F per + // allocation, through `execute_cleanup`" for an error at each AACS 1.0 AKE + // step, each post-auth read, a Stop mid-AKE / mid-finish, and success. + #[test] + fn agid_released_exactly_once_on_every_ake_error_v1() { + type Setup = fn(&mut DriveEmu); + let cases: [AkeCase; 9] = [ + ( + "step4 cert rejected", + |e| e.revoke_cert_sends = 1, + None, + "Reject", + ), + ( + "step5 zero drive cert", + |e| e.zero_drive_cert_reads = 1, + None, + "Reject", + ), + ( + "step6 bad signature", + |e| e.bad_step6_sig = true, + None, + "Reject", + ), + ( + "stop mid-AKE", + |_| {}, + Some(send_key_fmt(0x01)), + "Transport", + ), + ( + "VID dead bus", + |e| e.fault_on_vid_read = true, + None, + "Transport", + ), + ("VID MAC", |e| e.bad_vid_mac = true, None, "VidUnavailable"), + ("data-key dead bus", |_| {}, None, "Transport"), + ( + "stop after VID", + |e| e.serve_data_keys = true, + Some(is_vid_read), + "Transport", + ), + ("success", |e| e.serve_data_keys = true, None, "Ok"), + ]; + let hc = dummy_cert(); + for (case, setup, cancel_after, want) in cases { + let mut emu = DriveEmu::new(); + setup(&mut emu); + let (lx, ly) = (emu.la_x, emu.la_y); + let mut t = StopFake::new(emu); + t.cancel_after = cancel_after; + let o = attempt_one_cert(&mut t, &hc, 0, (&lx, &ly, &[0; 32], &[0; 32]), true, false); + assert_eq!(outcome_name(&o), want, "{case}"); + assert_released_once(case, &t.log); + } + } + + // UT2, AACS 2.0 half: the same invariant on the native P-256 AKE. + #[test] + fn agid_released_exactly_once_on_every_ake_error_v2() { + type Setup = fn(&mut DriveEmuP256, &mut Vec); + let cases: [AkeCase; 7] = [ + ( + "step5 bad LA signature", + |_, c| c[120] ^= 0xFF, + None, + "Reject", + ), + ( + "step5 unknown cert type", + |_, c| c[0] = 0x12, + None, + "Reject", + ), + ( + "step6 off-curve point", + |e, _| e.off_curve_point = true, + None, + "Reject", + ), + ( + "step6 bad signature", + |e, _| e.bad_step6_sig = true, + None, + "Reject", + ), + ( + "stop mid-AKE", + |_, _| {}, + Some(send_key_fmt(0x01)), + "Transport", + ), + ("VID unavailable", |_, _| {}, None, "VidUnavailable"), + ( + "stop at end of AKE", + |_, _| {}, + Some(send_key_fmt(0x02)), + "Transport", + ), + ]; + for (case, setup, cancel_after, want) in cases { + let (la_priv, la_x, la_y) = generate_host_key_pair_p256(); + let (lt_priv, lt_x, lt_y) = generate_host_key_pair_p256(); + let mut cert = p256_synth_cert(0x11, <_x, <_y, &la_priv); + let (host_priv, hx, hy) = generate_host_key_pair_p256(); + let hc = crate::HostCert { + private_key: [0u8; 20], + certificate: Vec::new(), + private_key_v2: Some(host_priv), + certificate_v2: Some(p256_synth_cert(0x11, &hx, &hy, &la_priv)), + }; + let mut emu = DriveEmuP256::new(lt_priv, Vec::new()); + setup(&mut emu, &mut cert); + emu.cert = cert; + let mut t = StopFake::new(emu); + t.cancel_after = cancel_after; + let la = (&[0u8; 20], &[0u8; 20], &la_x, &la_y); + let o = attempt_one_cert(&mut t, &hc, 0, la, false, true); + assert_eq!(outcome_name(&o), want, "{case}"); + assert_released_once(case, &t.log); + } + } + + // UT3 (stop-design-v5 §5.2): "the 0x3F precedes the fallback's first CDB" — + // the 1.0 AGID is released right after its failed VID read, before P-256. + #[test] + fn agid_released_before_p256_fallthrough() { + let drive = HybridDrive::new(); + let (l1, l2) = ((drive.la1_x, drive.la1_y), (drive.la2_x, drive.la2_y)); + let v1 = dummy_cert(); + let (hv2_priv, hv2_x, hv2_y) = generate_host_key_pair_p256(); + let (throwaway_la, _, _) = generate_host_key_pair_p256(); + let hc = crate::HostCert { + private_key: v1.private_key, + certificate: v1.certificate.clone(), + private_key_v2: Some(hv2_priv), + certificate_v2: Some(p256_synth_cert(0x11, &hv2_x, &hv2_y, &throwaway_la)), + }; + let mut t = StopFake::new(drive); + let la = (&l1.0, &l1.1, &l2.0, &l2.1); + let o = attempt_one_cert(&mut t, &hc, 0, la, true, true); + assert_eq!(outcome_name(&o), "Ok", "the P-256 fallback completes"); + assert!(t.inner.reached_p256); + let v = t + .log + .iter() + .position(|e| matches!(e, Ev::Exec(c) if is_vid_read(c))); + let v = v.expect("the 1.0 VID read"); + assert!( + matches!(&t.log[v + 1], Ev::Cleanup(c) if is_agid_release(c)), + "the 1.0 AGID release must be the very next CDB: {:?}", + t.log.get(v + 1) + ); + let (allocs, held) = agid_ledger(&t.log); + assert_eq!((allocs, held), (2, false), "one release per allocation"); + } + + // UT5, AACS half (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" + // — the pre-allocation invalidate loop uses `execute`, so it is refused. + #[test] + fn aacs_invalidate_loop_refused_after_cancel() { + let emu = DriveEmu::new(); + let (lx, ly) = (emu.la_x, emu.la_y); + let mut t = StopFake::new(emu); + t.cancelled = true; + let r = run_cert_handshake_with_anchors( + &mut t, + &[dummy_cert()], + (&lx, &ly), + (&[0; 32], &[0; 32]), + false, + ); + assert_eq!(r.unwrap_err(), crate::UnlockError::Transport); + assert!( + t.execs().is_empty(), + "no CDB reached the drive: {:?}", + t.log + ); + assert!( + t.cleanups().is_empty(), + "no AGID was allocated, so none released" + ); + assert!(t.inner.cdbs.is_empty()); + } + + // UT9, backoff half (stop-design-v5 §5.2): "a cancel during the 1 s backoff + // (`aacs/handshake.rs:1746`) … returns ≤ 1 s", via the transport's `pause`. + #[test] + fn per_cert_backoff_uses_pause() { + let mut emu = DriveEmu::new(); + emu.revoke_cert_sends = 1; // cert 1 rejected, so cert 2 waits the backoff + emu.serve_data_keys = true; + let (lx, ly) = (emu.la_x, emu.la_y); + let mut t = StopFake::new(emu); + t.cancel_on_pause = true; + let t0 = std::time::Instant::now(); + let r = run_cert_handshake_with_anchors( + &mut t, + &[dummy_cert(), dummy_cert()], + (&lx, &ly), + (&[0; 32], &[0; 32]), + false, + ); + assert!( + t0.elapsed() < std::time::Duration::from_secs(1), + "returns ≤ 1 s" + ); + assert_eq!( + r.unwrap_err(), + crate::UnlockError::Transport, + "a Stop aborts" + ); + let backoff = std::time::Duration::from_millis(1000); + assert_eq!( + t.log.last(), + Some(&Ev::PauseRefused(backoff)), + "{:?}", + t.log + ); + assert_eq!( + t.inner.certs_sent.len(), + 1, + "cert 2 never reached the drive" + ); + } } diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 583a74d..9ff9d35 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -442,6 +442,52 @@ mod tests { } status(0, data.len()) } + // Forward the Stop methods (stop-design-v5 §2.3): the defaults would + // swallow them, so wrapped tests would stop exercising cancellation. + fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> { + self.inner.pause(d) + } + fn begin_critical(&mut self) -> crate::scsi::Result<()> { + self.inner.begin_critical() + } + fn end_critical(&mut self) { + self.inner.end_critical() + } + fn execute_cleanup( + &mut self, + cdb: &[u8], + dir: crate::scsi::DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> crate::scsi::Result { + self.cdbs.push(cdb.to_vec()); + self.inner.execute_cleanup(cdb, dir, data, timeout_ms) + } + } + + // UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront` … forwards all + // four"): otherwise every FwFront-wrapped test silently stops exercising Stop. + #[test] + fn fwfront_forwards_new_methods() { + use crate::scsi::DataDirection; + use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake}; + let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2]))); + let mut t = FwFront::new(inner); + let d = std::time::Duration::from_millis(1); + t.pause(d).expect("forwarded pause"); + t.begin_critical().expect("forwarded begin"); + t.end_critical(); + let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0]; + t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000) + .expect("forwarded cleanup"); + assert_eq!( + t.inner.log, + vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())], + "all four reach the wrapped transport" + ); + t.inner.cancelled = true; + assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper"); + assert!(t.begin_critical().is_err()); } fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport { diff --git a/src/css/mod.rs b/src/css/mod.rs index cca51dd..a9ee0e2 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -10,7 +10,7 @@ mod error; use crate::css::error::{Error, Result, step_err}; -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{AgidGuard, DataDirection, ScsiTransport}; // Issue ONE CSS bus-auth CDB, honouring the transport contract: treats a // non-zero status as CssAuthFailed and checks bytes_transferred >= min_bytes @@ -310,12 +310,13 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()> // ── Step 1: Bus Authentication ──────────────────────────────────────────── // Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate, -// challenge/response). Returns the AGID; no bus key is derived. +// challenge/response). Returns the AGID, still held (D9); no bus key is derived. fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result { // Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes - // an abandoned AGID self-heal — which is not a reason to abandon one. + // an abandoned AGID self-heal — which is not a reason to abandon one. Plain + // `execute`: nothing is held yet, so a Stop refuses these (stop-design-v5 §2.3). for agid in 0..4u8 { - release_agid(scsi, agid); + invalidate_agid(scsi, agid); } // Allocate AGID @@ -329,28 +330,37 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result { )?; let agid = (buf[7] >> 6) & 0x03; - // From here on we hold the AGID; release it on any failure (see - // [`release_agid`]) instead of abandoning it. - let r = authenticate_with_agid(scsi, agid); - if r.is_err() { - release_agid(scsi, agid); - } - r.map(|()| agid) + // From here on we hold the AGID: a failure drops the guard, which releases + // it even after a Stop (SS-7, evidence: libaacs mmc.c AGID invalidation). + let mut guard = AgidGuard::new(scsi, agid, agid_release_cdb(agid)); + authenticate_with_agid(&mut *guard, agid)?; + // D9 (stop-design-v5 §2.3 "defused on success, so it is still held for + // `read_disc_key`"): hand the AGID over unreleased. + Ok(guard.defuse()) } -/// Release an AGID (REPORT KEY format 0x3F). Best-effort: a failure to release -/// is not a failure of the operation that is already failing. -fn release_agid(scsi: &mut dyn ScsiTransport, agid: u8) { +/// REPORT KEY key format 0x3F for `agid` (invalidate / release it). +fn agid_release_cdb(agid: u8) -> [u8; 12] { let mut cdb = [0u8; 12]; cdb[0] = crate::scsi::SCSI_REPORT_KEY; cdb[10] = (agid << 6) | 0x3F; + cdb +} + +/// Best-effort invalidate of an AGID this session does not hold (the +/// pre-allocation sweep). +fn invalidate_agid(scsi: &mut dyn ScsiTransport, agid: u8) { let mut buf = [0u8; 8]; - let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000); + let _ = scsi.execute( + &agid_release_cdb(agid), + DataDirection::FromDevice, + &mut buf, + 5_000, + ); } /// The challenge-response half of [`establish_authenticated_session`], with the -/// AGID already allocated. Split out so its caller can release the AGID on any -/// failure without a Drop guard or a release at each early return. +/// AGID already allocated and held by the caller's [`AgidGuard`]. fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> { // Host sends challenge. The spec wants a fresh per-session random nonce, // not a fixed constant — a predictable challenge weakens the bus-auth @@ -1394,4 +1404,132 @@ mod tests { "key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)" ); } + + // ── Stop (stop-design-v5 §2.3, ST-U1) ─────────────────────────────────── + + /// A drive that completes the CSS bus-auth for real: it answers Key1 for the + /// host challenge under `variant`, serves a challenge, and allocates AGID 1. + struct CssEmu { + variant: u8, + host_challenge: [u8; 10], + /// Answer Key1 with zeros, which matches no variant → CssAuthFailed. + bad_key1: bool, + } + + impl ScsiTransport for CssEmu { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::scsi::Result { + let mut r = vec![0u8; data.len()]; + match (cdb[0], cdb[10] & 0x3F) { + (crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1 + (crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => { + let k = crypt_key(0, self.variant, &self.host_challenge); + (0..5).for_each(|j| r[4 + j] = k[4 - j]); + } + (crate::scsi::SCSI_SEND_KEY, 0x01) => { + (0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]); + } + _ => {} + } + if cdb[0] != crate::scsi::SCSI_SEND_KEY { + data.copy_from_slice(&r); + } + Ok(crate::scsi::ScsiResult { + status: 0, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } + } + + fn css_emu() -> CssEmu { + CssEmu { + variant: 7, + host_challenge: [0u8; 10], + bad_key1: false, + } + } + + // UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is + // `defuse`d, so it is still held for `read_disc_key` and never released. + // Per design D9; do not change without a design citation proving otherwise. + #[test] + fn css_success_keeps_agid() { + use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release}; + let mut t = StopFake::new(css_emu()); + unlock_css_reads(&mut t, 0).expect("the bus-auth completes"); + let (allocs, held) = agid_ledger(&t.log); + assert_eq!( + (allocs, held), + (1, true), + "one AGID, still held after success" + ); + assert!(t.cleanups().is_empty(), "no release on success"); + let execs = t.execs(); + let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0); + let after = &execs[alloc.expect("allocated") + 1..]; + assert!( + !after.iter().any(|c| is_agid_release(c)), + "no 0x3F after success" + ); + let disc_key = after.last().expect("the disc-key read follows"); + assert_eq!( + (disc_key[0], disc_key[7]), + (0xAD, 0x02), + "READ DVD STRUCTURE fmt 2" + ); + assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1"); + } + + // SS-7 (evidence): a CSS challenge that fails after allocation releases the + // AGID exactly once, through `execute_cleanup`, even when a Stop caused it. + #[test] + fn css_failed_challenge_releases_agid_once_via_cleanup() { + use crate::scsi::mock::{StopFake, agid_ledger}; + let stop_at_challenge: fn(&[u8]) -> bool = + |c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01; + for cancel in [false, true] { + let mut emu = css_emu(); + if !cancel { + emu.bad_key1 = true; + } + let mut t = StopFake::new(emu); + if cancel { + t.cancel_after = Some(stop_at_challenge); + } + let e = establish_authenticated_session(&mut t).expect_err("auth fails"); + assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal"); + assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}"); + assert_eq!( + t.cleanups().len(), + 1, + "exactly one release, cancel={cancel}" + ); + } + } + + // UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the + // pre-allocation invalidate loop uses `execute`, so a Stop refuses it. + #[test] + fn invalidate_loops_refused_after_cancel() { + use crate::scsi::mock::StopFake; + let mut t = StopFake::new(css_emu()); + t.cancelled = true; + let e = establish_authenticated_session(&mut t).expect_err("cancelled"); + assert!(e.is_transport_failure()); + assert!( + t.execs().is_empty(), + "no CDB reached the drive: {:?}", + t.log + ); + assert!( + t.cleanups().is_empty(), + "nothing allocated, nothing released" + ); + } } diff --git a/src/ld/platform/mt1959/mod.rs b/src/ld/platform/mt1959/mod.rs index 9837c0d..5d9096a 100644 --- a/src/ld/platform/mt1959/mod.rs +++ b/src/ld/platform/mt1959/mod.rs @@ -518,7 +518,8 @@ fn ready_clock() -> Box { Box::new(WallClock::start()) } -/// The production clock: real time, a plain sleep between polls. +/// The production clock: real time, and the transport's cancellable `pause` +/// between polls (stop-design-v5 §2.3), so a Stop ends the wait at once. struct WallClock(std::time::Instant); impl WallClock { @@ -531,9 +532,8 @@ impl ReadyClock for WallClock { fn elapsed(&self) -> Duration { self.0.elapsed() } - fn wait(&mut self, _scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()> { - std::thread::sleep(d); - Ok(()) + fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()> { + scsi.pause(d) } } @@ -1799,6 +1799,137 @@ pub(crate) mod tests { assert_eq!((t.calls(), clock.waits), (1, 0)); } + // A Stop during the post-upload readiness poll ends it at the next wait: + // the production clock waits via the transport's cancellable `pause`, and + // the refusal is the dead-bus shape libfreemkv reclassifies as Halted (§2.3). + #[test] + fn media_ready_poll_stop_during_wait_ends_it() { + use crate::scsi::mock::{Ev, MockTransport, StopFake}; + let mut t = StopFake::new(MockTransport::always(not_ready(0x04, 0x01))); + t.cancel_after = Some(|c| c[0] == SCSI_TEST_UNIT_READY); + let t0 = std::time::Instant::now(); + let e = await_media_ready(&mut t, &mut WallClock::start()).expect_err("stopped"); + assert!( + t0.elapsed() < Duration::from_millis(100), + "no real sleep after a Stop" + ); + assert!( + e.is_transport_failure(), + "the refusal shape (Halted in libfreemkv)" + ); + assert_eq!( + t.log, + vec![Ev::Exec(vec![0u8; 6]), Ev::PauseRefused(READY_POLL)], + "the wait itself observed the Stop; no further TUR" + ); + } + + /// A Stop that lands during the upload, through `run_init` on `mode`: the + /// first do_unlock fails generically (so the firmware reloads), and the + /// cancel arrives once a CDB matching `stop_at` reaches the drive. + fn stop_during_upload( + is_variant_b: bool, + stop_at: fn(&[u8]) -> bool, + ) -> (Result<()>, Vec) { + use crate::scsi::mock::{MockTransport, Reply, StopFake}; + let sig = [0x11, 0x22, 0x33, 0x44]; + let mut profile = fixture_profile(sig); + profile.firmware = vec![0u8; 64]; + let unlock_ok = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG); + let inner = MockTransport::scripted( + vec![Reply::short(vec![0u8; 64], 10)], // run_init's do_unlock + Reply::good(unlock_ok), + ); + let mut t = StopFake::new(inner); + t.cancel_after = Some(stop_at); + let mut mt = Mt1959::new(profile, is_variant_b); + let r = mt.init(&mut t); + (r, t.log) + } + + fn is_unlock_cdb(c: &[u8]) -> bool { + c[0] == SCSI_READ_BUFFER && c[2] == BUFFER_ID_A && c[3] == SUB_CMD_UNLOCK + || c[0] == SCSI_READ_BUFFER && c[2] == BUFFER_ID_B && c[3] == SUB_CMD_UNLOCK + } + + // UT7 (stop-design-v5 §5.2; §2.3 D8 "the WRITE BUFFER at 30 s … and its verify + // run inside a `CriticalGuard`. C_max = 35 s"): the verify still executes + // after a Stop mid-upload, and the next do_unlock is refused. + #[test] + fn mt1959_a_upload_completes_after_cancel_then_stops() { + use crate::scsi::mock::Ev; + let (r, log) = stop_during_upload(false, |c| c[0] == SCSI_WRITE_BUFFER); + let e = r.expect_err("the Stop ends the init"); + assert!(e.is_transport_failure(), "a refusal is the dead-bus shape"); + let shape: Vec = log + .iter() + .map(|e| match e { + Ev::Exec(c) if is_unlock_cdb(c) => "unlock".into(), + Ev::Refused(c) if is_unlock_cdb(c) => "unlock refused".into(), + Ev::Exec(c) => format!("{:02X}/{:02X}", c[0], c[2]), + other => format!("{other:?}"), + }) + .collect(); + let want = ["unlock", "Begin", "3B/00", "3C/45", "End", "unlock refused"]; + assert_eq!(shape, want, "upload + verify complete; then stopped"); + } + + // UT7, cancel before the span: no WRITE BUFFER is ever started. + #[test] + fn mt1959_a_stop_before_upload_never_starts_it() { + use crate::scsi::mock::Ev; + let (r, log) = stop_during_upload(false, is_unlock_cdb); + assert!(r.expect_err("stopped").is_transport_failure()); + assert_eq!(log.len(), 2, "{log:?}"); + assert_eq!( + log[1], + Ev::BeginRefused, + "the span refuses to open after a Stop" + ); + } + + // UT8 (stop-design-v5 §5.2; §2.3 "MODE SELECT at 30 s …, READ BUFFER, WRITE + // BUFFER and F1 run inside a `CriticalGuard`. C_max = 45 s"), as UT7 for B. + #[test] + fn mt1959_b_upload_completes_after_cancel_then_stops() { + use crate::scsi::mock::Ev; + let (r, log) = stop_during_upload(true, |c| c[0] == 0x55); + assert!(r.expect_err("stopped").is_transport_failure()); + let shape: Vec = log + .iter() + .map(|e| match e { + Ev::Exec(c) if is_unlock_cdb(c) => "unlock".into(), + Ev::Refused(c) if is_unlock_cdb(c) => "unlock refused".into(), + Ev::Exec(c) => format!("{:02X}", c[0]), + other => format!("{other:?}"), + }) + .collect(); + let want = [ + "unlock", + "Begin", + "55", + "3C", + "3B", + "F1", + "End", + "unlock refused", + ]; + assert_eq!( + shape, want, + "MODE SELECT, READ/WRITE BUFFER, F1; then stopped" + ); + } + + // UT8, cancel before the span: no MODE SELECT is ever started. + #[test] + fn mt1959_b_stop_before_upload_never_starts_it() { + use crate::scsi::mock::Ev; + let (r, log) = stop_during_upload(true, is_unlock_cdb); + assert!(r.expect_err("stopped").is_transport_failure()); + assert_eq!(log.len(), 2, "{log:?}"); + assert_eq!(log[1], Ev::BeginRefused); + } + // ── run_probe entry / disc-type detection ─────────────────────────────── /// `run_probe` called before `init_complete` runs `do_unlock` itself diff --git a/src/ld/platform/mt1959/variant_a.rs b/src/ld/platform/mt1959/variant_a.rs index 9abefcd..d90dd61 100644 --- a/src/ld/platform/mt1959/variant_a.rs +++ b/src/ld/platform/mt1959/variant_a.rs @@ -4,7 +4,7 @@ use super::Mt1959; use crate::ld::error::Result; -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport}; use super::SCSI_WRITE_BUFFER; @@ -40,7 +40,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re 0x00, ]; let mut data = firmware.clone(); - scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?; + // D8 (stop-design-v5 §2.3): "the WRITE BUFFER at 30 s … and its verify run inside + // a `CriticalGuard`. C_max = 35 s" — a Stop mid-upload waits for both CDBs. + let mut span = CriticalGuard::enter(&mut *scsi)?; + span.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?; // Verify the firmware loaded (non-fatal — different buffer_id 0x45). No // documented expected payload exists, so the outcome is traced rather @@ -58,7 +61,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re 0x00, ]; let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize]; - match scsi.execute( + match span.execute( &verify_cdb, DataDirection::FromDevice, &mut verify_resp, @@ -96,6 +99,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re } } + drop(span); // the span ends here; the unlock below stays cancellable + // Double unlock after firmware upload: first is fatal on failure, second // is a best-effort confirmation pass (matching variant B) so a benign // hiccup there doesn't fail an already-successful unlock. diff --git a/src/ld/platform/mt1959/variant_b.rs b/src/ld/platform/mt1959/variant_b.rs index 30d46eb..e1fb732 100644 --- a/src/ld/platform/mt1959/variant_b.rs +++ b/src/ld/platform/mt1959/variant_b.rs @@ -4,7 +4,7 @@ use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER}; use crate::ld::error::Result; -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport}; const SCSI_MODE_SELECT: u8 = 0x55; const FIRMWARE_EXTRA: [u8; 16] = [0; 16]; @@ -77,7 +77,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re 0x00, ]; let mut data = firmware[..write_len].to_vec(); - scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?; + // stop-design-v5 §2.3: "MODE SELECT at 30 s …, READ BUFFER, WRITE BUFFER and F1 run + // inside a `CriticalGuard`. C_max = 45 s" — a Stop mid-upload waits for all four. + let mut span = CriticalGuard::enter(&mut *scsi)?; + span.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?; // Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000) let read_meta_cdb = [ @@ -95,7 +98,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re let mut meta_resp = [0u8; 16]; trace_step( "mt1959b_fw_meta", - scsi.execute( + span.execute( &read_meta_cdb, DataDirection::FromDevice, &mut meta_resp, @@ -119,7 +122,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re let mut data2 = FIRMWARE_EXTRA.to_vec(); trace_step( "mt1959b_fw_extra", - scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000), + span.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000), )?; // Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive. @@ -127,8 +130,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re let mut dummy = [0u8; 0]; trace_step( "mt1959b_fw_verify", - scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000), + span.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000), )?; + drop(span); // the span ends here; the unlock retries below stay cancellable // Step 5: Unlock retries (up to 5, then a final fatal attempt). The // confirmation pass after success is intentionally best-effort. diff --git a/src/scsi.rs b/src/scsi.rs index 5c1a6c9..d84e61c 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -31,6 +31,13 @@ pub type Result = std::result::Result; /// The one capability an unlocker needs from the host: run a raw CDB. `Ok` even /// on a SCSI sense (inspect `status`); `Err` only on a transport-layer fault. +/// +/// **Stop (cancellation).** The host may cancel the operation. After a cancel, +/// [`execute`](Self::execute) refuses with a dead-bus [`ScsiError`] (status 0xFF, +/// no sense), so every unlocker aborts through its existing transport-fault path. +/// The four defaulted methods let a cancellable host keep the drive safe. A host +/// with no cancellation keeps the defaults: plain sleep, no-op critical spans, and +/// cleanup CDBs that run as ordinary CDBs. pub trait ScsiTransport { fn execute( &mut self, @@ -39,6 +46,165 @@ pub trait ScsiTransport { data: &mut [u8], timeout_ms: u32, ) -> Result; + + /// Wait `d` between drive attempts. A cancellable host returns `Err` (dead + /// bus) as soon as the operation is cancelled, instead of finishing the wait. + fn pause(&mut self, d: std::time::Duration) -> Result<()> { + std::thread::sleep(d); + Ok(()) + } + + /// Open a critical span (see [`CriticalGuard`]): until the matching + /// [`end_critical`](Self::end_critical), `execute` must run even if the + /// operation is cancelled meanwhile. `Err` if it is already cancelled. + fn begin_critical(&mut self) -> Result<()> { + Ok(()) + } + + /// Close the span opened by [`begin_critical`](Self::begin_critical). + fn end_critical(&mut self) {} + + /// Run a clean-up CDB that must reach the drive even after a cancel (the + /// host allows only an AGID release it recorded, never an arbitrary CDB). + fn execute_cleanup( + &mut self, + cdb: &[u8], + direction: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> Result { + self.execute(cdb, direction, data, timeout_ms) + } +} + +/// A critical span on a transport: [`CriticalGuard::enter`] calls +/// [`ScsiTransport::begin_critical`] and `Drop` calls +/// [`ScsiTransport::end_critical`], on every exit including unwind. Used where +/// abandoning a CDB sequence midway could leave the drive unusable (a firmware +/// upload): a cancel that lands inside the span takes effect when it closes. +pub struct CriticalGuard<'a> { + t: &'a mut (dyn ScsiTransport + 'a), +} + +impl<'a> CriticalGuard<'a> { + /// Open the span; `Err` (and no span) if the operation is already cancelled. + pub fn enter(t: &'a mut (dyn ScsiTransport + 'a)) -> Result { + t.begin_critical()?; + Ok(CriticalGuard { t }) + } +} + +impl Drop for CriticalGuard<'_> { + fn drop(&mut self) { + self.t.end_critical(); + } +} + +impl<'a> std::ops::Deref for CriticalGuard<'a> { + type Target = dyn ScsiTransport + 'a; + fn deref(&self) -> &Self::Target { + &*self.t + } +} + +impl<'a> std::ops::DerefMut for CriticalGuard<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut *self.t + } +} + +/// Timeout for the AGID release CDB (the 5 s every AACS/CSS key CDB uses). +const AGID_RELEASE_TIMEOUT_MS: u32 = 5_000; + +/// An allocated AGID (Authentication Grant ID) on a transport. `Drop` releases it +/// with its REPORT KEY key-format 3Fh CDB, sent through +/// [`ScsiTransport::execute_cleanup`] so a cancelled operation still frees it. +/// [`AgidGuard::defuse`] instead hands the still-held AGID to the caller. +/// Exactly one release per allocation: the guard is the only releaser. +pub struct AgidGuard<'a> { + t: &'a mut (dyn ScsiTransport + 'a), + agid: u8, + release: [u8; 12], + armed: bool, +} + +impl<'a> AgidGuard<'a> { + /// Guard `agid`. `release` is its REPORT KEY (0xA4) key-format 3Fh CDB, built + /// by the caller so each scheme (AACS key class 2, CSS) keeps its exact bytes. + pub fn new(t: &'a mut (dyn ScsiTransport + 'a), agid: u8, release: [u8; 12]) -> Self { + // SS-7 (evidence, not spec): REPORT KEY key format 3Fh invalidates the AGID + // carried in CDB byte 10 bits 7-6 (libaacs mmc.c; the unlock AGID tests). + debug_assert!( + release[0] == SCSI_REPORT_KEY && release[10] == ((agid & 0x03) << 6) | 0x3F, + "AgidGuard needs this AGID's REPORT KEY format-3Fh CDB" + ); + AgidGuard { + t, + agid, + release, + armed: true, + } + } + + /// The guarded AGID. + pub fn agid(&self) -> u8 { + self.agid + } + + /// Hand the AGID over to the caller, still allocated: nothing is released. + pub fn defuse(mut self) -> u8 { + self.armed = false; + self.agid + } +} + +// Manual Debug: the transport is not `Debug`; the AGID and whether it is still +// armed (will be released on drop) are what a failure message needs. +impl std::fmt::Debug for AgidGuard<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgidGuard") + .field("agid", &self.agid) + .field("armed", &self.armed) + .finish() + } +} + +impl Drop for AgidGuard<'_> { + fn drop(&mut self) { + if !self.armed { + return; + } + // Buffer = the CDB's allocation length (AACS: 2), or 8 when it names none (CSS). + let alloc = ((self.release[8] as usize) << 8) | self.release[9] as usize; + let mut buf = vec![0u8; if alloc == 0 { 8 } else { alloc }]; + // Best-effort: a failed release is not a failure of the operation. + let r = self.t.execute_cleanup( + &self.release, + DataDirection::FromDevice, + &mut buf, + AGID_RELEASE_TIMEOUT_MS, + ); + tracing::debug!( + target: "freemkv::disc", + phase = "agid_released", + agid = self.agid, + ok = r.as_ref().is_ok_and(|r| r.status == 0), + "AGID release (REPORT KEY format 3Fh) sent" + ); + } +} + +impl<'a> std::ops::Deref for AgidGuard<'a> { + type Target = dyn ScsiTransport + 'a; + fn deref(&self) -> &Self::Target { + &*self.t + } +} + +impl<'a> std::ops::DerefMut for AgidGuard<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut *self.t + } } /// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command). @@ -285,6 +451,171 @@ pub(crate) mod mock { } } } + + /// One transport call as seen by [`StopFake`]. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) enum Ev { + /// `execute` reached the drive. + Exec(Vec), + /// `execute` refused because the operation was cancelled. + Refused(Vec), + /// `execute_cleanup` reached the drive. + Cleanup(Vec), + Pause(std::time::Duration), + PauseRefused(std::time::Duration), + Begin, + BeginRefused, + End, + } + + /// A cancellable host, as libfreemkv's adapter behaves (stop-design-v5 §2.3, + /// §2.4): after a cancel, `execute` refuses with 0xFF / no sense unless a + /// critical span opened BEFORE the cancel is still open; `begin_critical` and + /// `pause` fail; `execute_cleanup` still runs. Wraps a drive emulator `inner`. + pub(crate) struct StopFake { + pub(crate) inner: T, + pub(crate) cancelled: bool, + critical: u32, + /// Cancel right after a CDB matching this predicate reaches the drive + /// (a Stop that lands while that CDB is in flight). + pub(crate) cancel_after: Option bool>, + /// Cancel when the next `pause` starts (a Stop during the wait). + pub(crate) cancel_on_pause: bool, + pub(crate) log: Vec, + } + + fn refused() -> ScsiError { + ScsiError { + status: SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + } + } + + impl StopFake { + pub(crate) fn new(inner: T) -> Self { + StopFake { + inner, + cancelled: false, + critical: 0, + cancel_after: None, + cancel_on_pause: false, + log: Vec::new(), + } + } + /// CDBs that reached the drive through `execute`. + pub(crate) fn execs(&self) -> Vec<&Vec> { + self.log + .iter() + .filter_map(|e| match e { + Ev::Exec(c) => Some(c), + _ => None, + }) + .collect() + } + /// CDBs that reached the drive through `execute_cleanup`. + pub(crate) fn cleanups(&self) -> Vec<&Vec> { + self.log + .iter() + .filter_map(|e| match e { + Ev::Cleanup(c) => Some(c), + _ => None, + }) + .collect() + } + } + + impl ScsiTransport for StopFake { + fn execute( + &mut self, + cdb: &[u8], + dir: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> Result { + if self.cancelled && self.critical == 0 { + self.log.push(Ev::Refused(cdb.to_vec())); + return Err(refused()); + } + self.log.push(Ev::Exec(cdb.to_vec())); + let r = self.inner.execute(cdb, dir, data, timeout_ms); + if self.cancel_after.is_some_and(|p| p(cdb)) { + self.cancelled = true; + } + r + } + fn pause(&mut self, d: std::time::Duration) -> Result<()> { + if self.cancel_on_pause { + self.cancelled = true; + } + if self.cancelled { + self.log.push(Ev::PauseRefused(d)); + return Err(refused()); + } + self.log.push(Ev::Pause(d)); // no real sleep: tests run in microseconds + Ok(()) + } + fn begin_critical(&mut self) -> Result<()> { + if self.cancelled { + self.log.push(Ev::BeginRefused); + return Err(refused()); + } + self.critical += 1; + self.log.push(Ev::Begin); + Ok(()) + } + fn end_critical(&mut self) { + self.critical -= 1; + self.log.push(Ev::End); + } + fn execute_cleanup( + &mut self, + cdb: &[u8], + dir: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> Result { + self.log.push(Ev::Cleanup(cdb.to_vec())); + self.inner.execute(cdb, dir, data, timeout_ms) + } + } + + /// REPORT KEY (0xA4) key format 0x3F (AGID invalidate / release). + pub(crate) fn is_agid_release(cdb: &[u8]) -> bool { + cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x3F) + } + /// REPORT KEY (0xA4) key format 0x00 (AGID allocation). + pub(crate) fn is_agid_alloc(cdb: &[u8]) -> bool { + cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x00) + } + + /// SS-7 (evidence): every allocated AGID is released exactly once, through + /// `execute_cleanup`, before the next allocation; `execute` never releases a + /// held AGID (it only invalidates beforehand). Returns (allocations, held at end). + pub(crate) fn agid_ledger(log: &[Ev]) -> (usize, bool) { + let (mut allocs, mut held) = (0usize, false); + for (i, e) in log.iter().enumerate() { + match e { + Ev::Exec(c) if is_agid_alloc(c) => { + assert!(!held, "event {i}: allocated while still holding an AGID"); + held = true; + allocs += 1; + } + Ev::Exec(c) if is_agid_release(c) => { + assert!(!held, "event {i}: a held AGID was released via execute"); + } + Ev::Cleanup(c) => { + assert!( + is_agid_release(c), + "event {i}: cleanup of a non-release CDB" + ); + assert!(held, "event {i}: released an AGID that was not held"); + held = false; + } + _ => {} + } + } + (allocs, held) + } } #[cfg(test)] @@ -363,4 +694,72 @@ mod tests { assert_eq!(t.calls(), 4); } + + // UT1 (stop-design-v5 §5.2; §2.3 "`enter` calls `begin_critical()?`. `Drop` calls + // `end_critical`"): refused after a cancel; closed on drop AND on unwind. + #[test] + fn critical_guard_refused_after_cancel_and_ends_on_unwind() { + use mock::{Ev, StopFake}; + let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 4]))); + { + let mut g = CriticalGuard::enter(&mut t).expect("not cancelled: opens"); + g.execute(&[0x3B], DataDirection::ToDevice, &mut [0u8; 4], 0) + .expect("inside the span"); + } + assert_eq!(t.log[0], Ev::Begin); + assert_eq!(t.log.last(), Some(&Ev::End), "closed on drop"); + + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _g = CriticalGuard::enter(&mut t).expect("opens"); + panic!("unwind inside the span"); + })); + assert!(r.is_err()); + assert_eq!(t.log.last(), Some(&Ev::End), "closed on unwind"); + + t.cancelled = true; + let e = CriticalGuard::enter(&mut t) + .err() + .expect("refused after a cancel"); + assert!(is_dead_bus(&e), "a refusal is the dead-bus shape"); + assert_eq!(t.log.last(), Some(&Ev::BeginRefused), "no span, so no End"); + let begins = t.log.iter().filter(|e| **e == Ev::Begin).count(); + let ends = t.log.iter().filter(|e| **e == Ev::End).count(); + assert_eq!(begins, ends, "every opened span closed exactly once"); + } + + // The defaults keep all 32 existing transports compiling and behaving as + // before: pause sleeps, spans are no-ops, cleanup is a plain execute. + #[test] + fn default_stop_methods_preserve_plain_transport_behaviour() { + let mut t = MockTransport::always(Reply::good(vec![0u8; 2])); + let t0 = std::time::Instant::now(); + t.pause(std::time::Duration::from_millis(20)) + .expect("plain sleep"); + assert!(t0.elapsed() >= std::time::Duration::from_millis(20)); + t.begin_critical().expect("no-op"); + t.end_critical(); + t.execute_cleanup(&[0xA4], DataDirection::FromDevice, &mut [0u8; 2], 0) + .expect("plain execute"); + assert_eq!(t.cdbs, vec![vec![0xA4]], "cleanup reached the drive once"); + } + + // SS-7 (evidence): the guard's drop sends its REPORT KEY 3Fh CDB once, via + // execute_cleanup, even after a cancel; `defuse` sends nothing. + #[test] + fn agid_guard_releases_once_via_cleanup_and_defuse_keeps_it() { + use mock::{Ev, StopFake}; + let mut rel = [0u8; 12]; + rel[0] = SCSI_REPORT_KEY; + rel[10] = (2 << 6) | 0x3F; + let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 8]))); + t.cancelled = true; + drop(AgidGuard::new(&mut t, 2, rel)); + assert_eq!(t.log, vec![Ev::Cleanup(rel.to_vec())]); + + t.log.clear(); + let g = AgidGuard::new(&mut t, 2, rel); + assert_eq!(g.agid(), 2); + assert_eq!(g.defuse(), 2); + assert!(t.log.is_empty(), "defuse hands the AGID over unreleased"); + } } diff --git a/tests/op_path_sleep_guard.rs b/tests/op_path_sleep_guard.rs new file mode 100644 index 0000000..7817054 --- /dev/null +++ b/tests/op_path_sleep_guard.rs @@ -0,0 +1,144 @@ +//! Op-path sleep guard (Stop design v5 §5.8): "Ban `std::thread::sleep` on op +//! paths: unlock `src/{aacs,css,ld}` ... Use `Halt::wait` / `pause` instead." +//! +//! Every production (non-`#[cfg(test)]`) line under `src/aacs`, `src/css` and +//! `src/ld` is scanned for the substring `thread::sleep(` (catching both +//! `std::thread::sleep(` and an already-imported `thread::sleep(`). A raw sleep +//! on these op paths can't be cancelled; [`ScsiTransport::pause`] (which a +//! cancellable host wires to `Halt::wait`) is the sanctioned replacement. +//! `clippy.toml`'s `disallowed-methods` can't scope to a subdirectory, hence +//! this grep test instead of a lint config entry. + +use std::path::{Path, PathBuf}; + +/// The banned substring: matches `std::thread::sleep(` and `thread::sleep(`. +const BANNED: &str = "thread::sleep("; + +/// Every `.rs` file under `dir`, recursively. +fn rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + rs_files(&p, out); + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } +} + +/// 1-based line numbers of every `thread::sleep(` call in `src` that is NOT +/// inside a `#[cfg(test)]` item or block. Tracks brace depth: a `#[cfg(test)]` +/// attribute starts a "pending" item; if that item opens a `{`, everything +/// until the matching `}` is skipped; if it's a single-line item ending in +/// `,`/`;` with no `{` (e.g. a struct field), only that line is skipped. +fn op_path_hits(src: &str) -> Vec { + let mut hits = Vec::new(); + let mut depth: i32 = 0; + let mut paren_depth: i32 = 0; + let mut pending_cfg_test = false; + let mut skip_until_depth: Option = None; + + for (idx, line) in src.lines().enumerate() { + let lineno = idx + 1; + let trimmed = line.trim(); + let opens = line.matches('{').count() as i32; + let closes = line.matches('}').count() as i32; + + if let Some(d) = skip_until_depth { + depth += opens - closes; + if depth <= d { + skip_until_depth = None; + } + continue; + } + + if trimmed.starts_with("#[cfg(test)]") { + pending_cfg_test = true; + continue; + } + + if pending_cfg_test { + paren_depth += line.matches('(').count() as i32 - line.matches(')').count() as i32; + if opens > 0 { + let before = depth; + depth += opens - closes; + skip_until_depth = Some(before); + } else if paren_depth <= 0 && (trimmed.ends_with(',') || trimmed.ends_with(';')) { + pending_cfg_test = false; + } + // Multi-line signature before `{` (or an open param list): stay + // pending, keep scanning. + continue; + } + + depth += opens - closes; + if line.contains(BANNED) { + hits.push(lineno); + } + } + hits +} + +/// Guard: no raw `thread::sleep(` in production code under `src/aacs`, +/// `src/css` or `src/ld` (Stop design v5 §5.8). Use `pause` / `Halt::wait`. +#[test] +fn no_raw_sleep_on_unlock_op_paths() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files = Vec::new(); + for sub in ["src/aacs", "src/css", "src/ld"] { + rs_files(&root.join(sub), &mut files); + } + assert!( + files.len() >= 3, + "the scan must reach real files (found {})", + files.len() + ); + + let mut hits = Vec::new(); + for f in &files { + let src = std::fs::read_to_string(f).expect("read source"); + for line in op_path_hits(&src) { + hits.push(format!( + "{}:{line}", + f.strip_prefix(root).unwrap().display() + )); + } + } + assert!( + hits.is_empty(), + "raw thread::sleep on an unlock op path (use pause / Halt::wait instead):\n{}", + hits.join("\n") + ); +} + +/// Self-test: the guard skips `#[cfg(test)]` items/blocks and catches a +/// production-code sleep. +#[test] +fn the_guard_skips_cfg_test_and_catches_production_sleeps() { + let clean = [ + // A `mod tests { ... }` block, sleep inside it ignored. + "fn f() {}\n#[cfg(test)]\nmod tests {\n fn g() {\n std::thread::sleep(D);\n }\n}\n", + // A single test-only fn (one-liner body). + "#[cfg(test)]\nfn helper() { std::thread::sleep(D); }\n", + // A cfg(test)-gated struct field (single line, ends in `,`). + "struct S {\n #[cfg(test)]\n x: Option,\n}\n", + // A cfg(test)-gated statement inside a normal (production) fn. + "fn real() -> X {\n #[cfg(test)]\n if let Some(m) = HOOK.get() {\n thread::sleep(D);\n return m();\n }\n Y\n}\n", + // A multi-line test-only fn signature before the brace. + "#[cfg(test)]\nfn helper(\n a: u8,\n) -> Bar {\n thread::sleep(D);\n Bar\n}\n", + ]; + for s in &clean { + assert!(op_path_hits(s).is_empty(), "false positive: {s}"); + } + + let dirty = [ + "fn real() {\n std::thread::sleep(D);\n}\n", + "fn real() {\n thread::sleep(D);\n}\n", + ]; + for s in &dirty { + assert_eq!(op_path_hits(s), vec![2], "missed hit: {s}"); + } +}