1use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::time::{Duration, Instant};
6
7use tokio::io::{AsyncRead, AsyncReadExt};
8use tokio::process::{Child, Command};
9use tokio_util::sync::CancellationToken;
10
11use crate::Host;
12
13#[derive(Debug, Clone)]
15pub struct ExecRequest {
16 pub command: String,
18 pub cwd: PathBuf,
20 pub timeout: Option<Duration>,
22 pub env: Vec<(String, String)>,
24}
25
26#[derive(Debug, Clone)]
28pub struct ExecOutput {
29 pub stdout: String,
31 pub stderr: String,
33 pub combined: String,
35 pub exit_code: Option<i32>,
37 pub timed_out: bool,
39 pub cancelled: bool,
41 pub truncated: bool,
43 pub duration: Duration,
45}
46
47#[derive(Debug, thiserror::Error)]
50pub enum ExecError {
51 #[error("failed to spawn shell: {0}")]
53 Spawn(String),
54}
55
56impl Host {
57 pub async fn exec(
66 &self,
67 req: ExecRequest,
68 cancel: &CancellationToken,
69 ) -> Result<ExecOutput, ExecError> {
70 let timeout = req
71 .timeout
72 .unwrap_or(self.limits.default_timeout)
73 .min(self.limits.max_timeout);
74 let cmd = self.build_shell_command(&req);
75 self.run_captured(cmd, timeout, cancel).await
76 }
77
78 pub async fn run_capture(
85 &self,
86 program: &Path,
87 args: &[String],
88 cwd: &Path,
89 timeout: Option<Duration>,
90 cancel: &CancellationToken,
91 ) -> Result<ExecOutput, ExecError> {
92 let timeout = timeout
93 .unwrap_or(self.limits.default_timeout)
94 .min(self.limits.max_timeout);
95 let mut cmd = Command::new(program);
96 cmd.args(args);
97 cmd.current_dir(cwd);
98 cmd.stdin(Stdio::null())
99 .stdout(Stdio::piped())
100 .stderr(Stdio::piped());
101 #[cfg(unix)]
102 cmd.process_group(0);
103 self.run_captured(cmd, timeout, cancel).await
104 }
105
106 async fn run_captured(
108 &self,
109 mut cmd: Command,
110 timeout: Duration,
111 cancel: &CancellationToken,
112 ) -> Result<ExecOutput, ExecError> {
113 let started = Instant::now();
114 let cap = self.limits.max_output_bytes;
115
116 let mut child = cmd.spawn().map_err(|e| ExecError::Spawn(e.to_string()))?;
117 let pid = child.id();
118
119 let stdout = child.stdout.take();
120 let stderr = child.stderr.take();
121 let out_task = tokio::spawn(read_capped(stdout, cap));
122 let err_task = tokio::spawn(read_capped(stderr, cap));
123
124 let mut exit_code = None;
125 let mut timed_out = false;
126 let mut cancelled = false;
127 tokio::select! {
128 status = child.wait() => {
129 exit_code = status.ok().and_then(|s| s.code());
130 }
131 () = tokio::time::sleep(timeout) => {
132 timed_out = true;
133 terminate(&mut child, pid, self.limits.kill_grace).await;
134 }
135 () = cancel.cancelled() => {
136 cancelled = true;
137 terminate(&mut child, pid, self.limits.kill_grace).await;
138 }
139 }
140
141 let (stdout, out_trunc) = out_task.await.unwrap_or_else(|_| (Vec::new(), false));
142 let (stderr, err_trunc) = err_task.await.unwrap_or_else(|_| (Vec::new(), false));
143 let stdout = String::from_utf8_lossy(&stdout).into_owned();
144 let stderr = String::from_utf8_lossy(&stderr).into_owned();
145 let combined = combine(&stdout, &stderr);
146
147 Ok(ExecOutput {
148 stdout,
149 stderr,
150 combined,
151 exit_code,
152 timed_out,
153 cancelled,
154 truncated: out_trunc || err_trunc,
155 duration: started.elapsed(),
156 })
157 }
158
159 fn build_shell_command(&self, req: &ExecRequest) -> Command {
160 let mut cmd = Command::new(&self.shell_program);
161 cmd.arg(if self.login_shell { "-lc" } else { "-c" });
162 cmd.arg(&req.command);
163 cmd.current_dir(&req.cwd);
164 cmd.stdin(Stdio::null())
165 .stdout(Stdio::piped())
166 .stderr(Stdio::piped());
167 for (key, value) in &req.env {
168 cmd.env(key, value);
169 }
170 #[cfg(unix)]
173 cmd.process_group(0);
174 cmd
175 }
176}
177
178async fn read_capped<R: AsyncRead + Unpin>(reader: Option<R>, cap: usize) -> (Vec<u8>, bool) {
181 let Some(mut reader) = reader else {
182 return (Vec::new(), false);
183 };
184 let mut buf = Vec::new();
185 let mut chunk = [0u8; 8192];
186 let mut truncated = false;
187 loop {
188 match reader.read(&mut chunk).await {
189 Ok(0) | Err(_) => break,
190 Ok(n) => {
191 buf.extend_from_slice(&chunk[..n]);
192 if buf.len() > cap {
193 let excess = buf.len() - cap;
194 buf.drain(..excess);
195 truncated = true;
196 }
197 }
198 }
199 }
200 (buf, truncated)
201}
202
203async fn terminate(child: &mut Child, pid: Option<u32>, grace: Duration) {
206 if group_kill(child, pid, grace).await {
207 return;
208 }
209 let _ = child.start_kill();
210 let _ = child.wait().await;
211}
212
213#[cfg(unix)]
214async fn group_kill(child: &mut Child, pid: Option<u32>, grace: Duration) -> bool {
215 use nix::sys::signal::{Signal, killpg};
216 use nix::unistd::Pid;
217
218 let Some(pid) = pid else { return false };
219 let Ok(raw) = i32::try_from(pid) else {
220 return false;
221 };
222 if raw <= 1 {
225 return false;
226 }
227 let leader = Pid::from_raw(raw);
228 let _ = killpg(leader, Signal::SIGTERM);
229 if tokio::time::timeout(grace, child.wait()).await.is_err() {
230 let _ = killpg(leader, Signal::SIGKILL);
231 let _ = child.wait().await;
232 }
233 true
234}
235
236#[cfg(not(unix))]
237async fn group_kill(_child: &mut Child, _pid: Option<u32>, _grace: Duration) -> bool {
238 false
239}
240
241fn combine(stdout: &str, stderr: &str) -> String {
243 match (stdout.is_empty(), stderr.is_empty()) {
244 (false, false) => format!("{stdout}\n{stderr}"),
245 (false, true) => stdout.to_string(),
246 (true, false) => stderr.to_string(),
247 (true, true) => String::new(),
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::{ExecLimits, Host, HostConfig, PathPolicy, test_host};
255 use std::time::Duration;
256 use tempfile::tempdir;
257
258 fn req(command: &str, cwd: PathBuf) -> ExecRequest {
259 ExecRequest {
260 command: command.to_string(),
261 cwd,
262 timeout: None,
263 env: Vec::new(),
264 }
265 }
266
267 fn shell_host(root: &std::path::Path) -> Host {
270 test_host(root, PathPolicy::Jailed, false)
271 }
272
273 #[tokio::test]
274 async fn captures_stdout_and_exit() {
275 let dir = tempdir().unwrap();
276 let host = shell_host(dir.path());
277 let out = host
278 .exec(
279 req("echo hello", dir.path().into()),
280 &CancellationToken::new(),
281 )
282 .await
283 .unwrap();
284 assert!(out.stdout.contains("hello"));
285 assert_eq!(out.exit_code, Some(0));
286 assert!(!out.timed_out && !out.cancelled);
287 }
288
289 #[tokio::test]
290 async fn captures_stderr_and_nonzero_exit() {
291 let dir = tempdir().unwrap();
292 let host = shell_host(dir.path());
293 let out = host
294 .exec(
295 req(">&2 echo boom; exit 3", dir.path().into()),
296 &CancellationToken::new(),
297 )
298 .await
299 .unwrap();
300 assert!(out.stderr.contains("boom"));
301 assert_eq!(out.exit_code, Some(3));
302 }
303
304 #[tokio::test]
305 async fn timeout_kills_sleeper() {
306 let dir = tempdir().unwrap();
307 let mut config = HostConfig::new(dir.path());
308 config.login_shell = false;
309 config.exec = ExecLimits {
310 default_timeout: Duration::from_millis(200),
311 ..ExecLimits::default()
312 };
313 let host = Host::new(config).unwrap();
314 let started = Instant::now();
315 let out = host
316 .exec(
317 req("sleep 30", dir.path().into()),
318 &CancellationToken::new(),
319 )
320 .await
321 .unwrap();
322 assert!(out.timed_out);
323 assert!(out.exit_code.is_none());
324 assert!(
325 started.elapsed() < Duration::from_secs(5),
326 "should not wait for the sleep"
327 );
328 }
329
330 #[tokio::test]
331 async fn cancellation_kills_running_command() {
332 let dir = tempdir().unwrap();
333 let host = shell_host(dir.path());
334 let cancel = CancellationToken::new();
335 let child_cancel = cancel.clone();
336 tokio::spawn(async move {
337 tokio::time::sleep(Duration::from_millis(50)).await;
338 child_cancel.cancel();
339 });
340 let started = Instant::now();
341 let out = host
342 .exec(req("sleep 30", dir.path().into()), &cancel)
343 .await
344 .unwrap();
345 assert!(out.cancelled);
346 assert!(started.elapsed() < Duration::from_secs(5));
347 }
348
349 #[tokio::test]
350 async fn output_over_cap_is_truncated() {
351 let dir = tempdir().unwrap();
352 let mut config = HostConfig::new(dir.path());
353 config.login_shell = false;
354 config.exec = ExecLimits {
355 max_output_bytes: 1000,
356 ..ExecLimits::default()
357 };
358 let host = Host::new(config).unwrap();
359 let out = host
361 .exec(
362 req(
363 "for i in $(seq 1 20000); do echo 0123456789; done",
364 dir.path().into(),
365 ),
366 &CancellationToken::new(),
367 )
368 .await
369 .unwrap();
370 assert!(out.truncated);
371 assert!(
372 out.stdout.len() <= 1000 + 16,
373 "kept ~cap bytes, got {}",
374 out.stdout.len()
375 );
376 }
377
378 #[tokio::test]
379 async fn null_stdin_does_not_hang() {
380 let dir = tempdir().unwrap();
381 let host = shell_host(dir.path());
382 let out = host
384 .exec(req("cat", dir.path().into()), &CancellationToken::new())
385 .await
386 .unwrap();
387 assert_eq!(out.exit_code, Some(0));
388 }
389}