Skip to main content

ntfs_mac_core/
device.rs

1//! NTFS volume discovery via `diskutil list -plist` + `mount`.
2//!
3//! `diskutil list -plist` emits a nested plist document whose top-level
4//! is a **dict** with an `AllDisksAndPartitions` array. Each entry is a
5//! whole-disk record containing a nested `Partitions` array. Partitions
6//! carry only `Content`, `DeviceIdentifier`, `DiskUUID`, and `Size` —
7//! no mount point or volume name. Those are recovered by cross-referencing
8//! the `mount` command output, which is cheap (one subprocess) and avoids
9//! N+1 `diskutil info` calls.
10
11use 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/// A discovered NTFS volume/partition.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct Volume {
23    /// `disk2s2` style identifier.
24    pub device_identifier: String,
25    /// Volume name; may be empty for unlabelled volumes.
26    pub volume_name: String,
27    /// Media type: `com.microsoft.ntfs`, `Microsoft Basic Data`, …
28    pub media_type: String,
29    /// Logical volume UUID (if the plist provided one).
30    pub uuid: Option<String>,
31    /// Size in bytes.
32    pub size_bytes: u64,
33    /// Whether the volume is currently mounted.
34    pub mounted: bool,
35    /// Mount point when mounted (e.g. `/Volumes/MyData`).
36    pub mount_point: Option<String>,
37    /// Parent disk identifier (e.g. `disk2`).
38    pub parent_disk: Option<String>,
39    /// Human-readable size (e.g. `931.5 GiB`).
40    pub size_pretty: String,
41    /// Location: `internal` or `external`.
42    pub location: String,
43    /// Whole-disk contents (GUID_partition_scheme / Apple_partition_scheme / …).
44    pub contents: Option<String>,
45}
46
47impl Volume {
48    /// Short label for CLI display: prefer volume_name, fall back to
49    /// device_identifier.
50    #[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
60/// NTFS-related partition Content values accepted by [`is_ntfs_content`].
61const NTFS_CONTENTS: &[&str] = &[
62    "com.microsoft.ntfs",
63    "Microsoft Basic Data",
64    "Microsoft Reserved",
65    "Microsoft Reserved Code",
66];
67
68/// Return `true` when `content` identifies an NTFS-family partition.
69#[must_use]
70fn is_ntfs_content(content: &str) -> bool {
71    NTFS_CONTENTS.contains(&content)
72}
73
74/// List all NTFS volumes visible to macOS.
75pub 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    // Enrich with mount info from the `mount` command.
96    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
114/// Parse the plist output of `diskutil list -plist`.
115///
116/// The real top-level is a **dict** containing `AllDisksAndPartitions`
117/// (array of whole-disk dicts, each with a nested `Partitions` array).
118///
119/// This is a pure function so it can be unit-tested without spawning
120/// diskutil. Returned volumes have `mounted = false` and `mount_point =
121/// None`; call [`enrich_with_mounts`] to fill those in.
122pub 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/// Parse the output of the `mount` command into a map of
241/// device identifier → mount point.
242///
243/// Each line has the form:
244/// `/dev/disk2s2 on /Volumes/MyData (ntfs, local, …)`
245#[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        // Dev path looks like `/dev/disk2s2`; strip the prefix.
255        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        // Mount point is the first whitespace-delimited token.
263        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
277/// Fill in `mounted`, `mount_point`, and `volume_name` on each volume
278/// using the device→mount-point map produced by [`parse_mount_output`].
279pub 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            // Derive volume name from mount-point basename when the
285            // plist did not provide one (it never does for partitions).
286            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/// Format a byte count as `1.2 GiB`.
298#[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    /// Real-world-shaped `diskutil list -plist` output: top-level dict
327    /// with `AllDisksAndPartitions`, partitions carry only
328    /// Content/DeviceIdentifier/DiskUUID/Size.
329    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        // Microsoft Reserved + com.microsoft.ntfs = 2 NTFS volumes.
430        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        // Not yet enriched with mount info.
442        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        // Non-/dev entries are ignored.
462        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        // The Microsoft Reserved partition was also enriched.
486        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}