Skip to main content

retch_sysinfo/
disk.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Physical disk detection (model, size, type) and logical disk space reporting.
5
6/// Returns formatted disk space strings for real mounted filesystems.
7///
8/// Skips pseudo-filesystems unconditionally. Skips `fuse.*` mounts unless
9/// `include_fuse` is true — FUSE mounts can block indefinitely on `statvfs`
10/// (e.g. cryfs/EncFS vaults), so they are only enabled in `--full` mode.
11///
12/// On Linux, reads /proc/mounts and calls statvfs ourselves so we can filter
13/// before the blocking call. On other platforms, delegates to sysinfo::Disks.
14pub 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/// Filesystem types that are virtual/pseudo and should never appear in disk output.
28#[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    // fuse.* covers gvfsd-fuse, cryfs, gocryptfs, encfs, etc.
62    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        // Deduplicate bind mounts / multiple mounts of the same device.
88        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        // Skip partitions, virtual, and loop devices
160        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        // Skip partitions (they have a "partition" file)
172        if dev_path.join("partition").exists() {
173            continue;
174        }
175
176        // Skip devices with no queue (not a real block device)
177        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        // Size in 512-byte sectors
186        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    // `diskutil list -plist` lists all disks; parse the XML property list.
226    // We only want whole disks (not partitions), so we look at top-level entries.
227    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    // Use simple text parsing of the plist XML to avoid a plist dependency.
239    let text = String::from_utf8_lossy(&out.stdout);
240
241    // Parse the WholeDisks array — macOS pre-filters this to whole-disk identifiers
242    // (e.g. "disk0", "disk1"), excluding partitions like "disk0s1".
243    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/// Parses a `diskutil info -plist` XML text into a formatted disk label string.
289/// Returns `None` for virtual disks or unparseable output.
290#[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 gives the clean model string (e.g. "APPLE SSD AP1024Z");
314                // IORegistryEntryName appends " Media" and is used only as a fallback.
315                "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    // Skip APFS synthesized and other virtual disk objects
344    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/// Strips a trailing size token (e.g. "1024GB", "512GB", "2TB") from a model string.
369/// Many NVMe vendors embed the capacity in the model name; we compute it separately.
370#[cfg(target_os = "linux")]
371fn strip_embedded_size(model: &str) -> &str {
372    let bytes = model.as_bytes();
373    // Walk backwards over digits, then a unit suffix (GB/TB/MB), then optional space
374    let mut i = bytes.len();
375    // Strip trailing whitespace
376    while i > 0 && bytes[i - 1] == b' ' {
377        i -= 1;
378    }
379    // Must end with "GB" or "TB" or "MB"
380    if i >= 2 {
381        let suffix = &bytes[i - 2..i];
382        if matches!(suffix, b"GB" | b"TB" | b"MB") {
383            i -= 2;
384            // Strip the digits before the unit
385            let digits_end = i;
386            while i > 0 && bytes[i - 1].is_ascii_digit() {
387                i -= 1;
388            }
389            if i < digits_end {
390                // Strip one optional space between model name and size token
391                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/// Parses `Get-PhysicalDisk | ConvertTo-Csv` output into formatted disk labels.
431///
432/// Expected CSV header: `"FriendlyName","Size","MediaType","BusType"`
433#[cfg(target_os = "windows")]
434pub fn parse_get_physical_disk(csv: &str) -> Vec<String> {
435    let mut lines = csv.lines();
436    lines.next(); // skip header
437    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            // "Unspecified" with no NVMe bus — treat as SSD (e.g. eMMC, SD)
456            "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/// Splits one PowerShell `ConvertTo-Csv` line into unquoted fields.
471///
472/// `ConvertTo-Csv` wraps every value in double quotes: `"val1","val2",...`
473#[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)); // fusectl is always skipped
493        assert!(is_skip_fs("fusectl", true)); // even with include_fuse
494    }
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"); // Crucial model — no unit suffix
533        assert_eq!(
534            strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
535            "SAMSUNG MZQL23T8HCLS"
536        ); // no unit
537        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        // Matches real output from an Apple M-series Mac (disk0).
562        // IORegistryEntryName comes before MediaName in the plist; MediaName must win.
563        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        // Some SATA SSDs report MediaType as "Unspecified" but BusType as "SATA"
652        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}