switchyard/preflight/
checks.rs

1use std::fs;
2use std::os::unix::fs::MetadataExt;
3use std::path::Path;
4
5/// Ensure the filesystem backing `path` is read-write and not mounted with noexec.
6/// Returns Ok(()) if suitable; Err(String) with a human message otherwise.
7///
8/// # Errors
9///
10/// Returns an error string if the filesystem is not suitable or if there are issues
11/// accessing the filesystem information.
12pub fn ensure_mount_rw_exec(path: &Path) -> Result<(), String> {
13    // Delegate to fs::mount inspector; fail closed on ambiguity.
14    match crate::fs::mount::ensure_rw_exec(&crate::fs::mount::ProcStatfsInspector, path) {
15        Ok(()) => Ok(()),
16        Err(_) => Err(format!(
17            "Filesystem at '{}' not suitable or ambiguous (requires rw and exec)",
18            path.display()
19        )),
20    }
21}
22
23/// Detect hardlink hazard: returns Ok(true) when the target node has more than one
24/// hardlink (nlink > 1). Uses `symlink_metadata` to avoid following symlinks; callers
25/// may optionally resolve and re-check as needed.
26///
27/// # Errors
28///
29/// Returns an IO error if there are issues accessing the file metadata.
30pub fn check_hardlink_hazard(path: &Path) -> std::io::Result<bool> {
31    if let Ok(md) = fs::symlink_metadata(path) {
32        // Only consider regular files for this hazard; symlinks/dirs are ignored.
33        let ft = md.file_type();
34        if ft.is_file() {
35            let n = md.nlink();
36            return Ok(n > 1);
37        }
38    }
39    Ok(false)
40}
41
42/// Best-effort check for SUID/SGID risk on a target path.
43/// Returns Ok(true) when either SUID (04000) or SGID (02000) bit is set on the
44/// resolved file; Ok(false) otherwise. On errors reading metadata, returns Ok(false)
45/// to avoid spurious stops; callers may add an informational note if desired.
46///
47/// # Errors
48///
49/// Returns an IO error if there are issues accessing the file metadata.
50pub fn check_suid_sgid_risk(path: &Path) -> std::io::Result<bool> {
51    // If path is a symlink, resolve to the destination for inspection.
52    let inspect_path = if let Ok(md) = fs::symlink_metadata(path) {
53        if md.file_type().is_symlink() {
54            if let Some(p) = crate::fs::meta::resolve_symlink_target(path) {
55                p
56            } else {
57                path.to_path_buf()
58            }
59        } else {
60            path.to_path_buf()
61        }
62    } else {
63        path.to_path_buf()
64    };
65    if let Ok(meta) = fs::metadata(&inspect_path) {
66        let mode = meta.mode();
67        let risk = (mode & 0o6000) != 0; // SUID (04000) or SGID (02000)
68        return Ok(risk);
69    }
70    Ok(false)
71}
72
73/// Best-effort check for the immutable attribute via `lsattr -d`.
74/// Returns `Err(String)` only when the target itself is immutable.
75/// If `lsattr` is missing or fails, this returns `Ok(())` (best-effort).
76///
77/// # Errors
78///
79/// Returns an error string if the target is immutable.
80pub fn check_immutable(path: &Path) -> Result<(), String> {
81    // Heuristic via lsattr -d; best-effort and non-fatal when unavailable
82    let Ok(output) = std::process::Command::new("lsattr")
83        .arg("-d")
84        .arg(path) // avoid lossy UTF-8 conversion
85        .output()
86    else {
87        return Ok(());
88    };
89
90    if !output.status.success() {
91        return Ok(()); // non-zero exit from lsattr -> treat as inconclusive
92    }
93
94    let stdout = String::from_utf8_lossy(&output.stdout);
95    for line in stdout.lines() {
96        if let Some(attrs) = line.split_whitespace().next() {
97            if attrs.contains('i') {
98                return Err(format!(
99                    "Target '{}' is immutable (chattr +i). Run: chattr -i -- {}",
100                    path.display(),
101                    path.display()
102                ));
103            }
104        }
105    }
106    Ok(())
107}
108
109/// Source trust checks. Returns Err(String) if untrusted and `force` is false. When `force` is true,
110/// returns Ok(()) and leaves it to callers to emit warnings.
111///
112/// # Errors
113///
114/// Returns an error string if the source is untrusted and force is false, or if
115/// there are issues accessing the source file metadata.
116pub fn check_source_trust(source: &Path, force: bool) -> Result<(), String> {
117    let meta = fs::symlink_metadata(source).map_err(|e| format!("{e}"))?;
118    let mode = meta.mode();
119    if (mode & 0o002) != 0 && !force {
120        return Err(format!(
121            "Untrusted source (world-writable): {}. Pass --force to override.",
122            source.display()
123        ));
124    }
125    if meta.uid() != 0 && !force {
126        return Err(format!(
127            "Untrusted source (not root-owned): {}. Pass --force to override.",
128            source.display()
129        ));
130    }
131    ensure_mount_rw_exec(source)?;
132    if let Ok(home) = std::env::var("HOME") {
133        let home_p = Path::new(&home);
134        if source.starts_with(home_p) && !force {
135            return Err(format!(
136                "Untrusted source under HOME: {}. Pass --force to override.",
137                source.display()
138            ));
139        }
140    }
141    Ok(())
142}