Skip to main content

ntfs_mac_core/
mount.rs

1//! Mount / unmount NTFS volumes.
2//!
3//! Prefer `diskutil mount <disk>` because it transparently routes
4//! through whichever FUSE helper (ntfs-3g or macOS builtin) is
5//! registered for the media type. Fall back to a direct `ntfs-3g`
6//! invocation when diskutil refuses.
7
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13use crate::runner::{RunOptions, run_expect_success, which};
14use crate::{Config, Volume};
15
16/// Options for `mount`.
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct MountOptions {
19    /// Explicit mount point. If `None`, use `<cfg.mount_base>/<volume_name>`.
20    pub mount_point: Option<String>,
21    /// Read-only mount.
22    #[serde(default)]
23    pub readonly: bool,
24    /// Extra ntfs-3g mount options (space-separated).
25    #[serde(default)]
26    pub extra_options: Vec<String>,
27    /// Use `ntfs-3g` directly rather than `diskutil mount`.
28    #[serde(default)]
29    pub force_ntfs3g: bool,
30}
31
32/// Mount an NTFS volume. `vol` should come from `device::list_volumes`
33/// so that the identifier and label are already validated.
34pub fn mount(vol: &Volume, opts: &MountOptions, cfg: &Config) -> Result<String> {
35    if vol.mounted {
36        return Ok(vol
37            .mount_point
38            .clone()
39            .unwrap_or_else(|| vol.device_identifier.clone()));
40    }
41
42    let mount_point = opts.mount_point.clone().unwrap_or_else(|| {
43        let name = if vol.volume_name.trim().is_empty() {
44            vol.device_identifier.replace('s', "-")
45        } else {
46            vol.volume_name.clone()
47        };
48        // Sanitize the mount-point basename.
49        let safe: String = name
50            .chars()
51            .map(|c| {
52                if c.is_alphanumeric() || ['-', '_', '|', ' '].contains(&c) {
53                    c
54                } else {
55                    '_'
56                }
57            })
58            .collect();
59        std::path::PathBuf::from(&cfg.mount_base)
60            .join(&safe)
61            .to_string_lossy()
62            .to_string()
63    });
64
65    std::fs::create_dir_all(&mount_point).map_err(|e| Error::CommandFailed {
66        cmd: "mkdir".into(),
67        status: e.raw_os_error().unwrap_or(-1),
68        stderr: format!("cannot create mount point {}: {}", mount_point, e),
69        io: Some(e),
70    })?;
71
72    let opts_vec = build_mount_options(vol, opts, cfg);
73    let opts_str = opts_vec.join(",");
74    let dev_path = format!("/dev/{}", vol.device_identifier);
75
76    if opts.force_ntfs3g || !diskutil_mount(&dev_path, &mount_point, &opts_vec) {
77        // Fall back to direct ntfs-3g.
78        let ntfs_bin = which("ntfs-3g")?;
79        let args = ["-o", &opts_str, &dev_path, &mount_point];
80        run_expect_success(
81            ntfs_bin.to_str().unwrap_or("ntfs-3g"),
82            &args,
83            &RunOptions {
84                timeout: Some(Duration::from_secs(60)),
85                ..Default::default()
86            },
87        )?;
88        Ok(mount_point)
89    } else {
90        Ok(mount_point)
91    }
92}
93
94/// Remount a volume as read-write. If already read-write, no-op.
95/// If mounted read-only, unmounts and remounts without `ro`.
96/// If not mounted, mounts as read-write.
97pub fn remount_rw(vol: &Volume, cfg: &Config) -> Result<String> {
98    if !vol.mounted {
99        // Not mounted — mount as read-write.
100        let opts = MountOptions {
101            readonly: false,
102            ..Default::default()
103        };
104        return mount(vol, &opts, cfg);
105    }
106
107    // Check current mount options to see if it's read-only.
108    let mount_point = vol
109        .mount_point
110        .clone()
111        .unwrap_or_else(|| vol.device_identifier.clone());
112
113    // Use `mount` command to check options.
114    let out = crate::runner::run(
115        "mount",
116        &[&mount_point],
117        &RunOptions {
118            timeout: Some(Duration::from_secs(5)),
119            ..Default::default()
120        },
121    );
122
123    let readonly_mount = match out {
124        Ok(o) if o.success() => o.stdout.contains("(read-only)"),
125        _ => false,
126    };
127
128    if !readonly_mount {
129        // Already read-write — nothing to do.
130        return Ok(mount_point);
131    }
132
133    // Read-only mount detected — remount as read-write.
134    unmount(&vol.device_identifier)?;
135    let opts = MountOptions {
136        readonly: false,
137        ..Default::default()
138    };
139    mount(vol, &opts, cfg)
140}
141
142/// Unmount an NTFS volume. `device_identifier` should be the disk
143/// identifier returned by `list_volumes`.
144pub fn unmount(device_identifier: &str) -> Result<()> {
145    let dev_path = format!("/dev/{}", device_identifier);
146    run_expect_success(
147        "diskutil",
148        &["unmount", &dev_path],
149        &RunOptions {
150            timeout: Some(Duration::from_secs(30)),
151            ..Default::default()
152        },
153    )?;
154    Ok(())
155}
156
157/// Build the ntfs-3g option string from config + user options.
158fn build_mount_options(vol: &Volume, opts: &MountOptions, cfg: &Config) -> Vec<String> {
159    let mut out: Vec<String> = Vec::new();
160    // Always let macOS decide ownership; ntfs-3g defaults to root.
161    out.push("noowners".to_string());
162    // Preserve atime is optional and can slow things down.
163    out.push("atime".to_string());
164    if opts.readonly {
165        out.push("ro".to_string());
166    }
167    for extra in &cfg.mount_options {
168        out.push(extra.clone());
169    }
170    for extra in &opts.extra_options {
171        out.push(extra.clone());
172    }
173    let _ = vol; // Reserved for per-volume overrides.
174    out
175}
176
177/// Try `diskutil mount -o <opts> <dev> <mount_point>`. Returns
178/// `true` if it succeeded. Never returns an error — callers decide
179/// whether to fall back.
180fn diskutil_mount(dev_path: &str, mount_point: &str, opts: &[String]) -> bool {
181    use crate::runner::run;
182    let opts_str = opts.join(",");
183    let mut args: Vec<&str> = vec!["mount"];
184    if !opts.is_empty() {
185        args.push("-o");
186        args.push(&opts_str);
187    }
188    args.push(dev_path);
189    args.push(mount_point);
190    let out = run(
191        "diskutil",
192        &args,
193        &RunOptions {
194            timeout: Some(Duration::from_secs(30)),
195            ..Default::default()
196        },
197    );
198    match out {
199        Ok(o) => o.success(),
200        Err(_) => false,
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::path::Path;
208
209    #[test]
210    fn build_mount_options_defaults() {
211        let vol = Volume {
212            device_identifier: "disk2s2".into(),
213            volume_name: "X".into(),
214            media_type: "com.microsoft.ntfs".into(),
215            uuid: None,
216            size_bytes: 0,
217            mounted: false,
218            mount_point: None,
219            parent_disk: None,
220            size_pretty: "0 B".into(),
221            location: "external".into(),
222            contents: None,
223        };
224        let cfg = Config::default();
225        let opts = MountOptions::default();
226        let built = build_mount_options(&vol, &opts, &cfg);
227        assert!(built.contains(&"noowners".to_string()));
228        assert!(built.contains(&"atime".to_string()));
229        assert!(!built.contains(&"ro".to_string()));
230    }
231
232    #[test]
233    fn build_mount_options_includes_readonly() {
234        let vol = Volume {
235            device_identifier: "disk2s2".into(),
236            volume_name: "X".into(),
237            media_type: "com.microsoft.ntfs".into(),
238            uuid: None,
239            size_bytes: 0,
240            mounted: false,
241            mount_point: None,
242            parent_disk: None,
243            size_pretty: "0 B".into(),
244            location: "external".into(),
245            contents: None,
246        };
247        let cfg = Config {
248            mount_options: vec!["uid=501".into()],
249            ..Default::default()
250        };
251        let opts = MountOptions {
252            readonly: true,
253            extra_options: vec!["mask=777".into()],
254            ..Default::default()
255        };
256        let built = build_mount_options(&vol, &opts, &cfg);
257        assert!(built.contains(&"ro".to_string()));
258        assert!(built.contains(&"uid=501".to_string()));
259        assert!(built.contains(&"mask=777".to_string()));
260    }
261
262    #[test]
263    fn unmount_missing_device_errors() {
264        // This is a live system call; on CI or non-macOS it may fail
265        // differently. We only assert the function runs and returns a
266        // typed error, not a specific message.
267        let r = unmount("disk99999s99");
268        assert!(r.is_err());
269    }
270
271    #[test]
272    fn mount_point_path_sanity() {
273        // Just verify create_dir_all + a bogus mount doesn't panic.
274        let tmp = tempfile::tempdir().unwrap();
275        let _ = tmp.path().join("a");
276        let _ = Path::new(tmp.path());
277    }
278}