mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
3
Commits
v1.8.0-rc3
..
qa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6980b04e63 | ||
|
|
777cd6275c | ||
|
|
4d8cba6ff8 |
+2
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user