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 match mount_out {
108 Ok(out) if out.success() => {
109 let mount_map = parse_mount_output(&out.stdout);
110 enrich_with_mounts(&mut volumes, &mount_map);
111 }
112 Ok(out) => tracing::warn!(
113 target: "ntfs_mac_core::device",
114 "`mount` probe exited with status {}; volumes may incorrectly appear unmounted",
115 out.status
116 ),
117 Err(e) => tracing::warn!(
118 target: "ntfs_mac_core::device",
119 "`mount` probe failed: {e}; volumes may incorrectly appear unmounted"
120 ),
121 }
122
123 Ok(volumes)
124}
125
126pub fn parse_diskutil_plist(text: &str) -> Result<Vec<Volume>> {
135 let root = plist::Value::from_reader(Cursor::new(text.as_bytes())).map_err(|e| {
136 Error::CommandFailed {
137 cmd: "plist::from_reader".into(),
138 status: -1,
139 stderr: e.to_string(),
140 io: None,
141 }
142 })?;
143
144 let root_dict = match root.as_dictionary() {
145 Some(d) => d,
146 None => {
147 return Err(Error::CommandFailed {
148 cmd: "plist::from_reader".into(),
149 status: -1,
150 stderr: "expected top-level dict from diskutil list -plist".into(),
151 io: None,
152 });
153 }
154 };
155
156 let all_parts = match root_dict
157 .get("AllDisksAndPartitions")
158 .and_then(plist::Value::as_array)
159 {
160 Some(a) => a,
161 None => {
162 return Err(Error::CommandFailed {
163 cmd: "plist::from_reader".into(),
164 status: -1,
165 stderr: "missing AllDisksAndPartitions in diskutil plist".into(),
166 io: None,
167 });
168 }
169 };
170
171 let mut volumes = Vec::new();
172 for disk_val in all_parts {
173 let disk_dict = match disk_val.as_dictionary() {
174 Some(d) => d,
175 None => continue,
176 };
177 let content = disk_dict
178 .get("Content")
179 .and_then(plist::Value::as_string)
180 .map(str::to_string);
181 let parent_disk = disk_dict
182 .get("DeviceIdentifier")
183 .and_then(plist::Value::as_string)
184 .map(str::to_string);
185 let os_internal = disk_dict
186 .get("OSInternal")
187 .and_then(plist::Value::as_boolean)
188 .unwrap_or(true);
189 let location = if os_internal { "internal" } else { "external" };
190
191 let partitions = match disk_dict.get("Partitions").and_then(plist::Value::as_array) {
192 Some(a) => a,
193 None => continue,
194 };
195
196 for part_val in partitions {
197 let part_dict = match part_val.as_dictionary() {
198 Some(d) => d,
199 None => continue,
200 };
201 let Some(part_content) = part_dict.get("Content").and_then(plist::Value::as_string)
202 else {
203 continue;
204 };
205 if !is_ntfs_content(part_content) {
206 continue;
207 }
208
209 let Some(device_identifier) = part_dict
210 .get("DeviceIdentifier")
211 .and_then(plist::Value::as_string)
212 .map(str::to_string)
213 else {
214 continue;
215 };
216
217 let uuid = part_dict
218 .get("DiskUUID")
219 .and_then(plist::Value::as_string)
220 .map(str::to_string);
221
222 let volume_name = part_dict
223 .get("VolumeName")
224 .and_then(plist::Value::as_string)
225 .map(str::to_string)
226 .unwrap_or_default();
227
228 let size_bytes = part_dict
229 .get("Size")
230 .and_then(plist::Value::as_signed_integer)
231 .map(|i| i as u64)
232 .unwrap_or(0);
233
234 volumes.push(Volume {
235 device_identifier,
236 volume_name,
237 media_type: part_content.to_string(),
238 uuid,
239 size_bytes,
240 mounted: false,
241 mount_point: None,
242 parent_disk: parent_disk.clone(),
243 size_pretty: pretty_size(size_bytes),
244 location: location.to_string(),
245 contents: content.clone(),
246 });
247 }
248 }
249 Ok(volumes)
250}
251
252#[must_use]
258pub fn parse_mount_output(text: &str) -> HashMap<String, String> {
259 let mut map = HashMap::new();
260 for line in text.lines() {
261 let Some(space_pos) = line.find(" on ") else {
262 continue;
263 };
264 let dev_part = &line[..space_pos];
265 let rest = &line[space_pos + 4..];
266 let device_id = match dev_part.strip_prefix("/dev/") {
268 Some(id) => id.trim().to_string(),
269 None => continue,
270 };
271 if device_id.is_empty() {
272 continue;
273 }
274 let mount_point = rest
276 .split_whitespace()
277 .next()
278 .unwrap_or("")
279 .trim()
280 .to_string();
281 if mount_point.is_empty() {
282 continue;
283 }
284 map.insert(device_id, mount_point);
285 }
286 map
287}
288
289pub fn enrich_with_mounts(volumes: &mut [Volume], mount_map: &HashMap<String, String>) {
292 for vol in volumes.iter_mut() {
293 if let Some(mp) = mount_map.get(&vol.device_identifier) {
294 vol.mounted = true;
295 vol.mount_point = Some(mp.clone());
296 if vol.volume_name.trim().is_empty() {
299 if let Some(name) = Path::new(mp).file_name().and_then(|s| s.to_str()) {
300 if !name.is_empty() {
301 vol.volume_name = name.to_string();
302 }
303 }
304 }
305 }
306 }
307}
308
309#[must_use]
311pub fn pretty_size(bytes: u64) -> String {
312 const KB: u64 = 1024;
313 const MB: u64 = KB * 1024;
314 const GB: u64 = MB * 1024;
315 const TB: u64 = GB * 1024;
316 const PB: u64 = TB * 1024;
317
318 let (val, unit) = if bytes >= PB {
319 (bytes as f64 / PB as f64, "PiB")
320 } else if bytes >= TB {
321 (bytes as f64 / TB as f64, "TiB")
322 } else if bytes >= GB {
323 (bytes as f64 / GB as f64, "GiB")
324 } else if bytes >= MB {
325 (bytes as f64 / MB as f64, "MiB")
326 } else if bytes >= KB {
327 (bytes as f64 / KB as f64, "KiB")
328 } else {
329 return format!("{bytes} B");
330 };
331 format!("{val:.1} {unit}")
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 const REALISTIC_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
342<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
343<plist version="1.0">
344<dict>
345 <key>AllDisks</key>
346 <array>
347 <string>disk2</string>
348 <string>disk2s1</string>
349 <string>disk2s2</string>
350 <string>disk2s3</string>
351 </array>
352 <key>AllDisksAndPartitions</key>
353 <array>
354 <dict>
355 <key>Content</key>
356 <string>GUID_partition_scheme</string>
357 <key>DeviceIdentifier</key>
358 <string>disk2</string>
359 <key>OSInternal</key>
360 <false/>
361 <key>Size</key>
362 <integer>1000000000000</integer>
363 <key>Partitions</key>
364 <array>
365 <dict>
366 <key>Content</key>
367 <string>Microsoft Reserved</string>
368 <key>DeviceIdentifier</key>
369 <string>disk2s1</string>
370 <key>Size</key>
371 <integer>104857600</integer>
372 </dict>
373 <dict>
374 <key>Content</key>
375 <string>com.microsoft.ntfs</string>
376 <key>DeviceIdentifier</key>
377 <string>disk2s2</string>
378 <key>DiskUUID</key>
379 <string>ABC-123</string>
380 <key>Size</key>
381 <integer>999000000000</integer>
382 </dict>
383 <dict>
384 <key>Content</key>
385 <string>Apple_APFS</string>
386 <key>DeviceIdentifier</key>
387 <string>disk2s3</string>
388 <key>Size</key>
389 <integer>1000000000</integer>
390 </dict>
391 </array>
392 </dict>
393 <dict>
394 <key>Content</key>
395 <string>GUID_partition_scheme</string>
396 <key>DeviceIdentifier</key>
397 <string>disk5</string>
398 <key>OSInternal</key>
399 <true/>
400 <key>Size</key>
401 <integer>500277792768</integer>
402 <key>Partitions</key>
403 <array>
404 <dict>
405 <key>Content</key>
406 <string>Apple_APFS</string>
407 <key>DeviceIdentifier</key>
408 <string>disk5s1</string>
409 <key>Size</key>
410 <integer>500000000000</integer>
411 </dict>
412 </array>
413 </dict>
414 </array>
415 <key>VolumesFromDisks</key>
416 <array>
417 <string>MyData</string>
418 </array>
419 <key>WholeDisks</key>
420 <array>
421 <string>disk2</string>
422 <string>disk5</string>
423 </array>
424</dict>
425</plist>
426"#;
427
428 #[test]
429 fn pretty_size_units() {
430 assert_eq!(pretty_size(0), "0 B");
431 assert_eq!(pretty_size(512), "512 B");
432 assert_eq!(pretty_size(2048), "2.0 KiB");
433 assert_eq!(pretty_size(3 * 1024 * 1024), "3.0 MiB");
434 assert_eq!(pretty_size(2 * 1024usize.pow(3) as u64), "2.0 GiB");
435 assert_eq!(pretty_size(1024usize.pow(4) as u64), "1.0 TiB");
436 }
437
438 #[test]
439 fn parse_synthetic_plist_finds_ntfs_partitions() {
440 let vols = parse_diskutil_plist(REALISTIC_PLIST).unwrap();
441 assert_eq!(vols.len(), 2);
443 let ntfs = vols
444 .iter()
445 .find(|v| v.media_type == "com.microsoft.ntfs")
446 .unwrap();
447 assert_eq!(ntfs.device_identifier, "disk2s2");
448 assert_eq!(ntfs.uuid.as_deref(), Some("ABC-123"));
449 assert_eq!(ntfs.parent_disk.as_deref(), Some("disk2"));
450 assert_eq!(ntfs.location, "external");
451 assert!(ntfs.size_pretty.contains("GiB"));
452 assert_eq!(ntfs.contents.as_deref(), Some("GUID_partition_scheme"));
453 assert!(!ntfs.mounted);
455 assert!(ntfs.mount_point.is_none());
456 assert!(ntfs.volume_name.is_empty());
457 }
458
459 #[test]
460 fn parse_mount_output_extracts_device_to_mount_point() {
461 let mount = r#"/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)
462/dev/disk3s6 on /System/Volumes/VM (apfs, local, noexec, journaled, noatime, nobrowse)
463/dev/disk2s2 on /Volumes/MyData (ntfs, local, noowners, nobrowse)
464devfs on /dev (devfs, local, nobrowse)
465map auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)
466"#;
467 let map = parse_mount_output(mount);
468 assert_eq!(map.get("disk3s1s1").map(String::as_str), Some("/"));
469 assert_eq!(
470 map.get("disk2s2").map(String::as_str),
471 Some("/Volumes/MyData")
472 );
473 assert!(!map.contains_key("devfs"));
475 assert!(!map.contains_key("auto_home"));
476 assert!(!map.contains_key("map"));
477 }
478
479 #[test]
480 fn enrich_with_mounts_fills_mounted_and_volume_name() {
481 let vols = parse_diskutil_plist(REALISTIC_PLIST).unwrap();
482 let mount_map = HashMap::from([
483 ("disk2s2".to_string(), "/Volumes/MyData".to_string()),
484 ("disk2s1".to_string(), "/Volumes/RECOVERY".to_string()),
485 ]);
486 let mut vols = vols;
487 enrich_with_mounts(&mut vols, &mount_map);
488
489 let ntfs = vols
490 .iter()
491 .find(|v| v.media_type == "com.microsoft.ntfs")
492 .unwrap();
493 assert!(ntfs.mounted);
494 assert_eq!(ntfs.mount_point.as_deref(), Some("/Volumes/MyData"));
495 assert_eq!(ntfs.volume_name, "MyData");
496
497 let reserved = vols
499 .iter()
500 .find(|v| v.media_type == "Microsoft Reserved")
501 .unwrap();
502 assert!(reserved.mounted);
503 assert_eq!(reserved.volume_name, "RECOVERY");
504 }
505
506 #[test]
507 fn enrich_without_match_leaves_unmounted() {
508 let mut vols = parse_diskutil_plist(REALISTIC_PLIST).unwrap();
509 let empty_map = HashMap::new();
510 enrich_with_mounts(&mut vols, &empty_map);
511 for v in &vols {
512 assert!(!v.mounted);
513 assert!(v.mount_point.is_none());
514 assert!(v.volume_name.is_empty());
515 }
516 }
517
518 #[test]
519 fn parse_plist_rejects_non_dict_root() {
520 let text = r#"<plist version="1.0"><array><string>foo</string></array></plist>"#;
521 let r = parse_diskutil_plist(text);
522 assert!(r.is_err());
523 }
524
525 #[test]
526 fn parse_plist_rejects_missing_all_disks_and_partitions() {
527 let text =
528 r#"<plist version="1.0"><dict><key>Foo</key><string>bar</string></dict></plist>"#;
529 let r = parse_diskutil_plist(text);
530 assert!(r.is_err());
531 }
532
533 #[test]
534 fn display_label_prefers_volume_name() {
535 let v = Volume {
536 device_identifier: "disk2s2".into(),
537 volume_name: "MyData".into(),
538 media_type: "com.microsoft.ntfs".into(),
539 uuid: None,
540 size_bytes: 0,
541 mounted: false,
542 mount_point: None,
543 parent_disk: None,
544 size_pretty: "0 B".into(),
545 location: "external".into(),
546 contents: None,
547 };
548 assert_eq!(v.display_label(), "MyData (disk2s2)");
549 }
550
551 #[test]
552 fn display_label_falls_back_to_identifier() {
553 let v = Volume {
554 device_identifier: "disk2s2".into(),
555 volume_name: "".into(),
556 media_type: "com.microsoft.ntfs".into(),
557 uuid: None,
558 size_bytes: 0,
559 mounted: false,
560 mount_point: None,
561 parent_disk: None,
562 size_pretty: "0 B".into(),
563 location: "external".into(),
564 contents: None,
565 };
566 assert_eq!(v.display_label(), "disk2s2");
567 }
568}