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}