393 lines
15 KiB
Rust
393 lines
15 KiB
Rust
//! Integration tests for the SCSI error-decoding contract.
|
|
//!
|
|
//! v0.13.20 rewrote `scsi/linux.rs` to a synchronous blocking SG_IO and
|
|
//! consolidated sense parsing into the `parse_sense` helper that every
|
|
//! platform backend now shares. v0.13.23 replaced the `Error::ScsiError`
|
|
//! flat-fields shape with `{ opcode, status, sense: Option<ScsiSense> }`
|
|
//! so callers can route on structured sense data (key + ASC + ASCQ) via
|
|
//! [`Error::scsi_sense`] / [`Error::is_marginal_read`] /
|
|
//! [`ScsiSense::is_*`].
|
|
//!
|
|
//! The actual `ioctl(SG_IO, ...)` call is impossible to mock without a
|
|
//! kernel, so libc shims are deliberately avoided here. These tests
|
|
//! therefore pin the *contract* every backend must satisfy via a mock
|
|
//! `ScsiTransport`:
|
|
//!
|
|
//! 1. Healthy result → `Ok(ScsiResult { bytes_transferred = data.len() - resid })`.
|
|
//! 2. Transport-level failure (no SCSI status delivered: kernel
|
|
//! timeout, USB bridge wedge, IOKit service error) →
|
|
//! `Error::ScsiError { status: SCSI_STATUS_TRANSPORT_FAILURE, sense: None }`.
|
|
//! `Error::is_scsi_transport_failure()` returns `true`. Used by
|
|
//! `drive_has_disc` to detect the wedge signature.
|
|
//! 3. SCSI-level failure (drive replied CHECK CONDITION with sense) →
|
|
//! `Error::ScsiError { status: 0x02, sense: Some(ScsiSense {…}) }`
|
|
//! with the parsed key/ASC/ASCQ.
|
|
//! 4. `Error::is_marginal_read()` is `true` for MEDIUM ERROR /
|
|
//! ABORTED COMMAND / RECOVERED ERROR / NO SENSE; `false` for
|
|
//! HARDWARE / DATA PROTECT / UNIT ATTENTION / NOT READY / ILLEGAL
|
|
//! REQUEST and for transport failures.
|
|
//!
|
|
//! Inline `parse_sense_tests` in `src/scsi/mod.rs` cover the pure parse
|
|
//! logic (descriptor 0x72/0x73 vs fixed 0x70/0x71, short-buffer, VALID
|
|
//! bit masking, unknown response codes, ASC/ASCQ offsets); this file
|
|
//! covers the consumer side — a real transport feeding a real Error
|
|
//! variant to a real call site (`scsi::inquiry`).
|
|
|
|
use libfreemkv::error::Error;
|
|
use libfreemkv::scsi::{
|
|
DataDirection, SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE,
|
|
SENSE_KEY_ABORTED_COMMAND, SENSE_KEY_DATA_PROTECT, SENSE_KEY_HARDWARE_ERROR,
|
|
SENSE_KEY_ILLEGAL_REQUEST, SENSE_KEY_MEDIUM_ERROR, SENSE_KEY_NOT_READY,
|
|
SENSE_KEY_RECOVERED_ERROR, SENSE_KEY_UNIT_ATTENTION, ScsiResult, ScsiSense, ScsiTransport,
|
|
};
|
|
|
|
/// A scripted ScsiTransport. Each `execute()` consumes the next entry
|
|
/// from `script` and returns the corresponding outcome.
|
|
///
|
|
/// Outcomes mirror what each backend's `execute()` should produce after
|
|
/// the v0.13.23 sense plumbing: `Option<ScsiSense>` carrying the full
|
|
/// SPC-4 triple for drive-reported failures, `None` for transport-level
|
|
/// failures.
|
|
struct MockTransport {
|
|
script: Vec<MockOutcome>,
|
|
next: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum MockOutcome {
|
|
/// Healthy completion. `data` is what the transport wrote into the
|
|
/// caller's data buffer (truncated to the buffer length); `resid`
|
|
/// is reported back as `data.len() - bytes_transferred`.
|
|
Ok { data: Vec<u8>, resid: i32 },
|
|
/// Transport-level failure: `hdr.host_status = DID_TIME_OUT` on
|
|
/// Linux, `kIOReturnError` on macOS, `DeviceIoControl` returning 0
|
|
/// on Windows. Backends synthesise `SCSI_STATUS_TRANSPORT_FAILURE`
|
|
/// with `sense = None`.
|
|
TransportFailure,
|
|
/// Drive replied with sense data (typically `SCSI_STATUS_CHECK_CONDITION`
|
|
/// + a populated sense buffer).
|
|
ScsiFailure { status: u8, sense: ScsiSense },
|
|
}
|
|
|
|
impl MockTransport {
|
|
fn new(script: Vec<MockOutcome>) -> Self {
|
|
Self { script, next: 0 }
|
|
}
|
|
}
|
|
|
|
impl ScsiTransport for MockTransport {
|
|
fn execute(
|
|
&mut self,
|
|
cdb: &[u8],
|
|
_direction: DataDirection,
|
|
data: &mut [u8],
|
|
_timeout_ms: u32,
|
|
) -> libfreemkv::error::Result<ScsiResult> {
|
|
let i = self.next;
|
|
self.next += 1;
|
|
let outcome = self
|
|
.script
|
|
.get(i)
|
|
.cloned()
|
|
.expect("MockTransport script ran out — test wrote fewer outcomes than calls");
|
|
match outcome {
|
|
MockOutcome::Ok { data: d, resid } => {
|
|
let n = d.len().min(data.len());
|
|
data[..n].copy_from_slice(&d[..n]);
|
|
let bytes = (data.len() as i32).saturating_sub(resid).max(0) as usize;
|
|
Ok(ScsiResult {
|
|
status: 0,
|
|
bytes_transferred: bytes,
|
|
sense: [0u8; 32],
|
|
})
|
|
}
|
|
MockOutcome::TransportFailure => Err(Error::ScsiError {
|
|
opcode: cdb[0],
|
|
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
|
sense: None,
|
|
}),
|
|
MockOutcome::ScsiFailure { status, sense } => Err(Error::ScsiError {
|
|
opcode: cdb[0],
|
|
status,
|
|
sense: Some(sense),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper — build a CHECK CONDITION outcome with a given sense key
|
|
/// and zero ASC/ASCQ. Most consumer-side tests only care about the key.
|
|
fn check_cond(sense_key: u8) -> MockOutcome {
|
|
MockOutcome::ScsiFailure {
|
|
status: SCSI_STATUS_CHECK_CONDITION,
|
|
sense: ScsiSense {
|
|
sense_key,
|
|
asc: 0,
|
|
ascq: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── 1. Healthy read ───────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_healthy_inquiry_returns_ok_with_full_transfer() {
|
|
// Build a fake INQUIRY response (96 bytes per scsi::inquiry).
|
|
// Vendor "TEST-VND", model "TEST-MODEL ", firmware "1.00".
|
|
let mut payload = vec![0u8; 96];
|
|
payload[8..16].copy_from_slice(b"TEST-VND");
|
|
payload[16..32].copy_from_slice(b"TEST-MODEL ");
|
|
payload[32..36].copy_from_slice(b"1.00");
|
|
|
|
let mut transport = MockTransport::new(vec![MockOutcome::Ok {
|
|
data: payload,
|
|
resid: 0,
|
|
}]);
|
|
|
|
let r = libfreemkv::scsi::inquiry(&mut transport).expect("inquiry should succeed");
|
|
assert_eq!(r.vendor_id, "TEST-VND");
|
|
assert_eq!(r.model, "TEST-MODEL");
|
|
assert_eq!(r.firmware, "1.00");
|
|
}
|
|
|
|
// ── 2. Transport-level failure: no SCSI status, no sense data ─────────────
|
|
|
|
#[test]
|
|
fn test_transport_failure_surfaces_with_sense_none() {
|
|
let mut transport = MockTransport::new(vec![MockOutcome::TransportFailure]);
|
|
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
assert!(
|
|
err.is_scsi_transport_failure(),
|
|
"TransportFailure must satisfy is_scsi_transport_failure()"
|
|
);
|
|
assert!(
|
|
err.scsi_sense().is_none(),
|
|
"transport failure has no sense data"
|
|
);
|
|
assert!(
|
|
!err.is_marginal_read(),
|
|
"transport failure must not be classified as marginal-read"
|
|
);
|
|
match err {
|
|
Error::ScsiError { status, sense, .. } => {
|
|
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
|
|
assert!(sense.is_none());
|
|
}
|
|
other => panic!("expected ScsiError, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ── 3. CHECK CONDITION + ILLEGAL REQUEST ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_check_cond_illegal_request_carries_sense() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_ILLEGAL_REQUEST)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
|
assert_eq!(sense.sense_key, SENSE_KEY_ILLEGAL_REQUEST);
|
|
assert!(sense.is_illegal_request());
|
|
assert!(!err.is_marginal_read(), "ILLEGAL REQUEST is not marginal");
|
|
assert!(!err.is_scsi_transport_failure());
|
|
}
|
|
|
|
// ── 4. CHECK CONDITION + NOT READY (drive_has_disc relies on this) ────────
|
|
|
|
#[test]
|
|
fn test_check_cond_not_ready_predicate() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_NOT_READY)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
|
assert!(sense.is_not_ready(), "sense_key 2 ⇒ is_not_ready");
|
|
assert!(
|
|
err.is_marginal_read(),
|
|
"NOT READY is marginal (BU40N returns this for bad sectors)"
|
|
);
|
|
}
|
|
|
|
// ── 5. CHECK CONDITION + MEDIUM ERROR (canonical marginal-read) ───────────
|
|
|
|
#[test]
|
|
fn test_check_cond_medium_error_is_marginal() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_MEDIUM_ERROR)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
|
assert!(sense.is_medium_error());
|
|
assert!(sense.is_marginal());
|
|
assert!(
|
|
err.is_marginal_read(),
|
|
"MEDIUM ERROR is the canonical marginal-read signal"
|
|
);
|
|
}
|
|
|
|
// ── 6. CHECK CONDITION + ABORTED COMMAND (also marginal) ──────────────────
|
|
|
|
#[test]
|
|
fn test_check_cond_aborted_command_is_marginal() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_ABORTED_COMMAND)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
|
assert!(sense.is_aborted_command());
|
|
assert!(err.is_marginal_read(), "ABORTED COMMAND is marginal");
|
|
}
|
|
|
|
// ── 7. CHECK CONDITION + RECOVERED ERROR (drive recovered; marginal) ──────
|
|
|
|
#[test]
|
|
fn test_check_cond_recovered_error_is_marginal() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_RECOVERED_ERROR)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
assert!(
|
|
err.is_marginal_read(),
|
|
"RECOVERED ERROR is treated as marginal (drive recovered, retry-friendly class)"
|
|
);
|
|
}
|
|
|
|
// ── 8. CHECK CONDITION + HARDWARE ERROR (NOT marginal — bail) ─────────────
|
|
|
|
#[test]
|
|
fn test_check_cond_hardware_error_not_marginal() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_HARDWARE_ERROR)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
|
assert!(sense.is_hardware_error());
|
|
assert!(
|
|
!err.is_marginal_read(),
|
|
"HARDWARE ERROR must not be marginal — drive failing, retry can't help"
|
|
);
|
|
}
|
|
|
|
// ── 9. CHECK CONDITION + DATA PROTECT (NOT marginal — bail) ───────────────
|
|
|
|
#[test]
|
|
fn test_check_cond_data_protect_not_marginal() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_DATA_PROTECT)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
|
assert!(sense.is_data_protect());
|
|
assert!(
|
|
!err.is_marginal_read(),
|
|
"DATA PROTECT (AACS / region) must not be marginal — retry can't help"
|
|
);
|
|
}
|
|
|
|
// ── 10. CHECK CONDITION + UNIT ATTENTION (NOT marginal — caller rescans) ──
|
|
|
|
#[test]
|
|
fn test_check_cond_unit_attention_not_marginal() {
|
|
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_UNIT_ATTENTION)]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
|
assert!(sense.is_unit_attention());
|
|
assert!(
|
|
!err.is_marginal_read(),
|
|
"UNIT ATTENTION must not be marginal — caller should rescan, not retry"
|
|
);
|
|
}
|
|
|
|
// ── 11. ASC/ASCQ propagate from sense buffer to ScsiError.sense ───────────
|
|
|
|
#[test]
|
|
fn test_asc_ascq_round_trip_through_error() {
|
|
let outcome = MockOutcome::ScsiFailure {
|
|
status: SCSI_STATUS_CHECK_CONDITION,
|
|
sense: ScsiSense {
|
|
sense_key: SENSE_KEY_MEDIUM_ERROR,
|
|
asc: 0x11,
|
|
ascq: 0x05, // L-EC UNCORRECTABLE
|
|
},
|
|
};
|
|
let mut transport = MockTransport::new(vec![outcome]);
|
|
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
|
|
|
let sense = err.scsi_sense().expect("must carry sense");
|
|
assert_eq!(sense.sense_key, SENSE_KEY_MEDIUM_ERROR);
|
|
assert_eq!(sense.asc, 0x11);
|
|
assert_eq!(sense.ascq, 0x05);
|
|
}
|
|
|
|
// ── 12. Healthy short transfer (resid > 0) ────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_healthy_short_transfer_reports_partial_bytes() {
|
|
// Transport reported back fewer bytes than requested (resid = 16).
|
|
// For a 96-byte buffer that means bytes_transferred = 80.
|
|
let payload = vec![0u8; 96];
|
|
let mut transport = MockTransport::new(vec![MockOutcome::Ok {
|
|
data: payload,
|
|
resid: 16,
|
|
}]);
|
|
|
|
let cdb = [libfreemkv::scsi::SCSI_INQUIRY, 0, 0, 0, 0x60, 0];
|
|
let mut buf = [0u8; 96];
|
|
let r = transport
|
|
.execute(&cdb, DataDirection::FromDevice, &mut buf, 1_000)
|
|
.expect("ok");
|
|
assert_eq!(r.status, 0);
|
|
assert_eq!(
|
|
r.bytes_transferred, 80,
|
|
"bytes_transferred must equal data.len() - resid"
|
|
);
|
|
}
|
|
|
|
// ── 13. Error::Display does not leak English in the SCSI variant ──────────
|
|
//
|
|
// Library rule (project docs): no English in error display, only "E{code}"
|
|
// + structured data. Regression guard for the SCSI variant specifically
|
|
// because it's the most-emitted error in the rip path.
|
|
|
|
#[test]
|
|
fn test_scsi_error_display_format_is_codes_only() {
|
|
let err = Error::ScsiError {
|
|
opcode: 0x12,
|
|
status: SCSI_STATUS_CHECK_CONDITION,
|
|
sense: Some(ScsiSense {
|
|
sense_key: SENSE_KEY_ILLEGAL_REQUEST,
|
|
asc: 0x24,
|
|
ascq: 0x00,
|
|
}),
|
|
};
|
|
let s = err.to_string();
|
|
assert!(
|
|
s.starts_with("E4000:"),
|
|
"ScsiError must lead with E4000: {s}"
|
|
);
|
|
assert!(
|
|
s.contains("0x12") && s.contains("0x02") && s.contains("0x05") && s.contains("0x24"),
|
|
"ScsiError must show opcode/status/key/asc in hex: {s}"
|
|
);
|
|
// Crude English filter — same as the inline error.rs::display test.
|
|
for word in s.split(|c: char| !c.is_ascii_alphabetic()) {
|
|
assert!(
|
|
word.len() <= 4,
|
|
"ScsiError display contains suspicious word `{word}`: {s}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 14. Transport-failure Display omits sense fields ──────────────────────
|
|
|
|
#[test]
|
|
fn test_scsi_transport_failure_display_short_form() {
|
|
let err = Error::ScsiError {
|
|
opcode: 0x28,
|
|
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
|
sense: None,
|
|
};
|
|
let s = err.to_string();
|
|
assert!(s.starts_with("E4000:"));
|
|
assert!(s.contains("0x28") && s.contains("0xff"));
|
|
// No sense triple should appear in the no-sense form.
|
|
assert!(
|
|
!s.contains("0x00/0x00/0x00"),
|
|
"transport failure must not carry phantom sense: {s}"
|
|
);
|
|
}
|