1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct MountOptions {
22 pub mount_point: Option<String>,
24 #[serde(default)]
26 pub readonly: bool,
27 #[serde(default)]
29 pub extra_options: Vec<String>,
30 #[serde(default)]
32 pub force_ntfs3g: bool,
33}
34
35fn 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
59pub fn validate_mount_options(options: &[String]) -> Result<()> {
61 for opt in options {
62 validate_mount_option(opt)?;
63 }
64 Ok(())
65}
66
67pub 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 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 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
131pub fn remount_rw(vol: &Volume, cfg: &Config) -> Result<String> {
135 if !vol.mounted {
136 let opts = MountOptions {
138 readonly: false,
139 ..Default::default()
140 };
141 return mount(vol, &opts, cfg);
142 }
143
144 let mount_point = vol
146 .mount_point
147 .clone()
148 .unwrap_or_else(|| vol.device_identifier.clone());
149
150 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 return Ok(mount_point);
168 }
169
170 unmount(&vol.device_identifier)?;
172 let opts = MountOptions {
173 readonly: false,
174 ..Default::default()
175 };
176 mount(vol, &opts, cfg)
177}
178
179pub 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
194fn build_mount_options(vol: &Volume, opts: &MountOptions, cfg: &Config) -> Vec<String> {
196 let mut out: Vec<String> = Vec::new();
197 out.push("noowners".to_string());
199 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; out
212}
213
214fn 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 let r = unmount("disk99999s99");
326 assert!(r.is_err());
327 }
328
329 #[test]
330 fn mount_point_path_sanity() {
331 let tmp = tempfile::tempdir().unwrap();
333 let _ = tmp.path().join("a");
334 let _ = Path::new(tmp.path());
335 }
336}