Skip to main content

ntfs_mac_core/
device.rs

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