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