Skip to main content

cfgd_core/util/
process.rs

1use super::fs_perms::is_executable;
2
3/// Grace period between SIGTERM and SIGKILL when a watchdog kills a child.
4/// A SIGTERM-trapping child gets a chance to clean up; if it's still alive
5/// past this window the watchdog escalates to SIGKILL so the daemon can
6/// reclaim the slot regardless of what the child does.
7pub const KILL_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(2);
8
9/// Run a [`Command`] with a timeout. On timeout the watchdog sends SIGTERM,
10/// waits [`KILL_GRACE_PERIOD`] for the child to exit cleanly, then escalates
11/// to SIGKILL (Unix) / `TerminateProcess` retry (Windows).
12///
13/// **Caveat**: if the child forks descendants that inherit its stdout/stderr
14/// pipes (e.g. a shell wrapper spawning a long-running grandchild), SIGKILL
15/// on the immediate child will not close those pipes — `wait_with_output`
16/// will block on them until the grandchild also dies. Production callers
17/// should invoke the target binary directly rather than via a shell wrapper.
18pub fn command_output_with_timeout(
19    cmd: &mut std::process::Command,
20    timeout: std::time::Duration,
21) -> std::io::Result<std::process::Output> {
22    use std::sync::mpsc;
23
24    let child = cmd.spawn()?;
25    let id = child.id();
26    let (tx, rx) = mpsc::channel();
27
28    std::thread::spawn(move || {
29        if rx.recv_timeout(timeout).is_err() {
30            terminate_process(id);
31            // SIGTERM-trapping children can hang the wait_with_output below
32            // indefinitely. Give them a grace window to flush, then escalate.
33            if rx.recv_timeout(KILL_GRACE_PERIOD).is_err() {
34                force_kill_process(id);
35            }
36        }
37    });
38
39    let result = child.wait_with_output();
40    let _ = tx.send(());
41    result
42}
43
44/// Send a graceful termination signal to a process by PID.
45/// Unix: sends SIGTERM. Windows: calls TerminateProcess.
46#[cfg(unix)]
47pub fn terminate_process(pid: u32) {
48    use nix::sys::signal::{Signal, kill};
49    use nix::unistd::Pid;
50    let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
51}
52
53#[cfg(windows)]
54pub fn terminate_process(pid: u32) {
55    use windows_sys::Win32::Foundation::CloseHandle;
56    use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess};
57    // SAFETY: `OpenProcess` is always sound to call with valid flags; it
58    // returns NULL on failure (checked below) or a valid handle we own. We
59    // call `TerminateProcess` and `CloseHandle` only with that owned
60    // handle, and `CloseHandle` runs exactly once per successful open, so
61    // there is no double-close or use-after-close.
62    unsafe {
63        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
64        if !handle.is_null() {
65            TerminateProcess(handle, 1);
66            CloseHandle(handle);
67        }
68    }
69}
70
71/// Send an uncatchable kill signal to a process by PID after the graceful
72/// terminate window has elapsed. Unix: SIGKILL. Windows: a second
73/// TerminateProcess call (idempotent — Windows kills are already uncatchable).
74#[cfg(unix)]
75pub fn force_kill_process(pid: u32) {
76    use nix::sys::signal::{Signal, kill};
77    use nix::unistd::Pid;
78    let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
79}
80
81#[cfg(windows)]
82pub fn force_kill_process(pid: u32) {
83    terminate_process(pid);
84}
85
86/// Check if the current process is running with elevated privileges.
87/// Unix: checks euid == 0. Windows: checks IsUserAnAdmin().
88#[cfg(unix)]
89pub fn is_root() -> bool {
90    use nix::unistd::geteuid;
91    geteuid().is_root()
92}
93
94#[cfg(windows)]
95pub fn is_root() -> bool {
96    use windows_sys::Win32::UI::Shell::IsUserAnAdmin;
97    // SAFETY: `IsUserAnAdmin` takes no parameters, has no preconditions,
98    // and returns a BOOL. It is safe to call from any thread at any time.
99    unsafe { IsUserAnAdmin() != 0 }
100}
101
102/// Get the system hostname as a String. Returns "unknown" on failure.
103pub fn hostname_string() -> String {
104    hostname::get()
105        .map(|h| h.to_string_lossy().to_string())
106        .unwrap_or_else(|_| "unknown".to_string())
107}
108
109/// Extract stdout from a `Command` output as a trimmed, lossy UTF-8 string.
110pub fn stdout_lossy_trimmed(output: &std::process::Output) -> String {
111    String::from_utf8_lossy(&output.stdout).trim().to_string()
112}
113
114/// Extract stderr from a `Command` output as a trimmed, lossy UTF-8 string.
115pub fn stderr_lossy_trimmed(output: &std::process::Output) -> String {
116    String::from_utf8_lossy(&output.stderr).trim().to_string()
117}
118
119/// Check if a command is available on the system via PATH lookup.
120/// On Windows, tries common executable extensions (.exe, .cmd, .bat, .ps1, .com)
121/// since executables require an extension to be found.
122pub fn command_available(cmd: &str) -> bool {
123    let extensions: &[&str] = if cfg!(windows) {
124        &["", ".exe", ".cmd", ".bat", ".ps1", ".com"]
125    } else {
126        &[""]
127    };
128    std::env::var_os("PATH")
129        .map(|paths| {
130            std::env::split_paths(&paths).any(|dir| {
131                extensions.iter().any(|ext| {
132                    let name = format!("{}{}", cmd, ext);
133                    let path = dir.join(&name);
134                    path.is_file()
135                        && std::fs::metadata(&path)
136                            .map(|m| is_executable(&path, &m))
137                            .unwrap_or(false)
138                })
139            })
140        })
141        .unwrap_or(false)
142}
143
144/// Build a `tracing_subscriber::EnvFilter` from `RUST_LOG` if set, falling
145/// back to `default`. Consolidates the four identical
146/// `EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(..))`
147/// scaffolds in `cfgd/main.rs`, `cfgd/cli/plugin.rs`, `cfgd-operator/main.rs`,
148/// and `cfgd-csi/main.rs`.
149pub fn tracing_env_filter(default: &str) -> tracing_subscriber::EnvFilter {
150    tracing_subscriber::EnvFilter::try_from_default_env()
151        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default))
152}
153
154/// Check that a CLI tool is available on PATH, returning a unified error
155/// string otherwise. Before this helper, six `if !command_available("X")`
156/// gates across `oci.rs` and `cli/module.rs` each produced a slightly
157/// different "not found" message; strings had diverged in production. Pass
158/// `install_hint` (a short imperative like "install it from https://...")
159/// to make the hint specific; `None` falls back to a generic "install it
160/// or add it to PATH".
161pub fn require_tool(name: &str, install_hint: Option<&str>) -> std::result::Result<(), String> {
162    if command_available(name) {
163        return Ok(());
164    }
165    Err(match install_hint {
166        Some(hint) => format!("{name} not found — {hint}"),
167        None => format!("{name} not found — install it or add it to PATH"),
168    })
169}
170
171/// Resolve an external tool's binary path, honoring a per-tool env-var test
172/// seam. Production code reads no env var and gets `default` (which `Command`
173/// resolves via `PATH`); tests set `env_var` to an absolute path of a shim
174/// binary. This is the SOLE supported override pattern for external CLIs.
175///
176/// Empty `env_var` (`""`) is treated as "no seam" and returns `default`
177/// unchanged; callers may dispatch a per-binary seam via match and fall
178/// through to `""` for unseamed binaries without panicking.
179///
180/// Naming convention: every active seam uses `CFGD_<NAME>_BIN` (e.g.
181/// `CFGD_COSIGN_BIN`, `CFGD_AGE_BIN`, `CFGD_BREW_BIN`, `CFGD_APT_CACHE_BIN`).
182/// New backends MUST follow this shape and reuse this helper rather than
183/// reinventing the override surface — keeps the test-shim ergonomics uniform.
184/// Pair every seam consumer with `serial_test::serial` because env-var mutation
185/// is process-global.
186pub fn tool_binary_name(env_var: &str, default: &str) -> String {
187    if env_var.is_empty() {
188        return default.to_string();
189    }
190    std::env::var(env_var).unwrap_or_else(|_| default.to_string())
191}
192
193/// Build a `Command` for an external tool, honoring [`tool_binary_name`]'s
194/// env-var override. Sets `stderr` to piped so callers can surface the
195/// tool's stderr in error messages without spamming the user's terminal.
196pub fn tool_cmd(env_var: &str, default: &str) -> std::process::Command {
197    let mut cmd = std::process::Command::new(tool_binary_name(env_var, default));
198    cmd.stderr(std::process::Stdio::piped());
199    cmd
200}
201
202/// Verify an external tool is available, honoring [`tool_binary_name`]'s
203/// env-var override.
204///
205/// When `env_var` is unset, falls through to a normal PATH lookup via
206/// [`require_tool`]. When set, treats the value as an absolute path and
207/// only checks that the file exists — no PATH walking. This mirrors how
208/// `Command::new(absolute_path)` actually executes the binary in tests.
209///
210/// Pair this with [`tool_cmd`] so `is_available` checks and command
211/// construction both go through the same seam.
212pub fn require_tool_with_seam(
213    env_var: &str,
214    default: &str,
215    install_hint: Option<&str>,
216) -> std::result::Result<(), String> {
217    if let Ok(custom) = std::env::var(env_var) {
218        let p = std::path::Path::new(&custom);
219        if p.is_file() {
220            return Ok(());
221        }
222        return Err(format!("{env_var} points to {custom} which is not a file"));
223    }
224    require_tool(default, install_hint)
225}
226
227/// Like [`command_available`] but also returns true when the env-var seam
228/// points at an existing file. Use in `is_available()` checks where the
229/// caller wants a bool, not a `Result`.
230pub fn command_available_with_seam(env_var: &str, default: &str) -> bool {
231    if let Ok(custom) = std::env::var(env_var) {
232        return std::path::Path::new(&custom).is_file();
233    }
234    command_available(default)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use serial_test::serial;
241
242    #[test]
243    fn hostname_string_returns_non_empty() {
244        let h = hostname_string();
245        assert!(!h.is_empty());
246        assert_ne!(h, "unknown");
247    }
248
249    #[test]
250    fn stdout_lossy_trimmed_trims_whitespace() {
251        let output = std::process::Output {
252            status: std::process::ExitStatus::default(),
253            stdout: b"  hello world  \n".to_vec(),
254            stderr: Vec::new(),
255        };
256        assert_eq!(stdout_lossy_trimmed(&output), "hello world");
257    }
258
259    #[test]
260    fn stderr_lossy_trimmed_trims_whitespace() {
261        let output = std::process::Output {
262            status: std::process::ExitStatus::default(),
263            stdout: Vec::new(),
264            stderr: b"\nerror message\n  ".to_vec(),
265        };
266        assert_eq!(stderr_lossy_trimmed(&output), "error message");
267    }
268
269    #[test]
270    fn stdout_lossy_trimmed_handles_invalid_utf8() {
271        let output = std::process::Output {
272            status: std::process::ExitStatus::default(),
273            stdout: vec![0xFF, 0xFE, b'a', b'b'],
274            stderr: Vec::new(),
275        };
276        let result = stdout_lossy_trimmed(&output);
277        assert!(result.contains("ab"));
278    }
279
280    #[test]
281    fn command_available_finds_sh() {
282        assert!(command_available("sh"));
283    }
284
285    #[test]
286    fn command_available_rejects_nonexistent() {
287        assert!(!command_available("absolutely-not-a-real-command-xyz"));
288    }
289
290    #[test]
291    fn require_tool_succeeds_for_sh() {
292        assert!(require_tool("sh", None).is_ok());
293    }
294
295    #[test]
296    fn require_tool_fails_for_nonexistent() {
297        let err = require_tool("not-a-real-tool-xyz", None).unwrap_err();
298        assert!(err.contains("not-a-real-tool-xyz"));
299        assert!(err.contains("not found"));
300    }
301
302    #[test]
303    fn require_tool_includes_custom_hint() {
304        let err = require_tool("missing-tool", Some("install via cargo")).unwrap_err();
305        assert!(err.contains("install via cargo"));
306    }
307
308    #[test]
309    #[serial]
310    fn tool_binary_name_empty_env_var_returns_default() {
311        assert_eq!(tool_binary_name("", "cosign"), "cosign");
312    }
313
314    #[test]
315    #[serial]
316    fn tool_binary_name_reads_env_var() {
317        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_TEST_TOOL_BIN", "/custom/path");
318        assert_eq!(
319            tool_binary_name("CFGD_TEST_TOOL_BIN", "default"),
320            "/custom/path"
321        );
322    }
323
324    #[test]
325    #[serial]
326    fn tool_binary_name_unset_env_returns_default() {
327        let _guard = crate::test_helpers::EnvVarGuard::unset("CFGD_TEST_TOOL_BIN_UNSET");
328        assert_eq!(
329            tool_binary_name("CFGD_TEST_TOOL_BIN_UNSET", "fallback"),
330            "fallback"
331        );
332    }
333
334    #[test]
335    #[serial]
336    fn require_tool_with_seam_env_pointing_to_file_succeeds() {
337        let tmp = tempfile::TempDir::new().unwrap();
338        let bin = tmp.path().join("tool");
339        std::fs::write(&bin, "").unwrap();
340        let _guard =
341            crate::test_helpers::EnvVarGuard::set("CFGD_TEST_SEAM_BIN", bin.to_str().unwrap());
342        assert!(require_tool_with_seam("CFGD_TEST_SEAM_BIN", "tool", None).is_ok());
343    }
344
345    #[test]
346    #[serial]
347    fn require_tool_with_seam_env_pointing_to_missing_file_fails() {
348        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_TEST_SEAM_BAD", "/no/such/file");
349        let err = require_tool_with_seam("CFGD_TEST_SEAM_BAD", "tool", None).unwrap_err();
350        assert!(err.contains("CFGD_TEST_SEAM_BAD"));
351        assert!(err.contains("not a file"));
352    }
353
354    #[test]
355    #[serial]
356    fn require_tool_with_seam_no_env_falls_through() {
357        let _guard = crate::test_helpers::EnvVarGuard::unset("CFGD_TEST_SEAM_NONE");
358        assert!(require_tool_with_seam("CFGD_TEST_SEAM_NONE", "sh", None).is_ok());
359    }
360
361    #[test]
362    #[serial]
363    fn command_available_with_seam_env_file_exists() {
364        let tmp = tempfile::TempDir::new().unwrap();
365        let bin = tmp.path().join("tool");
366        std::fs::write(&bin, "").unwrap();
367        let _guard =
368            crate::test_helpers::EnvVarGuard::set("CFGD_TEST_AVAIL_SEAM", bin.to_str().unwrap());
369        assert!(command_available_with_seam(
370            "CFGD_TEST_AVAIL_SEAM",
371            "nonexistent"
372        ));
373    }
374
375    #[test]
376    #[serial]
377    fn command_available_with_seam_env_file_missing() {
378        let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_TEST_AVAIL_BAD", "/no/such/file");
379        assert!(!command_available_with_seam("CFGD_TEST_AVAIL_BAD", "sh"));
380    }
381
382    #[test]
383    #[serial]
384    fn command_available_with_seam_no_env_falls_through() {
385        let _guard = crate::test_helpers::EnvVarGuard::unset("CFGD_TEST_AVAIL_NONE");
386        assert!(command_available_with_seam("CFGD_TEST_AVAIL_NONE", "sh"));
387    }
388
389    #[test]
390    fn tool_cmd_creates_command_with_piped_stderr() {
391        let cmd = tool_cmd("", "echo");
392        let prog = std::path::Path::new(cmd.get_program())
393            .file_name()
394            .and_then(|s| s.to_str())
395            .unwrap_or("");
396        assert_eq!(prog, "echo");
397    }
398
399    #[test]
400    fn command_output_with_timeout_succeeds() {
401        let mut cmd = std::process::Command::new("echo");
402        cmd.arg("hello").stdout(std::process::Stdio::piped());
403        let output =
404            command_output_with_timeout(&mut cmd, std::time::Duration::from_secs(5)).unwrap();
405        assert!(output.status.success());
406        assert!(stdout_lossy_trimmed(&output).contains("hello"));
407    }
408
409    #[test]
410    fn command_output_with_timeout_kills_on_exceed() {
411        let mut cmd = std::process::Command::new("sleep");
412        cmd.arg("60");
413        let result = command_output_with_timeout(&mut cmd, std::time::Duration::from_millis(100));
414        assert!(
415            result.is_ok(),
416            "process should be killed but still return output"
417        );
418        let output = result.unwrap();
419        assert!(!output.status.success());
420    }
421
422    #[cfg(unix)]
423    #[test]
424    fn force_kill_process_signals_sigkill() {
425        // Spawn a SIGTERM-trapping child, force_kill_process it, assert it exits
426        // with SIGKILL (signal 9).
427        let mut child = std::process::Command::new("sh")
428            .arg("-c")
429            .arg("trap '' TERM; sleep 30")
430            .stdout(std::process::Stdio::null())
431            .stderr(std::process::Stdio::null())
432            .spawn()
433            .unwrap();
434        let pid = child.id();
435
436        force_kill_process(pid);
437
438        let status = child.wait().unwrap();
439        use std::os::unix::process::ExitStatusExt;
440        assert_eq!(
441            status.signal(),
442            Some(9),
443            "expected SIGKILL (9), got status: {status:?}"
444        );
445    }
446
447    #[test]
448    fn is_root_returns_bool() {
449        let _ = is_root();
450    }
451
452    #[test]
453    fn tracing_env_filter_uses_default_when_no_env() {
454        let filter = tracing_env_filter("warn");
455        let s = format!("{filter}");
456        assert!(s.contains("warn") || !s.is_empty());
457    }
458}