use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::runner::{RunOptions, run_expect_success, which};
use crate::{Config, Volume};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MountOptions {
pub mount_point: Option<String>,
#[serde(default)]
pub readonly: bool,
#[serde(default)]
pub extra_options: Vec<String>,
#[serde(default)]
pub force_ntfs3g: bool,
}
fn validate_mount_option(opt: &str) -> Result<()> {
let bad = |why: &str| {
Err(Error::InvalidArgument(format!(
"invalid mount option `{opt}`: {why}"
)))
};
if opt.trim().is_empty() {
return bad("empty option");
}
if opt.contains(|c: char| {
c.is_whitespace() || matches!(c, ',' | ';' | '"' | '\'' | '\\' | '`' | '$' | '\0')
}) {
return bad(
"option must be a single token without whitespace or separator characters (`, ; \" ' \\ \\` $`)",
);
}
Ok(())
}
pub fn validate_mount_options(options: &[String]) -> Result<()> {
for opt in options {
validate_mount_option(opt)?;
}
Ok(())
}
pub fn mount(vol: &Volume, opts: &MountOptions, cfg: &Config) -> Result<String> {
validate_mount_options(&cfg.mount_options)?;
validate_mount_options(&opts.extra_options)?;
if vol.mounted {
return Ok(vol
.mount_point
.clone()
.unwrap_or_else(|| vol.device_identifier.clone()));
}
let mount_point = opts.mount_point.clone().unwrap_or_else(|| {
let name = if vol.volume_name.trim().is_empty() {
vol.device_identifier.replace('s', "-")
} else {
vol.volume_name.clone()
};
let safe: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || ['-', '_', '|', ' '].contains(&c) {
c
} else {
'_'
}
})
.collect();
std::path::PathBuf::from(&cfg.mount_base)
.join(&safe)
.to_string_lossy()
.to_string()
});
std::fs::create_dir_all(&mount_point).map_err(|e| Error::CommandFailed {
cmd: "mkdir".into(),
status: e.raw_os_error().unwrap_or(-1),
stderr: format!("cannot create mount point {}: {}", mount_point, e),
io: Some(e),
})?;
let opts_vec = build_mount_options(vol, opts, cfg);
let opts_str = opts_vec.join(",");
let dev_path = format!("/dev/{}", vol.device_identifier);
if opts.force_ntfs3g || !diskutil_mount(&dev_path, &mount_point, &opts_vec) {
let ntfs_bin = which("ntfs-3g")?;
let args = ["-o", &opts_str, &dev_path, &mount_point];
run_expect_success(
ntfs_bin.to_str().unwrap_or("ntfs-3g"),
&args,
&RunOptions {
timeout: Some(Duration::from_secs(60)),
..Default::default()
},
)?;
Ok(mount_point)
} else {
Ok(mount_point)
}
}
pub fn remount_rw(vol: &Volume, cfg: &Config) -> Result<String> {
if !vol.mounted {
let opts = MountOptions {
readonly: false,
..Default::default()
};
return mount(vol, &opts, cfg);
}
let mount_point = vol
.mount_point
.clone()
.unwrap_or_else(|| vol.device_identifier.clone());
let out = crate::runner::run(
"mount",
&[&mount_point],
&RunOptions {
timeout: Some(Duration::from_secs(5)),
..Default::default()
},
);
let readonly_mount = match out {
Ok(o) if o.success() => o.stdout.contains("(read-only)"),
_ => false,
};
if !readonly_mount {
return Ok(mount_point);
}
unmount(&vol.device_identifier)?;
let opts = MountOptions {
readonly: false,
..Default::default()
};
mount(vol, &opts, cfg)
}
pub fn unmount(device_identifier: &str) -> Result<()> {
let dev_path = format!("/dev/{}", device_identifier);
run_expect_success(
"diskutil",
&["unmount", &dev_path],
&RunOptions {
timeout: Some(Duration::from_secs(30)),
..Default::default()
},
)?;
Ok(())
}
fn build_mount_options(vol: &Volume, opts: &MountOptions, cfg: &Config) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
out.push("noowners".to_string());
out.push("atime".to_string());
if opts.readonly {
out.push("ro".to_string());
}
for extra in &cfg.mount_options {
out.push(extra.clone());
}
for extra in &opts.extra_options {
out.push(extra.clone());
}
let _ = vol; out
}
fn diskutil_mount(dev_path: &str, mount_point: &str, opts: &[String]) -> bool {
use crate::runner::run;
let opts_str = opts.join(",");
let mut args: Vec<&str> = vec!["mount"];
if !opts.is_empty() {
args.push("-o");
args.push(&opts_str);
}
args.push(dev_path);
args.push(mount_point);
let out = run(
"diskutil",
&args,
&RunOptions {
timeout: Some(Duration::from_secs(30)),
..Default::default()
},
);
match out {
Ok(o) => o.success(),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn mount_option_validation_accepts_wellformed() {
assert!(validate_mount_options(&["big_writes".into()]).is_ok());
assert!(validate_mount_options(&["uid=501".into(), "auto_xattr".into()]).is_ok());
assert!(validate_mount_options(&[]).is_ok());
}
#[test]
fn mount_option_validation_rejects_bad_tokens() {
assert!(validate_mount_options(&["".into()]).is_err());
assert!(validate_mount_options(&[" ".into()]).is_err());
assert!(validate_mount_options(&["big writes".into()]).is_err());
assert!(validate_mount_options(&["a,b".into()]).is_err());
assert!(validate_mount_options(&["a;b".into()]).is_err());
assert!(validate_mount_options(&["a\"b".into()]).is_err());
assert!(validate_mount_options(&["a'b".into()]).is_err());
assert!(validate_mount_options(&["a\\b".into()]).is_err());
assert!(validate_mount_options(&["a$b".into()]).is_err());
assert!(validate_mount_options(&["a`b".into()]).is_err());
}
#[test]
fn build_mount_options_defaults() {
let vol = Volume {
device_identifier: "disk2s2".into(),
volume_name: "X".into(),
media_type: "com.microsoft.ntfs".into(),
uuid: None,
size_bytes: 0,
mounted: false,
mount_point: None,
parent_disk: None,
size_pretty: "0 B".into(),
location: "external".into(),
contents: None,
};
let cfg = Config::default();
let opts = MountOptions::default();
let built = build_mount_options(&vol, &opts, &cfg);
assert!(built.contains(&"noowners".to_string()));
assert!(built.contains(&"atime".to_string()));
assert!(!built.contains(&"ro".to_string()));
}
#[test]
fn build_mount_options_includes_readonly() {
let vol = Volume {
device_identifier: "disk2s2".into(),
volume_name: "X".into(),
media_type: "com.microsoft.ntfs".into(),
uuid: None,
size_bytes: 0,
mounted: false,
mount_point: None,
parent_disk: None,
size_pretty: "0 B".into(),
location: "external".into(),
contents: None,
};
let cfg = Config {
mount_options: vec!["uid=501".into()],
..Default::default()
};
let opts = MountOptions {
readonly: true,
extra_options: vec!["mask=777".into()],
..Default::default()
};
let built = build_mount_options(&vol, &opts, &cfg);
assert!(built.contains(&"ro".to_string()));
assert!(built.contains(&"uid=501".to_string()));
assert!(built.contains(&"mask=777".to_string()));
}
#[test]
fn unmount_missing_device_errors() {
let r = unmount("disk99999s99");
assert!(r.is_err());
}
#[test]
fn mount_point_path_sanity() {
let tmp = tempfile::tempdir().unwrap();
let _ = tmp.path().join("a");
let _ = Path::new(tmp.path());
}
}