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