Skip to main content

locode_host/
shell.rs

1//! Shell execution: capture stdout/stderr/exit, hard timeout, output cap, group-kill.
2
3use 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/// A command to run through the host's shell.
14#[derive(Debug, Clone)]
15pub struct ExecRequest {
16    /// The command line, run as `<shell> -lc <command>` (or `-c` when not a login shell).
17    pub command: String,
18    /// Working directory (the caller jail-resolves this).
19    pub cwd: PathBuf,
20    /// Per-call timeout; `None` uses the host default, then clamped to the host max.
21    pub timeout: Option<Duration>,
22    /// Extra environment variables, layered on top of the inherited environment.
23    pub env: Vec<(String, String)>,
24}
25
26/// The captured result of a command (completed, timed out, or cancelled).
27#[derive(Debug, Clone)]
28pub struct ExecOutput {
29    /// Captured stdout (tail-retained to the byte cap, lossy UTF-8).
30    pub stdout: String,
31    /// Captured stderr (tail-retained to the byte cap, lossy UTF-8).
32    pub stderr: String,
33    /// stdout then stderr, the convenience most tools render.
34    pub combined: String,
35    /// The exit code, or `None` if the command was killed by a signal.
36    pub exit_code: Option<i32>,
37    /// Whether the hard timeout fired.
38    pub timed_out: bool,
39    /// Whether cooperative cancellation fired.
40    pub cancelled: bool,
41    /// Whether either stream hit the byte cap (older bytes dropped).
42    pub truncated: bool,
43    /// Wall-clock duration.
44    pub duration: Duration,
45}
46
47/// A failure to *spawn or capture* — not a failed command (that is a successful capture
48/// with a non-zero `exit_code`).
49#[derive(Debug, thiserror::Error)]
50pub enum ExecError {
51    /// The shell process could not be spawned.
52    #[error("failed to spawn shell: {0}")]
53    Spawn(String),
54}
55
56impl Host {
57    /// Run `req` through the shell, capturing output under the host's limits and honoring
58    /// cooperative `cancel`.
59    ///
60    /// A command that fails, times out, or is cancelled is a **successful capture** with
61    /// the corresponding fields set — the pack decides how to present it.
62    ///
63    /// # Errors
64    /// [`ExecError::Spawn`] only if the shell process itself cannot be started.
65    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    /// Run a resolved program with explicit argv (**no shell** — safe for untrusted args
79    /// like a regex or glob), capturing output under the same limits/kill/cancel machinery
80    /// as [`Host::exec`]. Used by rg-backed tools (Task 11).
81    ///
82    /// # Errors
83    /// [`ExecError::Spawn`] if the program cannot be started (e.g. `rg` not on PATH).
84    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    /// The shared spawn → bounded-capture → timeout/cancel-kill → assemble body.
107    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        // New process group so we can kill the whole tree, not just the shell. This is a
171        // safe API — the fork-time `setpgid` lives inside std/tokio, not our crate.
172        #[cfg(unix)]
173        cmd.process_group(0);
174        cmd
175    }
176}
177
178/// Read a child stream, retaining at most `cap` bytes (drop oldest — the tail holds the
179/// error/exit summary). Bounds peak memory to O(cap) rather than O(total output).
180async 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
203/// Kill a running child: its whole process group on Unix (SIGTERM → grace → SIGKILL),
204/// else the direct child. Best-effort; always reaps.
205async 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    // Guard against a degenerate pgid (0/1) that would broadcast the signal. A child
223    // spawned with `process_group(0)` leads its own group with pid > 1.
224    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
241/// stdout then stderr, separated by a newline when both are present.
242fn 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    // Tests run the shell as a NON-login shell (`-c`) so login-profile output can't
268    // pollute the captured streams.
269    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        // Print ~200 KB.
360        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        // `cat` with no args reads stdin; null stdin → immediate EOF.
383        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}