diff --git a/CHANGELOG.md b/CHANGELOG.md index c246516..25d0487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,94 @@ # Changelog +## 0.13.2 (2026-04-24) + +### Public discovery + presence APIs; SCSI/USB primitives no longer +### exposed to consumer crates + +The autorip / freemkv-CLI side of the ecosystem was reimplementing +hardware discovery (sysfs walking, SCSI type-5 filtering, sg-path +construction) and SCSI recovery primitives in their own crates — a +direct violation of the architectural rule that ALL hardware-aware +code lives in libfreemkv. 0.13.2 closes that gap with two cheap public +probes that absorb everything consumers were doing themselves, plus +visibility tightening to make future violations a compile error. + +#### New public APIs + +- `pub struct DriveInfo { path, vendor, model, firmware }` — a single + enumerated optical drive's identity. Returned by `list_drives()`, + populated from a single SCSI INQUIRY at enumeration time. No + firmware reset, no `init`. +- `pub fn list_drives() -> Vec` — one-shot enumeration + across Linux/macOS/Windows. Linux walks `/sys/class/scsi_generic/` + with the SCSI type-5 filter and `/dev/sg0..15` fallback; macOS + walks `/dev/disk0..15` with the INQUIRY peripheral-type-5 filter; + Windows iterates `CdRom0..15`. Cheap (~10 ms / drive); cache the + result and refresh on udev events. +- `pub fn drive_has_disc(path: &Path) -> Result` — single TEST + UNIT READY. Returns `Ok(true)` when ready / `Ok(false)` on sense-key 2 + ("medium not present") / `Err` only after recovery has been exhausted. + **Internal wedge recovery is hidden from callers** — when the kernel + returns the wedge-signature pattern (status 0xFF, no sense), this + function transparently escalates: SCSI bus reset → if still wedged → + USB device reset → retry TUR. Consumers never see the escalation. + +#### USB-layer reset, multi-platform + +`USBDEVFS_RESET` (Linux) is the only thing that recovers a kernel- +level USB Mass Storage wedge — software equivalent of unplug-replug. +Now wired for all three OSes: + +- **Linux**: `USBDEVFS_RESET` ioctl on `/dev/bus/usb/BBB/DDD`. Resolves + sg → USB device via sysfs walk (`busnum`/`devnum` parents). +- **macOS**: `IOUSBDeviceInterface::ResetDevice()`. Walks IORegistry + parents from the SCSI service to the USB device, queries the IOKit + USB plugin, calls ResetDevice. +- **Windows**: existing `IOCTL_STORAGE_RESET_DEVICE` covers both SCSI + and USB layers via storport, so `usb_reset` returns `DeviceNotFound` + by design — the recovery escalation in `drive_has_disc` falls + through cleanly. (See the `windows::usb_reset` doc comment for + why a separate cycle-port IOCTL isn't needed on Windows.) + +All wrapped in a thread + `mpsc::recv_timeout` so a kernel ioctl that +hangs forever can't lock up the caller (the inner thread leaks one OS +thread per hard wedge — acceptable for a daemon that recovers vs. one +that wedges the whole poll loop). + +#### Visibility tightening (architectural enforcement) + +These were `pub` in 0.13.1; consumer crates could (and did) call them +directly, leaking SCSI knowledge across the lib boundary: + +- `scsi::reset` → `pub(crate)` +- `scsi::reset_with_timeout` → `pub(crate)` +- `scsi::usb_reset` → `pub(crate)` +- `scsi::usb_reset_with_timeout` → `pub(crate)` +- `DEFAULT_RESET_TIMEOUT_SECS` / `DEFAULT_USB_RESET_TIMEOUT_SECS` → + `pub(crate)` + +Consumers now reach recovery exclusively through `drive_has_disc`, +which folds the escalation in. **Compile-time guarantee** that no +future autorip/CLI/bdemu commit can reintroduce direct SCSI access. + +#### Why this design + +`Drive::open(path)` runs a ~2 s firmware-reset preamble + identify +sequence; suitable for ripping but wasteful for a poll loop probing +"is there a disc?". Pre-0.13.2 autorip called `Drive::open` 4 × every +5 s = ~17 000 speculative SCSI sessions/day, hammering the drives +between actual rips. The wedge in production at 23:51 UTC was +triggered by exactly this hot-loop pattern. With `drive_has_disc`, +the same poll cadence costs ~50 ms / drive (one TUR) — 40× cheaper +and side-effect-free on a healthy drive. + +#### Tests + +- 233 lib tests pass (no change in count; APIs covered indirectly via + the existing transport tests + a new `device_key` test on the + autorip side). +- `cargo clippy --all-targets -D warnings` clean across Linux/macOS. + ## 0.13.1 (2026-04-24) ### `scsi::reset()` now has a hard wallclock timeout diff --git a/Cargo.toml b/Cargo.toml index 244b89b..670211e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.13.1" +version = "0.13.2" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/drive/mod.rs b/src/drive/mod.rs index a858965..2f3432f 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -8,12 +8,15 @@ pub mod capture; +// Per-platform discovery helpers (the `pub(crate)` `find_drives` / +// equivalents). Crate-public so `scsi/{linux,macos,windows}.rs` can +// reuse the existing enumeration logic when shaping `DriveInfo`. #[cfg(target_os = "linux")] -mod linux; +pub(crate) mod linux; #[cfg(target_os = "macos")] -mod macos; +pub(crate) mod macos; #[cfg(windows)] -mod windows; +pub(crate) mod windows; use crate::error::{Error, Result}; use crate::event::{Event, EventKind}; diff --git a/src/lib.rs b/src/lib.rs index 37e6174..282f028 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -167,7 +167,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; // out-of-tree platform backends. `SectorReader` lets callers feed any byte // source (test harness, network image, SMB share) into the disc scan // pipeline; `FileSectorReader` is the standard ISO-on-disk implementation. -pub use scsi::ScsiTransport; +pub use scsi::{DriveInfo, ScsiTransport, drive_has_disc, list_drives}; pub use sector::{FileSectorReader, SectorReader}; pub use speed::DriveSpeed; pub use udf::{UdfFs, read_filesystem}; diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index f099b75..c47cd82 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -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 { + 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(device: &Path) -> Result { 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 { + 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 { + 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 { + 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 { + 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 { + // 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; diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index d001eb1..dde4954 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index 514d718..0a710b8 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -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> { /// 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 { + #[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 { + #[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. diff --git a/src/scsi/windows.rs b/src/scsi/windows.rs index e5e64e4..33b9935 100644 --- a/src/scsi/windows.rs +++ b/src/scsi/windows.rs @@ -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 { + 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 { + 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 {