v0.13.20 — sync blocking SG_IO + cross-platform parity strip

- scsi/linux.rs: full rewrite from async write/poll/read+1.5s timeout+
  close-on-timeout to one synchronous ioctl(fd, SG_IO, &hdr). Kernel
  honors hdr.timeout and runs its own ABORT/RESET escalation. Errors
  check host_status and driver_status (both 0xFF-synthesised) plus
  status. Sense-key parser handles descriptor (0x72/0x73) + fixed
  (0x70/0x71) formats. Deleted fd_recovery, bg close+open thread, fd
  swap dance. -331/+155 lines.

- scsi/macos.rs: try_recover() removed (userspace handle-recovery on
  task failure was the same anti-pattern stripped from Linux). bsd_name
  field deleted. Errors bubble up directly.

- scsi/windows.rs: try_recover() removed, wide_path field deleted,
  INVALID_HANDLE guard removed.

- scsi/mod.rs: parse_sense_key() helper extracted (used by all three
  platforms now — single canonical sense-key parse rather than three
  inlined copies). +10 unit tests covering descriptor format, fixed
  format, truncated buffers, unknown response codes.

- drive/mod.rs: Drive::reset() deleted (escalating eject + STOP/START +
  reinit recovery — per audit, kernel handles its own escalation;
  userspace shouldn't).
  pub fn find_drives() -> Vec<Drive> deleted (opened N drives just to
  throw most away). find_drive() now uses discover_drives() directly.
  wait_ready() simplified — drops the reset path on sense_key=5,
  just keeps polling TUR for 60 iterations.

- lib.rs: find_drives re-export removed.

- benches/sgio_read.rs: switched to find_drive() (no longer iterates a
  drive list).

Net: 9 files changed, 226 insertions(+), 473 deletions(-). 329 tests
pass, clippy -D warnings clean. No consumer breakage (CLI, autorip,
bdemu compile + test green).

Architecture decision documented in
(internal)/docs/audits/2026-04-26-scsi-architecture-research.md
(primary-source survey of MakeMKV, sg_dd, ddrescue, and the kernel
mid-layer's own scsi_eh.rst escalation ladder).
This commit is contained in:
MattJackson
2026-04-26 09:51:46 -07:00
parent b4de5d343d
commit d2905ba7bb
9 changed files with 332 additions and 473 deletions
+136
View File
@@ -43,6 +43,36 @@ pub const AACS_KEY_CLASS: u8 = 0x02;
/// a poll-loop tick.
pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000;
// ── Sense-key parsing ───────────────────────────────────────────────────────
/// Extract the SPC-4 sense key from a sense buffer.
///
/// Handles both response-code formats:
/// - Descriptor format (0x72 / 0x73): sense key in the low nibble of byte 1.
/// - Fixed format (0x70 / 0x71): sense key in the low nibble of byte 2.
///
/// `sb_len_wr` is the number of bytes the transport actually wrote into
/// `sense`. When < 3 (or `sense.len() < 3`) we can't safely read either
/// the format byte or the key byte — return 0 (NO SENSE) per SPC-4 §4.5.3.
///
/// Pure function. Same parse runs on every platform backend so
/// callers don't have to special-case Linux SG_IO vs macOS IOKit vs
/// Windows SPTI sense layouts.
pub(crate) fn parse_sense_key(sense: &[u8], sb_len_wr: u8) -> u8 {
if (sb_len_wr as usize) < 3 || sense.len() < 3 {
return 0;
}
let response_code = sense[0] & 0x7F;
if response_code == 0x72 || response_code == 0x73 {
sense[1] & 0x0F
} else {
// Fixed format (0x70/0x71) and any unknown code fall through here;
// SPC-4 says implementations MUST tolerate unknown response codes
// and treat them as fixed — matches what reference projects do.
sense[2] & 0x0F
}
}
// ── Types ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -326,3 +356,109 @@ pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
0x00,
]
}
#[cfg(test)]
mod parse_sense_tests {
//! Unit tests for `parse_sense_key`. Covers both SPC-4 sense data
//! formats (descriptor / fixed) and the short-buffer fallback. The
//! same helper runs on every platform backend so a regression here
//! would silently miscategorize SCSI errors on Linux, macOS, and
//! Windows simultaneously.
use super::parse_sense_key;
/// Helper: build a 32-byte sense buffer whose first three bytes are
/// the given prefix; the rest are zeroes (sense data area).
fn buf(b0: u8, b1: u8, b2: u8) -> [u8; 32] {
let mut s = [0u8; 32];
s[0] = b0;
s[1] = b1;
s[2] = b2;
s
}
#[test]
fn descriptor_format_72_picks_byte_1() {
// Response code 0x72 (current, descriptor): sense key is the
// low nibble of byte 1. Byte 2 here is 0x77 to prove it is NOT
// the byte the parser reads.
let s = buf(0x72, 0x05, 0x77); // ILLEGAL REQUEST
assert_eq!(parse_sense_key(&s, 8), 5);
}
#[test]
fn descriptor_format_73_picks_byte_1() {
// Response code 0x73 (deferred, descriptor): same parse rule
// as 0x72.
let s = buf(0x73, 0x06, 0xFF); // UNIT ATTENTION
assert_eq!(parse_sense_key(&s, 8), 6);
}
#[test]
fn fixed_format_70_picks_byte_2() {
// Response code 0x70 (current, fixed): sense key is the low
// nibble of byte 2. Byte 1 is 0x77 to prove it is NOT read.
let s = buf(0x70, 0x77, 0x05); // ILLEGAL REQUEST
assert_eq!(parse_sense_key(&s, 18), 5);
}
#[test]
fn fixed_format_71_picks_byte_2() {
// Response code 0x71 (deferred, fixed): same parse as 0x70.
let s = buf(0x71, 0x77, 0x02); // NOT READY
assert_eq!(parse_sense_key(&s, 18), 2);
}
#[test]
fn high_bit_in_byte_0_is_masked() {
// SPC-4 sets the top bit of byte 0 ("INFORMATION VALID" / "VALID")
// independently of the response code. parse_sense_key must mask
// it off before classifying the format.
let s = buf(0xF2, 0x05, 0x77);
assert_eq!(parse_sense_key(&s, 8), 5, "VALID-bit must not leak into format detection");
let s = buf(0xF0, 0x77, 0x02);
assert_eq!(parse_sense_key(&s, 18), 2);
}
#[test]
fn high_nibble_in_key_byte_is_masked() {
// Sense key is byte_n & 0x0F (low nibble). Top nibble holds
// FILEMARK / EOM / ILI / SDAT_OVFL flags, which must not bleed
// into the key value.
let s = buf(0x70, 0x00, 0xE5); // 0xE0 flags + key 5
assert_eq!(parse_sense_key(&s, 18), 5);
}
#[test]
fn sb_len_wr_zero_returns_no_sense() {
// Transport set status non-zero but wrote zero sense bytes —
// SPC-4 §4.5.3 says treat as NO SENSE (key 0).
let s = buf(0x72, 0x05, 0x05);
assert_eq!(parse_sense_key(&s, 0), 0);
}
#[test]
fn sb_len_wr_below_three_returns_no_sense() {
// Less than three bytes in the buffer means we can't safely
// read either format byte 0 or key byte 2 — return 0.
let s = buf(0x72, 0x05, 0x05);
assert_eq!(parse_sense_key(&s, 1), 0);
assert_eq!(parse_sense_key(&s, 2), 0);
}
#[test]
fn slice_below_three_returns_no_sense() {
// Defense-in-depth: even if a caller passes a too-short slice
// with a falsely-large sb_len_wr, we don't panic and we return 0.
let s = [0x72u8, 0x05];
assert_eq!(parse_sense_key(&s, 8), 0);
}
#[test]
fn unknown_response_code_falls_through_to_fixed() {
// SPC-4 mandates implementations tolerate unknown response
// codes and treat them as fixed format. Vendor-specific codes
// in the 0x74..0x7E range surface here.
let s = buf(0x7A, 0x77, 0x03); // MEDIUM ERROR via "fixed"
assert_eq!(parse_sense_key(&s, 18), 3);
}
}