mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
stop-design-v5 §2.3, second commit of the two-commit shape (UT1-UT9 green): - AACS: aacs1_ake / aacs2_authenticate_p256_with_anchor return the AGID in its AgidGuard; finish_auth takes it and releases on every exit, so the 1.0 AGID is freed before the P-256 fall-through. release_agid is gone: exactly one release per allocation, through execute_cleanup (SS-7). - CSS: the AGID is guarded during the challenge and defused on success, still held for read_disc_key (D9); the pre-allocation invalidate sweep stays on execute, so a Stop refuses it. - MT1959: A's WRITE BUFFER + verify (D8, C_max 35 s) and B's MODE SELECT, READ BUFFER, WRITE BUFFER, F1 (C_max 45 s) run in a CriticalGuard; do_unlock and the retries stay cancellable. - The per-cert backoff uses pause; an interrupted wait aborts as Transport. - The test-only FwFront forwards all four new methods. UnlockError, the Unlocker trait and every public signature are unchanged.
766 lines
27 KiB
Rust
766 lines
27 KiB
Rust
//! The SCSI transport contract every unlocker issues CDBs through. The consumer
|
|
//! (libfreemkv) implements [`ScsiTransport`] over its own SCSI; the unlockers
|
|
//! never see a concrete transport. Common MMC/SPC opcodes live here too.
|
|
|
|
/// Direction of a SCSI data transfer.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DataDirection {
|
|
None,
|
|
FromDevice,
|
|
ToDevice,
|
|
}
|
|
|
|
/// Result of a SCSI command: status byte, bytes transferred, raw sense.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ScsiResult {
|
|
pub status: u8,
|
|
pub bytes_transferred: usize,
|
|
pub sense: [u8; 32],
|
|
}
|
|
|
|
/// A transport-layer SCSI failure (the command could not complete — bridge
|
|
/// crash / disconnect), as opposed to a drive sense returned in [`ScsiResult`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct ScsiError {
|
|
pub status: u8,
|
|
pub sense: Option<[u8; 32]>,
|
|
}
|
|
|
|
/// Transport-layer result.
|
|
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,
|
|
cdb: &[u8],
|
|
direction: DataDirection,
|
|
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).
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ScsiSense {
|
|
pub sense_key: u8,
|
|
pub asc: u8,
|
|
pub ascq: u8,
|
|
}
|
|
|
|
impl ScsiSense {
|
|
/// Parse the fixed-format sense buffer (key at byte 2, ASC at 12, ASCQ at 13).
|
|
pub fn from_buf(sense: &[u8; 32]) -> Self {
|
|
ScsiSense {
|
|
sense_key: sense[2] & 0x0F,
|
|
asc: sense[12],
|
|
ascq: sense[13],
|
|
}
|
|
}
|
|
/// ILLEGAL REQUEST (sense key 0x05) — the drive won't honor the command.
|
|
pub fn is_illegal_request(&self) -> bool {
|
|
self.sense_key == 0x05
|
|
}
|
|
}
|
|
|
|
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
|
|
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
|
|
/// Whether a transport error is a genuine dead bus (a senseless transport-failure
|
|
/// status) rather than a drive rejection surfaced through a non-conforming
|
|
/// transport (`Err` carrying a sense).
|
|
pub(crate) fn is_dead_bus(e: &ScsiError) -> bool {
|
|
e.status == SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
|
}
|
|
|
|
/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the
|
|
/// status contract; currently referenced only by tests asserting the
|
|
/// transport-vs-check-condition distinction.
|
|
#[allow(dead_code)]
|
|
pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02;
|
|
|
|
// Common opcodes used by the unlocker modules.
|
|
pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB;
|
|
pub(crate) const SCSI_SEND_KEY: u8 = 0xA3;
|
|
pub(crate) const SCSI_REPORT_KEY: u8 = 0xA4;
|
|
pub(crate) const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
|
|
pub(crate) const SCSI_GET_CONFIGURATION: u8 = 0x46;
|
|
/// AACS key class selector used in REPORT/SEND KEY CDBs.
|
|
pub(crate) const AACS_KEY_CLASS: u8 = 0x02;
|
|
|
|
/// Build a SET CD SPEED (0xBB) CDB requesting `read_speed` (KB/s; 0xFFFF = max).
|
|
pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
|
[
|
|
SCSI_SET_CD_SPEED,
|
|
0x00,
|
|
(read_speed >> 8) as u8,
|
|
read_speed as u8,
|
|
0xFF,
|
|
0xFF,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
0x00,
|
|
]
|
|
}
|
|
|
|
// ── Test fixture ──────────────────────────────────────────────────────────── Crate-wide mock
|
|
// transport, able to express all three transport outcomes.
|
|
#[cfg(test)]
|
|
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
|
|
pub(crate) mod mock {
|
|
use super::*;
|
|
use std::collections::VecDeque;
|
|
|
|
/// One scripted answer to one `execute()` call.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) enum Reply {
|
|
/// `Ok` + GOOD status. `payload` is copied into the caller's buffer;
|
|
/// `bytes_transferred` defaults to the number of bytes copied.
|
|
Data {
|
|
payload: Vec<u8>,
|
|
bytes_transferred: Option<usize>,
|
|
},
|
|
/// `Ok` + a NON-ZERO SCSI status (CHECK CONDITION) with a drive sense.
|
|
/// Per the contract at [`ScsiTransport::execute`] this is NOT a
|
|
/// transport fault — the caller must inspect `status`, and a caller that
|
|
/// doesn't will consume the caller's zero-filled buffer as drive data.
|
|
Sense {
|
|
status: u8,
|
|
sense_key: u8,
|
|
asc: u8,
|
|
ascq: u8,
|
|
},
|
|
/// `Err` with the transport-failure status and no sense — a genuine
|
|
/// transport-layer fault (bridge crash / disconnect). MUST abort.
|
|
TransportFault,
|
|
/// `Err` carrying a real status + parsed sense. This is what a
|
|
/// NON-conforming transport does (libfreemkv's adapter returns `Err` for
|
|
/// any non-zero SCSI status); classification must still treat it as a
|
|
/// drive rejection, not a dead bus.
|
|
ErrWithSense {
|
|
status: u8,
|
|
sense_key: u8,
|
|
asc: u8,
|
|
ascq: u8,
|
|
},
|
|
}
|
|
|
|
impl Reply {
|
|
/// `Ok`, GOOD status, full transfer of `payload`.
|
|
pub(crate) fn good(payload: Vec<u8>) -> Reply {
|
|
Reply::Data {
|
|
payload,
|
|
bytes_transferred: None,
|
|
}
|
|
}
|
|
/// `Ok`, GOOD status, but the drive only delivered `n` bytes.
|
|
pub(crate) fn short(payload: Vec<u8>, n: usize) -> Reply {
|
|
Reply::Data {
|
|
payload,
|
|
bytes_transferred: Some(n),
|
|
}
|
|
}
|
|
/// `Ok`, GOOD status, ZERO bytes delivered — the buffer the caller reads
|
|
/// is entirely its own zero fill.
|
|
pub(crate) fn zero_transfer(len: usize) -> Reply {
|
|
Reply::Data {
|
|
payload: vec![0u8; len],
|
|
bytes_transferred: Some(0),
|
|
}
|
|
}
|
|
/// `Ok` + CHECK CONDITION / ILLEGAL REQUEST (0x05, ASC 0x20 invalid
|
|
/// command) — the ordinary way a drive refuses a vendor command.
|
|
pub(crate) fn illegal_request() -> Reply {
|
|
Reply::Sense {
|
|
status: SCSI_STATUS_CHECK_CONDITION,
|
|
sense_key: 0x05,
|
|
asc: 0x20,
|
|
ascq: 0x00,
|
|
}
|
|
}
|
|
/// `Err` + CHECK CONDITION / ILLEGAL REQUEST — the same drive refusal as
|
|
/// seen through a non-conforming transport.
|
|
pub(crate) fn illegal_request_as_err() -> Reply {
|
|
Reply::ErrWithSense {
|
|
status: SCSI_STATUS_CHECK_CONDITION,
|
|
sense_key: 0x05,
|
|
asc: 0x20,
|
|
ascq: 0x00,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sense_buf(sense_key: u8, asc: u8, ascq: u8) -> [u8; 32] {
|
|
let mut b = [0u8; 32];
|
|
b[2] = sense_key & 0x0F;
|
|
b[12] = asc;
|
|
b[13] = ascq;
|
|
b
|
|
}
|
|
|
|
/// A scripted transport: answers each `execute()` from `script`, falling
|
|
/// back to `default` once the script runs out, and records every CDB.
|
|
pub(crate) struct MockTransport {
|
|
pub(crate) script: VecDeque<Reply>,
|
|
pub(crate) default: Reply,
|
|
pub(crate) cdbs: Vec<Vec<u8>>,
|
|
}
|
|
|
|
impl MockTransport {
|
|
/// Every command gets the same answer.
|
|
pub(crate) fn always(reply: Reply) -> Self {
|
|
MockTransport {
|
|
script: VecDeque::new(),
|
|
default: reply,
|
|
cdbs: Vec::new(),
|
|
}
|
|
}
|
|
/// The first N commands follow `script`; the rest get `default`.
|
|
pub(crate) fn scripted(script: Vec<Reply>, default: Reply) -> Self {
|
|
MockTransport {
|
|
script: script.into(),
|
|
default,
|
|
cdbs: Vec::new(),
|
|
}
|
|
}
|
|
/// How many CDBs were issued (lets a test assert a dead bus aborted
|
|
/// instead of retrying).
|
|
pub(crate) fn calls(&self) -> usize {
|
|
self.cdbs.len()
|
|
}
|
|
}
|
|
|
|
impl ScsiTransport for MockTransport {
|
|
fn execute(
|
|
&mut self,
|
|
cdb: &[u8],
|
|
_direction: DataDirection,
|
|
data: &mut [u8],
|
|
_timeout_ms: u32,
|
|
) -> Result<ScsiResult> {
|
|
self.cdbs.push(cdb.to_vec());
|
|
let reply = self
|
|
.script
|
|
.pop_front()
|
|
.unwrap_or_else(|| self.default.clone());
|
|
match reply {
|
|
Reply::Data {
|
|
payload,
|
|
bytes_transferred,
|
|
} => {
|
|
let n = payload.len().min(data.len());
|
|
data[..n].copy_from_slice(&payload[..n]);
|
|
Ok(ScsiResult {
|
|
status: 0,
|
|
bytes_transferred: bytes_transferred.unwrap_or(n),
|
|
sense: [0u8; 32],
|
|
})
|
|
}
|
|
Reply::Sense {
|
|
status,
|
|
sense_key,
|
|
asc,
|
|
ascq,
|
|
} => Ok(ScsiResult {
|
|
status,
|
|
bytes_transferred: 0,
|
|
sense: sense_buf(sense_key, asc, ascq),
|
|
}),
|
|
Reply::TransportFault => Err(ScsiError {
|
|
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
|
sense: None,
|
|
}),
|
|
Reply::ErrWithSense {
|
|
status,
|
|
sense_key,
|
|
asc,
|
|
ascq,
|
|
} => Err(ScsiError {
|
|
status,
|
|
sense: Some(sense_buf(sense_key, asc, ascq)),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)]
|
|
mod tests {
|
|
use super::*;
|
|
use mock::{MockTransport, Reply};
|
|
|
|
/// The fixed-format sense layout the whole crate reads its diagnostics off
|
|
/// (key at byte 2 low nibble, ASC at 12, ASCQ at 13). This file had no test
|
|
/// at all, so nothing pinned the offsets.
|
|
#[test]
|
|
fn sense_parses_at_the_fixed_format_offsets() {
|
|
let mut buf = [0u8; 32];
|
|
buf[2] = 0xF5; // sense key is the LOW nibble only
|
|
buf[12] = 0x24;
|
|
buf[13] = 0x01;
|
|
let s = ScsiSense::from_buf(&buf);
|
|
assert_eq!(s.sense_key, 0x05);
|
|
assert_eq!(s.asc, 0x24);
|
|
assert_eq!(s.ascq, 0x01);
|
|
assert!(s.is_illegal_request());
|
|
|
|
buf[2] = 0x02; // NOT READY
|
|
assert!(!ScsiSense::from_buf(&buf).is_illegal_request());
|
|
}
|
|
|
|
/// SET CD SPEED encodes the read speed big-endian at bytes 2-3 and leaves
|
|
/// the write speed at 0xFFFF.
|
|
#[test]
|
|
fn set_cd_speed_encodes_the_read_speed_big_endian() {
|
|
let cdb = build_set_cd_speed(0x1234);
|
|
assert_eq!(cdb[0], SCSI_SET_CD_SPEED);
|
|
assert_eq!([cdb[2], cdb[3]], [0x12, 0x34]);
|
|
assert_eq!([cdb[4], cdb[5]], [0xFF, 0xFF]);
|
|
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
|
|
}
|
|
|
|
// The fixture must honour the contract it tests: a drive sense is `Ok`
|
|
// with non-zero status, a bus fault is `Err`. If this drifts, every
|
|
// transport-contract test built on it silently stops testing anything.
|
|
#[test]
|
|
fn mock_transport_expresses_the_three_contract_outcomes() {
|
|
let mut t = MockTransport::scripted(
|
|
vec![
|
|
Reply::good(vec![0xAB; 4]),
|
|
Reply::illegal_request(),
|
|
Reply::zero_transfer(4),
|
|
],
|
|
Reply::TransportFault,
|
|
);
|
|
let mut buf = [0u8; 4];
|
|
|
|
let r = t
|
|
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
|
.expect("data reply is Ok");
|
|
assert_eq!(r.status, 0);
|
|
assert_eq!(r.bytes_transferred, 4);
|
|
assert_eq!(buf, [0xAB; 4]);
|
|
|
|
let r = t
|
|
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
|
.expect("A DRIVE SENSE IS Ok, NOT Err — the load-bearing contract");
|
|
assert_eq!(r.status, SCSI_STATUS_CHECK_CONDITION);
|
|
assert!(ScsiSense::from_buf(&r.sense).is_illegal_request());
|
|
|
|
let r = t
|
|
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
|
.expect("a zero-length transfer is still Ok");
|
|
assert_eq!(r.bytes_transferred, 0);
|
|
|
|
let e = t
|
|
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
|
.expect_err("a transport fault is Err");
|
|
assert_eq!(e.status, SCSI_STATUS_TRANSPORT_FAILURE);
|
|
assert!(e.sense.is_none(), "a bus fault carries no drive sense");
|
|
|
|
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");
|
|
}
|
|
}
|