cfgd_core/util/
process.rs1use super::fs_perms::is_executable;
2
3pub const KILL_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(2);
8
9pub 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 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#[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 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#[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#[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 unsafe { IsUserAnAdmin() != 0 }
100}
101
102pub 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
109pub fn stdout_lossy_trimmed(output: &std::process::Output) -> String {
111 String::from_utf8_lossy(&output.stdout).trim().to_string()
112}
113
114pub fn stderr_lossy_trimmed(output: &std::process::Output) -> String {
116 String::from_utf8_lossy(&output.stderr).trim().to_string()
117}
118
119pub 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
144pub 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
154pub 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
171pub 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
193pub 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
202pub 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
227pub 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 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}