1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct MountOptions {
19 pub mount_point: Option<String>,
21 #[serde(default)]
23 pub readonly: bool,
24 #[serde(default)]
26 pub extra_options: Vec<String>,
27 #[serde(default)]
29 pub force_ntfs3g: bool,
30}
31
32pub 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 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 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
94pub fn remount_rw(vol: &Volume, cfg: &Config) -> Result<String> {
98 if !vol.mounted {
99 let opts = MountOptions {
101 readonly: false,
102 ..Default::default()
103 };
104 return mount(vol, &opts, cfg);
105 }
106
107 let mount_point = vol
109 .mount_point
110 .clone()
111 .unwrap_or_else(|| vol.device_identifier.clone());
112
113 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 return Ok(mount_point);
131 }
132
133 unmount(&vol.device_identifier)?;
135 let opts = MountOptions {
136 readonly: false,
137 ..Default::default()
138 };
139 mount(vol, &opts, cfg)
140}
141
142pub 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
157fn build_mount_options(vol: &Volume, opts: &MountOptions, cfg: &Config) -> Vec<String> {
159 let mut out: Vec<String> = Vec::new();
160 out.push("noowners".to_string());
162 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; out
175}
176
177fn 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 let r = unmount("disk99999s99");
268 assert!(r.is_err());
269 }
270
271 #[test]
272 fn mount_point_path_sanity() {
273 let tmp = tempfile::tempdir().unwrap();
275 let _ = tmp.path().join("a");
276 let _ = Path::new(tmp.path());
277 }
278}