Compare commits

..
3 Commits
Author SHA1 Message Date
Matthew Jackson 6980b04e63 v1.8.1: bump version 2026-10-07 03:56:38 +00:00
Matthew Jackson 777cd6275c docs: 1.8.1 release notes 2026-10-06 20:19:16 -07:00
Matthew Jackson 4d8cba6ff8 feat: report each drive's OS display name
DriveInfo gains display_name: the drive letter on Windows, /dev/srN on
Linux and diskN on macOS while a disc is mounted, or the path when the OS
has no name. path stays the selector to open. Windows lists lettered drives
in letter order, then unlettered drives by CdRomN.
2026-10-06 18:21:41 -07:00
10 changed files with 166 additions and 16 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# Changelog
## Unreleased
## [1.8.1] — 2026-10-07
- `DriveInfo::display_name` is the name the OS shows users: the drive letter (`E:`) on Windows, `/dev/srN` on Linux and `diskN` on macOS while a disc is mounted; it equals `path` when the OS has no name. `path` remains the selector to open. Windows lists lettered drives in letter order, then unlettered drives by `CdRomN`.
- Drive listing enumerates instead of guessing device numbers. Windows lists every `CdRomN` from `QueryDosDeviceW` plus optical drive letters (previously only `CdRom0`–`15`), retries INQUIRY with 36 bytes for strict USB bridges, and logs skipped devices. Linux also lists optical `sr` devices that have no `sg` node (the `sg` module is not loaded) and, without sysfs, reads `/dev` instead of probing `sg0`–`15`. macOS no longer stops at 8 drives.
- macOS CLI/GUI/server transport diagnostics include selected IOKit service, plug-in metadata, interface initialization stages, exclusive-access attempts and cleanup results.
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.8.0"
version = "1.8.1"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -146,7 +146,7 @@ harness = false
# freemkv-unlock is off crates.io: resolve it from its GIT TAG. Consumers carry
# the same patch; a gitignored .cargo/config.toml overrides it to a local path.
[patch.crates-io]
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.0" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.1" }
[lints.rust]
# `loom` is set by RUSTFLAGS for the model checker above, never by a feature.
+20
View File
@@ -560,6 +560,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
// probe time, stashed under `.../sgN/device/`. Survives even when
// the drive is wedged below the USB bridge and our INQUIRY times out.
let (sysfs_vendor, sysfs_model, sysfs_firmware) = sysfs_identity(&name);
let display_name = display_name(std::path::Path::new(SG_CLASS), &name, &path);
// INQUIRY-only probe — open transport, run INQUIRY, drop. No
// identify, no init, no firmware reset preamble's secondary
@@ -580,6 +581,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let info = match probed {
Ok(Ok(r)) => super::DriveInfo {
path: path.clone(),
display_name,
vendor: pick_identity(r.vendor_id, &sysfs_vendor),
model: pick_identity(r.model, &sysfs_model),
firmware: pick_identity(r.firmware, &sysfs_firmware),
@@ -588,6 +590,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
// listed from what sysfs knows, so autorip reports it, not an unplug.
_ => super::DriveInfo {
path: path.clone(),
display_name,
vendor: sysfs_vendor,
model: sysfs_model,
firmware: sysfs_firmware,
@@ -609,6 +612,23 @@ fn pick_identity(live: String, sysfs: &str) -> String {
}
}
const SG_CLASS: &str = "/sys/class/scsi_generic";
/// The `/dev/srN` users know for `sgN`, from `<sg_class>/sgN/device/block/`;
/// `path` when the node is already `srN` or sysfs has no block device for it.
fn display_name(sg_class: &std::path::Path, name: &str, path: &str) -> String {
if name.starts_with("sr") {
return path.to_string();
}
std::fs::read_dir(sg_class.join(name).join("device/block"))
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.find(|block| block.starts_with("sr"))
.map_or_else(|| path.to_string(), |sr| format!("/dev/{sr}"))
}
/// Read the kernel's cached INQUIRY identity strings for `sgN` from
/// `/sys/class/scsi_generic/sgN/device/{vendor,model,rev}`. Empty strings
/// when sysfs is unavailable (minimal container, non-Linux filesystem).
+17
View File
@@ -9,3 +9,20 @@ fn device_nodes_sort_numerically_not_lexically() {
assert_eq!(names, ["sg0", "sg2", "sg10", "sr0"]);
assert_eq!(node_number("sg"), u32::MAX);
}
#[test]
fn display_name_is_the_sr_block_device_behind_sg() {
let class = tempfile::tempdir().unwrap();
std::fs::create_dir_all(class.path().join("sg3/device/block/sr1")).unwrap();
assert_eq!(display_name(class.path(), "sg3", "/dev/sg3"), "/dev/sr1");
}
#[test]
fn display_name_falls_back_to_the_path() {
let class = tempfile::tempdir().unwrap();
// No sysfs entry for the node.
assert_eq!(display_name(class.path(), "sg3", "/dev/sg3"), "/dev/sg3");
// An `sr` node is already the name users know.
std::fs::create_dir_all(class.path().join("sr0/device/block/sr0")).unwrap();
assert_eq!(display_name(class.path(), "sr0", "/dev/sr0"), "/dev/sr0");
}
+10 -1
View File
@@ -44,6 +44,7 @@ struct ShimDriveInfo {
vendor: [u8; 32],
model: [u8; 48],
firmware: [u8; 16],
bsd_name: [u8; 32],
}
// shim_open_exclusive's result when the cancel byte was set (`SHIM_CANCELLED` in the shim).
@@ -266,6 +267,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
bsd_name: [0; 32],
};
// The shim stops at the buffer size; a full buffer means there may be more.
let mut capacity = 16usize;
@@ -288,8 +290,15 @@ fn drive_info_from_shim(info: &ShimDriveInfo) -> Option<super::DriveInfo> {
if selector.is_empty() {
return None;
}
let path = device_path_for_selector(selector);
let bsd_name = cstr_to_str(&info.bsd_name);
Some(super::DriveInfo {
path: device_path_for_selector(selector),
display_name: if bsd_name.is_empty() {
path.clone()
} else {
bsd_name.to_string()
},
path,
vendor: cstr_to_str(&info.vendor).to_string(),
model: cstr_to_str(&info.model).to_string(),
firmware: cstr_to_str(&info.firmware).to_string(),
+3
View File
@@ -197,6 +197,7 @@ typedef struct {
char vendor[32];
char model[48];
char firmware[16];
char bsd_name[32];
} ShimDriveInfo;
// ── Global handle (single-drive, same as before) ──────────────────────────
@@ -940,6 +941,8 @@ static int visit_list(io_service_t svc, void *ctx) {
// optical service survives: always use its ID so a busy drive cannot be
// enumerated as a second, apparently empty drive after opening it.
registry_id_selector(svc, info->device_selector, sizeof(info->device_selector));
// Only present while a disc is mounted; the drive's user-visible name.
bdsvc_to_bsd_name(svc, info->bsd_name, sizeof(info->bsd_name));
if (info->device_selector[0]) lc->count++;
return lc->count >= lc->max ? 2 : 0;
}
+11
View File
@@ -52,6 +52,7 @@ fn shim_drive(selector: &[u8], vendor: &[u8], model: &[u8], firmware: &[u8]) ->
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
bsd_name: [0; 32],
};
info.device_selector[..selector.len()].copy_from_slice(selector);
info.vendor[..vendor.len()].copy_from_slice(vendor);
@@ -80,11 +81,21 @@ fn shim_drive_record_keeps_empty_tray_registry_selector() {
);
let mapped = drive_info_from_shim(&info).expect("registry selector");
assert_eq!(mapped.path, "ioreg:18446744073709551615");
assert_eq!(mapped.display_name, "ioreg:18446744073709551615");
assert_eq!(mapped.vendor, "HL-DT-ST");
assert_eq!(mapped.model, "BD-RE BU40N");
assert_eq!(mapped.firmware, "1.03");
}
#[test]
fn shim_drive_record_shows_mounted_bsd_name_but_keeps_registry_selector() {
let mut info = shim_drive(b"ioreg:4295000001", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
info.bsd_name[..5].copy_from_slice(b"disk4");
let mapped = drive_info_from_shim(&info).expect("registry selector");
assert_eq!(mapped.path, "ioreg:4295000001");
assert_eq!(mapped.display_name, "disk4");
}
#[test]
fn shim_drive_record_with_empty_selector_is_skipped() {
let info = shim_drive(b"", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
+4
View File
@@ -443,6 +443,10 @@ pub struct DriveInfo {
/// `ioreg:<id>` selector (macOS), `\\.\CdRomN`
/// (Windows).
pub path: String,
/// The name the OS shows users: a drive letter like `E:` (Windows),
/// `/dev/srN` (Linux), `diskN` (macOS, only while a disc is mounted).
/// Equals [`path`](Self::path) when the OS has no name for the drive.
pub display_name: String,
/// SCSI INQUIRY vendor identifier (e.g. `"HL-DT-ST"`).
pub vendor: String,
/// SCSI INQUIRY product identifier (e.g. `"BD-RE BU40N"`).
+58 -12
View File
@@ -242,32 +242,77 @@ impl SptiTransport {
/// must answer INQUIRY as an optical peripheral (type 0x05).
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let names = cdrom_device_names();
let listed: Vec<u32> = names.iter().filter_map(|name| cdrom_number(name)).collect();
let mut candidates: Vec<String> = names
let letters: Vec<(char, Option<u32>)> = optical_drive_letters()
.into_iter()
.map(|name| format!("\\\\.\\{name}"))
.map(|letter| (letter, device_number(&format!("\\\\.\\{letter}:"))))
.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 path in candidates {
for path in candidate_paths(names, &letters) {
match probe(&path) {
Ok(Some(drive)) => drives.push(drive),
Ok(Some(mut drive)) => {
drive.display_name = display_name(&drive.path, &letters);
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.sort_by_key(|drive| sort_key(&drive.path, &drive.display_name));
drives
}
/// Every `CdRomN` device, plus each optical letter whose `CdRomN` is not among them.
fn candidate_paths(names: Vec<String>, letters: &[(char, Option<u32>)]) -> Vec<String> {
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, number) in letters {
if !number.is_some_and(|n| listed.contains(&n)) {
candidates.push(format!("\\\\.\\{letter}:"));
}
}
candidates
}
/// The drive letter Explorer shows for `path` (`E:`), else `path` itself.
fn display_name(path: &str, letters: &[(char, Option<u32>)]) -> String {
let device = path.strip_prefix("\\\\.\\").unwrap_or(path);
let letter = match cdrom_number(device) {
Some(n) => letters
.iter()
.find(|(_, number)| *number == Some(n))
.map(|(letter, _)| *letter),
None => drive_letter(device),
};
letter.map_or_else(|| path.to_string(), |letter| format!("{letter}:"))
}
fn drive_letter(device: &str) -> Option<char> {
let mut chars = device.chars();
match (chars.next(), chars.next(), chars.next()) {
(Some(letter), Some(':'), None) if letter.is_ascii_alphabetic() => Some(letter),
_ => None,
}
}
/// Lettered drives in letter order, then the rest by `CdRomN`.
fn sort_key(path: &str, display_name: &str) -> (bool, u32, String) {
if display_name != path {
return (false, 0, display_name.to_string());
}
let device = path.strip_prefix("\\\\.\\").unwrap_or(path);
(
true,
cdrom_number(device).unwrap_or(u32::MAX),
path.to_string(),
)
}
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()) {
@@ -278,6 +323,7 @@ fn probe(path: &str) -> Result<Option<super::DriveInfo>> {
Ok(
super::is_optical_peripheral(&r.raw).then(|| super::DriveInfo {
path: normalize_device_path(path),
display_name: normalize_device_path(path),
vendor: r.vendor_id,
model: r.model,
firmware: r.firmware,
+39
View File
@@ -120,3 +120,42 @@ fn cdrom_names_come_from_the_dos_device_list_without_a_number_limit() {
assert_eq!(cdrom_number("CdRom"), None);
assert_eq!(cdrom_number("PhysicalDrive0"), None);
}
// Explorer's letter is the name; the `CdRomN` path stays the selector.
#[test]
fn display_name_is_the_drive_letter_for_its_cdrom_device() {
let letters = [('D', Some(0)), ('E', Some(3))];
assert_eq!(display_name(r"\\.\CdRom3", &letters), "E:");
assert_eq!(display_name(r"\\.\CdRom0", &letters), "D:");
// A letter-only candidate is opened by its letter.
assert_eq!(display_name(r"\\.\F:", &letters), "F:");
}
#[test]
fn display_name_without_a_letter_is_the_path() {
let letters = [('D', Some(0)), ('E', None)];
assert_eq!(display_name(r"\\.\CdRom2", &letters), r"\\.\CdRom2");
}
#[test]
fn candidates_add_only_letters_without_a_listed_cdrom() {
let names = vec!["CdRom0".to_string(), "CdRom3".to_string()];
let letters = [('D', Some(0)), ('E', Some(7)), ('F', None)];
assert_eq!(
candidate_paths(names, &letters),
[r"\\.\CdRom0", r"\\.\CdRom3", r"\\.\E:", r"\\.\F:"]
);
}
#[test]
fn drives_sort_by_letter_then_unlettered_cdrom_number() {
let mut drives = [
(r"\\.\CdRom10", r"\\.\CdRom10"),
(r"\\.\CdRom0", "F:"),
(r"\\.\CdRom2", r"\\.\CdRom2"),
(r"\\.\CdRom5", "D:"),
];
drives.sort_by_key(|(path, name)| sort_key(path, name));
let names: Vec<_> = drives.iter().map(|(_, name)| *name).collect();
assert_eq!(names, ["D:", "F:", r"\\.\CdRom2", r"\\.\CdRom10"]);
}