fix(windows): list every optical drive without guessing device numbers

Enumeration probed only \\.\CdRom0..15 and fell back to drive letters only
when that found nothing, so drives numbered above 15 (common after USB
replugging) were silently missing. List every CdRomN from QueryDosDeviceW,
add optical drive letters (GetDriveTypeW) not already covered by their
device number, retry INQUIRY with the standard 36 bytes for strict USB
bridges, and log each skipped device with its reason. Also syncs the stale
Cargo.lock with the 1.8.0 manifest.
This commit is contained in:
Matthew Jackson
2026-10-06 15:15:10 -07:00
parent 0b146ef784
commit d1e0a8a98c
4 changed files with 179 additions and 33 deletions
Generated
+9 -2
View File
@@ -221,12 +221,13 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.7"
version = "1.8.0"
dependencies = [
"aes",
"base64",
"num-bigint",
"num-traits",
"pioneer-optical",
"rand",
"serde",
"serde_json",
@@ -317,7 +318,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.7.7"
version = "1.8.0"
dependencies = [
"aes",
"base64",
@@ -430,6 +431,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pioneer-optical"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf5074a27cc39fb664d0b994814ffe8530e3e8590aff0d104723332dbcb1abc4"
[[package]]
name = "proc-macro2"
version = "1.0.107"
+12 -2
View File
@@ -544,8 +544,18 @@ const INQUIRY_MIN_LEN: usize = 36;
/// Send INQUIRY and parse standard response fields. A reply shorter than the identity
/// fields is an error, never a blank identity.
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
let cdb = [SCSI_INQUIRY, 0x00, 0x00, 0x00, INQUIRY_ALLOC as u8, 0x00];
let mut buf = [0u8; INQUIRY_ALLOC];
inquiry_alloc(scsi, INQUIRY_ALLOC)
}
/// INQUIRY with the standard 36-byte allocation, for bridges that reject longer ones.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn inquiry_standard(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
inquiry_alloc(scsi, INQUIRY_MIN_LEN)
}
fn inquiry_alloc(scsi: &mut dyn ScsiTransport, alloc: usize) -> Result<InquiryResult> {
let cdb = [SCSI_INQUIRY, 0x00, 0x00, 0x00, alloc as u8, 0x00];
let mut buf = vec![0u8; alloc];
let r = scsi.execute(
&cdb,
DataDirection::FromDevice,
+146 -29
View File
@@ -15,6 +15,13 @@ const IOCTL_SCSI_PASS_THROUGH_DIRECT: u32 = 0x4D014;
/// IOCTL_STORAGE_QUERY_PROPERTY — CTL_CODE(IOCTL_STORAGE_BASE(0x2D),
/// 0x500, METHOD_BUFFERED(0), FILE_ANY_ACCESS(0)) = 0x002D1400.
const IOCTL_STORAGE_QUERY_PROPERTY: u32 = 0x002D1400;
/// IOCTL_STORAGE_GET_DEVICE_NUMBER — CTL_CODE(0x2D, 0x420, METHOD_BUFFERED, FILE_ANY_ACCESS).
const IOCTL_STORAGE_GET_DEVICE_NUMBER: u32 = 0x002D1080;
/// FILE_DEVICE_CD_ROM, the `DeviceType` of an optical drive.
const FILE_DEVICE_CD_ROM: u32 = 0x02;
/// GetDriveTypeW result for an optical drive letter.
const DRIVE_CDROM: u32 = 5;
const ERROR_INSUFFICIENT_BUFFER: u32 = 122;
/// STORAGE_PROPERTY_ID::StorageAdapterProperty.
const STORAGE_ADAPTER_PROPERTY: u32 = 1;
/// STORAGE_QUERY_TYPE::PropertyStandardQuery.
@@ -101,6 +108,15 @@ struct StorageAdapterDescriptor {
BusMinorVersion: u16,
}
/// `STORAGE_DEVICE_NUMBER` (winioctl.h).
#[repr(C)]
#[allow(non_snake_case)]
struct StorageDeviceNumber {
DeviceType: u32,
DeviceNumber: u32,
PartitionNumber: u32,
}
// ── Windows FFI ────────────────────────────────────────────────────────────
unsafe extern "system" {
@@ -118,6 +134,12 @@ unsafe extern "system" {
fn GetLastError() -> u32;
fn QueryDosDeviceW(lpDeviceName: *const u16, lpTargetPath: *mut u16, ucchMax: u32) -> u32;
fn GetLogicalDrives() -> u32;
fn GetDriveTypeW(lpRootPathName: *const u16) -> u32;
fn DeviceIoControl(
hDevice: isize,
dwIoControlCode: u32,
@@ -215,45 +237,140 @@ impl SptiTransport {
}
}
/// Enumerate optical drives on Windows: probe `\\.\CdRom0..15`, falling back to
/// drive letters `D..Z` only if none match, INQUIRY each, and keep the optical
/// ones (peripheral device type 0x05).
///
/// Self-contained at the SCSI layer via `scsi::open` + `scsi::inquiry` (as the
/// Linux backend is), with no dependency on the `rip`-gated `drive`/`identity`
/// modules — `scsi` builds without `rip` (freemkv-firmware), where the old
/// `crate::drive::windows::find_drives()` delegation failed to compile.
/// Enumerate optical drives on Windows without guessing device numbers: every
/// `CdRomN` the object manager knows (from `QueryDosDeviceW`, so any N), plus
/// any optical drive letter whose device is not already listed. Each candidate
/// must answer INQUIRY as an optical peripheral (type 0x05).
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
fn probe(path: &str, out: &mut Vec<super::DriveInfo>) {
let Ok(mut transport) = crate::scsi::open(Path::new(path)) else {
return;
};
let Ok(r) = super::inquiry(transport.as_mut()) else {
return;
};
if super::is_optical_peripheral(&r.raw) {
out.push(super::DriveInfo {
path: normalize_device_path(path),
vendor: r.vendor_id,
model: r.model,
firmware: r.firmware,
});
let names = cdrom_device_names();
let listed: Vec<u32> = names.iter().filter_map(|name| cdrom_number(name)).collect();
let mut candidates: Vec<String> = names
.into_iter()
.map(|name| format!("\\\\.\\{name}"))
.collect();
for letter in optical_drive_letters() {
let path = format!("\\\\.\\{letter}:");
match device_number(&path) {
Some(n) if listed.contains(&n) => {}
_ => candidates.push(path),
}
}
let mut drives = Vec::new();
for i in 0..16 {
probe(&format!("\\\\.\\CdRom{}", i), &mut drives);
}
// Only fall back to drive letters if the CdRom scan found nothing.
if drives.is_empty() {
for letter in b'D'..=b'Z' {
probe(&format!("{}:", letter as char), &mut drives);
for path in candidates {
match probe(&path) {
Ok(Some(drive)) => drives.push(drive),
Ok(None) => tracing::info!(path = %path, "skipping non-optical device"),
Err(e) => {
tracing::warn!(path = %path, error = %e, "skipping optical device that did not answer")
}
}
}
drives
}
fn probe(path: &str) -> Result<Option<super::DriveInfo>> {
let mut transport = crate::scsi::open(Path::new(path))?;
let r = match super::inquiry(transport.as_mut()) {
Ok(r) => r,
// Some USB bridges reject a non-standard allocation length.
Err(_) => super::inquiry_standard(transport.as_mut())?,
};
Ok(
super::is_optical_peripheral(&r.raw).then(|| super::DriveInfo {
path: normalize_device_path(path),
vendor: r.vendor_id,
model: r.model,
firmware: r.firmware,
}),
)
}
/// Every `CdRomN` DOS device name, sorted by N.
fn cdrom_device_names() -> Vec<String> {
let mut buf = vec![0u16; 64 * 1024];
loop {
let len = unsafe { QueryDosDeviceW(std::ptr::null(), buf.as_mut_ptr(), buf.len() as u32) };
if len != 0 {
return cdrom_names_from_multi_sz(&buf[..len as usize]);
}
if unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER || buf.len() >= 16 * 1024 * 1024 {
tracing::warn!("QueryDosDeviceW failed; no CdRom devices listed");
return Vec::new();
}
buf.resize(buf.len() * 4, 0);
}
}
/// Pick the `CdRomN` names out of a `QueryDosDeviceW` multi-string.
fn cdrom_names_from_multi_sz(multi_sz: &[u16]) -> Vec<String> {
let mut names: Vec<String> = multi_sz
.split(|&c| c == 0)
.map(String::from_utf16_lossy)
.filter(|name| cdrom_number(name).is_some())
.collect();
names.sort_by_key(|name| cdrom_number(name));
names.dedup();
names
}
fn cdrom_number(name: &str) -> Option<u32> {
let digits = name.strip_prefix("CdRom")?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
/// Drive letters Windows reports as optical.
fn optical_drive_letters() -> Vec<char> {
let mask = unsafe { GetLogicalDrives() };
(0..26u32)
.filter(|bit| mask & (1 << bit) != 0)
.map(|bit| (b'A' + bit as u8) as char)
.filter(|letter| {
let root: Vec<u16> = format!("{letter}:\\").encode_utf16().chain([0]).collect();
unsafe { GetDriveTypeW(root.as_ptr()) == DRIVE_CDROM }
})
.collect()
}
/// The `CdRomN` number behind a device path, when Windows reports one.
fn device_number(path: &str) -> Option<u32> {
let wide: Vec<u16> = path.encode_utf16().chain([0]).collect();
// Zero access rights: querying the device number needs no read/write access.
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_EXISTING,
0,
std::ptr::null(),
)
};
if handle == INVALID_HANDLE_VALUE {
return None;
}
let mut number: StorageDeviceNumber = unsafe { std::mem::zeroed() };
let mut returned = 0u32;
let ok = unsafe {
DeviceIoControl(
handle,
IOCTL_STORAGE_GET_DEVICE_NUMBER,
std::ptr::null_mut(),
0,
(&mut number as *mut StorageDeviceNumber).cast(),
std::mem::size_of::<StorageDeviceNumber>() as u32,
&mut returned,
std::ptr::null_mut(),
)
};
unsafe { CloseHandle(handle) };
(ok != 0 && number.DeviceType == FILE_DEVICE_CD_ROM).then_some(number.DeviceNumber)
}
/// TEST UNIT READY probe on Windows. No in-library recovery: the answer is
/// classified by [`super::tur_disc_presence`].
pub(super) fn disc_presence(path: &Path) -> Result<super::DiscPresence> {
+12
View File
@@ -108,3 +108,15 @@ fn windows_ffi_constants_match_sdk() {
assert_eq!(FILE_ATTRIBUTE_NORMAL, 0x80);
assert_eq!(INVALID_HANDLE_VALUE, -1);
}
#[test]
fn cdrom_names_come_from_the_dos_device_list_without_a_number_limit() {
let multi_sz: Vec<u16> =
"C:\0CdRom17\0PhysicalDrive0\0CdRom2\0CdRomX\0CdRom\0Volume{1}\0CdRom2\0\0"
.encode_utf16()
.collect();
assert_eq!(cdrom_names_from_multi_sz(&multi_sz), ["CdRom2", "CdRom17"]);
assert_eq!(cdrom_number("CdRom17"), Some(17));
assert_eq!(cdrom_number("CdRom"), None);
assert_eq!(cdrom_number("PhysicalDrive0"), None);
}