1pub fn detect_logical_disks(include_fuse: bool) -> Vec<(String, u64, u64, String)> {
15 #[cfg(target_os = "linux")]
16 {
17 detect_logical_linux(include_fuse)
18 }
19
20 #[cfg(not(target_os = "linux"))]
21 {
22 let _ = include_fuse;
23 detect_logical_sysinfo()
24 }
25}
26
27#[cfg(target_os = "linux")]
29fn is_skip_fs(fs_type: &str, include_fuse: bool) -> bool {
30 const SKIP: &[&str] = &[
31 "sysfs",
32 "proc",
33 "devtmpfs",
34 "tmpfs",
35 "devpts",
36 "cgroup",
37 "cgroup2",
38 "pstore",
39 "bpf",
40 "tracefs",
41 "debugfs",
42 "securityfs",
43 "hugetlbfs",
44 "mqueue",
45 "fusectl",
46 "rpc_pipefs",
47 "configfs",
48 "autofs",
49 "efivarfs",
50 "binfmt_misc",
51 "squashfs",
52 "overlay",
53 "ramfs",
54 "rootfs",
55 "nsfs",
56 "pipefs",
57 "sockfs",
58 "anon_inodefs",
59 "cpuset",
60 ];
61 SKIP.contains(&fs_type) || (fs_type.starts_with("fuse.") && !include_fuse)
63}
64
65#[cfg(target_os = "linux")]
66fn detect_logical_linux(include_fuse: bool) -> Vec<(String, u64, u64, String)> {
67 use std::collections::HashSet;
68 use std::ffi::CString;
69
70 let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
71 let mut results = Vec::new();
72 let mut seen_devs: HashSet<String> = HashSet::new();
73
74 for line in mounts.lines() {
75 let parts: Vec<&str> = line.splitn(4, ' ').collect();
76 if parts.len() < 3 {
77 continue;
78 }
79 let device = parts[0];
80 let mount_point = parts[1];
81 let fs_type = parts[2];
82
83 if is_skip_fs(fs_type, include_fuse) {
84 continue;
85 }
86
87 if device.starts_with('/') && !seen_devs.insert(device.to_string()) {
89 continue;
90 }
91
92 let Ok(mp_c) = CString::new(mount_point) else {
93 continue;
94 };
95
96 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
97 if unsafe { libc::statvfs(mp_c.as_ptr(), &mut stat) } != 0 {
98 continue;
99 }
100
101 let total = (stat.f_blocks as u64).saturating_mul(stat.f_frsize as u64);
102 let avail = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
103
104 if total == 0 {
105 continue;
106 }
107
108 results.push((mount_point.to_string(), total, avail, fs_type.to_string()));
109 }
110
111 results
112}
113
114#[cfg(not(target_os = "linux"))]
115fn detect_logical_sysinfo() -> Vec<(String, u64, u64, String)> {
116 use sysinfo::Disks;
117 Disks::new_with_refreshed_list()
118 .iter()
119 .filter(|d| d.total_space() > 0)
120 .map(|d| {
121 (
122 d.mount_point().to_string_lossy().to_string(),
123 d.total_space(),
124 d.available_space(),
125 d.file_system().to_string_lossy().to_string(),
126 )
127 })
128 .collect()
129}
130
131pub fn detect_physical_disks() -> Vec<String> {
132 #[cfg(target_os = "linux")]
133 return detect_linux();
134
135 #[cfg(target_os = "macos")]
136 return detect_macos();
137
138 #[cfg(target_os = "windows")]
139 return detect_windows();
140
141 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
142 return Vec::new();
143}
144
145#[cfg(target_os = "linux")]
146fn detect_linux() -> Vec<String> {
147 use std::fs;
148
149 let Ok(entries) = fs::read_dir("/sys/class/block") else {
150 return Vec::new();
151 };
152
153 let mut disks = Vec::new();
154
155 for entry in entries.flatten() {
156 let name = entry.file_name();
157 let name = name.to_string_lossy();
158
159 if name.starts_with("loop")
161 || name.starts_with("ram")
162 || name.starts_with("zram")
163 || name.starts_with("dm-")
164 || name.starts_with("md")
165 {
166 continue;
167 }
168
169 let dev_path = entry.path();
170
171 if dev_path.join("partition").exists() {
173 continue;
174 }
175
176 if !dev_path.join("queue").exists() {
178 continue;
179 }
180
181 let model = fs::read_to_string(dev_path.join("device/model"))
182 .map(|s| strip_embedded_size(s.trim()).to_string())
183 .unwrap_or_default();
184
185 let size_bytes = fs::read_to_string(dev_path.join("size"))
187 .ok()
188 .and_then(|s| s.trim().parse::<u64>().ok())
189 .map(|sectors| sectors * 512);
190
191 let rotational = fs::read_to_string(dev_path.join("queue/rotational"))
192 .map(|s| s.trim() == "1")
193 .unwrap_or(false);
194
195 let is_nvme = name.starts_with("nvme");
196
197 let kind = if is_nvme {
198 "NVMe SSD"
199 } else if rotational {
200 "HDD"
201 } else {
202 "SSD"
203 };
204
205 let size_str = size_bytes.map(format_size).unwrap_or_default();
206
207 let label = if model.is_empty() {
208 format!("{} [{}]", size_str, kind)
209 } else {
210 format!("{} {} [{}]", model.trim(), size_str, kind)
211 };
212
213 let label = label.trim().to_string();
214 if !label.is_empty() {
215 disks.push(label);
216 }
217 }
218
219 disks.sort();
220 disks
221}
222
223#[cfg(target_os = "macos")]
224fn detect_macos() -> Vec<String> {
225 let output = std::process::Command::new("diskutil")
228 .args(["list", "-plist"])
229 .output();
230
231 let Ok(out) = output else {
232 return Vec::new();
233 };
234 if !out.status.success() {
235 return Vec::new();
236 }
237
238 let text = String::from_utf8_lossy(&out.stdout);
240
241 let mut disk_ids: Vec<String> = Vec::new();
244 let mut in_whole_disks = false;
245 for line in text.lines() {
246 let trimmed = line.trim();
247 if trimmed == "<key>WholeDisks</key>" {
248 in_whole_disks = true;
249 continue;
250 }
251 if in_whole_disks {
252 if trimmed == "</array>" {
253 break;
254 }
255 if let Some(inner) = trimmed
256 .strip_prefix("<string>")
257 .and_then(|s| s.strip_suffix("</string>"))
258 {
259 disk_ids.push(inner.to_string());
260 }
261 }
262 }
263
264 let mut disks = Vec::new();
265 for id in disk_ids {
266 if let Some(entry) = diskutil_info(&id) {
267 disks.push(entry);
268 }
269 }
270 disks
271}
272
273#[cfg(target_os = "macos")]
274fn diskutil_info(disk_id: &str) -> Option<String> {
275 let output = std::process::Command::new("diskutil")
276 .args(["info", "-plist", disk_id])
277 .output()
278 .ok()?;
279
280 if !output.status.success() {
281 return None;
282 }
283
284 let text = String::from_utf8_lossy(&output.stdout);
285 parse_diskutil_info_plist(&text)
286}
287
288#[cfg(target_os = "macos")]
291pub fn parse_diskutil_info_plist(text: &str) -> Option<String> {
292 let mut model = String::new();
293 let mut size_bytes: Option<u64> = None;
294 let mut is_ssd = false;
295 let mut protocol = String::new();
296 let mut virtual_or_physical = String::new();
297
298 let mut last_key = String::new();
299 for line in text.lines() {
300 let trimmed = line.trim();
301 if let Some(key) = trimmed
302 .strip_prefix("<key>")
303 .and_then(|s| s.strip_suffix("</key>"))
304 {
305 last_key = key.to_string();
306 continue;
307 }
308 if let Some(val) = trimmed
309 .strip_prefix("<string>")
310 .and_then(|s| s.strip_suffix("</string>"))
311 {
312 match last_key.as_str() {
313 "MediaName" => {
316 if !val.is_empty() {
317 model = val.to_string();
318 }
319 }
320 "IORegistryEntryName" => {
321 if model.is_empty() && !val.is_empty() {
322 model = val.to_string();
323 }
324 }
325 "BusProtocol" => protocol = val.to_string(),
326 "VirtualOrPhysical" => virtual_or_physical = val.to_string(),
327 _ => {}
328 }
329 }
330 if let Some(val) = trimmed
331 .strip_prefix("<integer>")
332 .and_then(|s| s.strip_suffix("</integer>"))
333 {
334 if last_key == "TotalSize" {
335 size_bytes = val.parse().ok();
336 }
337 }
338 if trimmed == "<true/>" && last_key == "SolidState" {
339 is_ssd = true;
340 }
341 }
342
343 if virtual_or_physical == "Virtual" {
345 return None;
346 }
347
348 let kind =
349 if protocol.to_lowercase().contains("pcie") || protocol.to_lowercase().contains("nvme") {
350 "NVMe SSD"
351 } else if is_ssd {
352 "SSD"
353 } else {
354 "HDD"
355 };
356
357 let size_str = size_bytes.map(format_size).unwrap_or_default();
358
359 let label = if model.is_empty() {
360 format!("{} [{}]", size_str, kind)
361 } else {
362 format!("{} {} [{}]", model.trim(), size_str, kind)
363 };
364
365 Some(label.trim().to_string())
366}
367
368#[cfg(target_os = "linux")]
371fn strip_embedded_size(model: &str) -> &str {
372 let bytes = model.as_bytes();
373 let mut i = bytes.len();
375 while i > 0 && bytes[i - 1] == b' ' {
377 i -= 1;
378 }
379 if i >= 2 {
381 let suffix = &bytes[i - 2..i];
382 if matches!(suffix, b"GB" | b"TB" | b"MB") {
383 i -= 2;
384 let digits_end = i;
386 while i > 0 && bytes[i - 1].is_ascii_digit() {
387 i -= 1;
388 }
389 if i < digits_end {
390 if i > 0 && bytes[i - 1] == b' ' {
392 i -= 1;
393 }
394 return model[..i].trim_end();
395 }
396 }
397 }
398 model
399}
400
401#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
402fn format_size(bytes: u64) -> String {
403 const TB: u64 = 1_000_000_000_000;
404 const GB: u64 = 1_000_000_000;
405 if bytes >= TB {
406 format!("{:.1} TB", bytes as f64 / TB as f64)
407 } else {
408 format!("{:.0} GB", bytes as f64 / GB as f64)
409 }
410}
411
412#[cfg(target_os = "windows")]
413fn detect_windows() -> Vec<String> {
414 let cmd = "Get-PhysicalDisk | \
415 Select-Object FriendlyName,Size,MediaType,BusType | \
416 ConvertTo-Csv -NoTypeInformation";
417 let Ok(output) = std::process::Command::new("powershell")
418 .args(["-NoProfile", "-NonInteractive", "-Command", cmd])
419 .output()
420 else {
421 return Vec::new();
422 };
423 if !output.status.success() {
424 return Vec::new();
425 }
426 let text = String::from_utf8_lossy(&output.stdout);
427 parse_get_physical_disk(&text)
428}
429
430#[cfg(target_os = "windows")]
434pub fn parse_get_physical_disk(csv: &str) -> Vec<String> {
435 let mut lines = csv.lines();
436 lines.next(); let mut disks = Vec::new();
438 for line in lines {
439 let fields = unquoted_csv_fields(line);
440 if fields.len() < 4 {
441 continue;
442 }
443 let name = fields[0].trim();
444 let size_bytes: Option<u64> = fields[1].trim().parse().ok();
445 let media_type = fields[2].trim();
446 let bus_type = fields[3].trim();
447
448 let kind = if bus_type.eq_ignore_ascii_case("NVMe") {
449 "NVMe SSD"
450 } else if media_type.eq_ignore_ascii_case("SSD") {
451 "SSD"
452 } else if media_type.eq_ignore_ascii_case("HDD") {
453 "HDD"
454 } else {
455 "SSD"
457 };
458
459 let size_str = size_bytes.map(format_size).unwrap_or_default();
460 let label = if name.is_empty() {
461 format!("{} [{}]", size_str, kind)
462 } else {
463 format!("{} {} [{}]", name, size_str, kind)
464 };
465 disks.push(label.trim().to_string());
466 }
467 disks
468}
469
470#[cfg(target_os = "windows")]
474fn unquoted_csv_fields(line: &str) -> Vec<String> {
475 let line = line.trim().trim_matches('"');
476 line.split("\",\"").map(|s| s.to_string()).collect()
477}
478
479#[cfg(test)]
480mod tests {
481 #[cfg(any(target_os = "linux", target_os = "macos"))]
482 use super::format_size;
483 #[cfg(target_os = "linux")]
484 use super::{is_skip_fs, strip_embedded_size};
485
486 #[cfg(target_os = "linux")]
487 #[test]
488 fn test_is_skip_fs_pseudo() {
489 assert!(is_skip_fs("sysfs", false));
490 assert!(is_skip_fs("proc", false));
491 assert!(is_skip_fs("tmpfs", false));
492 assert!(is_skip_fs("fusectl", false)); assert!(is_skip_fs("fusectl", true)); }
495
496 #[cfg(target_os = "linux")]
497 #[test]
498 fn test_is_skip_fs_fuse_excluded_by_default() {
499 assert!(is_skip_fs("fuse.gvfsd-fuse", false));
500 assert!(is_skip_fs("fuse.sshfs", false));
501 assert!(is_skip_fs("fuse.cryfs", false));
502 }
503
504 #[cfg(target_os = "linux")]
505 #[test]
506 fn test_is_skip_fs_fuse_included_in_full() {
507 assert!(!is_skip_fs("fuse.gvfsd-fuse", true));
508 assert!(!is_skip_fs("fuse.sshfs", true));
509 assert!(!is_skip_fs("fuse.cryfs", true));
510 }
511
512 #[cfg(target_os = "linux")]
513 #[test]
514 fn test_is_skip_fs_real_fs() {
515 assert!(!is_skip_fs("ext4", false));
516 assert!(!is_skip_fs("btrfs", false));
517 assert!(!is_skip_fs("vfat", false));
518 }
519
520 #[cfg(target_os = "linux")]
521 #[test]
522 fn test_strip_embedded_size() {
523 assert_eq!(
524 strip_embedded_size("BC901 NVMe SK hynix 1024GB"),
525 "BC901 NVMe SK hynix"
526 );
527 assert_eq!(
528 strip_embedded_size("Samsung SSD 970 EVO 500GB"),
529 "Samsung SSD 970 EVO"
530 );
531 assert_eq!(strip_embedded_size("WD Blue 2TB"), "WD Blue");
532 assert_eq!(strip_embedded_size("CT500MX500SSD1"), "CT500MX500SSD1"); assert_eq!(
534 strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
535 "SAMSUNG MZQL23T8HCLS"
536 ); assert_eq!(strip_embedded_size("Some Drive 256GB"), "Some Drive");
538 }
539
540 #[cfg(any(target_os = "linux", target_os = "macos"))]
541 #[test]
542 fn test_format_size_gb() {
543 assert_eq!(format_size(512_110_190_592), "512 GB");
544 }
545
546 #[cfg(any(target_os = "linux", target_os = "macos"))]
547 #[test]
548 fn test_format_size_tb() {
549 assert_eq!(format_size(1_000_204_886_016), "1.0 TB");
550 }
551
552 #[cfg(any(target_os = "linux", target_os = "macos"))]
553 #[test]
554 fn test_format_size_2tb() {
555 assert_eq!(format_size(2_000_398_934_016), "2.0 TB");
556 }
557
558 #[cfg(target_os = "macos")]
559 #[test]
560 fn test_parse_diskutil_info_plist_apple_silicon() {
561 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
564<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
565<plist version="1.0">
566<dict>
567 <key>BusProtocol</key>
568 <string>Apple Fabric</string>
569 <key>IORegistryEntryName</key>
570 <string>APPLE SSD AP1024Z Media</string>
571 <key>MediaName</key>
572 <string>APPLE SSD AP1024Z</string>
573 <key>SolidState</key>
574 <true/>
575 <key>TotalSize</key>
576 <integer>1000555581440</integer>
577 <key>VirtualOrPhysical</key>
578 <string>Unknown</string>
579</dict>
580</plist>"#;
581 let result = super::parse_diskutil_info_plist(plist);
582 assert_eq!(result, Some("APPLE SSD AP1024Z 1.0 TB [SSD]".to_string()));
583 }
584
585 #[cfg(target_os = "macos")]
586 #[test]
587 fn test_parse_diskutil_info_plist_nvme() {
588 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
589<plist version="1.0">
590<dict>
591 <key>BusProtocol</key>
592 <string>PCIe</string>
593 <key>MediaName</key>
594 <string>Samsung SSD 990 Pro</string>
595 <key>SolidState</key>
596 <true/>
597 <key>TotalSize</key>
598 <integer>2000398934016</integer>
599 <key>VirtualOrPhysical</key>
600 <string>Physical</string>
601</dict>
602</plist>"#;
603 let result = super::parse_diskutil_info_plist(plist);
604 assert_eq!(
605 result,
606 Some("Samsung SSD 990 Pro 2.0 TB [NVMe SSD]".to_string())
607 );
608 }
609
610 #[cfg(target_os = "macos")]
611 #[test]
612 fn test_parse_diskutil_info_plist_virtual_skipped() {
613 let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
614<plist version="1.0">
615<dict>
616 <key>MediaName</key>
617 <string>APFS Container Disk</string>
618 <key>TotalSize</key>
619 <integer>500000000000</integer>
620 <key>VirtualOrPhysical</key>
621 <string>Virtual</string>
622</dict>
623</plist>"#;
624 let result = super::parse_diskutil_info_plist(plist);
625 assert_eq!(result, None);
626 }
627
628 #[cfg(target_os = "windows")]
629 #[test]
630 fn test_parse_get_physical_disk_nvme() {
631 let csv = r#""FriendlyName","Size","MediaType","BusType"
632"Samsung SSD 980 Pro 1TB","1000204886016","SSD","NVMe"
633"#;
634 let disks = super::parse_get_physical_disk(csv);
635 assert_eq!(disks, vec!["Samsung SSD 980 Pro 1TB 1.0 TB [NVMe SSD]"]);
636 }
637
638 #[cfg(target_os = "windows")]
639 #[test]
640 fn test_parse_get_physical_disk_hdd() {
641 let csv = r#""FriendlyName","Size","MediaType","BusType"
642"WD Blue 2TB","2000398934016","HDD","SATA"
643"#;
644 let disks = super::parse_get_physical_disk(csv);
645 assert_eq!(disks, vec!["WD Blue 2TB 2.0 TB [HDD]"]);
646 }
647
648 #[cfg(target_os = "windows")]
649 #[test]
650 fn test_parse_get_physical_disk_unspecified_sata_ssd() {
651 let csv = r#""FriendlyName","Size","MediaType","BusType"
653"Crucial CT500MX500SSD1","500107862016","Unspecified","SATA"
654"#;
655 let disks = super::parse_get_physical_disk(csv);
656 assert_eq!(disks, vec!["Crucial CT500MX500SSD1 500 GB [SSD]"]);
657 }
658
659 #[cfg(target_os = "windows")]
660 #[test]
661 fn test_parse_get_physical_disk_multi() {
662 let csv = r#""FriendlyName","Size","MediaType","BusType"
663"Samsung SSD 980 Pro","1000204886016","SSD","NVMe"
664"WD Blue","2000398934016","HDD","SATA"
665"#;
666 let disks = super::parse_get_physical_disk(csv);
667 assert_eq!(disks.len(), 2);
668 assert_eq!(disks[0], "Samsung SSD 980 Pro 1.0 TB [NVMe SSD]");
669 assert_eq!(disks[1], "WD Blue 2.0 TB [HDD]");
670 }
671}