use crate::domain::drive_info::{DriveInfo, UsbInfo};
pub async fn load_drives() -> Vec<DriveInfo> {
load_drives_sync()
}
pub fn load_drives_sync() -> Vec<DriveInfo> {
#[cfg(target_os = "linux")]
let mut drives = linux::enumerate();
#[cfg(target_os = "macos")]
let mut drives = macos::enumerate();
#[cfg(target_os = "windows")]
let mut drives = windows::enumerate();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
let mut drives: Vec<DriveInfo> = Vec::new();
drives.sort_by(|a, b| a.device_path.cmp(&b.device_path));
drives
}
fn build_display_name(info: &UsbInfo, dev_name: &str) -> String {
let label = info.display_label();
if label.contains(':') {
dev_name.to_string()
} else {
format!("{} ({})", label, dev_name)
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub fn enumerate() -> Vec<DriveInfo> {
let block_root = Path::new("/sys/block");
let Ok(entries) = std::fs::read_dir(block_root) else {
return Vec::new();
};
let mounts = read_proc_mounts();
let mut drives = Vec::new();
for entry in entries.flatten() {
let dev_name = entry.file_name().to_string_lossy().to_string();
if should_skip_device(&dev_name) {
continue;
}
let block_sysfs = block_root.join(&dev_name);
let canonical = match std::fs::canonicalize(&block_sysfs) {
Ok(p) => p,
Err(_) => continue,
};
let Some(usb_dir) = find_usb_sysfs_dir(&canonical) else {
continue;
};
let size_sectors = read_sysfs_u64(&block_sysfs.join("size")).unwrap_or(0);
if size_sectors == 0 {
continue;
}
let size_gb = (size_sectors * 512) as f64 / (1024.0 * 1024.0 * 1024.0);
let is_read_only = read_sysfs_u64(&block_sysfs.join("ro"))
.map(|v| v != 0)
.unwrap_or(false);
let device_path = format!("/dev/{dev_name}");
let mount_point =
find_mount_point(&dev_name, &mounts).unwrap_or_else(|| device_path.clone());
let usb_info = read_usb_info_from_sysfs(&usb_dir);
let name = build_display_name(&usb_info, &dev_name);
let drive = DriveInfo::with_constraints(
name,
mount_point,
size_gb,
device_path,
false, is_read_only,
)
.with_usb_info(usb_info);
drives.push(drive);
}
drives
}
pub(super) fn should_skip_device(name: &str) -> bool {
name.starts_with("loop")
|| name.starts_with("ram")
|| name.starts_with("dm-")
|| name.starts_with("zram")
|| name.starts_with("nvme")
|| name.starts_with("mmcblk")
|| name.starts_with("sr")
|| name.is_empty()
}
pub(super) fn read_sysfs_u64(path: &Path) -> Option<u64> {
std::fs::read_to_string(path)
.ok()?
.trim()
.parse::<u64>()
.ok()
}
pub(super) fn read_sysfs_str(path: &Path) -> Option<String> {
let s = std::fs::read_to_string(path).ok()?;
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub(super) fn parse_hex_u16(s: &str) -> Option<u16> {
u16::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
}
pub(super) fn sysfs_speed_string(raw: &str) -> String {
match raw.trim() {
"1.5" => "Low Speed (1.5 Mbps)".to_string(),
"12" => "Full Speed (12 Mbps)".to_string(),
"480" => "High Speed (480 Mbps)".to_string(),
"5000" => "SuperSpeed (5 Gbps)".to_string(),
"10000" => "SuperSpeed+ (10 Gbps)".to_string(),
"20000" => "SuperSpeed+ (20 Gbps)".to_string(),
other => format!("{other} Mbps"),
}
}
pub(super) fn find_usb_sysfs_dir(path: &Path) -> Option<PathBuf> {
let mut current = path.to_path_buf();
loop {
if current.join("idVendor").exists() {
return Some(current);
}
if current == Path::new("/sys/devices") || current == Path::new("/sys") {
return None;
}
match current.parent() {
Some(p) if p != current => current = p.to_path_buf(),
_ => return None,
}
}
}
pub(super) fn read_usb_info_from_sysfs(usb_dir: &Path) -> UsbInfo {
let vendor_id = read_sysfs_str(&usb_dir.join("idVendor"))
.and_then(|s| parse_hex_u16(&s))
.unwrap_or(0);
let product_id = read_sysfs_str(&usb_dir.join("idProduct"))
.and_then(|s| parse_hex_u16(&s))
.unwrap_or(0);
let manufacturer = read_sysfs_str(&usb_dir.join("manufacturer"));
let product = read_sysfs_str(&usb_dir.join("product"));
let serial = read_sysfs_str(&usb_dir.join("serial"));
let speed = read_sysfs_str(&usb_dir.join("speed")).map(|s| sysfs_speed_string(&s));
UsbInfo {
vendor_id,
product_id,
manufacturer,
product,
serial,
speed,
}
}
pub(super) fn read_proc_mounts() -> HashMap<String, String> {
let content = std::fs::read_to_string("/proc/mounts")
.or_else(|_| std::fs::read_to_string("/proc/self/mounts"))
.unwrap_or_default();
let mut map = HashMap::new();
for line in content.lines() {
let mut parts = line.split_whitespace();
let dev = match parts.next() {
Some(d) if d.starts_with("/dev/") => d,
_ => continue,
};
let mount = match parts.next() {
Some(m) => m,
None => continue,
};
if let Some(name) = Path::new(dev).file_name() {
map.insert(name.to_string_lossy().to_string(), mount.to_string());
}
}
map
}
pub(super) fn find_mount_point(
dev_name: &str,
mounts: &HashMap<String, String>,
) -> Option<String> {
if let Some(mp) = mounts.get(dev_name) {
return Some(mp.clone());
}
for (mounted_dev, mount_point) in mounts {
if mounted_dev.starts_with(dev_name)
&& mounted_dev.len() > dev_name.len()
&& (mounted_dev.as_bytes()[dev_name.len()].is_ascii_digit()
|| mounted_dev.as_bytes()[dev_name.len()] == b'p')
{
return Some(mount_point.clone());
}
}
None
}
}
#[cfg(any(target_os = "macos", test))]
#[derive(Debug, Default)]
pub(crate) struct MacDiskInfo {
pub(crate) whole_disk: bool,
pub(crate) removable: bool,
pub(crate) size_bytes: u64,
pub(crate) read_only: bool,
pub(crate) mount_point: Option<String>,
pub(crate) usb_vendor_id: Option<u16>,
pub(crate) usb_product_id: Option<u16>,
pub(crate) usb_serial: Option<String>,
}
#[cfg(any(target_os = "macos", test))]
#[derive(Debug, Default)]
pub(crate) struct SpUsbDevice {
pub(crate) vendor_id: Option<u16>,
pub(crate) product_id: Option<u16>,
pub(crate) serial: Option<String>,
pub(crate) manufacturer: Option<String>,
pub(crate) product: Option<String>,
pub(crate) speed: Option<String>,
}
#[cfg(any(target_os = "macos", test))]
mod macos_parse {
use super::*;
pub fn find_sp_device<'a>(
devices: &'a [SpUsbDevice],
vid: Option<u16>,
pid: Option<u16>,
serial: Option<&str>,
) -> Option<&'a SpUsbDevice> {
let (v, p) = (vid?, pid?);
if let Some(s) = serial {
if let Some(dev) = devices.iter().find(|dev| {
dev.vendor_id == Some(v)
&& dev.product_id == Some(p)
&& dev.serial.as_deref() == Some(s)
}) {
return Some(dev);
}
}
devices
.iter()
.find(|dev| dev.vendor_id == Some(v) && dev.product_id == Some(p))
}
pub fn parse_system_profiler_json(json: &str) -> Vec<SpUsbDevice> {
let mut devices = Vec::new();
collect_items_arrays(json, &mut devices);
devices
}
fn collect_items_arrays(json: &str, out: &mut Vec<SpUsbDevice>) {
let mut remaining = json;
while let Some(pos) = remaining.find("\"_items\"") {
remaining = &remaining[pos + "\"_items\"".len()..];
let after_colon = match remaining.find('[') {
Some(p) => &remaining[p + 1..],
None => continue,
};
let array_content = extract_json_array(after_colon);
let mut arr = array_content;
while let Some(brace) = arr.find('{') {
arr = &arr[brace + 1..];
let block = extract_json_object(arr);
if block.contains("\"vendor_id\"") || block.contains("\"product_id\"") {
let dev = parse_sp_device_block(block);
if dev.vendor_id.is_some() || dev.product_id.is_some() {
out.push(dev);
}
}
if block.contains("\"_items\"") {
collect_items_arrays(block, out);
}
arr = &arr[block.len()..];
}
}
}
fn extract_json_array(s: &str) -> &str {
let mut depth = 1usize;
let mut idx = 0;
for (i, ch) in s.char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
idx = i;
break;
}
}
_ => {}
}
}
&s[..idx]
}
fn extract_json_object(s: &str) -> &str {
let mut depth = 1usize;
let mut idx = 0;
for (i, ch) in s.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
idx = i;
break;
}
}
_ => {}
}
}
&s[..idx]
}
pub fn parse_sp_device_block(block: &str) -> SpUsbDevice {
SpUsbDevice {
vendor_id: json_str_value(block, "vendor_id").and_then(|s| parse_sp_hex_id(&s)),
product_id: json_str_value(block, "product_id").and_then(|s| parse_sp_hex_id(&s)),
serial: json_str_value(block, "serial_num"),
manufacturer: json_str_value(block, "manufacturer"),
product: json_str_value(block, "_name"),
speed: json_str_value(block, "device_speed").map(|s| sp_speed_string(&s)),
}
}
pub fn json_str_value(block: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let pos = block.find(&needle)?;
let after_key = &block[pos + needle.len()..];
let colon = after_key.find(':')?;
let after_colon = after_key[colon + 1..].trim_start();
if let Some(inner) = after_colon.strip_prefix('"') {
let end = inner.find('"')?;
Some(inner[..end].to_string())
} else {
None
}
}
pub fn parse_sp_hex_id(s: &str) -> Option<u16> {
u16::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
}
pub fn sp_speed_string(raw: &str) -> String {
match raw.trim() {
"low_speed" => "Low Speed (1.5 Mbps)".to_string(),
"full_speed" => "Full Speed (12 Mbps)".to_string(),
"high_speed" => "High Speed (480 Mbps)".to_string(),
"super_speed" => "SuperSpeed (5 Gbps)".to_string(),
"super_speed_plus" => "SuperSpeed+ (10 Gbps)".to_string(),
other => other.to_string(),
}
}
pub fn parse_plist_string_array(plist: &str, key: &str) -> Vec<String> {
let key_tag = format!("<key>{key}</key>");
let Some(key_pos) = plist.find(&key_tag) else {
return Vec::new();
};
let after_key = &plist[key_pos + key_tag.len()..];
let Some(array_start) = after_key.find("<array>") else {
return Vec::new();
};
let after_array = &after_key[array_start + "<array>".len()..];
let Some(array_end) = after_array.find("</array>") else {
return Vec::new();
};
let array_content = &after_array[..array_end];
let mut results = Vec::new();
let mut remaining = array_content;
while let Some(s) = remaining.find("<string>") {
remaining = &remaining[s + "<string>".len()..];
if let Some(e) = remaining.find("</string>") {
results.push(remaining[..e].trim().to_string());
remaining = &remaining[e + "</string>".len()..];
} else {
break;
}
}
results
}
pub fn parse_disk_info_plist(plist: &str, _bsd_name: &str) -> Option<MacDiskInfo> {
let mut info = MacDiskInfo::default();
let mut cursor = plist;
while let Some(key_start) = cursor.find("<key>") {
cursor = &cursor[key_start + "<key>".len()..];
let key_end = cursor.find("</key>")?;
let key = cursor[..key_end].trim();
cursor = &cursor[key_end + "</key>".len()..];
let value_start = cursor.find('<').unwrap_or(cursor.len());
let value_section = cursor[value_start..].trim_start();
match key {
"WholeDisk" => {
info.whole_disk = value_section.starts_with("<true/>");
}
"Ejectable" | "Removable" | "RemovableMedia" | "RemovableMediaOrExternalDevice"
if value_section.starts_with("<true/>") =>
{
info.removable = true;
}
"ReadOnly" => {
info.read_only = value_section.starts_with("<true/>");
}
"TotalSize" | "DiskSize" if info.size_bytes == 0 => {
if let Some(v) = extract_integer(value_section) {
info.size_bytes = v;
}
}
"MountPoint" => {
if let Some(v) = extract_string(value_section) {
if !v.is_empty() {
info.mount_point = Some(v);
}
}
}
"USBVendorID" => {
if let Some(v) = extract_integer(value_section) {
info.usb_vendor_id = Some(v as u16);
}
}
"USBProductID" => {
if let Some(v) = extract_integer(value_section) {
info.usb_product_id = Some(v as u16);
}
}
"USBSerialNumber" => {
if let Some(v) = extract_string(value_section) {
if !v.is_empty() {
info.usb_serial = Some(v);
}
}
}
_ => {}
}
cursor = advance_past_value(cursor);
}
if info.size_bytes == 0 {
return None;
}
Some(info)
}
fn advance_past_value(cursor: &str) -> &str {
let s = cursor.trim_start();
if s.starts_with("<true/>") {
return &cursor[cursor.find("<true/>").unwrap() + "<true/>".len()..];
}
if s.starts_with("<false/>") {
return &cursor[cursor.find("<false/>").unwrap() + "<false/>".len()..];
}
let tag_end = match s.find('>') {
Some(p) => p,
None => return "",
};
let tag_name_start = match s.find('<') {
Some(p) => p + 1,
None => return "",
};
if tag_name_start >= tag_end {
return "";
}
let tag_name = &s[tag_name_start..tag_end];
let close_tag = format!("</{tag_name}>");
if let Some(pos) = cursor.find(&close_tag) {
&cursor[pos + close_tag.len()..]
} else {
""
}
}
pub fn extract_integer(s: &str) -> Option<u64> {
for tag in &["<integer>", "<real>"] {
if let Some(start) = s.find(tag) {
let after = &s[start + tag.len()..];
let close = tag.replace('<', "</");
if let Some(end) = after.find(&close) {
let text = after[..end].trim();
let int_part = text.split('.').next().unwrap_or(text);
if let Ok(v) = int_part.parse::<u64>() {
return Some(v);
}
}
}
}
None
}
pub fn extract_string(s: &str) -> Option<String> {
let start = s.find("<string>")? + "<string>".len();
let end = s[start..].find("</string>")?;
Some(s[start..start + end].trim().to_string())
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::macos_parse::*;
use super::*;
pub fn enumerate() -> Vec<DriveInfo> {
let whole_disks = list_external_disks();
if whole_disks.is_empty() {
return Vec::new();
}
let sp_devices = query_system_profiler();
let mut drives = Vec::new();
for bsd_name in &whole_disks {
let Some(info) = disk_info(bsd_name) else {
continue;
};
if !info.whole_disk || !info.removable || info.size_bytes == 0 {
continue;
}
let size_gb = info.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let sp = find_sp_device(
&sp_devices,
info.usb_vendor_id,
info.usb_product_id,
info.usb_serial.as_deref(),
);
let usb_info = UsbInfo {
vendor_id: info.usb_vendor_id.unwrap_or(0),
product_id: info.usb_product_id.unwrap_or(0),
manufacturer: sp.and_then(|d| d.manufacturer.clone()),
product: sp.and_then(|d| d.product.clone()),
serial: info.usb_serial.clone(),
speed: sp.and_then(|d| d.speed.clone()),
};
let device_path = format!("/dev/r{bsd_name}");
let mount_point = info
.mount_point
.clone()
.unwrap_or_else(|| device_path.clone());
let name = build_display_name(&usb_info, bsd_name);
let drive = DriveInfo::with_constraints(
name,
mount_point,
size_gb,
device_path,
false,
info.read_only,
)
.with_usb_info(usb_info);
drives.push(drive);
}
drives
}
fn list_external_disks() -> Vec<String> {
let output = std::process::Command::new("diskutil")
.args(["list", "-plist", "external"])
.output();
let Ok(out) = output else {
eprintln!("[drive_detection] diskutil list failed");
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
parse_plist_string_array(&text, "WholeDisks")
}
fn disk_info(bsd_name: &str) -> Option<MacDiskInfo> {
let output = std::process::Command::new("diskutil")
.args(["info", "-plist", &format!("/dev/{bsd_name}")])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
parse_disk_info_plist(&text, bsd_name)
}
fn query_system_profiler() -> Vec<SpUsbDevice> {
let output = std::process::Command::new("system_profiler")
.args(["SPUSBDataType", "-json"])
.output();
let Ok(out) = output else {
return Vec::new();
};
let json = String::from_utf8_lossy(&out.stdout);
parse_system_profiler_json(&json)
}
}
#[derive(Debug)]
#[allow(dead_code)]
pub(crate) struct WmicDisk {
pub(crate) device_id: String,
pub(crate) size_bytes: u64,
pub(crate) model: String,
pub(crate) serial: String,
pub(crate) vendor_id: Option<u16>,
pub(crate) product_id: Option<u16>,
}
mod windows_parse {
use super::*;
#[allow(dead_code)]
pub fn parse_wmic_csv(csv: &str) -> Vec<WmicDisk> {
let mut lines = csv.lines().map(|l| l.trim()).filter(|l| !l.is_empty());
let header = loop {
match lines.next() {
Some(h) if h.to_lowercase().contains("deviceid") => break h,
Some(_) => continue,
None => return Vec::new(),
}
};
let cols: Vec<&str> = header.split(',').collect();
let idx = |name: &str| -> Option<usize> {
cols.iter()
.position(|c| c.trim().to_lowercase() == name.to_lowercase())
};
let idx_device = idx("DeviceID");
let idx_size = idx("Size");
let idx_model = idx("Model");
let idx_serial = idx("SerialNumber");
let idx_pnp = idx("PNPDeviceID");
let mut disks = Vec::new();
for line in lines {
let fields: Vec<&str> = line.split(',').collect();
let get = |i: Option<usize>| -> &str {
i.and_then(|i| fields.get(i).copied()).unwrap_or("").trim()
};
let device_id = get(idx_device);
if device_id.is_empty() || !device_id.contains("PhysicalDrive") {
continue;
}
let size_bytes = get(idx_size).parse::<u64>().unwrap_or(0);
let (vendor_id, product_id) = parse_vid_pid_from_pnp(get(idx_pnp));
disks.push(WmicDisk {
device_id: device_id.to_string(),
size_bytes,
model: get(idx_model).to_string(),
serial: get(idx_serial).to_string(),
vendor_id,
product_id,
});
}
disks
}
#[allow(dead_code)]
pub fn parse_vid_pid_from_pnp(pnp: &str) -> (Option<u16>, Option<u16>) {
let upper = pnp.to_uppercase();
let vid = upper.find("VID_").and_then(|pos| {
let hex = &upper[pos + 4..];
let end = hex
.find(|c: char| !c.is_ascii_hexdigit())
.unwrap_or(hex.len());
u16::from_str_radix(&hex[..end], 16).ok()
});
let pid = upper.find("PID_").and_then(|pos| {
let hex = &upper[pos + 4..];
let end = hex
.find(|c: char| !c.is_ascii_hexdigit())
.unwrap_or(hex.len());
u16::from_str_radix(&hex[..end], 16).ok()
});
(vid, pid)
}
}
#[cfg(target_os = "windows")]
mod windows {
use super::windows_parse::*;
use super::*;
pub fn enumerate() -> Vec<DriveInfo> {
let wmic_disks = query_wmic_usb_disks();
if wmic_disks.is_empty() {
return Vec::new();
}
let mut drives = Vec::new();
for disk in &wmic_disks {
if disk.size_bytes == 0 {
continue;
}
let size_gb = disk.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let usb_info = UsbInfo {
vendor_id: disk.vendor_id.unwrap_or(0),
product_id: disk.product_id.unwrap_or(0),
manufacturer: None,
product: if disk.model.is_empty() {
None
} else {
Some(disk.model.clone())
},
serial: if disk.serial.is_empty() {
None
} else {
Some(disk.serial.clone())
},
speed: None,
};
let short_name = disk
.device_id
.split('\\')
.next_back()
.unwrap_or(&disk.device_id);
let name = build_display_name(&usb_info, short_name);
let drive = DriveInfo::with_constraints(
name,
disk.device_id.clone(),
size_gb,
disk.device_id.clone(),
false,
false,
)
.with_usb_info(usb_info);
drives.push(drive);
}
drives
}
fn query_wmic_usb_disks() -> Vec<WmicDisk> {
let output = std::process::Command::new("wmic")
.args([
"diskdrive",
"where",
"InterfaceType=\"USB\"",
"get",
"DeviceID,Size,Model,SerialNumber,PNPDeviceID",
"/format:csv",
])
.output();
let Ok(out) = output else {
eprintln!("[drive_detection] wmic query failed");
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
parse_wmic_csv(&text)
}
}
#[cfg(test)]
mod tests {
use super::macos_parse::*;
use super::windows_parse::*;
use super::*;
#[cfg(target_os = "linux")]
use std::collections::HashMap;
#[cfg(target_os = "linux")]
use std::path::Path;
#[cfg(target_os = "linux")]
macro_rules! skip_device_tests {
($( $name:ident : $device:literal => $expected:expr ),+ $(,)?) => {
$(
#[cfg(target_os = "linux")]
#[test]
fn $name() {
assert_eq!(linux::should_skip_device($device), $expected,
"should_skip_device({:?}) should be {}", $device, $expected);
}
)+
};
}
#[cfg(target_os = "linux")]
skip_device_tests! {
test_skip_loop_devices_loop0: "loop0" => true,
test_skip_loop_devices_loop1: "loop1" => true,
test_skip_nvme_devices: "nvme0n1" => true,
test_skip_ram_devices: "ram0" => true,
test_skip_dm_devices: "dm-0" => true,
test_skip_optical_drives: "sr0" => true,
test_skip_empty_name: "" => true,
test_allow_sata_usb_sda: "sda" => false,
test_allow_sata_usb_sdb: "sdb" => false,
test_allow_sata_usb_sdc: "sdc" => false,
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_sysfs_u64_valid() {
let path = std::env::temp_dir().join("fk_sysfs_u64_test.txt");
std::fs::write(&path, "1953525168\n").unwrap();
assert_eq!(linux::read_sysfs_u64(&path), Some(1953525168u64));
let _ = std::fs::remove_file(path);
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_sysfs_u64_invalid() {
let path = std::env::temp_dir().join("fk_sysfs_invalid.txt");
std::fs::write(&path, "not_a_number\n").unwrap();
assert_eq!(linux::read_sysfs_u64(&path), None);
let _ = std::fs::remove_file(path);
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_sysfs_u64_missing_file() {
assert_eq!(
linux::read_sysfs_u64(Path::new("/nonexistent/sysfs/file")),
None
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_mount_point_exact_device() {
let mut mounts = HashMap::new();
mounts.insert("sdb".to_string(), "/media/usb".to_string());
assert_eq!(
linux::find_mount_point("sdb", &mounts),
Some("/media/usb".to_string())
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_mount_point_partition() {
let mut mounts = HashMap::new();
mounts.insert("sdb1".to_string(), "/media/usb".to_string());
assert_eq!(
linux::find_mount_point("sdb", &mounts),
Some("/media/usb".to_string())
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_mount_point_not_found() {
assert_eq!(linux::find_mount_point("sdb", &HashMap::new()), None);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_mount_point_different_device_not_matched() {
let mut mounts = HashMap::new();
mounts.insert("sdc1".to_string(), "/media/other".to_string());
assert_eq!(linux::find_mount_point("sdb", &mounts), None);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_usb_sysfs_dir_stops_at_sys_root() {
assert!(linux::find_usb_sysfs_dir(Path::new("/sys")).is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_usb_sysfs_dir_no_id_vendor_in_tmp() {
let dir = std::env::temp_dir().join("fk_no_id_vendor");
std::fs::create_dir_all(&dir).unwrap();
assert!(linux::find_usb_sysfs_dir(&dir).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_usb_sysfs_dir_finds_id_vendor_in_dir() {
let dir = std::env::temp_dir().join("fk_usb_test_direct");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("idVendor"), "0781\n").unwrap();
assert_eq!(linux::find_usb_sysfs_dir(&dir), Some(dir.clone()));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_usb_sysfs_dir_finds_id_vendor_in_parent() {
let base = std::env::temp_dir().join("fk_usb_test_parent");
let child = base.join("child_block");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(base.join("idVendor"), "0781\n").unwrap();
assert_eq!(linux::find_usb_sysfs_dir(&child), Some(base.clone()));
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_usb_info_from_sysfs_all_fields() {
let dir = std::env::temp_dir().join("fk_usb_info_all");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("idVendor"), "0781\n").unwrap();
std::fs::write(dir.join("idProduct"), "5581\n").unwrap();
std::fs::write(dir.join("manufacturer"), "SanDisk\n").unwrap();
std::fs::write(dir.join("product"), "Ultra\n").unwrap();
std::fs::write(dir.join("serial"), "SN123\n").unwrap();
std::fs::write(dir.join("speed"), "5000\n").unwrap();
let info = linux::read_usb_info_from_sysfs(&dir);
assert_eq!(info.vendor_id, 0x0781);
assert_eq!(info.product_id, 0x5581);
assert_eq!(info.manufacturer.as_deref(), Some("SanDisk"));
assert_eq!(info.product.as_deref(), Some("Ultra"));
assert_eq!(info.serial.as_deref(), Some("SN123"));
assert_eq!(info.speed.as_deref(), Some("SuperSpeed (5 Gbps)"));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_usb_info_from_sysfs_missing_optional_fields() {
let dir = std::env::temp_dir().join("fk_usb_info_minimal");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("idVendor"), "1234\n").unwrap();
std::fs::write(dir.join("idProduct"), "abcd\n").unwrap();
let info = linux::read_usb_info_from_sysfs(&dir);
assert_eq!(info.vendor_id, 0x1234);
assert_eq!(info.product_id, 0xabcd);
assert!(info.manufacturer.is_none());
assert!(info.product.is_none());
assert!(info.serial.is_none());
assert!(info.speed.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_usb_info_from_sysfs_empty_string_fields() {
let dir = std::env::temp_dir().join("fk_usb_info_empty_str");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("idVendor"), "0781\n").unwrap();
std::fs::write(dir.join("idProduct"), "5581\n").unwrap();
std::fs::write(dir.join("manufacturer"), " \n").unwrap();
std::fs::write(dir.join("product"), "\n").unwrap();
let info = linux::read_usb_info_from_sysfs(&dir);
assert!(info.manufacturer.is_none());
assert!(info.product.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn test_sysfs_speed_all_variants() {
assert_eq!(linux::sysfs_speed_string("1.5"), "Low Speed (1.5 Mbps)");
assert_eq!(linux::sysfs_speed_string("12"), "Full Speed (12 Mbps)");
assert_eq!(linux::sysfs_speed_string("480"), "High Speed (480 Mbps)");
assert_eq!(linux::sysfs_speed_string("5000"), "SuperSpeed (5 Gbps)");
assert_eq!(linux::sysfs_speed_string("10000"), "SuperSpeed+ (10 Gbps)");
assert_eq!(linux::sysfs_speed_string("20000"), "SuperSpeed+ (20 Gbps)");
assert_eq!(linux::sysfs_speed_string("999"), "999 Mbps");
}
#[cfg(target_os = "linux")]
#[test]
fn test_sysfs_speed_trims_whitespace() {
assert_eq!(
linux::sysfs_speed_string(" 480 "),
"High Speed (480 Mbps)"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_parse_hex_u16_bare() {
assert_eq!(linux::parse_hex_u16("0781"), Some(0x0781));
}
#[cfg(target_os = "linux")]
#[test]
fn test_parse_hex_u16_with_prefix() {
assert_eq!(linux::parse_hex_u16("0x5581"), Some(0x5581));
}
#[cfg(target_os = "linux")]
#[test]
fn test_parse_hex_u16_overflow() {
assert!(linux::parse_hex_u16("10000").is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn test_parse_hex_u16_invalid() {
assert!(linux::parse_hex_u16("zzzz").is_none());
assert!(linux::parse_hex_u16("").is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_mount_point_p_partition_suffix() {
let mut mounts = HashMap::new();
mounts.insert("sdap1".to_string(), "/media/card".to_string());
assert_eq!(
linux::find_mount_point("sda", &mounts),
Some("/media/card".to_string())
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_mount_point_multiple_partitions_returns_first_match() {
let mut mounts = HashMap::new();
mounts.insert("sdb1".to_string(), "/media/part1".to_string());
let result = linux::find_mount_point("sdb", &mounts);
assert!(result.is_some());
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_proc_mounts_does_not_panic() {
let _ = linux::read_proc_mounts();
}
#[cfg(target_os = "linux")]
#[test]
fn test_read_proc_mounts_parses_dev_entries() {
let path = std::env::temp_dir().join("fk_proc_mounts_test");
std::fs::write(
&path,
"/dev/sdb1 /media/usb vfat rw 0 0\n\
/dev/sda1 / ext4 rw 0 0\n\
tmpfs /tmp tmpfs rw 0 0\n",
)
.unwrap();
let mut map = HashMap::new();
map.insert("sdb1".to_string(), "/media/usb".to_string());
assert_eq!(
linux::find_mount_point("sdb", &map),
Some("/media/usb".to_string())
);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_parse_system_profiler_single_device() {
let json = r#"{
"SPUSBDataType": [
{
"_items": [
{
"_name": "Ultra",
"manufacturer": "SanDisk",
"vendor_id": "0x0781",
"product_id": "0x5581",
"serial_num": "AA01234567890",
"device_speed": "high_speed"
}
]
}
]
}"#;
let devices = parse_system_profiler_json(json);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].vendor_id, Some(0x0781));
assert_eq!(devices[0].product_id, Some(0x5581));
assert_eq!(devices[0].serial.as_deref(), Some("AA01234567890"));
assert_eq!(devices[0].manufacturer.as_deref(), Some("SanDisk"));
assert_eq!(devices[0].product.as_deref(), Some("Ultra"));
assert_eq!(devices[0].speed.as_deref(), Some("High Speed (480 Mbps)"));
}
#[test]
fn test_parse_system_profiler_multiple_devices() {
let json = r#"{
"SPUSBDataType": [
{
"_items": [
{
"_name": "Ultra",
"vendor_id": "0x0781",
"product_id": "0x5581",
"serial_num": "SN001"
},
{
"_name": "DataTraveler",
"vendor_id": "0x0951",
"product_id": "0x1666",
"serial_num": "SN002"
}
]
}
]
}"#;
let devices = parse_system_profiler_json(json);
assert_eq!(devices.len(), 2);
assert_eq!(devices[0].vendor_id, Some(0x0781));
assert_eq!(devices[1].vendor_id, Some(0x0951));
}
#[test]
fn test_parse_system_profiler_empty_items() {
let json = r#"{"SPUSBDataType": [{"_items": []}]}"#;
let devices = parse_system_profiler_json(json);
assert!(devices.is_empty());
}
#[test]
fn test_parse_system_profiler_no_usb_data() {
let json = r#"{"SPUSBDataType": []}"#;
let devices = parse_system_profiler_json(json);
assert!(devices.is_empty());
}
#[test]
fn test_parse_sp_hex_id_with_prefix() {
assert_eq!(parse_sp_hex_id("0x0781"), Some(0x0781));
}
#[test]
fn test_parse_sp_hex_id_bare() {
assert_eq!(parse_sp_hex_id("0781"), Some(0x0781));
}
#[test]
fn test_parse_sp_hex_id_invalid() {
assert!(parse_sp_hex_id("zzzz").is_none());
assert!(parse_sp_hex_id("").is_none());
}
#[test]
fn test_sp_speed_string_all_variants() {
assert_eq!(sp_speed_string("low_speed"), "Low Speed (1.5 Mbps)");
assert_eq!(sp_speed_string("full_speed"), "Full Speed (12 Mbps)");
assert_eq!(sp_speed_string("high_speed"), "High Speed (480 Mbps)");
assert_eq!(sp_speed_string("super_speed"), "SuperSpeed (5 Gbps)");
assert_eq!(sp_speed_string("super_speed_plus"), "SuperSpeed+ (10 Gbps)");
assert_eq!(sp_speed_string("warp_speed"), "warp_speed");
}
#[test]
fn test_find_sp_device_by_vid_pid_serial() {
let devices = vec![
SpUsbDevice {
vendor_id: Some(0x0781),
product_id: Some(0x5581),
serial: Some("SN001".to_string()),
manufacturer: Some("SanDisk".to_string()),
product: Some("Ultra".to_string()),
speed: None,
},
SpUsbDevice {
vendor_id: Some(0x0951),
product_id: Some(0x1666),
serial: Some("SN002".to_string()),
manufacturer: Some("Kingston".to_string()),
product: Some("DataTraveler".to_string()),
speed: None,
},
];
let found = find_sp_device(&devices, Some(0x0781), Some(0x5581), Some("SN001"));
assert!(found.is_some());
assert_eq!(found.unwrap().manufacturer.as_deref(), Some("SanDisk"));
}
#[test]
fn test_find_sp_device_by_vid_pid_only() {
let devices = vec![SpUsbDevice {
vendor_id: Some(0x0781),
product_id: Some(0x5581),
serial: None,
manufacturer: Some("SanDisk".to_string()),
product: Some("Ultra".to_string()),
speed: None,
}];
let found = find_sp_device(&devices, Some(0x0781), Some(0x5581), None);
assert!(found.is_some());
}
#[test]
fn test_find_sp_device_wrong_serial_falls_back_to_vid_pid() {
let devices = vec![SpUsbDevice {
vendor_id: Some(0x0781),
product_id: Some(0x5581),
serial: Some("CORRECT".to_string()),
manufacturer: Some("SanDisk".to_string()),
product: Some("Ultra".to_string()),
speed: None,
}];
let found = find_sp_device(&devices, Some(0x0781), Some(0x5581), Some("WRONG"));
assert!(found.is_some());
}
#[test]
fn test_find_sp_device_no_match() {
let devices = vec![SpUsbDevice {
vendor_id: Some(0x0781),
product_id: Some(0x5581),
serial: None,
manufacturer: None,
product: None,
speed: None,
}];
let found = find_sp_device(&devices, Some(0x1234), Some(0xabcd), None);
assert!(found.is_none());
}
#[test]
fn test_find_sp_device_none_vid_returns_none() {
let devices = vec![SpUsbDevice {
vendor_id: Some(0x0781),
product_id: Some(0x5581),
serial: None,
manufacturer: None,
product: None,
speed: None,
}];
let found = find_sp_device(&devices, None, Some(0x5581), None);
assert!(found.is_none());
}
#[test]
fn test_json_str_value_present() {
let block = r#""manufacturer": "SanDisk", "product_id": "0x5581""#;
assert_eq!(
json_str_value(block, "manufacturer"),
Some("SanDisk".to_string())
);
}
#[test]
fn test_json_str_value_missing_key() {
let block = r#""other": "value""#;
assert!(json_str_value(block, "manufacturer").is_none());
}
#[test]
fn test_json_str_value_non_string_value() {
let block = r#""count": 42"#;
assert!(json_str_value(block, "count").is_none());
}
#[test]
fn test_parse_plist_string_array_whole_disks() {
let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "">
<plist version="1.0">
<dict>
<key>AllDisksAndPartitions</key>
<array/>
<key>WholeDisks</key>
<array>
<string>disk2</string>
<string>disk3</string>
</array>
</dict>
</plist>"#;
let result = parse_plist_string_array(plist, "WholeDisks");
assert_eq!(result, vec!["disk2", "disk3"]);
}
#[test]
fn test_parse_plist_string_array_missing_key() {
let plist = "<plist><dict><key>Other</key><array/></dict></plist>";
assert!(parse_plist_string_array(plist, "WholeDisks").is_empty());
}
#[test]
fn test_parse_disk_info_plist_whole_removable() {
let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>WholeDisk</key><true/>
<key>RemovableMediaOrExternalDevice</key><true/>
<key>ReadOnly</key><false/>
<key>TotalSize</key><integer>32017047552</integer>
<key>MountPoint</key><string>/Volumes/SANDISK</string>
<key>USBVendorID</key><integer>1921</integer>
<key>USBProductID</key><integer>21889</integer>
<key>USBSerialNumber</key><string>AA01234567890</string>
</dict>
</plist>"#;
let info = parse_disk_info_plist(plist, "disk2").unwrap();
assert!(info.whole_disk);
assert!(info.removable);
assert!(!info.read_only);
assert_eq!(info.size_bytes, 32017047552);
assert_eq!(info.mount_point.as_deref(), Some("/Volumes/SANDISK"));
assert_eq!(info.usb_vendor_id, Some(1921));
assert_eq!(info.usb_product_id, Some(21889));
assert_eq!(info.usb_serial.as_deref(), Some("AA01234567890"));
}
#[test]
fn test_parse_disk_info_plist_not_whole_disk() {
let plist = r#"<plist version="1.0"><dict>
<key>WholeDisk</key><false/>
<key>RemovableMediaOrExternalDevice</key><true/>
<key>TotalSize</key><integer>16000000000</integer>
</dict></plist>"#;
let info = parse_disk_info_plist(plist, "disk2s1").unwrap();
assert!(!info.whole_disk);
}
#[test]
fn test_parse_disk_info_plist_zero_size_returns_none() {
let plist = r#"<plist version="1.0"><dict>
<key>WholeDisk</key><true/>
<key>TotalSize</key><integer>0</integer>
</dict></plist>"#;
assert!(parse_disk_info_plist(plist, "disk2").is_none());
}
#[test]
fn test_extract_integer_from_integer_tag() {
assert_eq!(extract_integer("<integer>12345</integer>"), Some(12345));
}
#[test]
fn test_extract_integer_from_real_tag() {
assert_eq!(
extract_integer("<real>32017047552.0</real>"),
Some(32017047552)
);
}
#[test]
fn test_extract_string_tag() {
assert_eq!(
extract_string("<string>/Volumes/USB</string>"),
Some("/Volumes/USB".to_string())
);
}
#[test]
fn test_parse_wmic_csv_basic() {
let csv = "\r\nNode,DeviceID,Model,PNPDeviceID,SerialNumber,Size\r\n\
MYPC,\\\\.\\PhysicalDrive1,SanDisk Ultra,USB\\VID_0781&PID_5581\\AA0123,AA0123,32017047552\r\n";
let disks = parse_wmic_csv(csv);
assert_eq!(disks.len(), 1);
assert!(disks[0].device_id.contains("PhysicalDrive1"));
assert_eq!(disks[0].size_bytes, 32017047552);
assert_eq!(disks[0].serial, "AA0123");
assert_eq!(disks[0].vendor_id, Some(0x0781));
assert_eq!(disks[0].product_id, Some(0x5581));
}
#[test]
fn test_parse_wmic_csv_multiple_disks() {
let csv = "Node,DeviceID,Model,PNPDeviceID,SerialNumber,Size\r\n\
PC,\\\\.\\PhysicalDrive1,SanDisk Ultra,USB\\VID_0781&PID_5581\\SN1,SN1,32017047552\r\n\
PC,\\\\.\\PhysicalDrive2,Kingston DT,USB\\VID_0951&PID_1666\\SN2,SN2,16000000000\r\n";
let disks = parse_wmic_csv(csv);
assert_eq!(disks.len(), 2);
assert_eq!(disks[0].vendor_id, Some(0x0781));
assert_eq!(disks[1].vendor_id, Some(0x0951));
}
#[test]
fn test_parse_wmic_csv_zero_size_included() {
let csv = "Node,DeviceID,Model,PNPDeviceID,SerialNumber,Size\r\n\
PC,\\\\.\\PhysicalDrive1,Unknown,USB\\VID_1234&PID_5678\\SN,,0\r\n";
let disks = parse_wmic_csv(csv);
assert_eq!(disks.len(), 1);
assert_eq!(disks[0].size_bytes, 0);
}
#[test]
fn test_parse_wmic_csv_empty() {
assert!(parse_wmic_csv("").is_empty());
}
#[test]
fn test_parse_wmic_csv_header_only() {
let csv = "Node,DeviceID,Model,PNPDeviceID,SerialNumber,Size\r\n";
assert!(parse_wmic_csv(csv).is_empty());
}
#[test]
fn test_parse_wmic_csv_skips_non_physical() {
let csv = "Node,DeviceID,Model,PNPDeviceID,SerialNumber,Size\r\n\
MYPC,\\\\.\\CDROM0,Some CD Drive,,,\r\n";
assert!(parse_wmic_csv(csv).is_empty());
}
#[test]
fn test_parse_wmic_csv_missing_pnp_column_still_parses() {
let csv = "Node,DeviceID,Model,SerialNumber,Size\r\n\
PC,\\\\.\\PhysicalDrive1,SanDisk,SN1,32017047552\r\n";
let disks = parse_wmic_csv(csv);
assert_eq!(disks.len(), 1);
assert_eq!(disks[0].vendor_id, None);
assert_eq!(disks[0].product_id, None);
}
#[test]
fn test_parse_vid_pid_from_pnp_standard() {
let (vid, pid) = parse_vid_pid_from_pnp("USB\\VID_0781&PID_5581\\AA01234567890");
assert_eq!(vid, Some(0x0781));
assert_eq!(pid, Some(0x5581));
}
#[test]
fn test_parse_vid_pid_from_pnp_lowercase() {
let (vid, pid) = parse_vid_pid_from_pnp("usb\\vid_0781&pid_5581\\SN");
assert_eq!(vid, Some(0x0781));
assert_eq!(pid, Some(0x5581));
}
#[test]
fn test_parse_vid_pid_from_pnp_empty() {
let (vid, pid) = parse_vid_pid_from_pnp("");
assert_eq!(vid, None);
assert_eq!(pid, None);
}
#[test]
fn test_parse_vid_pid_from_pnp_no_pid() {
let (vid, pid) = parse_vid_pid_from_pnp("USB\\VID_0781\\SN");
assert_eq!(vid, Some(0x0781));
assert_eq!(pid, None);
}
#[test]
fn test_parse_vid_pid_from_pnp_no_vid() {
let (vid, pid) = parse_vid_pid_from_pnp("USB\\PID_5581\\SN");
assert_eq!(vid, None);
assert_eq!(pid, Some(0x5581));
}
#[test]
fn test_parse_vid_pid_from_pnp_non_usb_path() {
let (vid, pid) = parse_vid_pid_from_pnp("SCSI\\DISK&VEN_WDC&PROD_WD10EZEX");
assert_eq!(vid, None);
assert_eq!(pid, None);
}
#[test]
fn test_parse_vid_pid_from_pnp_ffff_values() {
let (vid, pid) = parse_vid_pid_from_pnp("USB\\VID_FFFF&PID_FFFF\\SN");
assert_eq!(vid, Some(0xFFFF));
assert_eq!(pid, Some(0xFFFF));
}
#[test]
fn test_build_display_name_with_label() {
let info = crate::domain::drive_info::UsbInfo {
vendor_id: 0x0781,
product_id: 0x5581,
manufacturer: Some("SanDisk".into()),
product: Some("Ultra".into()),
serial: None,
speed: None,
};
assert_eq!(build_display_name(&info, "sdb"), "SanDisk Ultra (sdb)");
}
#[test]
fn test_build_display_name_vid_pid_fallback() {
let info = crate::domain::drive_info::UsbInfo {
vendor_id: 0x1234,
product_id: 0xabcd,
manufacturer: None,
product: None,
serial: None,
speed: None,
};
assert_eq!(build_display_name(&info, "sdb"), "sdb");
}
#[test]
fn test_build_display_name_product_only() {
let info = crate::domain::drive_info::UsbInfo {
vendor_id: 0x0781,
product_id: 0x5581,
manufacturer: None,
product: Some("Ultra".into()),
serial: None,
speed: None,
};
assert_eq!(build_display_name(&info, "sdb"), "Ultra (sdb)");
}
#[test]
fn test_load_drives_sync_returns_vec() {
let drives = load_drives_sync();
let _ = drives;
}
}