use crate::core::shell_escape::sh_squote;
use crate::core::types::Resource;
use crate::resources::verdict;
fn sed_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '\\' | '|' | '.' | '*' | '[' | ']' | '^' | '$' | '/') {
out.push('\\');
}
out.push(c);
}
out
}
const OWNERSHIP_KEYS: [&str; 5] = ["uid", "gid", "file_mode", "dir_mode", "mode"];
fn declared_value(key: &str, value: &str) -> String {
let numeric = !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit());
let v = sh_squote(value);
match key {
"uid" | "gid" if numeric => v,
"uid" => format!("\"$(id -u {v} 2>/dev/null)\""),
"gid" => format!("\"$(getent group {v} 2>/dev/null | cut -d: -f3)\""),
_ => format!("\"$(printf '%s' {v} | sed 's/^0*\\(.\\)/\\1/')\""),
}
}
fn omitted_default(key: &str) -> &'static str {
match key {
"uid" | "gid" => "0",
"mode" => "1777",
_ => "",
}
}
fn options_condition(target: &str, options: Option<&str>) -> Option<String> {
let t = sh_squote(target);
let parts: Vec<String> = options?
.split(',')
.filter_map(|o| o.split_once('='))
.filter(|(k, _)| OWNERSHIP_KEYS.contains(k))
.map(|(k, v)| {
let live = format!(
"$(findmnt -n -o OPTIONS {t} 2>/dev/null | tail -1 | tr ',' '\\n' | sed -n 's/^{k}=//p' | sed 's/^0*\\(.\\)/\\1/')"
);
format!(
"{{ _fj_v=\"{live}\"; [ \"${{_fj_v:-{}}}\" = {} ]; }}",
omitted_default(k),
declared_value(k, v)
)
})
.collect();
(!parts.is_empty()).then(|| parts.join(" && "))
}
fn fs_count(t: &str) -> String {
format!(
"$(findmnt -n -o FSTYPE {t} 2>/dev/null | awk '$1 != \"autofs\" {{ n++ }} END {{ print n + 0 }}')"
)
}
fn is_automount(options: &str) -> bool {
options.split(',').any(|o| o == "x-systemd.automount")
}
fn detach_stack(t: &str, lazy: bool) -> String {
let n = fs_count(t);
let busy = if lazy {
format!("umount -l {t} 2>/dev/null || true")
} else {
format!(
"{{ echo \"forjar: mount \"{t}\" options drift, but it is busy; not detaching a path in use (#642)\" >&2; exit 1; }}"
)
};
format!(
"_fj_i=0\n \
while [ \"{n}\" -gt 0 ]; do\n \
_fj_i=$((_fj_i + 1)); [ \"$_fj_i\" -le 8 ] || {{ echo \"forjar: \"{t}\" re-mounts as fast as it is detached (#648)\" >&2; exit 1; }}\n \
umount {t} 2>/dev/null || {busy}\n \
done"
)
}
fn remount(t: &str, ft: &str, o: &str, s: &str, automount: bool, lazy: bool) -> String {
let detach = detach_stack(t, lazy);
if automount {
format!("systemctl daemon-reload\n {detach}\n ls {t} >/dev/null")
} else {
format!("{detach}\n mount -t {ft} -o {o} {s} {t}")
}
}
pub fn check_script(resource: &Resource) -> String {
let target = resource.path.as_deref().unwrap_or("/mnt/unknown");
let t = sh_squote(target);
let source = resource.source.as_deref().unwrap_or("none");
let s = sh_squote(source);
let mut condition = if resource.source.is_some() {
format!("[ \"$(findmnt -n -o SOURCE {t} 2>/dev/null | tail -1)\" = {s} ]")
} else {
format!("mountpoint -q {t} 2>/dev/null")
};
if let Some(opts) = options_condition(target, resource.options.as_deref()) {
condition = format!("{condition} && {opts}");
}
if resource.source.is_some() {
condition = format!("{condition} && [ \"{}\" -le 1 ]", fs_count(&t));
}
verdict::single(
&condition,
&format!("mounted:{target}"),
&format!("unmounted:{target}"),
)
}
pub fn apply_script(resource: &Resource) -> String {
let source = resource.source.as_deref().unwrap_or("none");
let target = resource.path.as_deref().unwrap_or("/mnt/unknown");
let fstype = resource.fs_type.as_deref().unwrap_or("auto");
let options = resource.options.as_deref().unwrap_or("defaults");
let state = resource.state.as_deref().unwrap_or("mounted");
let s = sh_squote(source);
let t = sh_squote(target);
let ft = sh_squote(fstype);
let o = sh_squote(options);
let mut lines = vec!["set -euo pipefail".to_string()];
match state {
"mounted" => {
lines.push(format!("mkdir -p {t}"));
let fstab_line = format!("{source} {target} {fstype} {options} 0 0");
let q_line = sh_squote(&fstab_line);
let awk_drop = sh_squote(&format!("$2 != \"{target}\""));
lines.push(format!(
"if ! grep -qxF {q_line} /etc/fstab 2>/dev/null; then\n \
_fj_tmp=$(mktemp)\n \
awk {awk_drop} /etc/fstab > \"$_fj_tmp\" 2>/dev/null || true\n \
printf '%s\\n' {q_line} >> \"$_fj_tmp\"\n \
cat \"$_fj_tmp\" > /etc/fstab\n \
rm -f \"$_fj_tmp\"\n\
fi"
));
let automount = is_automount(options);
lines.push(format!(
"_fj_cur=$(findmnt -n -o SOURCE {t} 2>/dev/null | tail -1 || true)\n\
if [ \"$_fj_cur\" != {s} ]; then\n \
{}\n\
fi",
remount(&t, &ft, &o, &s, automount, !automount)
));
let stacked = format!("[ \"{}\" -gt 1 ]", fs_count(&t));
let drift = match options_condition(target, resource.options.as_deref()) {
Some(opts) => format!("! {{ {opts}; }} || {stacked}"),
None => stacked,
};
lines.push(format!(
"if [ \"$(findmnt -n -o SOURCE {t} 2>/dev/null | tail -1 || true)\" = {s} ] && {{ {drift}; }}; then\n \
{}\n\
fi",
remount(&t, &ft, &o, &s, automount, false)
));
}
"unmounted" => {
lines.push(format!("if mountpoint -q {t}; then\n umount {t}\nfi"));
}
"absent" => {
lines.push(format!("if mountpoint -q {t}; then\n umount {t}\nfi"));
let sed_pattern = sed_escape(target);
lines.push(format!(
"sed -i {} /etc/fstab 2>/dev/null || true",
sh_squote(&format!("\\|{sed_pattern}|d"))
));
}
_ => {}
}
lines.join("\n")
}
pub fn state_query_script(resource: &Resource) -> String {
let target = resource.path.as_deref().unwrap_or("/mnt/unknown");
let t = sh_squote(target);
format!(
"if mountpoint -q {t}; then\n\
findmnt -n -o SOURCE,FSTYPE,OPTIONS {t} 2>/dev/null\n\
else\n\
echo 'UNMOUNTED'\n\
fi"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{MachineTarget, ResourceType};
fn mount_resource() -> Resource {
Resource {
resource_type: ResourceType::Mount,
machine: MachineTarget::Single("m1".to_string()),
path: Some("/mnt/data".to_string()),
source: Some("nas:/export".to_string()),
fs_type: Some("nfs".to_string()),
options: Some("rw,noatime".to_string()),
..Default::default()
}
}
#[test]
fn fj154_mount_fields_quoted() {
let r = mount_resource();
let script = apply_script(&r);
assert!(script.contains("mount -t 'nfs' -o 'rw,noatime' 'nas:/export' '/mnt/data'"));
assert!(script.contains("'nas:/export /mnt/data nfs rw,noatime 0 0'"));
}
#[test]
fn fj154_mount_source_injection_neutralized() {
let mut r = mount_resource();
r.source = Some("x';reboot;'".to_string());
let script = apply_script(&r);
assert!(script.contains("'x'\"'\"';reboot;'\"'\"''"));
assert!(!script.contains(" 'x';reboot"));
}
#[test]
fn fj154_mount_absent_sed_program_quoted() {
let mut r = mount_resource();
r.state = Some("absent".to_string());
let script = apply_script(&r);
assert!(script.contains("sed -i '\\|\\/mnt\\/data|d' /etc/fstab"));
}
#[test]
fn fj154_mount_absent_quote_in_path_neutralized() {
let mut r = mount_resource();
r.state = Some("absent".to_string());
r.path = Some("/mnt/x';reboot;'".to_string());
let script = apply_script(&r);
assert!(script.contains("'\"'\"'"));
assert!(!script.contains("sed -i '\\|\\/mnt\\/x';reboot"));
}
#[test]
fn fj154_mount_check_and_query_quoted() {
let r = mount_resource();
let c = check_script(&r);
assert!(c.contains("'/mnt/data'"), "target must be quoted: {c}");
assert!(c.contains("'nas:/export'"), "source must be quoted: {c}");
assert!(state_query_script(&r).contains("mountpoint -q '/mnt/data'"));
}
#[test]
fn fj165_mount_check_label_injection_neutralized() {
let mut r = mount_resource();
r.path = Some("x$(touch /tmp/pwn)".to_string());
let script = check_script(&r);
assert!(script.contains("echo 'mounted:x$(touch /tmp/pwn)'"));
assert!(script.contains("echo 'unmounted:x$(touch /tmp/pwn)'"));
assert!(!script.contains("echo mounted:x$(touch"));
assert!(!script.contains("' $(touch"));
}
}