earl_protocol_bash/
executor.rs1use 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
12pub 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 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 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 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 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 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
132pub 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}