Skip to main content

ntfs_mac_core/
mount.rs

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