1pub 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 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 if dev_path.join("partition").exists() {
45 continue;
46 }
47
48 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 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 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 let text = String::from_utf8_lossy(&out.stdout);
112
113 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#[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" => {
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 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#[cfg(target_os = "linux")]
243fn strip_embedded_size(model: &str) -> &str {
244 let bytes = model.as_bytes();
245 let mut i = bytes.len();
247 while i > 0 && bytes[i - 1] == b' ' {
249 i -= 1;
250 }
251 if i >= 2 {
253 let suffix = &bytes[i - 2..i];
254 if matches!(suffix, b"GB" | b"TB" | b"MB") {
255 i -= 2;
256 let digits_end = i;
258 while i > 0 && bytes[i - 1].is_ascii_digit() {
259 i -= 1;
260 }
261 if i < digits_end {
262 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"); assert_eq!(
305 strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
306 "SAMSUNG MZQL23T8HCLS"
307 ); 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 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}