mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
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.
This commit is contained in:
+344
-58
@@ -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<AacsAuth> {
|
||||
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<AacsAuth, V1Fail> {
|
||||
) -> 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<AacsAuth> {
|
||||
) -> 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<CertHandshake, FinishErr> {
|
||||
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<S> = (&'static str, S, Option<fn(&[u8]) -> 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<Setup>; 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<u8>);
|
||||
let cases: [AkeCase<Setup>; 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<crate::scsi::ScsiResult> {
|
||||
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<T>` … 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 {
|
||||
|
||||
+155
-17
@@ -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<u8> {
|
||||
// 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<u8> {
|
||||
)?;
|
||||
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<crate::scsi::ScsiResult> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +518,8 @@ fn ready_clock() -> Box<dyn ReadyClock> {
|
||||
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<crate::scsi::mock::Ev>) {
|
||||
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<String> = 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<String> = 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+399
@@ -31,6 +31,13 @@ pub type Result<T> = std::result::Result<T, ScsiError>;
|
||||
|
||||
/// 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<ScsiResult>;
|
||||
|
||||
/// 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<ScsiResult> {
|
||||
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<Self> {
|
||||
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<u8>),
|
||||
/// `execute` refused because the operation was cancelled.
|
||||
Refused(Vec<u8>),
|
||||
/// `execute_cleanup` reached the drive.
|
||||
Cleanup(Vec<u8>),
|
||||
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<T> {
|
||||
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<fn(&[u8]) -> bool>,
|
||||
/// Cancel when the next `pause` starts (a Stop during the wait).
|
||||
pub(crate) cancel_on_pause: bool,
|
||||
pub(crate) log: Vec<Ev>,
|
||||
}
|
||||
|
||||
fn refused() -> ScsiError {
|
||||
ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> StopFake<T> {
|
||||
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<u8>> {
|
||||
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<u8>> {
|
||||
self.log
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Ev::Cleanup(c) => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for StopFake<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
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<ScsiResult> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>) {
|
||||
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<usize> {
|
||||
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<i32> = 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<u8>,\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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user