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, skipping
7/// pseudo-filesystems and FUSE mounts that may block indefinitely on statvfs.
8///
9/// On Linux, reads /proc/mounts and calls statvfs ourselves so we can filter
10/// before the blocking call. On other platforms, delegates to sysinfo::Disks.
11pub fn detect_logical_disks() -> Vec<(String, u64, u64, String)> {
12    #[cfg(target_os = "linux")]
13    return detect_logical_linux();
14
15    #[cfg(not(target_os = "linux"))]
16    return detect_logical_sysinfo();
17}
18
19/// Filesystem types that are virtual/pseudo and should never appear in disk output.
20#[cfg(target_os = "linux")]
21fn is_skip_fs(fs_type: &str) -> bool {
22    const SKIP: &[&str] = &[
23        "sysfs",
24        "proc",
25        "devtmpfs",
26        "tmpfs",
27        "devpts",
28        "cgroup",
29        "cgroup2",
30        "pstore",
31        "bpf",
32        "tracefs",
33        "debugfs",
34        "securityfs",
35        "hugetlbfs",
36        "mqueue",
37        "fusectl",
38        "rpc_pipefs",
39        "configfs",
40        "autofs",
41        "efivarfs",
42        "binfmt_misc",
43        "squashfs",
44        "overlay",
45        "ramfs",
46        "rootfs",
47        "nsfs",
48        "pipefs",
49        "sockfs",
50        "anon_inodefs",
51        "cpuset",
52    ];
53    // fuse.* covers gvfsd-fuse, cryfs, gocryptfs, encfs, etc.
54    SKIP.contains(&fs_type) || fs_type.starts_with("fuse.")
55}
56
57#[cfg(target_os = "linux")]
58fn detect_logical_linux() -> Vec<(String, u64, u64, String)> {
59    use std::collections::HashSet;
60    use std::ffi::CString;
61
62    let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
63    let mut results = Vec::new();
64    let mut seen_devs: HashSet<String> = HashSet::new();
65
66    for line in mounts.lines() {
67        let parts: Vec<&str> = line.splitn(4, ' ').collect();
68        if parts.len() < 3 {
69            continue;
70        }
71        let device = parts[0];
72        let mount_point = parts[1];
73        let fs_type = parts[2];
74
75        if is_skip_fs(fs_type) {
76            continue;
77        }
78
79        // Deduplicate bind mounts / multiple mounts of the same device.
80        if device.starts_with('/') && !seen_devs.insert(device.to_string()) {
81            continue;
82        }
83
84        let Ok(mp_c) = CString::new(mount_point) else {
85            continue;
86        };
87
88        let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
89        if unsafe { libc::statvfs(mp_c.as_ptr(), &mut stat) } != 0 {
90            continue;
91        }
92
93        let total = (stat.f_blocks as u64).saturating_mul(stat.f_frsize as u64);
94        let avail = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
95
96        if total == 0 {
97            continue;
98        }
99
100        results.push((mount_point.to_string(), total, avail, fs_type.to_string()));
101    }
102
103    results
104}
105
106#[cfg(not(target_os = "linux"))]
107fn detect_logical_sysinfo() -> Vec<(String, u64, u64, String)> {
108    use sysinfo::Disks;
109    Disks::new_with_refreshed_list()
110        .iter()
111        .filter(|d| d.total_space() > 0)
112        .map(|d| {
113            (
114                d.mount_point().to_string_lossy().to_string(),
115                d.total_space(),
116                d.available_space(),
117                d.file_system().to_string_lossy().to_string(),
118            )
119        })
120        .collect()
121}
122
123pub fn detect_physical_disks() -> Vec<String> {
124    #[cfg(target_os = "linux")]
125    return detect_linux();
126
127    #[cfg(target_os = "macos")]
128    return detect_macos();
129
130    #[cfg(target_os = "windows")]
131    return detect_windows();
132
133    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
134    return Vec::new();
135}
136
137#[cfg(target_os = "linux")]
138fn detect_linux() -> Vec<String> {
139    use std::fs;
140
141    let Ok(entries) = fs::read_dir("/sys/class/block") else {
142        return Vec::new();
143    };
144
145    let mut disks = Vec::new();
146
147    for entry in entries.flatten() {
148        let name = entry.file_name();
149        let name = name.to_string_lossy();
150
151        // Skip partitions, virtual, and loop devices
152        if name.starts_with("loop")
153            || name.starts_with("ram")
154            || name.starts_with("zram")
155            || name.starts_with("dm-")
156            || name.starts_with("md")
157        {
158            continue;
159        }
160
161        let dev_path = entry.path();
162
163        // Skip partitions (they have a "partition" file)
164        if dev_path.join("partition").exists() {
165            continue;
166        }
167
168        // Skip devices with no queue (not a real block device)
169        if !dev_path.join("queue").exists() {
170            continue;
171        }
172
173        let model = fs::read_to_string(dev_path.join("device/model"))
174            .map(|s| strip_embedded_size(s.trim()).to_string())
175            .unwrap_or_default();
176
177        // Size in 512-byte sectors
178        let size_bytes = fs::read_to_string(dev_path.join("size"))
179            .ok()
180            .and_then(|s| s.trim().parse::<u64>().ok())
181            .map(|sectors| sectors * 512);
182
183        let rotational = fs::read_to_string(dev_path.join("queue/rotational"))
184            .map(|s| s.trim() == "1")
185            .unwrap_or(false);
186
187        let is_nvme = name.starts_with("nvme");
188
189        let kind = if is_nvme {
190            "NVMe SSD"
191        } else if rotational {
192            "HDD"
193        } else {
194            "SSD"
195        };
196
197        let size_str = size_bytes.map(format_size).unwrap_or_default();
198
199        let label = if model.is_empty() {
200            format!("{} [{}]", size_str, kind)
201        } else {
202            format!("{} {} [{}]", model.trim(), size_str, kind)
203        };
204
205        let label = label.trim().to_string();
206        if !label.is_empty() {
207            disks.push(label);
208        }
209    }
210
211    disks.sort();
212    disks
213}
214
215#[cfg(target_os = "macos")]
216fn detect_macos() -> Vec<String> {
217    // `diskutil list -plist` lists all disks; parse the XML property list.
218    // We only want whole disks (not partitions), so we look at top-level entries.
219    let output = std::process::Command::new("diskutil")
220        .args(["list", "-plist"])
221        .output();
222
223    let Ok(out) = output else {
224        return Vec::new();
225    };
226    if !out.status.success() {
227        return Vec::new();
228    }
229
230    // Use simple text parsing of the plist XML to avoid a plist dependency.
231    let text = String::from_utf8_lossy(&out.stdout);
232
233    // Parse the WholeDisks array — macOS pre-filters this to whole-disk identifiers
234    // (e.g. "disk0", "disk1"), excluding partitions like "disk0s1".
235    let mut disk_ids: Vec<String> = Vec::new();
236    let mut in_whole_disks = false;
237    for line in text.lines() {
238        let trimmed = line.trim();
239        if trimmed == "<key>WholeDisks</key>" {
240            in_whole_disks = true;
241            continue;
242        }
243        if in_whole_disks {
244            if trimmed == "</array>" {
245                break;
246            }
247            if let Some(inner) = trimmed
248                .strip_prefix("<string>")
249                .and_then(|s| s.strip_suffix("</string>"))
250            {
251                disk_ids.push(inner.to_string());
252            }
253        }
254    }
255
256    let mut disks = Vec::new();
257    for id in disk_ids {
258        if let Some(entry) = diskutil_info(&id) {
259            disks.push(entry);
260        }
261    }
262    disks
263}
264
265#[cfg(target_os = "macos")]
266fn diskutil_info(disk_id: &str) -> Option<String> {
267    let output = std::process::Command::new("diskutil")
268        .args(["info", "-plist", disk_id])
269        .output()
270        .ok()?;
271
272    if !output.status.success() {
273        return None;
274    }
275
276    let text = String::from_utf8_lossy(&output.stdout);
277    parse_diskutil_info_plist(&text)
278}
279
280/// Parses a `diskutil info -plist` XML text into a formatted disk label string.
281/// Returns `None` for virtual disks or unparseable output.
282#[cfg(target_os = "macos")]
283pub fn parse_diskutil_info_plist(text: &str) -> Option<String> {
284    let mut model = String::new();
285    let mut size_bytes: Option<u64> = None;
286    let mut is_ssd = false;
287    let mut protocol = String::new();
288    let mut virtual_or_physical = String::new();
289
290    let mut last_key = String::new();
291    for line in text.lines() {
292        let trimmed = line.trim();
293        if let Some(key) = trimmed
294            .strip_prefix("<key>")
295            .and_then(|s| s.strip_suffix("</key>"))
296        {
297            last_key = key.to_string();
298            continue;
299        }
300        if let Some(val) = trimmed
301            .strip_prefix("<string>")
302            .and_then(|s| s.strip_suffix("</string>"))
303        {
304            match last_key.as_str() {
305                // MediaName gives the clean model string (e.g. "APPLE SSD AP1024Z");
306                // IORegistryEntryName appends " Media" and is used only as a fallback.
307                "MediaName" => {
308                    if !val.is_empty() {
309                        model = val.to_string();
310                    }
311                }
312                "IORegistryEntryName" => {
313                    if model.is_empty() && !val.is_empty() {
314                        model = val.to_string();
315                    }
316                }
317                "BusProtocol" => protocol = val.to_string(),
318                "VirtualOrPhysical" => virtual_or_physical = val.to_string(),
319                _ => {}
320            }
321        }
322        if let Some(val) = trimmed
323            .strip_prefix("<integer>")
324            .and_then(|s| s.strip_suffix("</integer>"))
325        {
326            if last_key == "TotalSize" {
327                size_bytes = val.parse().ok();
328            }
329        }
330        if trimmed == "<true/>" && last_key == "SolidState" {
331            is_ssd = true;
332        }
333    }
334
335    // Skip APFS synthesized and other virtual disk objects
336    if virtual_or_physical == "Virtual" {
337        return None;
338    }
339
340    let kind =
341        if protocol.to_lowercase().contains("pcie") || protocol.to_lowercase().contains("nvme") {
342            "NVMe SSD"
343        } else if is_ssd {
344            "SSD"
345        } else {
346            "HDD"
347        };
348
349    let size_str = size_bytes.map(format_size).unwrap_or_default();
350
351    let label = if model.is_empty() {
352        format!("{} [{}]", size_str, kind)
353    } else {
354        format!("{} {} [{}]", model.trim(), size_str, kind)
355    };
356
357    Some(label.trim().to_string())
358}
359
360/// Strips a trailing size token (e.g. "1024GB", "512GB", "2TB") from a model string.
361/// Many NVMe vendors embed the capacity in the model name; we compute it separately.
362#[cfg(target_os = "linux")]
363fn strip_embedded_size(model: &str) -> &str {
364    let bytes = model.as_bytes();
365    // Walk backwards over digits, then a unit suffix (GB/TB/MB), then optional space
366    let mut i = bytes.len();
367    // Strip trailing whitespace
368    while i > 0 && bytes[i - 1] == b' ' {
369        i -= 1;
370    }
371    // Must end with "GB" or "TB" or "MB"
372    if i >= 2 {
373        let suffix = &bytes[i - 2..i];
374        if matches!(suffix, b"GB" | b"TB" | b"MB") {
375            i -= 2;
376            // Strip the digits before the unit
377            let digits_end = i;
378            while i > 0 && bytes[i - 1].is_ascii_digit() {
379                i -= 1;
380            }
381            if i < digits_end {
382                // Strip one optional space between model name and size token
383                if i > 0 && bytes[i - 1] == b' ' {
384                    i -= 1;
385                }
386                return model[..i].trim_end();
387            }
388        }
389    }
390    model
391}
392
393#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
394fn format_size(bytes: u64) -> String {
395    const TB: u64 = 1_000_000_000_000;
396    const GB: u64 = 1_000_000_000;
397    if bytes >= TB {
398        format!("{:.1} TB", bytes as f64 / TB as f64)
399    } else {
400        format!("{:.0} GB", bytes as f64 / GB as f64)
401    }
402}
403
404#[cfg(target_os = "windows")]
405fn detect_windows() -> Vec<String> {
406    let cmd = "Get-PhysicalDisk | \
407               Select-Object FriendlyName,Size,MediaType,BusType | \
408               ConvertTo-Csv -NoTypeInformation";
409    let Ok(output) = std::process::Command::new("powershell")
410        .args(["-NoProfile", "-NonInteractive", "-Command", cmd])
411        .output()
412    else {
413        return Vec::new();
414    };
415    if !output.status.success() {
416        return Vec::new();
417    }
418    let text = String::from_utf8_lossy(&output.stdout);
419    parse_get_physical_disk(&text)
420}
421
422/// Parses `Get-PhysicalDisk | ConvertTo-Csv` output into formatted disk labels.
423///
424/// Expected CSV header: `"FriendlyName","Size","MediaType","BusType"`
425#[cfg(target_os = "windows")]
426pub fn parse_get_physical_disk(csv: &str) -> Vec<String> {
427    let mut lines = csv.lines();
428    lines.next(); // skip header
429    let mut disks = Vec::new();
430    for line in lines {
431        let fields = unquoted_csv_fields(line);
432        if fields.len() < 4 {
433            continue;
434        }
435        let name = fields[0].trim();
436        let size_bytes: Option<u64> = fields[1].trim().parse().ok();
437        let media_type = fields[2].trim();
438        let bus_type = fields[3].trim();
439
440        let kind = if bus_type.eq_ignore_ascii_case("NVMe") {
441            "NVMe SSD"
442        } else if media_type.eq_ignore_ascii_case("SSD") {
443            "SSD"
444        } else if media_type.eq_ignore_ascii_case("HDD") {
445            "HDD"
446        } else {
447            // "Unspecified" with no NVMe bus — treat as SSD (e.g. eMMC, SD)
448            "SSD"
449        };
450
451        let size_str = size_bytes.map(format_size).unwrap_or_default();
452        let label = if name.is_empty() {
453            format!("{} [{}]", size_str, kind)
454        } else {
455            format!("{} {} [{}]", name, size_str, kind)
456        };
457        disks.push(label.trim().to_string());
458    }
459    disks
460}
461
462/// Splits one PowerShell `ConvertTo-Csv` line into unquoted fields.
463///
464/// `ConvertTo-Csv` wraps every value in double quotes: `"val1","val2",...`
465#[cfg(target_os = "windows")]
466fn unquoted_csv_fields(line: &str) -> Vec<String> {
467    let line = line.trim().trim_matches('"');
468    line.split("\",\"").map(|s| s.to_string()).collect()
469}
470
471#[cfg(test)]
472mod tests {
473    #[cfg(any(target_os = "linux", target_os = "macos"))]
474    use super::format_size;
475    #[cfg(target_os = "linux")]
476    use super::strip_embedded_size;
477
478    #[cfg(target_os = "linux")]
479    #[test]
480    fn test_strip_embedded_size() {
481        assert_eq!(
482            strip_embedded_size("BC901 NVMe SK hynix 1024GB"),
483            "BC901 NVMe SK hynix"
484        );
485        assert_eq!(
486            strip_embedded_size("Samsung SSD 970 EVO 500GB"),
487            "Samsung SSD 970 EVO"
488        );
489        assert_eq!(strip_embedded_size("WD Blue 2TB"), "WD Blue");
490        assert_eq!(strip_embedded_size("CT500MX500SSD1"), "CT500MX500SSD1"); // Crucial model — no unit suffix
491        assert_eq!(
492            strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
493            "SAMSUNG MZQL23T8HCLS"
494        ); // no unit
495        assert_eq!(strip_embedded_size("Some Drive 256GB"), "Some Drive");
496    }
497
498    #[cfg(any(target_os = "linux", target_os = "macos"))]
499    #[test]
500    fn test_format_size_gb() {
501        assert_eq!(format_size(512_110_190_592), "512 GB");
502    }
503
504    #[cfg(any(target_os = "linux", target_os = "macos"))]
505    #[test]
506    fn test_format_size_tb() {
507        assert_eq!(format_size(1_000_204_886_016), "1.0 TB");
508    }
509
510    #[cfg(any(target_os = "linux", target_os = "macos"))]
511    #[test]
512    fn test_format_size_2tb() {
513        assert_eq!(format_size(2_000_398_934_016), "2.0 TB");
514    }
515
516    #[cfg(target_os = "macos")]
517    #[test]
518    fn test_parse_diskutil_info_plist_apple_silicon() {
519        // Matches real output from an Apple M-series Mac (disk0).
520        // IORegistryEntryName comes before MediaName in the plist; MediaName must win.
521        let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
522<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
523<plist version="1.0">
524<dict>
525	<key>BusProtocol</key>
526	<string>Apple Fabric</string>
527	<key>IORegistryEntryName</key>
528	<string>APPLE SSD AP1024Z Media</string>
529	<key>MediaName</key>
530	<string>APPLE SSD AP1024Z</string>
531	<key>SolidState</key>
532	<true/>
533	<key>TotalSize</key>
534	<integer>1000555581440</integer>
535	<key>VirtualOrPhysical</key>
536	<string>Unknown</string>
537</dict>
538</plist>"#;
539        let result = super::parse_diskutil_info_plist(plist);
540        assert_eq!(result, Some("APPLE SSD AP1024Z 1.0 TB [SSD]".to_string()));
541    }
542
543    #[cfg(target_os = "macos")]
544    #[test]
545    fn test_parse_diskutil_info_plist_nvme() {
546        let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
547<plist version="1.0">
548<dict>
549	<key>BusProtocol</key>
550	<string>PCIe</string>
551	<key>MediaName</key>
552	<string>Samsung SSD 990 Pro</string>
553	<key>SolidState</key>
554	<true/>
555	<key>TotalSize</key>
556	<integer>2000398934016</integer>
557	<key>VirtualOrPhysical</key>
558	<string>Physical</string>
559</dict>
560</plist>"#;
561        let result = super::parse_diskutil_info_plist(plist);
562        assert_eq!(
563            result,
564            Some("Samsung SSD 990 Pro 2.0 TB [NVMe SSD]".to_string())
565        );
566    }
567
568    #[cfg(target_os = "macos")]
569    #[test]
570    fn test_parse_diskutil_info_plist_virtual_skipped() {
571        let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
572<plist version="1.0">
573<dict>
574	<key>MediaName</key>
575	<string>APFS Container Disk</string>
576	<key>TotalSize</key>
577	<integer>500000000000</integer>
578	<key>VirtualOrPhysical</key>
579	<string>Virtual</string>
580</dict>
581</plist>"#;
582        let result = super::parse_diskutil_info_plist(plist);
583        assert_eq!(result, None);
584    }
585
586    #[cfg(target_os = "windows")]
587    #[test]
588    fn test_parse_get_physical_disk_nvme() {
589        let csv = r#""FriendlyName","Size","MediaType","BusType"
590"Samsung SSD 980 Pro 1TB","1000204886016","SSD","NVMe"
591"#;
592        let disks = super::parse_get_physical_disk(csv);
593        assert_eq!(disks, vec!["Samsung SSD 980 Pro 1TB 1.0 TB [NVMe SSD]"]);
594    }
595
596    #[cfg(target_os = "windows")]
597    #[test]
598    fn test_parse_get_physical_disk_hdd() {
599        let csv = r#""FriendlyName","Size","MediaType","BusType"
600"WD Blue 2TB","2000398934016","HDD","SATA"
601"#;
602        let disks = super::parse_get_physical_disk(csv);
603        assert_eq!(disks, vec!["WD Blue 2TB 2.0 TB [HDD]"]);
604    }
605
606    #[cfg(target_os = "windows")]
607    #[test]
608    fn test_parse_get_physical_disk_unspecified_sata_ssd() {
609        // Some SATA SSDs report MediaType as "Unspecified" but BusType as "SATA"
610        let csv = r#""FriendlyName","Size","MediaType","BusType"
611"Crucial CT500MX500SSD1","500107862016","Unspecified","SATA"
612"#;
613        let disks = super::parse_get_physical_disk(csv);
614        assert_eq!(disks, vec!["Crucial CT500MX500SSD1 500 GB [SSD]"]);
615    }
616
617    #[cfg(target_os = "windows")]
618    #[test]
619    fn test_parse_get_physical_disk_multi() {
620        let csv = r#""FriendlyName","Size","MediaType","BusType"
621"Samsung SSD 980 Pro","1000204886016","SSD","NVMe"
622"WD Blue","2000398934016","HDD","SATA"
623"#;
624        let disks = super::parse_get_physical_disk(csv);
625        assert_eq!(disks.len(), 2);
626        assert_eq!(disks[0], "Samsung SSD 980 Pro 1.0 TB [NVMe SSD]");
627        assert_eq!(disks[1], "WD Blue 2.0 TB [HDD]");
628    }
629}