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 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}