1use std::process::Stdio;
4use std::time::Duration;
5
6use tokio::process::Command;
7
8pub 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 child = Command::new(program)
24 .args(args)
25 .stdout(Stdio::piped())
26 .stderr(Stdio::piped())
27 .spawn()?;
28
29 let output =
30 match tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output())
31 .await
32 {
33 Ok(result) => result?,
34 Err(_) => anyhow::bail!("command timed out after {}s", timeout_secs),
35 };
36
37 let stdout = String::from_utf8_lossy(&output.stdout);
38 let stderr = String::from_utf8_lossy(&output.stderr);
39 let combined = if stderr.is_empty() {
40 stdout.into_owned()
41 } else if stdout.is_empty() {
42 stderr.into_owned()
43 } else {
44 format!("{stdout}{stderr}")
45 };
46
47 Ok((combined, output.status.success()))
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53
54 #[tokio::test]
55 async fn run_echo_no_shell() {
56 let (out, ok) = run_argv_command("echo hello", 5).await.unwrap();
57 assert!(ok);
58 assert!(out.contains("hello"));
59 }
60
61 #[tokio::test]
62 async fn semicolon_not_shell_metachar() {
63 let (out, ok) = run_argv_command("echo one;two", 5).await.unwrap();
64 assert!(ok);
65 assert!(out.contains("one;two") || out.contains("one"));
66 }
67}