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