1use std::collections::HashMap;
15use std::io::Cursor;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::{Error, Result};
21use crate::runner::{RunOptions, run};
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct Volume {
26 pub device_identifier: String,
28 pub volume_name: String,
30 pub media_type: String,
32 pub uuid: Option<String>,
34 pub size_bytes: u64,
36 pub mounted: bool,
38 pub mount_point: Option<String>,
40 pub parent_disk: Option<String>,
42 pub size_pretty: String,
44 pub location: String,
46 pub contents: Option<String>,
48}
49
50impl Volume {
51 #[must_use]
54 pub fn display_label(&self) -> String {
55 if self.volume_name.trim().is_empty() {
56 self.device_identifier.clone()
57 } else {
58 format!("{} ({})", self.volume_name, self.device_identifier)
59 }
60 }
61}
62
63const NTFS_CONTENTS: &[&str] = &[
65 "com.microsoft.ntfs",
66 "Microsoft Basic Data",
67 "Microsoft Reserved",
68 "Microsoft Reserved Code",
69];
70
71#[must_use]
73fn is_ntfs_content(content: &str) -> bool {
74 NTFS_CONTENTS.contains(&content)
75}
76
77pub fn list_volumes() -> Result<Vec<Volume>> {
79 let plist_out = run(
80 "diskutil",
81 &["list", "-plist"],
82 &RunOptions {
83 timeout: Some(std::time::Duration::from_secs(15)),
84 ..Default::default()
85 },
86 )?;
87 if !plist_out.success() {
88 return Err(Error::CommandFailed {
89 cmd: "diskutil list -plist".into(),
90 status: plist_out.status,
91 stderr: plist_out.stderr,
92 io: None,
93 });
94 }
95
96 let mut volumes = parse_diskutil_plist(&plist_out.stdout)?;
97
98 let mount_out = run(
100 "mount",
101 &[],
102 &RunOptions {
103 timeout: Some(std::time::Duration::from_secs(5)),
104 ..Default::default()
105 },
106 );
107 if let Ok(out) = mount_out {
108 if out.success() {
109 let mount_map = parse_mount_output(&out.stdout);
110 enrich_with_mounts(&mut volumes, &mount_map);
111 }
112 }
113
114 Ok(volumes)
115}
116
117pub fn parse_diskutil_plist(text: &str) -> Result<Vec<Volume>> {
126 let root = plist::Value::from_reader(Cursor::new(text.as_bytes())).map_err(|e| {
127 Error::CommandFailed {
128 cmd: "plist::from_reader".into(),
129 status: -1,
130 stderr: e.to_string(),
131 io: None,
132 }
133 })?;
134
135 let root_dict = match root.as_dictionary() {
136 Some(d) => d,
137 None => {
138 return Err(Error::CommandFailed {
139 cmd: "plist::from_reader".into(),
140 status: -1,
141 stderr: "expected top-level dict from diskutil list -plist".into(),
142 io: None,
143 });
144 }
145 };
146
147 let all_parts = match root_dict
148 .get("AllDisksAndPartitions")
149 .and_then(plist::Value::as_array)
150 {
151 Some(a) => a,
152 None => {
153 return Err(Error::CommandFailed {
154 cmd: "plist::from_reader".into(),
155 status: -1,
156 stderr: "missing AllDisksAndPartitions in diskutil plist".into(),
157 io: None,
158 });
159 }
160 };
161
162 let mut volumes = Vec::new();
163 for disk_val in all_parts {
164 let disk_dict = match disk_val.as_dictionary() {
165 Some(d) => d,
166 None => continue,
167 };
168 let content = disk_dict
169 .get("Content")
170 .and_then(plist::Value::as_string)
171 .map(str::to_string);
172 let parent_disk = disk_dict
173 .get("DeviceIdentifier")
174 .and_then(plist::Value::as_string)
175 .map(str::to_string);
176 let os_internal = disk_dict
177 .get("OSInternal")
178 .and_then(plist::Value::as_boolean)
179 .unwrap_or(true);
180 let location = if os_internal { "internal" } else { "external" };
181
182 let partitions = match disk_dict.get("Partitions").and_then(plist::Value::as_array) {
183 Some(a) => a,
184 None => continue,
185 };
186
187 for part_val in partitions {
188 let part_dict = match part_val.as_dictionary() {
189 Some(d) => d,
190 None => continue,
191 };
192 let Some(part_content) = part_dict.get("Content").and_then(plist::Value::as_string)
193 else {
194 continue;
195 };
196 if !is_ntfs_content(part_content) {
197 continue;
198 }
199
200 let Some(device_identifier) = part_dict
201 .get("DeviceIdentifier")
202 .and_then(plist::Value::as_string)
203 .map(str::to_string)
204 else {
205 continue;
206 };
207
208 let uuid = part_dict
209 .get("DiskUUID")
210 .and_then(plist::Value::as_string)
211 .map(str::to_string);
212
213 let volume_name = part_dict
214 .get("VolumeName")
215 .and_then(plist::Value::as_string)
216 .map(str::to_string)
217 .unwrap_or_default();
218
219 let size_bytes = part_dict
220 .get("Size")
221 .and_then(plist::Value::as_signed_integer)
222 .map(|i| i as u64)
223 .unwrap_or(0);
224
225 volumes.push(Volume {
226 device_identifier,
227 volume_name,
228 media_type: part_content.to_string(),
229 uuid,
230 size_bytes,
231 mounted: false,
232 mount_point: None,
233 parent_disk: parent_disk.clone(),
234 size_pretty: pretty_size(size_bytes),
235 location: location.to_string(),
236 contents: content.clone(),
237 });
238 }
239 }
240 Ok(volumes)
241}
242
243#[must_use]
249pub fn parse_mount_output(text: &str) -> HashMap<String, String> {
250 let mut map = HashMap::new();
251 for line in text.lines() {
252 let Some(space_pos) = line.find(" on ") else {
253 continue;
254 };
255 let dev_part = &line[..space_pos];
256 let rest = &line[space_pos + 4..];
257 let device_id = match dev_part.strip_prefix("/dev/") {
259 Some(id) => id.trim().to_string(),
260 None => continue,
261 };
262 if device_id.is_empty() {
263 continue;
264 }
265 let mount_point = rest
267 .split_whitespace()
268 .next()
269 .unwrap_or("")
270 .trim()
271 .to_string();
272 if mount_point.is_empty() {
273 continue;
274 }
275 map.insert(device_id, mount_point);
276 }
277 map
278}
279
280pub fn enrich_with_mounts(volumes: &mut [Volume], mount_map: &HashMap<String, String>) {
283 for vol in volumes.iter_mut() {
284 if let Some(mp) = mount_map.get(&vol.device_identifier) {
285 vol.mounted = true;
286 vol.mount_point = Some(mp.clone());
287 if vol.volume_name.trim().is_empty() {
290 if let Some(name) = Path::new(mp).file_name().and_then(|s| s.to_str()) {
291 if !name.is_empty() {
292 vol.volume_name = name.to_string();
293 }
294 }
295 }
296 }
297 }
298}
299
300#[must_use]
302pub fn pretty_size(bytes: u64) -> String {
303 const KB: u64 = 1024;
304 const MB: u64 = KB * 1024;
305 const GB: u64 = MB * 1024;
306 const TB: u64 = GB * 1024;
307 const PB: u64 = TB * 1024;
308
309 let (val, unit) = if bytes >= PB {
310 (bytes as f64 / PB as f64, "PiB")
311 } else if bytes >= TB {
312 (bytes as f64 / TB as f64, "TiB")
313 } else if bytes >= GB {
314 (bytes as f64 / GB as f64, "GiB")
315 } else if bytes >= MB {
316 (bytes as f64 / MB as f64, "MiB")
317 } else if bytes >= KB {
318 (bytes as f64 / KB as f64, "KiB")
319 } else {
320 return format!("{bytes} B");
321 };
322 format!("{val:.1} {unit}")
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 const REALISTIC_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
333<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
334<plist version="1.0">
335<dict>
336 <key>AllDisks</key>
337 <array>
338 <string>disk2</string>
339 <string>disk2s1</string>
340 <string>disk2s2</string>
341 <string>disk2s3</string>
342 </array>
343 <key>AllDisksAndPartitions</key>
344 <array>
345 <dict>
346 <key>Content</key>
347 <string>GUID_partition_scheme</string>
348 <key>DeviceIdentifier</key>
349 <string>disk2</string>
350 <key>OSInternal</key>
351 <false/>
352 <key>Size</key>
353 <integer>1000000000000</integer>
354 <key>Partitions</key>
355 <array>
356 <dict>
357 <key>Content</key>
358 <string>Microsoft Reserved</string>
359 <key>DeviceIdentifier</key>
360 <string>disk2s1</string>
361 <key>Size</key>
362 <integer>104857600</integer>
363 </dict>
364 <dict>
365 <key>Content</key>
366 <string>com.microsoft.ntfs</string>
367 <key>DeviceIdentifier</key>
368 <string>disk2s2</string>
369 <key>DiskUUID</key>
370 <string>ABC-123</string>
371 <key>Size</key>
372 <integer>999000000000</integer>
373 </dict>
374 <dict>
375 <key>Content</key>
376 <string>Apple_APFS</string>
377 <key>DeviceIdentifier</key>
378 <string>disk2s3</string>
379 <key>Size</key>
380 <integer>1000000000</integer>
381 </dict>
382 </array>
383 </dict>
384 <dict>
385 <key>Content</key>
386 <string>GUID_partition_scheme</string>
387 <key>DeviceIdentifier</key>
388 <string>disk5</string>
389 <key>OSInternal</key>
390 <true/>
391 <key>Size</key>
392 <integer>500277792768</integer>
393 <key>Partitions</key>
394 <array>
395 <dict>
396 <key>Content</key>
397 <string>Apple_APFS</string>
398 <key>DeviceIdentifier</key>
399 <string>disk5s1</string>
400 <key>Size</key>
401 <integer>500000000000</integer>
402 </dict>
403 </array>
404 </dict>
405 </array>
406 <key>VolumesFromDisks</key>
407 <array>
408 <string>MyData</string>
409 </array>
410 <key>WholeDisks</key>
411 <array>
412 <string>disk2</string>
413 <string>disk5</string>
414 </array>
415</dict>
416</plist>
417"#;
418
419 #[test]
420 fn pretty_size_units() {
421 assert_eq!(pretty_size(0), "0 B");
422 assert_eq!(pretty_size(512), "512 B");
423 assert_eq!(pretty_size(2048), "2.0 KiB");
424 assert_eq!(pretty_size(3 * 1024 * 1024), "3.0 MiB");
425 assert_eq!(pretty_size(2 * 1024usize.pow(3) as u64), "2.0 GiB");
426 assert_eq!(pretty_size(1024usize.pow(4) as u64), "1.0 TiB");
427 }
428
429 #[test]
430 fn parse_synthetic_plist_finds_ntfs_partitions() {
431 let vols = parse_diskutil_plist(REALISTIC_PLIST).unwrap();
432 assert_eq!(vols.len(), 2);
434 let ntfs = vols
435 .iter()
436 .find(|v| v.media_type == "com.microsoft.ntfs")
437 .unwrap();
438 assert_eq!(ntfs.device_identifier, "disk2s2");
439 assert_eq!(ntfs.uuid.as_deref(), Some("ABC-123"));
440 assert_eq!(ntfs.parent_disk.as_deref(), Some("disk2"));
441 assert_eq!(ntfs.location, "external");
442 assert!(ntfs.size_pretty.contains("GiB"));
443 assert_eq!(ntfs.contents.as_deref(), Some("GUID_partition_scheme"));
444 assert!(!ntfs.mounted);
446 assert!(ntfs.mount_point.is_none());
447 assert!(ntfs.volume_name.is_empty());
448 }
449
450 #[test]
451 fn parse_mount_output_extracts_device_to_mount_point() {
452 let mount = r#"/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)
453/dev/disk3s6 on /System/Volumes/VM (apfs, local, noexec, journaled, noatime, nobrowse)
454/dev/disk2s2 on /Volumes/MyData (ntfs, local, noowners, nobrowse)
455devfs on /dev (devfs, local, nobrowse)
456map auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)
457"#;
458 let map = parse_mount_output(mount);
459 assert_eq!(map.get("disk3s1s1").map(String::as_str), Some("/"));
460 assert_eq!(
461 map.get("disk2s2").map(String::as_str),
462 Some("/Volumes/MyData")
463 );
464 assert!(!map.contains_key("devfs"));
466 assert!(!map.contains_key("auto_home"));
467 assert!(!map.contains_key("map"));
468 }
469
470 #[test]
471 fn enrich_with_mounts_fills_mounted_and_volume_name() {
472 let vols = parse_diskutil_plist(REALISTIC_PLIST).unwrap();
473 let mount_map = HashMap::from([
474 ("disk2s2".to_string(), "/Volumes/MyData".to_string()),
475 ("disk2s1".to_string(), "/Volumes/RECOVERY".to_string()),
476 ]);
477 let mut vols = vols;
478 enrich_with_mounts(&mut vols, &mount_map);
479
480 let ntfs = vols
481 .iter()
482 .find(|v| v.media_type == "com.microsoft.ntfs")
483 .unwrap();
484 assert!(ntfs.mounted);
485 assert_eq!(ntfs.mount_point.as_deref(), Some("/Volumes/MyData"));
486 assert_eq!(ntfs.volume_name, "MyData");
487
488 let reserved = vols
490 .iter()
491 .find(|v| v.media_type == "Microsoft Reserved")
492 .unwrap();
493 assert!(reserved.mounted);
494 assert_eq!(reserved.volume_name, "RECOVERY");
495 }
496
497 #[test]
498 fn enrich_without_match_leaves_unmounted() {
499 let mut vols = parse_diskutil_plist(REALISTIC_PLIST).unwrap();
500 let empty_map = HashMap::new();
501 enrich_with_mounts(&mut vols, &empty_map);
502 for v in &vols {
503 assert!(!v.mounted);
504 assert!(v.mount_point.is_none());
505 assert!(v.volume_name.is_empty());
506 }
507 }
508
509 #[test]
510 fn parse_plist_rejects_non_dict_root() {
511 let text = r#"<plist version="1.0"><array><string>foo</string></array></plist>"#;
512 let r = parse_diskutil_plist(text);
513 assert!(r.is_err());
514 }
515
516 #[test]
517 fn parse_plist_rejects_missing_all_disks_and_partitions() {
518 let text =
519 r#"<plist version="1.0"><dict><key>Foo</key><string>bar</string></dict></plist>"#;
520 let r = parse_diskutil_plist(text);
521 assert!(r.is_err());
522 }
523
524 #[test]
525 fn display_label_prefers_volume_name() {
526 let v = Volume {
527 device_identifier: "disk2s2".into(),
528 volume_name: "MyData".into(),
529 media_type: "com.microsoft.ntfs".into(),
530 uuid: None,
531 size_bytes: 0,
532 mounted: false,
533 mount_point: None,
534 parent_disk: None,
535 size_pretty: "0 B".into(),
536 location: "external".into(),
537 contents: None,
538 };
539 assert_eq!(v.display_label(), "MyData (disk2s2)");
540 }
541
542 #[test]
543 fn display_label_falls_back_to_identifier() {
544 let v = Volume {
545 device_identifier: "disk2s2".into(),
546 volume_name: "".into(),
547 media_type: "com.microsoft.ntfs".into(),
548 uuid: None,
549 size_bytes: 0,
550 mounted: false,
551 mount_point: None,
552 parent_disk: None,
553 size_pretty: "0 B".into(),
554 location: "external".into(),
555 contents: None,
556 };
557 assert_eq!(v.display_label(), "disk2s2");
558 }
559}