Skip to main content

apollo/
process_cmd.rs

1//! Spawn processes from a command line without `sh -c` (no shell metacharacter interpretation).
2
3use std::process::Stdio;
4use std::time::Duration;
5
6use tokio::process::Command;
7
8/// Run `command` as argv[0..] via `shlex` (no shell). Returns (stdout+stderr text, success).
9pub async fn run_argv_command(command: &str, timeout_secs: u64) -> anyhow::Result<(String, bool)> {
10    let trimmed = command.trim();
11    if trimmed.is_empty() {
12        anyhow::bail!("empty command");
13    }
14
15    let parts = shlex::split(trimmed).ok_or_else(|| anyhow::anyhow!("invalid command quoting"))?;
16    if parts.is_empty() {
17        anyhow::bail!("empty command");
18    }
19
20    let program = &parts[0];
21    let args = &parts[1..];
22
23    let mut child = Command::new(program)
24        .args(args)
25        .stdout(Stdio::piped())
26        .stderr(Stdio::piped())
27        .kill_on_drop(true)
28        .spawn()?;
29
30    let output = match crate::tools::child_proc::wait_with_timeout(
31        &mut child,
32        Duration::from_secs(timeout_secs),
33    )
34    .await?
35    {
36        Some(output) => output,
37        None => anyhow::bail!("command timed out after {}s", timeout_secs),
38    };
39
40    let stdout = String::from_utf8_lossy(&output.stdout);
41    let stderr = String::from_utf8_lossy(&output.stderr);
42    let combined = if stderr.is_empty() {
43        stdout.into_owned()
44    } else if stdout.is_empty() {
45        stderr.into_owned()
46    } else {
47        format!("{stdout}{stderr}")
48    };
49
50    Ok((combined, output.status.success()))
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[tokio::test]
58    async fn run_echo_no_shell() {
59        let (out, ok) = run_argv_command("echo hello", 5).await.unwrap();
60        assert!(ok);
61        assert!(out.contains("hello"));
62    }
63
64    #[tokio::test]
65    async fn semicolon_not_shell_metachar() {
66        let (out, ok) = run_argv_command("echo one;two", 5).await.unwrap();
67        assert!(ok);
68        assert!(out.contains("one;two") || out.contains("one"));
69    }
70
71    #[tokio::test]
72    async fn empty_command_is_rejected() {
73        for command in ["", "   ", "\t\n"] {
74            let err = run_argv_command(command, 5).await.unwrap_err();
75            assert!(
76                err.to_string().contains("empty command"),
77                "{command:?} -> {err}"
78            );
79        }
80    }
81
82    #[tokio::test]
83    async fn invalid_quoting_is_rejected() {
84        let err = run_argv_command("echo 'unterminated", 5).await.unwrap_err();
85        assert!(
86            err.to_string().contains("invalid command quoting"),
87            "got: {err}"
88        );
89    }
90
91    #[tokio::test]
92    async fn nonzero_exit_is_reported_as_failure() {
93        let (out, ok) = run_argv_command("false", 5).await.unwrap();
94        assert!(!ok, "got output: {out}");
95    }
96
97    #[tokio::test]
98    async fn a_timed_out_command_is_killed_not_left_running() {
99        let tmp = tempfile::tempdir().unwrap();
100        let marker = tmp.path().join("still-alive");
101        let script = tmp.path().join("hold.sh");
102        std::fs::write(
103            &script,
104            format!("#!/bin/sh\nsleep 3\ntouch '{}'\n", marker.display()),
105        )
106        .unwrap();
107        #[cfg(unix)]
108        {
109            use std::os::unix::fs::PermissionsExt;
110            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
111        }
112
113        let err = run_argv_command(script.to_str().unwrap(), 1)
114            .await
115            .unwrap_err();
116        assert!(err.to_string().contains("timed out"), "got: {err}");
117        tokio::time::sleep(std::time::Duration::from_secs(4)).await;
118        assert!(
119            !marker.exists(),
120            "the killed command must not have kept running to completion"
121        );
122    }
123}