mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
428 lines
14 KiB
Rust
428 lines
14 KiB
Rust
//! [`FakeTransport`]: a scripted [`ScsiTransport`] with a drive state model, for Stop
|
|
//! tests (stop design §5.0). Durations are scaled: a CDB's `timeout_ms` is divided by
|
|
//! [`FakeTransport::scale`] (default 1000, so a 60 s READ times out at 60 ms).
|
|
//!
|
|
//! It keeps the tray and AGID ledger a real drive would, logs every CDB, counts live
|
|
//! handles, and **panics on any CDB that reaches it after the watched token is
|
|
//! cancelled** unless the §2.4 allow-list (shared with `Drive::exec_cleanup`) or a
|
|
//! test's [`FakeTransport::allow_after_cancel`] admits it.
|
|
|
|
use crate::drive::allow::{self, CleanupCtx, Ledger};
|
|
use crate::error::{Error, Result};
|
|
use crate::halt::Halt;
|
|
use crate::scsi::{DataDirection, ScsiResult, ScsiSense, ScsiTransport};
|
|
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
|
|
use std::time::{Duration, Instant};
|
|
|
|
type Pred = Box<dyn Fn(&[u8]) -> bool + Send>;
|
|
|
|
/// How the fake answers a CDB a rule matches.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum FakeMode {
|
|
/// Answer after `d` (wall time, not scaled) as the default serving would. Past the
|
|
/// CDB's scaled timeout it times out instead, like a real transport.
|
|
Complete(Duration),
|
|
/// Block until [`FakeHandle::release`], or time out at the CDB's scaled timeout.
|
|
Stall,
|
|
/// A transport fault: status 0xFF, no sense (a dead bus).
|
|
Fault,
|
|
/// CHECK CONDITION with this sense; `progress` is the sense-key specific progress
|
|
/// indication reported through [`ScsiTransport::last_sense_progress`].
|
|
Sense {
|
|
sense: ScsiSense,
|
|
progress: Option<u16>,
|
|
},
|
|
}
|
|
|
|
/// One CDB as the fake received it.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct FakeCdb {
|
|
pub cdb: Vec<u8>,
|
|
pub timeout_ms: u32,
|
|
/// Whether the watched token was already cancelled when it arrived.
|
|
pub after_cancel: bool,
|
|
}
|
|
|
|
struct Rule {
|
|
pred: Pred,
|
|
mode: FakeMode,
|
|
times: Option<usize>,
|
|
}
|
|
|
|
struct State {
|
|
log: Vec<FakeCdb>,
|
|
rules: Vec<Rule>,
|
|
image: Option<Vec<u8>>,
|
|
profile: u16,
|
|
scale: u32,
|
|
watch: Option<Halt>,
|
|
allow: Vec<Pred>,
|
|
cancel_on: Vec<(Pred, Halt)>,
|
|
ledger: Ledger,
|
|
live: usize,
|
|
released: u64,
|
|
}
|
|
|
|
struct Shared {
|
|
state: Mutex<State>,
|
|
wake: Condvar,
|
|
}
|
|
|
|
impl Shared {
|
|
fn lock(&self) -> MutexGuard<'_, State> {
|
|
self.state.lock().unwrap_or_else(|p| p.into_inner())
|
|
}
|
|
}
|
|
|
|
/// A scripted transport; build it, keep its [`FakeHandle`], and hand it to
|
|
/// `Drive::from_transport` / `Drive::from_transport_with`.
|
|
pub struct FakeTransport {
|
|
shared: Arc<Shared>,
|
|
inner: Option<Box<dyn ScsiTransport>>,
|
|
last_progress: Option<u16>,
|
|
}
|
|
|
|
/// The test's view of a [`FakeTransport`]: its CDB log, ledger and live count, and
|
|
/// the release for stalled CDBs. Cheap to clone.
|
|
#[derive(Clone)]
|
|
pub struct FakeHandle(Arc<Shared>);
|
|
|
|
impl FakeTransport {
|
|
/// A fake that answers every CDB GOOD with a zeroed buffer.
|
|
pub fn new() -> (Self, FakeHandle) {
|
|
let shared = Arc::new(Shared {
|
|
state: Mutex::new(State {
|
|
log: Vec::new(),
|
|
rules: Vec::new(),
|
|
image: None,
|
|
profile: 0x0040,
|
|
scale: 1000,
|
|
watch: None,
|
|
allow: Vec::new(),
|
|
cancel_on: Vec::new(),
|
|
ledger: Ledger::default(),
|
|
live: 1,
|
|
released: 0,
|
|
}),
|
|
wake: Condvar::new(),
|
|
});
|
|
let t = FakeTransport {
|
|
shared: shared.clone(),
|
|
inner: None,
|
|
last_progress: None,
|
|
};
|
|
(t, FakeHandle(shared))
|
|
}
|
|
|
|
/// Serve READ(10) and READ CAPACITY from `image` (2048-byte sectors).
|
|
pub fn with_image(self, image: Vec<u8>) -> Self {
|
|
self.shared.lock().image = Some(image);
|
|
self
|
|
}
|
|
|
|
/// Delegate every CDB no rule matches to `inner` (e.g. the AKE handshake fixture).
|
|
pub fn with_inner(mut self, inner: Box<dyn ScsiTransport>) -> Self {
|
|
self.inner = Some(inner);
|
|
self
|
|
}
|
|
|
|
/// The GET CONFIGURATION current profile (default 0x0040, BD-ROM).
|
|
pub fn profile(self, profile: u16) -> Self {
|
|
self.shared.lock().profile = profile;
|
|
self
|
|
}
|
|
|
|
/// Divide every CDB timeout by `divisor` (default 1000).
|
|
pub fn scale(self, divisor: u32) -> Self {
|
|
self.shared.lock().scale = divisor.max(1);
|
|
self
|
|
}
|
|
|
|
/// Answer CDBs matching `pred` with `mode`; the first matching rule wins.
|
|
pub fn rule(self, pred: impl Fn(&[u8]) -> bool + Send + 'static, mode: FakeMode) -> Self {
|
|
self.push_rule(Box::new(pred), mode, None)
|
|
}
|
|
|
|
/// As [`rule`](Self::rule), for the next `times` matching CDBs only.
|
|
pub fn rule_n(
|
|
self,
|
|
pred: impl Fn(&[u8]) -> bool + Send + 'static,
|
|
mode: FakeMode,
|
|
times: usize,
|
|
) -> Self {
|
|
self.push_rule(Box::new(pred), mode, Some(times))
|
|
}
|
|
|
|
fn push_rule(self, pred: Pred, mode: FakeMode, times: Option<usize>) -> Self {
|
|
self.shared.lock().rules.push(Rule { pred, mode, times });
|
|
self
|
|
}
|
|
|
|
/// Panic on any CDB arriving after `halt` is cancelled that §2.4 does not admit.
|
|
pub fn watch(self, halt: &Halt) -> Self {
|
|
self.shared.lock().watch = Some(halt.clone());
|
|
self
|
|
}
|
|
|
|
/// Also admit post-cancel CDBs matching `pred` (a critical section, `finish(Eject)`:
|
|
/// contexts the fake cannot see from below the Drive).
|
|
pub fn allow_after_cancel(self, pred: impl Fn(&[u8]) -> bool + Send + 'static) -> Self {
|
|
self.shared.lock().allow.push(Box::new(pred));
|
|
self
|
|
}
|
|
|
|
/// Cancel `halt` as soon as a CDB matching `pred` has been answered.
|
|
pub fn cancel_on(self, pred: impl Fn(&[u8]) -> bool + Send + 'static, halt: &Halt) -> Self {
|
|
self.shared
|
|
.lock()
|
|
.cancel_on
|
|
.push((Box::new(pred), halt.clone()));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl FakeHandle {
|
|
/// Every CDB received so far, in order.
|
|
pub fn log(&self) -> Vec<FakeCdb> {
|
|
self.0.lock().log.clone()
|
|
}
|
|
|
|
/// The CDB bytes received so far.
|
|
pub fn cdbs(&self) -> Vec<Vec<u8>> {
|
|
self.0.lock().log.iter().map(|c| c.cdb.clone()).collect()
|
|
}
|
|
|
|
/// How many received CDBs match `pred`.
|
|
pub fn count(&self, pred: impl Fn(&[u8]) -> bool) -> usize {
|
|
self.0.lock().log.iter().filter(|c| pred(&c.cdb)).count()
|
|
}
|
|
|
|
/// Let every CDB stalled so far complete.
|
|
pub fn release(&self) {
|
|
self.0.lock().released += 1;
|
|
self.0.wake.notify_all();
|
|
}
|
|
|
|
/// Live `FakeTransport`s built from this handle (0 once the Drive dropped it).
|
|
pub fn live_handles(&self) -> usize {
|
|
self.0.lock().live
|
|
}
|
|
|
|
/// Threads holding a Drive (`spawn_drive_holder`) still running, process-wide.
|
|
pub fn drive_holders(&self) -> usize {
|
|
crate::halt::live_drive_holders()
|
|
}
|
|
|
|
/// The tray lock as the fake drive sees it.
|
|
pub fn tray_locked(&self) -> bool {
|
|
self.0.lock().ledger.tray_locked
|
|
}
|
|
|
|
/// AGIDs the fake drive holds (bit `n` = AGID `n`).
|
|
pub fn agids(&self) -> u8 {
|
|
self.0.lock().ledger.agids
|
|
}
|
|
|
|
/// Wait (up to `limit`) until at least `n` CDBs matching `pred` arrived.
|
|
pub fn wait_for(&self, n: usize, pred: impl Fn(&[u8]) -> bool, limit: Duration) -> bool {
|
|
let end = Instant::now() + limit;
|
|
while Instant::now() < end {
|
|
if self.count(&pred) >= n {
|
|
return true;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(1));
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
impl Drop for FakeTransport {
|
|
fn drop(&mut self) {
|
|
self.shared.lock().live -= 1;
|
|
}
|
|
}
|
|
|
|
fn check_condition(opcode: u8, sense: ScsiSense) -> Error {
|
|
Error::ScsiError {
|
|
opcode,
|
|
status: 2,
|
|
sense: Some(sense),
|
|
}
|
|
}
|
|
|
|
fn transport_failure(opcode: u8) -> Error {
|
|
Error::ScsiError {
|
|
opcode,
|
|
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
|
sense: None,
|
|
}
|
|
}
|
|
|
|
impl FakeTransport {
|
|
// The default drive: an image for READ(10) / READ CAPACITY, a profile, GOOD
|
|
// with zeros for everything else.
|
|
fn serve_default(&self, cdb: &[u8], data: &mut [u8]) -> Result<ScsiResult> {
|
|
data.fill(0);
|
|
let st = self.shared.lock();
|
|
match (cdb[0], &st.image) {
|
|
(crate::scsi::SCSI_READ_10, Some(img)) if cdb.len() >= 9 => {
|
|
let lba = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize;
|
|
let n = u16::from_be_bytes([cdb[7], cdb[8]]) as usize;
|
|
let (start, len) = (lba * 2048, n * 2048);
|
|
if start + len > img.len() || data.len() < len {
|
|
let lba_out_of_range = ScsiSense {
|
|
sense_key: crate::scsi::SENSE_KEY_ILLEGAL_REQUEST,
|
|
asc: 0x21,
|
|
ascq: 0x00,
|
|
};
|
|
return Err(check_condition(cdb[0], lba_out_of_range));
|
|
}
|
|
data[..len].copy_from_slice(&img[start..start + len]);
|
|
}
|
|
(crate::scsi::SCSI_READ_CAPACITY, Some(img)) if data.len() >= 8 => {
|
|
let last = (img.len() / 2048).saturating_sub(1) as u32;
|
|
data[..4].copy_from_slice(&last.to_be_bytes());
|
|
data[4..8].copy_from_slice(&2048u32.to_be_bytes());
|
|
}
|
|
(crate::scsi::SCSI_GET_CONFIGURATION, _) if data.len() >= 8 => {
|
|
data[6..8].copy_from_slice(&st.profile.to_be_bytes());
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(ScsiResult {
|
|
status: 0,
|
|
bytes_transferred: data.len(),
|
|
sense: [0u8; 32],
|
|
})
|
|
}
|
|
|
|
fn serve(
|
|
&mut self,
|
|
cdb: &[u8],
|
|
dir: DataDirection,
|
|
data: &mut [u8],
|
|
t: u32,
|
|
) -> Result<ScsiResult> {
|
|
match self.inner.as_mut() {
|
|
Some(inner) => inner.execute(cdb, dir, data, t),
|
|
None => self.serve_default(cdb, data),
|
|
}
|
|
}
|
|
|
|
// Block until released (a new `release` generation) or `limit` passes.
|
|
// `gen0` is the release generation read when the CDB was logged, so a release
|
|
// between the log and this wait still counts.
|
|
fn stall(&self, limit: Duration, gen0: u64) -> bool {
|
|
let end = Instant::now() + limit;
|
|
let mut st = self.shared.lock();
|
|
while st.released == gen0 {
|
|
let left = end.saturating_duration_since(Instant::now());
|
|
if left.is_zero() {
|
|
return false;
|
|
}
|
|
st = self
|
|
.shared
|
|
.wake
|
|
.wait_timeout(st, left)
|
|
.unwrap_or_else(|p| p.into_inner())
|
|
.0;
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
impl ScsiTransport for FakeTransport {
|
|
fn execute(
|
|
&mut self,
|
|
cdb: &[u8],
|
|
dir: DataDirection,
|
|
data: &mut [u8],
|
|
timeout_ms: u32,
|
|
) -> Result<ScsiResult> {
|
|
self.last_progress = None;
|
|
let (mode, limit, gen0) = {
|
|
let mut st = self.shared.lock();
|
|
let after_cancel = st.watch.as_ref().is_some_and(Halt::is_cancelled);
|
|
st.log.push(FakeCdb {
|
|
cdb: cdb.to_vec(),
|
|
timeout_ms,
|
|
after_cancel,
|
|
});
|
|
if after_cancel
|
|
&& !allow::allowed_after_cancel(cdb, st.ledger, CleanupCtx::Plain)
|
|
&& !st.allow.iter().any(|p| p(cdb))
|
|
{
|
|
drop(st);
|
|
// Already unwinding (a Drive dropped by a failing test): refuse, don't abort.
|
|
if std::thread::panicking() {
|
|
return Err(transport_failure(cdb[0]));
|
|
}
|
|
panic!("FakeTransport: CDB {cdb:02x?} after a cancel, outside the §2.4 allow-list");
|
|
}
|
|
let limit = Duration::from_micros(u64::from(timeout_ms) * 1000 / u64::from(st.scale));
|
|
let mut mode = None;
|
|
for r in st.rules.iter_mut() {
|
|
if r.times != Some(0) && (r.pred)(cdb) {
|
|
if let Some(n) = r.times.as_mut() {
|
|
*n -= 1;
|
|
}
|
|
mode = Some(r.mode);
|
|
break;
|
|
}
|
|
}
|
|
(mode, limit, st.released)
|
|
};
|
|
let r = match mode {
|
|
None => self.serve(cdb, dir, data, timeout_ms),
|
|
Some(FakeMode::Complete(d)) if d > limit => {
|
|
std::thread::sleep(limit);
|
|
Err(transport_failure(cdb[0]))
|
|
}
|
|
Some(FakeMode::Complete(d)) => {
|
|
std::thread::sleep(d);
|
|
self.serve(cdb, dir, data, timeout_ms)
|
|
}
|
|
Some(FakeMode::Stall) => match self.stall(limit, gen0) {
|
|
true => self.serve(cdb, dir, data, timeout_ms),
|
|
false => Err(transport_failure(cdb[0])),
|
|
},
|
|
Some(FakeMode::Fault) => Err(transport_failure(cdb[0])),
|
|
Some(FakeMode::Sense { sense, progress }) => {
|
|
self.last_progress = progress;
|
|
Err(check_condition(cdb[0], sense))
|
|
}
|
|
};
|
|
let mut st = self.shared.lock();
|
|
note_ledger(&mut st.ledger, cdb, data, r.is_ok());
|
|
for (pred, halt) in &st.cancel_on {
|
|
if pred(cdb) {
|
|
halt.cancel();
|
|
}
|
|
}
|
|
r
|
|
}
|
|
|
|
fn last_sense_progress(&self) -> Option<u16> {
|
|
self.last_progress
|
|
}
|
|
}
|
|
|
|
// The fake drive's own tray and AGID state, from the CDBs it answered.
|
|
fn note_ledger(l: &mut Ledger, cdb: &[u8], resp: &[u8], ok: bool) {
|
|
match cdb.first() {
|
|
Some(&allow::PREVENT_ALLOW) if ok => l.tray_locked = cdb.get(4).is_some_and(|b| b & 1 == 1),
|
|
Some(&allow::REPORT_KEY) if cdb.len() >= 11 => {
|
|
if let Some(agid) = allow::invalidated_agid(cdb) {
|
|
l.agids &= !(1 << agid);
|
|
} else if cdb[10] & 0x3F == 0 && ok && resp.len() > 7 {
|
|
l.agids |= 1 << (resp[7] >> 6);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "test_util_fake_stall_tests.rs"]
|
|
mod stall_tests;
|