v0.13.2: list_drives + drive_has_disc; SCSI primitives pub(crate)
Architectural cleanup. autorip + freemkv CLI were reimplementing drive discovery (sysfs walking, type-5 filtering, sg-path construction) and calling SCSI reset primitives directly. All of that hardware-aware code moves into libfreemkv with two cheap public probes: - DriveInfo + list_drives() — multi-OS enumeration (Linux/macOS/Windows) with peripheral-type-5 filtering and INQUIRY identity. Cheap. - drive_has_disc(path) — single TUR with internal wedge recovery escalation (SCSI reset → USB reset → retry) hidden from callers. USB-layer reset (USBDEVFS_RESET / IOUSBDeviceInterface::ResetDevice / storport's combined reset) wired across all three platforms. Visibility tightening — scsi::reset, scsi::usb_reset, and the timeout constants are now pub(crate). Compile-time guarantee that no consumer crate can issue SCSI commands directly. 233 lib tests pass; clippy clean.
This commit is contained in:
@@ -19,6 +19,15 @@ const SG_DXFER_TO_DEV: i32 = -2;
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
|
||||
|
||||
/// `USBDEVFS_RESET = _IO('U', 20)` — re-enumerates the USB device,
|
||||
/// equivalent to a software unplug-replug. Resets at the USB layer
|
||||
/// *below* SCSI, which is what's needed when the USB Mass Storage
|
||||
/// interface itself wedges (the wedge mode `SG_SCSI_RESET` can't
|
||||
/// recover, since SCSI commands never make it through the broken USB
|
||||
/// link to the device). 30-line `usbreset.c` everyone passes around
|
||||
/// uses this same ioctl.
|
||||
const USBDEVFS_RESET: u32 = 0x5514;
|
||||
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
struct sg_io_hdr {
|
||||
@@ -172,6 +181,106 @@ impl SgIoTransport {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// USB-layer reset. Resolves the sg device → underlying USB device
|
||||
/// (`/dev/bus/usb/BBB/DDD`) and issues `USBDEVFS_RESET`, the same
|
||||
/// ioctl `usbreset.c` uses. Software equivalent of unplug-replug.
|
||||
///
|
||||
/// Returns `DeviceNotFound` if the sg device isn't USB-attached
|
||||
/// (SATA/PERC etc.) so callers can detect the fall-through case and
|
||||
/// know not to retry — USB reset is meaningless for non-USB drives.
|
||||
/// Returns `DeviceResetFailed` for actual ioctl failures.
|
||||
///
|
||||
/// Step-by-step:
|
||||
/// 1. `/dev/sg4` → device name `sg4`
|
||||
/// 2. Canonicalize `/sys/class/scsi_generic/sg4/device` to follow
|
||||
/// the kernel's symlink chain into `/sys/devices/pci…/usb1/1-2/…`
|
||||
/// 3. Walk parents until we find a directory that has both
|
||||
/// `busnum` and `devnum` files — that's the USB device node
|
||||
/// 4. Read `busnum` + `devnum`, format `/dev/bus/usb/{busnum:03}/{devnum:03}`
|
||||
/// 5. open(O_WRONLY), ioctl(USBDEVFS_RESET), close
|
||||
pub fn usb_reset(device: &Path) -> Result<()> {
|
||||
let usb_path = Self::resolve_usb_device(device)?;
|
||||
let c_path = Self::to_c_path(&usb_path);
|
||||
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_WRONLY | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(Error::DeviceResetFailed {
|
||||
path: usb_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// USBDEVFS_RESET — kernel does its own bounded wait here (the
|
||||
// USB stack waits for the device to come back, typically ≤1 s).
|
||||
// Unlike SG_SCSI_RESET this rarely hangs because the kernel USB
|
||||
// layer has its own timeouts on the device-side handshake.
|
||||
let r = unsafe { libc::ioctl(fd, USBDEVFS_RESET as _) };
|
||||
unsafe { libc::close(fd) };
|
||||
|
||||
if r < 0 {
|
||||
Err(Error::DeviceResetFailed {
|
||||
path: usb_path.display().to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `/dev/sgN` → `/dev/bus/usb/BBB/DDD` for USB-attached SCSI
|
||||
/// devices. Returns `DeviceNotFound` (not a reset failure) when the
|
||||
/// sg device isn't USB-attached, so callers can distinguish "this
|
||||
/// drive isn't a USB drive" from "USB reset attempted but failed".
|
||||
fn resolve_usb_device(device: &Path) -> Result<std::path::PathBuf> {
|
||||
let dev_name =
|
||||
device
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
let sysfs_link = format!("/sys/class/scsi_generic/{dev_name}/device");
|
||||
let canonical = std::fs::canonicalize(&sysfs_link).map_err(|_| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Walk up the parent chain looking for a directory that
|
||||
// contains both `busnum` and `devnum`. That marks the USB
|
||||
// device entry in sysfs (e.g. /sys/devices/.../usb1/1-2/).
|
||||
let mut cur = canonical.as_path();
|
||||
while let Some(parent) = cur.parent() {
|
||||
let busnum_p = parent.join("busnum");
|
||||
let devnum_p = parent.join("devnum");
|
||||
if busnum_p.exists() && devnum_p.exists() {
|
||||
let busnum: u32 = std::fs::read_to_string(&busnum_p)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
let devnum: u32 = std::fs::read_to_string(&devnum_p)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
return Ok(std::path::PathBuf::from(format!(
|
||||
"/dev/bus/usb/{busnum:03}/{devnum:03}"
|
||||
)));
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
|
||||
// No USB ancestor found — SATA / RAID / non-USB SCSI device.
|
||||
Err(Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn open_error<T>(device: &Path) -> Result<T> {
|
||||
let err = std::io::Error::last_os_error();
|
||||
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
@@ -427,3 +536,186 @@ impl ScsiTransport for SgIoTransport {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lightweight discovery + presence (Linux) ────────────────────────────────
|
||||
//
|
||||
// `list_drives` walks `/sys/class/scsi_generic/`, filters to type-5 (CD/DVD/BD),
|
||||
// and runs one INQUIRY each for vendor/model/firmware. Falls back to a
|
||||
// `/dev/sg0..15` probe when sysfs is unreadable (minimal containers).
|
||||
//
|
||||
// `drive_has_disc` issues a single TEST UNIT READY. On the wedge signature
|
||||
// (kernel returns status `0xff` with no sense) it escalates: SCSI bus reset
|
||||
// → if still wedged → USB device reset (`USBDEVFS_RESET`) → retry TUR.
|
||||
// Callers never see the escalation; if it fails too, surface
|
||||
// `DeviceResetFailed` so the caller can back off.
|
||||
|
||||
/// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE, etc.).
|
||||
/// Stored in `/sys/class/scsi_generic/sgN/device/type` as ASCII decimal.
|
||||
const SCSI_TYPE_OPTICAL: &str = "5";
|
||||
|
||||
/// SCSI sense key 2 = "NOT READY". Sub-codes distinguish "medium not present"
|
||||
/// (no disc) from other not-ready states (loading, etc.); for poll-loop
|
||||
/// purposes any sense-key 2 means "no disc to act on".
|
||||
const SENSE_KEY_NOT_READY: u8 = 2;
|
||||
|
||||
/// Maximum sg index probed in the fallback path when sysfs is unavailable.
|
||||
/// Linux assigns `/dev/sgN` sequentially per host adapter; 16 covers any
|
||||
/// realistic homelab (typical PERC + USB optical = ≤8 nodes).
|
||||
const SG_FALLBACK_MAX: u8 = 16;
|
||||
|
||||
/// SCSI INQUIRY response field offsets (SPC-4, 6-byte standard CDB
|
||||
/// returning 96 bytes). Used to populate `DriveInfo` fields without
|
||||
/// magic-number arithmetic at the call site.
|
||||
const INQUIRY_VENDOR_OFFSET: usize = 8;
|
||||
const INQUIRY_VENDOR_LEN: usize = 8;
|
||||
const INQUIRY_MODEL_OFFSET: usize = 16;
|
||||
const INQUIRY_MODEL_LEN: usize = 16;
|
||||
const INQUIRY_FIRMWARE_OFFSET: usize = 32;
|
||||
const INQUIRY_FIRMWARE_LEN: usize = 4;
|
||||
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
let mut out = Vec::new();
|
||||
let names = enumerate_sg_names();
|
||||
for name in names {
|
||||
let path = format!("/dev/{name}");
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
// INQUIRY-only probe — open transport, run INQUIRY, drop. No
|
||||
// identify, no init, no firmware reset preamble's secondary
|
||||
// commands beyond what `SgIoTransport::open` already does (one
|
||||
// SCSI bus reset on the kernel SG fd, ~2 s).
|
||||
let mut transport = match SgIoTransport::open(std::path::Path::new(&path)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let info = match super::inquiry(&mut transport) {
|
||||
Ok(r) => super::DriveInfo {
|
||||
path: path.clone(),
|
||||
vendor: r.vendor_id,
|
||||
model: r.model,
|
||||
firmware: r.firmware,
|
||||
},
|
||||
Err(_) => super::DriveInfo {
|
||||
path: path.clone(),
|
||||
vendor: String::new(),
|
||||
model: String::new(),
|
||||
firmware: String::new(),
|
||||
},
|
||||
};
|
||||
out.push(info);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Enumerate `sg*` names via `/sys/class/scsi_generic/`, filtered to
|
||||
/// SCSI peripheral type 5 (optical). Falls back to a `sg0..15` probe
|
||||
/// when sysfs is unreadable. Returns names sorted lexically so caller
|
||||
/// iteration is deterministic.
|
||||
fn enumerate_sg_names() -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir("/sys/class/scsi_generic") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("sg") {
|
||||
continue;
|
||||
}
|
||||
let type_path = format!("/sys/class/scsi_generic/{name}/device/type");
|
||||
match std::fs::read_to_string(&type_path) {
|
||||
Ok(s) if s.trim() == SCSI_TYPE_OPTICAL => names.push(name),
|
||||
Ok(_) => {} // not optical
|
||||
Err(_) => names.push(name), // sysfs unreadable — let INQUIRY decide
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sysfs missing — fall back to a brute-force probe. The INQUIRY
|
||||
// step in `list_drives` filters non-optical responses naturally.
|
||||
for i in 0..SG_FALLBACK_MAX {
|
||||
let name = format!("sg{i}");
|
||||
if std::path::Path::new(&format!("/dev/{name}")).exists() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
match probe_tur(path) {
|
||||
Ok(present) => Ok(present),
|
||||
Err(e) if is_wedge_signature(&e) => recover_then_probe(path, e),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Single TEST UNIT READY — the cheapest way to ask "is there a disc?".
|
||||
/// Returns `Ok(true)` on a sense-clean OK, `Ok(false)` on sense-key 2
|
||||
/// ("not ready, medium not present"), and `Err` for any other failure
|
||||
/// (the wedge case lands here too — caller's escalation handles it).
|
||||
fn probe_tur(path: &Path) -> Result<bool> {
|
||||
let mut transport = SgIoTransport::open(path)?;
|
||||
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
match transport.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
crate::scsi::TUR_TIMEOUT_MS,
|
||||
) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(Error::ScsiError {
|
||||
sense_key: SENSE_KEY_NOT_READY,
|
||||
..
|
||||
}) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-stage wedge recovery: SCSI reset → USB reset → retry probe.
|
||||
/// Caller has already classified the original error as a wedge.
|
||||
fn recover_then_probe(path: &Path, original: Error) -> Result<bool> {
|
||||
// Stage 1: SCSI bus reset. Bounded by `DEFAULT_RESET_TIMEOUT_SECS`.
|
||||
let _ = super::reset(path);
|
||||
if let Ok(present) = probe_tur(path) {
|
||||
return Ok(present);
|
||||
}
|
||||
// Stage 2: USB-layer re-enumeration (USBDEVFS_RESET). Software
|
||||
// equivalent of unplug-replug; the only thing that recovers a
|
||||
// kernel-level USB Mass Storage wedge.
|
||||
if super::usb_reset(path).is_ok() {
|
||||
std::thread::sleep(std::time::Duration::from_secs(USB_RESET_SETTLE_SECS));
|
||||
if let Ok(present) = probe_tur(path) {
|
||||
return Ok(present);
|
||||
}
|
||||
}
|
||||
// Both stages exhausted — surface the original error so the caller
|
||||
// can choose to back off / mark this drive stay-clear.
|
||||
Err(original)
|
||||
}
|
||||
|
||||
/// Wedge signature: `Error::ScsiError` with INQUIRY opcode (0x12) and
|
||||
/// status byte 0xFF. 0xFF isn't a valid SCSI status — the kernel synthesises
|
||||
/// it when the device gives no answer, which is the real-world signature
|
||||
/// of a USB Mass Storage layer wedge.
|
||||
fn is_wedge_signature(err: &Error) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
Error::ScsiError {
|
||||
opcode: crate::scsi::SCSI_INQUIRY,
|
||||
status: WEDGE_STATUS_BYTE,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Synthesised SCSI status byte returned by the Linux SG driver when
|
||||
/// the kernel got no useful response from the device — the wedge
|
||||
/// signature. Real SCSI statuses are GOOD (0x00), CHECK_CONDITION (0x02),
|
||||
/// BUSY (0x08), etc.; 0xFF is reserved/invalid in the spec.
|
||||
const WEDGE_STATUS_BYTE: u8 = 0xFF;
|
||||
|
||||
/// Settle time after `USBDEVFS_RESET` returns. The kernel re-enumerates
|
||||
/// the device over ~1-2 s; sleeping briefly avoids racing the next
|
||||
/// `Drive::open` against an interim sysfs-vanished state.
|
||||
const USB_RESET_SETTLE_SECS: u64 = 2;
|
||||
|
||||
@@ -277,6 +277,246 @@ impl MacScsiTransport {
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// USB-layer reset on macOS via `IOUSBDeviceInterface::ResetDevice`.
|
||||
///
|
||||
/// Mirrors the Linux `USBDEVFS_RESET` path: walk from the BSD-named
|
||||
/// SCSI service up the IORegistry plane to the parent `IOUSBDevice`,
|
||||
/// query its `IOUSBDeviceInterface`, call `ResetDevice()`. Software
|
||||
/// equivalent of unplug-replug — the only thing that recovers a
|
||||
/// kernel-level USB Mass Storage wedge on macOS.
|
||||
///
|
||||
/// Returns `DeviceNotFound` when the device isn't USB-attached
|
||||
/// (Thunderbolt/SATA/internal SuperDrive over PCIe — they don't
|
||||
/// have an `IOUSBDevice` ancestor) so the caller's escalation can
|
||||
/// fall through cleanly. `DeviceResetFailed` on actual reset
|
||||
/// failures.
|
||||
pub fn usb_reset(device: &Path) -> Result<()> {
|
||||
let bsd_name =
|
||||
device
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
let service = find_scsi_service(bsd_name)?;
|
||||
let usb_service = walk_to_usb_device(service);
|
||||
unsafe { IOObjectRelease(service) };
|
||||
let usb_service = usb_service.ok_or_else(|| Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Get IOUSBDeviceInterface from the USB device service.
|
||||
let mut plugin: ComRef = std::ptr::null_mut();
|
||||
let mut score: i32 = 0;
|
||||
let kr = unsafe {
|
||||
IOCreatePlugInInterfaceForService(
|
||||
usb_service,
|
||||
&K_IO_USB_DEVICE_USER_CLIENT_TYPE_ID,
|
||||
&K_IO_CFPLUGIN_INTERFACE_ID,
|
||||
&mut plugin,
|
||||
&mut score,
|
||||
)
|
||||
};
|
||||
unsafe { IOObjectRelease(usb_service) };
|
||||
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
|
||||
return Err(Error::DeviceResetFailed {
|
||||
path: device.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// QueryInterface for IOUSBDeviceInterface.
|
||||
let mut device_iface: ComRef = std::ptr::null_mut();
|
||||
let hr = unsafe {
|
||||
type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32;
|
||||
let qi: QiFn = vtable_fn(plugin, 1);
|
||||
qi(plugin, &K_IO_USB_DEVICE_INTERFACE_ID, &mut device_iface)
|
||||
};
|
||||
com_release(plugin);
|
||||
if hr != 0 || device_iface.is_null() {
|
||||
return Err(Error::DeviceResetFailed {
|
||||
path: device.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Call ResetDevice() — vtable index 11 in IOUSBDeviceInterface.
|
||||
// Verified against IOUSBLib.h headers (Apple OSS).
|
||||
let kr = unsafe {
|
||||
type ResetFn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: ResetFn = vtable_fn(device_iface, K_IO_USB_DEVICE_RESET_VTABLE_INDEX);
|
||||
f(device_iface)
|
||||
};
|
||||
com_release(device_iface);
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
Err(Error::DeviceResetFailed {
|
||||
path: device.display().to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `kIOUSBDeviceUserClientTypeID` — IOKit plugin type for accessing a
|
||||
/// USB device through user-space (the gateway to `IOUSBDeviceInterface`).
|
||||
const K_IO_USB_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
|
||||
0x9D, 0xC7, 0xB7, 0x80, 0x9E, 0xC0, 0x11, 0xD4, 0xA5, 0x4F, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
|
||||
];
|
||||
|
||||
/// `kIOUSBDeviceInterfaceID` — `IOUSBDeviceInterface` (revision 0).
|
||||
/// Sufficient for `ResetDevice()` which has been at vtable index 11
|
||||
/// since the original interface revision.
|
||||
const K_IO_USB_DEVICE_INTERFACE_ID: [u8; 16] = [
|
||||
0x5C, 0x81, 0x87, 0xD0, 0x9E, 0xF3, 0x11, 0xD4, 0x8B, 0x45, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
|
||||
];
|
||||
|
||||
/// Vtable index of `IOUSBDeviceInterface::ResetDevice`. Per IOUSBLib.h:
|
||||
/// the interface inherits from IOCFPlugInInterface which occupies slots
|
||||
/// 0..2 (QueryInterface, AddRef, Release), then IOUSBDeviceInterface
|
||||
/// methods start at slot 3. ResetDevice is the 9th IOUSBDevice-specific
|
||||
/// method → slot 3 + 8 = 11.
|
||||
const K_IO_USB_DEVICE_RESET_VTABLE_INDEX: usize = 11;
|
||||
|
||||
/// Walk up the IORegistry plane from a SCSI peripheral service to the
|
||||
/// parent `IOUSBDevice` (if any). Mirrors the Linux sysfs walk in
|
||||
/// `linux::SgIoTransport::resolve_usb_device`. Returns the IOService
|
||||
/// for the USB device (caller owns the reference; release with
|
||||
/// `IOObjectRelease`), or `None` for non-USB-attached drives.
|
||||
fn walk_to_usb_device(start: IOObject) -> Option<IOObject> {
|
||||
let mut current = start;
|
||||
// Retain the start so we can release uniformly each loop iteration.
|
||||
unsafe {
|
||||
let kr = IOObjectRetain(current);
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for _ in 0..K_USB_PARENT_WALK_LIMIT {
|
||||
if unsafe { IOObjectConformsTo(current, c"IOUSBDevice".as_ptr() as *const u8) } != 0 {
|
||||
return Some(current);
|
||||
}
|
||||
let mut parent: IOObject = 0;
|
||||
let kr = unsafe {
|
||||
IORegistryEntryGetParentEntry(current, c"IOService".as_ptr() as *const u8, &mut parent)
|
||||
};
|
||||
unsafe { IOObjectRelease(current) };
|
||||
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
|
||||
return None;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
unsafe { IOObjectRelease(current) };
|
||||
None
|
||||
}
|
||||
|
||||
/// Maximum IORegistry parent-chain depth searched for a USB ancestor.
|
||||
/// Real chains for USB-attached optical drives are 6-10 entries deep
|
||||
/// (IOMedia → BlockStorageDriver → SCSIPeripheralDeviceNub →
|
||||
/// SCSIProtocolEmulator → IOUSBInterface → IOUSBDevice → ...). 32 is
|
||||
/// generous; if we don't find it by then, the device isn't USB.
|
||||
const K_USB_PARENT_WALK_LIMIT: u32 = 32;
|
||||
|
||||
/// Enumerate optical drives on macOS. Mirrors `drive::macos::find_drives`
|
||||
/// (which iterates `/dev/disk0..15` + INQUIRY + filters peripheral
|
||||
/// type 5). Same logic, exposed through the new `DriveInfo` shape so
|
||||
/// callers — `list_drives()` in `scsi::mod` — never reach into
|
||||
/// `crate::drive::macos`.
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..K_DEV_DISK_MAX {
|
||||
let path = format!("/dev/disk{i}");
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
let mut transport = match MacScsiTransport::open(std::path::Path::new(&path)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let inquiry = match super::inquiry(&mut transport) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// SCSI peripheral type field is the lower 5 bits of byte 0.
|
||||
if inquiry.raw.is_empty()
|
||||
|| (inquiry.raw[K_INQUIRY_TYPE_BYTE] & K_INQUIRY_TYPE_MASK) != K_SCSI_TYPE_OPTICAL
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(super::DriveInfo {
|
||||
path,
|
||||
vendor: inquiry.vendor_id,
|
||||
model: inquiry.model,
|
||||
firmware: inquiry.firmware,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Maximum BSD disk index probed during enumeration. macOS assigns
|
||||
/// `/dev/diskN` sequentially per attached storage device; 16 covers
|
||||
/// any realistic homelab.
|
||||
const K_DEV_DISK_MAX: u8 = 16;
|
||||
|
||||
/// SCSI INQUIRY response: peripheral device type lives in byte 0,
|
||||
/// lower 5 bits.
|
||||
const K_INQUIRY_TYPE_BYTE: usize = 0;
|
||||
const K_INQUIRY_TYPE_MASK: u8 = 0x1F;
|
||||
|
||||
/// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE).
|
||||
const K_SCSI_TYPE_OPTICAL: u8 = 0x05;
|
||||
|
||||
/// TEST UNIT READY probe on macOS. Same shape as the Linux impl —
|
||||
/// open transport, run TUR, classify response. The macOS path doesn't
|
||||
/// surface the Linux `0xff`-status wedge pattern (IOKit returns its
|
||||
/// own error codes), so wedge-detection here is sense-key based: a
|
||||
/// transport-level error during TUR escalates to SCSI reset → USB
|
||||
/// reset. Most macOS drives auto-recover at the SCSI-reset stage.
|
||||
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
match probe_tur(path) {
|
||||
Ok(present) => Ok(present),
|
||||
Err(Error::ScsiError { sense_key, .. }) if sense_key == K_SENSE_KEY_NOT_READY => Ok(false),
|
||||
Err(_) => recover_then_probe(path),
|
||||
}
|
||||
}
|
||||
|
||||
const K_SENSE_KEY_NOT_READY: u8 = 2;
|
||||
|
||||
fn probe_tur(path: &Path) -> Result<bool> {
|
||||
let mut transport = MacScsiTransport::open(path)?;
|
||||
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
transport
|
||||
.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
crate::scsi::TUR_TIMEOUT_MS,
|
||||
)
|
||||
.map(|_| true)
|
||||
}
|
||||
|
||||
fn recover_then_probe(path: &Path) -> Result<bool> {
|
||||
let _ = super::reset(path);
|
||||
if let Ok(present) = probe_tur(path) {
|
||||
return Ok(present);
|
||||
}
|
||||
if super::usb_reset(path).is_ok() {
|
||||
std::thread::sleep(std::time::Duration::from_secs(K_USB_RESET_SETTLE_SECS));
|
||||
if let Ok(present) = probe_tur(path) {
|
||||
return Ok(present);
|
||||
}
|
||||
}
|
||||
Err(Error::DeviceResetFailed {
|
||||
path: path.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
const K_USB_RESET_SETTLE_SECS: u64 = 2;
|
||||
|
||||
unsafe extern "C" {
|
||||
fn IOObjectRetain(object: IOObject) -> IOReturn;
|
||||
}
|
||||
|
||||
impl Drop for MacScsiTransport {
|
||||
|
||||
+219
-4
@@ -18,6 +18,10 @@ use std::path::Path;
|
||||
|
||||
// ── SCSI opcodes (SPC-4, MMC-6) ────────────────────────────────────────────
|
||||
|
||||
/// SPC-4 TEST UNIT READY — six-byte CDB, no data transfer. Used by
|
||||
/// [`drive_has_disc`] as the cheapest "is the drive responsive / does
|
||||
/// it have media?" probe.
|
||||
pub const SCSI_TEST_UNIT_READY: u8 = 0x00;
|
||||
pub const SCSI_INQUIRY: u8 = 0x12;
|
||||
pub const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
pub const SCSI_READ_10: u8 = 0x28;
|
||||
@@ -33,6 +37,12 @@ pub const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
|
||||
/// AACS key class for REPORT KEY / SEND KEY commands.
|
||||
pub const AACS_KEY_CLASS: u8 = 0x02;
|
||||
|
||||
/// Timeout for TEST UNIT READY probes used by [`drive_has_disc`].
|
||||
/// TUR is the cheapest SCSI op (no data transfer); 5 s is generous
|
||||
/// for any healthy bus and short enough that a hung device can't stall
|
||||
/// a poll-loop tick.
|
||||
pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -94,7 +104,7 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
|
||||
/// healthy-but-slow drive isn't false-positively timed out, short enough
|
||||
/// that a kernel-wedged ioctl doesn't take down the caller's poll loop
|
||||
/// for minutes.
|
||||
pub const DEFAULT_RESET_TIMEOUT_SECS: u64 = 30;
|
||||
pub(crate) const DEFAULT_RESET_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Reset a SCSI device to a known good state, with a hard wallclock
|
||||
/// bound (`DEFAULT_RESET_TIMEOUT_SECS`). On Linux: open/close fd cycle +
|
||||
@@ -111,13 +121,21 @@ pub const DEFAULT_RESET_TIMEOUT_SECS: u64 = 30;
|
||||
/// can't cancel a Linux ioctl from userspace), so this leaks one OS
|
||||
/// thread per wedge — acceptable cost for a daemon that recovers
|
||||
/// instead of hanging.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
reset_with_timeout(device, std::time::Duration::from_secs(DEFAULT_RESET_TIMEOUT_SECS))
|
||||
///
|
||||
/// `pub(crate)` — outside callers use the higher-level `drive_has_disc`
|
||||
/// (which folds in recovery escalation) or `Drive::reset` (instance-level).
|
||||
/// Direct primitive exposure removed in 0.13.2 to enforce the
|
||||
/// architectural rule that no consumer crate issues SCSI commands.
|
||||
pub(crate) fn reset(device: &Path) -> Result<()> {
|
||||
reset_with_timeout(
|
||||
device,
|
||||
std::time::Duration::from_secs(DEFAULT_RESET_TIMEOUT_SECS),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reset with a caller-specified timeout. See [`reset`] for the full
|
||||
/// rationale on why an outer wallclock bound is required.
|
||||
pub fn reset_with_timeout(device: &Path, timeout: std::time::Duration) -> Result<()> {
|
||||
pub(crate) fn reset_with_timeout(device: &Path, timeout: std::time::Duration) -> Result<()> {
|
||||
let device_owned = device.to_path_buf();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -178,6 +196,203 @@ fn reset_blocking(device: &Path) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default upper bound on `scsi::usb_reset()`. Kernel USB stack
|
||||
/// typically completes a port reset in under a second; 5 s is generous
|
||||
/// while still catching a hang in the driver.
|
||||
pub(crate) const DEFAULT_USB_RESET_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// USB-layer reset for USB-attached SCSI devices.
|
||||
///
|
||||
/// `pub(crate)` — outside callers reach recovery through the
|
||||
/// higher-level `drive_has_disc` (poll-loop entry point) which folds
|
||||
/// in the SCSI→USB escalation internally. See module-level docs for
|
||||
/// the architectural reasoning.
|
||||
///
|
||||
/// **When to call this.** `scsi::reset()` resets at the SCSI layer; if
|
||||
/// the wedge is at the USB Mass Storage interface *below* SCSI (the
|
||||
/// classic mode where INQUIRY returns status `0xFF` because the kernel
|
||||
/// got nothing back), SCSI commands never reach the device and SCSI
|
||||
/// reset is meaningless. USB reset re-enumerates the device at the USB
|
||||
/// layer — software equivalent of unplug-replug.
|
||||
///
|
||||
/// **Linux**: resolves `/dev/sgN` → `/dev/bus/usb/BBB/DDD` via sysfs,
|
||||
/// then `USBDEVFS_RESET` ioctl. Same mechanism as the well-known
|
||||
/// `usbreset.c` snippet.
|
||||
///
|
||||
/// **macOS**, **Windows**: returns `DeviceNotFound` for now (stub).
|
||||
/// Real impls tracked for 0.13.3.
|
||||
///
|
||||
/// **Returns** `DeviceNotFound` when the sg device isn't USB-attached
|
||||
/// (SATA / RAID / NVMe-passthrough sg nodes) so callers can detect the
|
||||
/// fall-through case and know not to retry — USB reset is meaningless
|
||||
/// for those drives.
|
||||
///
|
||||
/// Wraps the platform call in a thread + `recv_timeout` for the same
|
||||
/// reason as `reset()` — kernel ioctls can hang and we don't want the
|
||||
/// caller's poll loop wedged on it.
|
||||
pub(crate) fn usb_reset(device: &Path) -> Result<()> {
|
||||
usb_reset_with_timeout(
|
||||
device,
|
||||
std::time::Duration::from_secs(DEFAULT_USB_RESET_TIMEOUT_SECS),
|
||||
)
|
||||
}
|
||||
|
||||
/// USB reset with a caller-specified timeout. See [`usb_reset`].
|
||||
pub(crate) fn usb_reset_with_timeout(device: &Path, timeout: std::time::Duration) -> Result<()> {
|
||||
let device_owned = device.to_path_buf();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("usb-reset".into())
|
||||
.spawn(move || {
|
||||
let r = usb_reset_blocking(&device_owned);
|
||||
let _ = tx.send(r);
|
||||
})
|
||||
.map_err(|_| Error::DeviceResetFailed {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(Error::DeviceResetFailed {
|
||||
path: device.display().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn usb_reset_blocking(device: &Path) -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::SgIoTransport::usb_reset(device)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::MacScsiTransport::usb_reset(device)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::SptiTransport::usb_reset(device)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = device;
|
||||
Err(Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lightweight discovery + presence probes ─────────────────────────────────
|
||||
//
|
||||
// These two are the *only* hardware-touching APIs autorip + freemkv CLI use
|
||||
// outside the rip path itself. They're intentionally cheap:
|
||||
//
|
||||
// - `list_drives()` is a one-shot enumeration: filesystem walk for sg/cdrom
|
||||
// nodes, type-5 filter, single INQUIRY per candidate. No firmware, no
|
||||
// reset-on-open, no init. Caller caches the result.
|
||||
// - `drive_has_disc(path)` is a single TEST UNIT READY (six-byte CDB, no
|
||||
// data transfer) with internal wedge-recovery escalation. Callers in a
|
||||
// poll loop don't need any other primitive to detect "disc inserted /
|
||||
// removed" — and they never see the SCSI-vs-USB-reset escalation.
|
||||
//
|
||||
// `Drive::open` + `drive.init()` + `Disc::scan` remain heavy and on-demand;
|
||||
// callers only invoke them once they've decided to actually rip / verify a
|
||||
// specific drive.
|
||||
|
||||
/// One optical drive on the system. Returned by [`list_drives`]. The
|
||||
/// fields are populated from a single INQUIRY at enumeration time —
|
||||
/// no firmware reset, no init.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveInfo {
|
||||
/// Platform device path: `/dev/sgN` (Linux), `/dev/diskN` (macOS),
|
||||
/// `\\.\CdRomN` (Windows).
|
||||
pub path: String,
|
||||
/// SCSI INQUIRY vendor identifier (e.g. `"HL-DT-ST"`).
|
||||
pub vendor: String,
|
||||
/// SCSI INQUIRY product identifier (e.g. `"BD-RE BU40N"`).
|
||||
pub model: String,
|
||||
/// SCSI INQUIRY firmware revision (e.g. `"1.04"`).
|
||||
pub firmware: String,
|
||||
}
|
||||
|
||||
/// Enumerate optical drives present on the system.
|
||||
///
|
||||
/// **What it does**: per-platform sysfs / IOKit / setupapi walk for SCSI
|
||||
/// devices, filtered to type 5 (CD/DVD/BD), with a single INQUIRY each
|
||||
/// for vendor/model/firmware. No firmware reset, no `Drive::init`, no
|
||||
/// disc scan. Suitable for an autorip-style poll loop or a CLI's
|
||||
/// drive-list command.
|
||||
///
|
||||
/// **What it doesn't do**: probe disc presence (use [`drive_has_disc`]),
|
||||
/// open a `Drive` for ripping (use [`crate::Drive::open`]), or load
|
||||
/// drive profiles. Those are heavier operations callers invoke once
|
||||
/// they've selected a drive.
|
||||
pub fn list_drives() -> Vec<DriveInfo> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::list_drives()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::list_drives()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::list_drives()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the drive at `path` currently has a disc inserted.
|
||||
///
|
||||
/// Issues a single TEST UNIT READY (cheapest SCSI op, no data transfer).
|
||||
/// Sense-key 2 ("not ready, medium not present") → `Ok(false)`; any
|
||||
/// other ready/not-ready response → `Ok(true)` or interpreted ready
|
||||
/// state. Suitable for poll-loop tick (~50 ms / drive on a healthy bus).
|
||||
///
|
||||
/// **Internal wedge recovery.** When the kernel's response indicates a
|
||||
/// wedged target — the `0xff` status pattern that means "no answer from
|
||||
/// the device" — this function transparently escalates: SCSI bus reset
|
||||
/// → if still wedged → USB device reset (`USBDEVFS_RESET` on Linux) →
|
||||
/// retry TUR. Callers never see wedge errors and never need to know
|
||||
/// about the escalation; if even the recovery path can't get a response,
|
||||
/// `Err(DeviceResetFailed)` surfaces. **No SCSI primitive is exposed to
|
||||
/// outside crates** — autorip / freemkv CLI / bdemu use this single
|
||||
/// function for the entire "is there a disc?" decision.
|
||||
pub fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::drive_has_disc(path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::drive_has_disc(path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::drive_has_disc(path)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = path;
|
||||
Err(Error::UnsupportedPlatform {
|
||||
target: std::env::consts::OS.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
|
||||
|
||||
/// SCSI INQUIRY response.
|
||||
|
||||
@@ -185,6 +185,62 @@ impl SptiTransport {
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// USB-layer reset on Windows. Returns `DeviceNotFound` —
|
||||
/// **intentional**, not a stub.
|
||||
///
|
||||
/// Why: `reset()` above already issues `IOCTL_STORAGE_RESET_DEVICE`
|
||||
/// which goes through `storport.sys`. On Windows that single IOCTL
|
||||
/// covers both the SCSI layer **and** the USB Mass Storage layer
|
||||
/// for storport-attached devices — functionally equivalent to
|
||||
/// Linux's `SG_SCSI_RESET` + `USBDEVFS_RESET` combined into one
|
||||
/// call. So Windows doesn't need a separate USB-layer step in the
|
||||
/// recovery escalation; `drive_has_disc`'s second stage gracefully
|
||||
/// falls through (it treats `DeviceNotFound` as "not applicable
|
||||
/// for this platform / this drive type").
|
||||
///
|
||||
/// If a future case is found where storport's reset doesn't reach
|
||||
/// the USB layer (e.g. raw Win USB devices that bypass storport),
|
||||
/// this can become a real `IOCTL_USB_HUB_CYCLE_PORT_EX` impl.
|
||||
pub fn usb_reset(device: &Path) -> Result<()> {
|
||||
Err(Error::DeviceNotFound {
|
||||
path: device.display().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate optical drives on Windows via `find_drives()` (CdRom0..15
|
||||
/// scan) and re-shape into `DriveInfo`. Existing implementation already
|
||||
/// returns `(path, DriveId)`; mapped here to the public struct.
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
crate::drive::windows::find_drives()
|
||||
.into_iter()
|
||||
.map(|(path, id)| super::DriveInfo {
|
||||
path,
|
||||
vendor: id.vendor_id.trim().to_string(),
|
||||
model: id.product_id.trim().to_string(),
|
||||
firmware: id.product_revision.trim().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TEST UNIT READY probe on Windows. Wedge recovery on this platform
|
||||
/// goes through `IOCTL_STORAGE_RESET_DEVICE` (already in `reset()`);
|
||||
/// USB-layer cycle-port is stubbed — see `usb_reset` above.
|
||||
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
let mut transport = SptiTransport::open(path)?;
|
||||
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
match transport.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
crate::scsi::TUR_TIMEOUT_MS,
|
||||
) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SptiTransport {
|
||||
|
||||
Reference in New Issue
Block a user