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:
@@ -1,5 +1,94 @@
|
|||||||
# Changelog
|
# 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<DriveInfo>` — 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<bool>` — 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)
|
## 0.13.1 (2026-04-24)
|
||||||
|
|
||||||
### `scsi::reset()` now has a hard wallclock timeout
|
### `scsi::reset()` now has a hard wallclock timeout
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "0.13.1"
|
version = "0.13.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|||||||
+6
-3
@@ -8,12 +8,15 @@
|
|||||||
|
|
||||||
pub mod capture;
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
pub(crate) mod linux;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod macos;
|
pub(crate) mod macos;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod windows;
|
pub(crate) mod windows;
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::event::{Event, EventKind};
|
use crate::event::{Event, EventKind};
|
||||||
|
|||||||
+1
-1
@@ -167,7 +167,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
|||||||
// out-of-tree platform backends. `SectorReader` lets callers feed any byte
|
// out-of-tree platform backends. `SectorReader` lets callers feed any byte
|
||||||
// source (test harness, network image, SMB share) into the disc scan
|
// source (test harness, network image, SMB share) into the disc scan
|
||||||
// pipeline; `FileSectorReader` is the standard ISO-on-disk implementation.
|
// 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 sector::{FileSectorReader, SectorReader};
|
||||||
pub use speed::DriveSpeed;
|
pub use speed::DriveSpeed;
|
||||||
pub use udf::{UdfFs, read_filesystem};
|
pub use udf::{UdfFs, read_filesystem};
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ const SG_DXFER_TO_DEV: i32 = -2;
|
|||||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||||
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
|
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)]
|
#[repr(C)]
|
||||||
#[allow(non_camel_case_types)]
|
#[allow(non_camel_case_types)]
|
||||||
struct sg_io_hdr {
|
struct sg_io_hdr {
|
||||||
@@ -172,6 +181,106 @@ impl SgIoTransport {
|
|||||||
Ok(())
|
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> {
|
fn open_error<T>(device: &Path) -> Result<T> {
|
||||||
let err = std::io::Error::last_os_error();
|
let err = std::io::Error::last_os_error();
|
||||||
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
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));
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
Ok(())
|
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 {
|
impl Drop for MacScsiTransport {
|
||||||
|
|||||||
+219
-4
@@ -18,6 +18,10 @@ use std::path::Path;
|
|||||||
|
|
||||||
// ── SCSI opcodes (SPC-4, MMC-6) ────────────────────────────────────────────
|
// ── 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_INQUIRY: u8 = 0x12;
|
||||||
pub const SCSI_READ_CAPACITY: u8 = 0x25;
|
pub const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||||
pub const SCSI_READ_10: u8 = 0x28;
|
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.
|
/// AACS key class for REPORT KEY / SEND KEY commands.
|
||||||
pub const AACS_KEY_CLASS: u8 = 0x02;
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[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
|
/// 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
|
/// that a kernel-wedged ioctl doesn't take down the caller's poll loop
|
||||||
/// for minutes.
|
/// 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
|
/// Reset a SCSI device to a known good state, with a hard wallclock
|
||||||
/// bound (`DEFAULT_RESET_TIMEOUT_SECS`). On Linux: open/close fd cycle +
|
/// 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
|
/// can't cancel a Linux ioctl from userspace), so this leaks one OS
|
||||||
/// thread per wedge — acceptable cost for a daemon that recovers
|
/// thread per wedge — acceptable cost for a daemon that recovers
|
||||||
/// instead of hanging.
|
/// 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
|
/// Reset with a caller-specified timeout. See [`reset`] for the full
|
||||||
/// rationale on why an outer wallclock bound is required.
|
/// 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 device_owned = device.to_path_buf();
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
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) ────────────────────────────────────────
|
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
|
||||||
|
|
||||||
/// SCSI INQUIRY response.
|
/// SCSI INQUIRY response.
|
||||||
|
|||||||
@@ -185,6 +185,62 @@ impl SptiTransport {
|
|||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
Ok(())
|
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 {
|
impl Drop for SptiTransport {
|
||||||
|
|||||||
Reference in New Issue
Block a user