Skip to main content

earl_protocol_bash/
executor.rs

1use std::process::Stdio;
2use std::time::Duration;
3
4use anyhow::{Context, Result, bail};
5use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
6
7use earl_core::{ExecutionContext, RawExecutionResult};
8
9use crate::PreparedBashScript;
10use crate::sandbox::{build_sandboxed_command, sandbox_available, sandbox_tool_name};
11
12/// Execute a single bash script inside a sandbox and return the result.
13pub async fn execute_bash_once(
14    data: &PreparedBashScript,
15    ctx: &ExecutionContext,
16) -> Result<RawExecutionResult> {
17    if !sandbox_available() {
18        bail!(
19            "bash sandbox tool ({}) is not available on this system; \
20             install it or disable the bash feature",
21            sandbox_tool_name()
22        );
23    }
24
25    let mut command =
26        build_sandboxed_command(&data.script, &data.env, data.cwd.as_deref(), &data.sandbox)?;
27
28    command.stdout(Stdio::piped());
29    command.stderr(Stdio::piped());
30
31    if data.stdin.is_some() {
32        command.stdin(Stdio::piped());
33    } else {
34        command.stdin(Stdio::null());
35    }
36
37    // SAFETY: setsid() creates a new session / process group so that we can
38    // kill the entire group on timeout without leaking children.
39    unsafe {
40        command.pre_exec(|| {
41            libc::setsid();
42            Ok(())
43        });
44    }
45
46    let mut child = command
47        .spawn()
48        .context("failed spawning sandboxed bash command")?;
49
50    let pid = child
51        .id()
52        .ok_or_else(|| anyhow::anyhow!("failed to obtain PID of spawned bash process"))?;
53
54    let stdout = child
55        .stdout
56        .take()
57        .ok_or_else(|| anyhow::anyhow!("failed capturing bash stdout"))?;
58    let stderr = child
59        .stderr
60        .take()
61        .ok_or_else(|| anyhow::anyhow!("failed capturing bash stderr"))?;
62
63    // Use sandbox output limit if set, otherwise fall back to transport limit.
64    let max_bytes = data
65        .sandbox
66        .max_output_bytes
67        .unwrap_or(ctx.transport.max_response_bytes);
68
69    let stdout_reader =
70        tokio::spawn(async move { read_stream_limited(stdout, max_bytes, "stdout").await });
71    let stderr_reader =
72        tokio::spawn(async move { read_stream_limited(stderr, max_bytes, "stderr").await });
73
74    // Write stdin if present, then drop the handle so the child sees EOF.
75    if let Some(input) = &data.stdin
76        && let Some(mut stdin_handle) = child.stdin.take()
77    {
78        stdin_handle
79            .write_all(input.as_bytes())
80            .await
81            .context("failed writing stdin to bash process")?;
82    }
83
84    // Use sandbox timeout if set, otherwise fall back to transport timeout.
85    let timeout = data
86        .sandbox
87        .max_time_ms
88        .map(Duration::from_millis)
89        .unwrap_or(ctx.transport.timeout);
90
91    let status = match tokio::time::timeout(timeout, child.wait()).await {
92        Ok(wait_result) => wait_result.context("failed waiting for bash process")?,
93        Err(_) => {
94            // Timeout: kill the entire process group.
95            if let Ok(pgid) = i32::try_from(pid) {
96                unsafe { libc::killpg(pgid, libc::SIGKILL) };
97            }
98            let _ = child.kill().await;
99            let _ = child.wait().await;
100            bail!("bash script timed out after {timeout:?}");
101        }
102    };
103
104    let stdout_bytes = stdout_reader
105        .await
106        .context("failed joining stdout reader task")??;
107    let stderr_bytes = stderr_reader
108        .await
109        .context("failed joining stderr reader task")??;
110
111    let exit_code = status
112        .code()
113        .map(|c| c.clamp(0, u16::MAX as i32) as u16)
114        .unwrap_or(1);
115
116    let output_bytes = if stdout_bytes.is_empty() {
117        &stderr_bytes
118    } else {
119        &stdout_bytes
120    };
121
122    Ok(RawExecutionResult {
123        status: exit_code,
124        url: "bash://script".into(),
125        body: output_bytes.to_vec(),
126        content_type: None,
127    })
128}
129
130use earl_core::ProtocolExecutor;
131
132/// Bash protocol executor.
133pub struct BashExecutor;
134
135impl ProtocolExecutor for BashExecutor {
136    type PreparedData = PreparedBashScript;
137
138    async fn execute(
139        &mut self,
140        data: &PreparedBashScript,
141        ctx: &ExecutionContext,
142    ) -> anyhow::Result<RawExecutionResult> {
143        execute_bash_once(data, ctx).await
144    }
145}
146
147async fn read_stream_limited<R>(mut reader: R, limit: usize, label: &str) -> Result<Vec<u8>>
148where
149    R: AsyncRead + Unpin,
150{
151    let mut out = Vec::new();
152    let mut buf = [0_u8; 8192];
153
154    loop {
155        let bytes_read = reader
156            .read(&mut buf)
157            .await
158            .with_context(|| format!("failed reading bash {label}"))?;
159        if bytes_read == 0 {
160            break;
161        }
162        if out.len().saturating_add(bytes_read) > limit {
163            bail!("bash {label} exceeded configured max_response_bytes ({limit} bytes)");
164        }
165        out.extend_from_slice(&buf[..bytes_read]);
166    }
167
168    Ok(out)
169}