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(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 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 if dev_path.join("partition").exists() {
48 continue;
49 }
50
51 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 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 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 let text = String::from_utf8_lossy(&out.stdout);
115
116 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#[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" => {
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 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#[cfg(target_os = "linux")]
246fn strip_embedded_size(model: &str) -> &str {
247 let bytes = model.as_bytes();
248 let mut i = bytes.len();
250 while i > 0 && bytes[i - 1] == b' ' {
252 i -= 1;
253 }
254 if i >= 2 {
256 let suffix = &bytes[i - 2..i];
257 if matches!(suffix, b"GB" | b"TB" | b"MB") {
258 i -= 2;
259 let digits_end = i;
261 while i > 0 && bytes[i - 1].is_ascii_digit() {
262 i -= 1;
263 }
264 if i < digits_end {
265 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#[cfg(target_os = "windows")]
309pub fn parse_get_physical_disk(csv: &str) -> Vec<String> {
310 let mut lines = csv.lines();
311 lines.next(); 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 "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#[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"); assert_eq!(
375 strip_embedded_size("SAMSUNG MZQL23T8HCLS"),
376 "SAMSUNG MZQL23T8HCLS"
377 ); 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 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 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}