1use std::{
2 io::{self, Read, Write},
3 path::PathBuf,
4 process::{Child, Command, ExitStatus, Stdio},
5 sync::{
6 Arc, Mutex,
7 mpsc::{self, Receiver, RecvTimeoutError, Sender},
8 },
9 thread,
10 time::{Duration, Instant},
11};
12
13#[cfg(unix)]
14use std::os::unix::process::CommandExt as _;
15
16use sim_kernel::{CapabilityName, Cx, Error, Expr, NumberLiteral, Result, Symbol};
17
18use crate::timeout::terminate_timed_out_child;
19
20pub fn exec_capability() -> CapabilityName {
22 CapabilityName::new("exec")
23}
24
25pub fn proc_result_symbol() -> Symbol {
27 Symbol::new("ProcResult")
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct ExecOptions {
33 pub cwd: Option<PathBuf>,
37 pub root: Option<PathBuf>,
43 pub timeout_ms: u64,
45 pub max_output_bytes: usize,
47 pub stdin: Option<Vec<u8>>,
49}
50
51impl ExecOptions {
52 pub fn new(timeout_ms: u64, max_output_bytes: usize) -> Self {
54 Self {
55 cwd: None,
56 root: None,
57 timeout_ms,
58 max_output_bytes,
59 stdin: None,
60 }
61 }
62
63 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
65 self.cwd = Some(cwd.into());
66 self.root = Some(root.into());
67 self
68 }
69
70 pub fn with_stdin(mut self, stdin: impl Into<Vec<u8>>) -> Self {
72 self.stdin = Some(stdin.into());
73 self
74 }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct ProcResult {
80 pub stdout: String,
82 pub stderr: String,
84 pub exit_code: i32,
86 pub truncated: bool,
88}
89
90impl ProcResult {
91 pub fn to_constructor_expr(&self) -> Expr {
93 Expr::Call {
94 operator: Box::new(Expr::Symbol(proc_result_symbol())),
95 args: vec![
96 Expr::String(self.stdout.clone()),
97 Expr::String(self.stderr.clone()),
98 Expr::Number(NumberLiteral {
99 domain: Symbol::qualified("numbers", "i64"),
100 canonical: self.exit_code.to_string(),
101 }),
102 Expr::Bool(self.truncated),
103 ],
104 }
105 }
106}
107
108pub fn exec(cx: &mut Cx, argv: &[String], options: &ExecOptions) -> Result<ProcResult> {
114 cx.require(&exec_capability())?;
115 validate_request(argv, options)?;
116
117 #[cfg(not(unix))]
118 {
119 return Err(Error::HostError(
120 "exec is unavailable on this platform until process-tree timeout enforcement is implemented"
121 .to_owned(),
122 ));
123 }
124
125 let mut command = Command::new(&argv[0]);
126 command.args(&argv[1..]);
127 command.stdin(Stdio::piped());
128 command.stdout(Stdio::piped());
129 command.stderr(Stdio::piped());
130 #[cfg(unix)]
131 command.process_group(0);
132 if let Some(cwd) = confined_cwd(options)? {
133 command.current_dir(cwd);
134 }
135
136 let mut child = command
137 .spawn()
138 .map_err(|err| Error::HostError(format!("exec spawn {}: {err}", argv[0])))?;
139 run_child(&mut child, options)
140}
141
142fn validate_request(argv: &[String], options: &ExecOptions) -> Result<()> {
143 if argv.is_empty() {
144 return Err(Error::Eval(
145 "exec requires a non-empty argv list".to_owned(),
146 ));
147 }
148 if options.timeout_ms == 0 {
149 return Err(Error::Eval(
150 "exec requires a non-zero timeout_ms".to_owned(),
151 ));
152 }
153 Ok(())
154}
155
156fn confined_cwd(options: &ExecOptions) -> Result<Option<PathBuf>> {
157 if options.cwd.is_none() && options.root.is_none() {
158 return Ok(None);
159 }
160
161 let root = match &options.root {
162 Some(root) => root.clone(),
163 None => std::env::current_dir()
164 .map_err(|err| Error::HostError(format!("exec current dir: {err}")))?,
165 };
166 let cwd = options.cwd.clone().unwrap_or_else(|| root.clone());
167 let root = canonicalize_path(root, "exec root")?;
168 let cwd = canonicalize_path(cwd, "exec cwd")?;
169 if !cwd.starts_with(&root) {
170 return Err(Error::HostError(format!(
171 "exec cwd {} escapes root {}",
172 cwd.display(),
173 root.display()
174 )));
175 }
176 Ok(Some(cwd))
177}
178
179fn canonicalize_path(path: PathBuf, label: &'static str) -> Result<PathBuf> {
180 path.canonicalize()
181 .map_err(|err| Error::HostError(format!("{label} {}: {err}", path.display())))
182}
183
184fn run_child(child: &mut Child, options: &ExecOptions) -> Result<ProcResult> {
185 let stdout = child
186 .stdout
187 .take()
188 .ok_or_else(|| Error::HostError("exec stdout pipe missing".to_owned()))?;
189 let stderr = child
190 .stderr
191 .take()
192 .ok_or_else(|| Error::HostError("exec stderr pipe missing".to_owned()))?;
193 let stdin = child.stdin.take();
194
195 let budget = Arc::new(Mutex::new(CaptureBudget::new(options.max_output_bytes)));
196 let deadline = Instant::now()
197 .checked_add(Duration::from_millis(options.timeout_ms))
198 .ok_or_else(|| Error::Eval("exec timeout is too large".to_owned()))?;
199
200 let (tx, rx) = mpsc::channel();
201 spawn_reader(
202 stdout,
203 Arc::clone(&budget),
204 CaptureStream::Stdout,
205 tx.clone(),
206 );
207 spawn_reader(
208 stderr,
209 Arc::clone(&budget),
210 CaptureStream::Stderr,
211 tx.clone(),
212 );
213 let stdin_pending = if let Some(stdin) = stdin {
214 spawn_writer(stdin, options.stdin.clone(), tx.clone());
215 true
216 } else {
217 false
218 };
219 drop(tx);
220
221 let completion = wait_for_completion(child, &rx, deadline, stdin_pending);
222 match completion {
223 Ok(ChildCompletion {
224 status,
225 stdout,
226 stderr,
227 stdin,
228 }) => {
229 stdin?;
230 let stdout = stdout?;
231 let stderr = stderr?;
232 let truncated = budget
233 .lock()
234 .map_err(|_| Error::PoisonedLock("exec output budget"))?
235 .truncated;
236 Ok(ProcResult {
237 stdout: String::from_utf8_lossy(&stdout).into_owned(),
238 stderr: String::from_utf8_lossy(&stderr).into_owned(),
239 exit_code: exit_code(status),
240 truncated,
241 })
242 }
243 Err(WaitError::Timeout { child_exited }) => {
244 Err(timeout_error(child, options.timeout_ms, child_exited))
245 }
246 Err(WaitError::Host(err)) => Err(err),
247 }
248}
249
250fn spawn_reader<R>(
251 reader: R,
252 budget: Arc<Mutex<CaptureBudget>>,
253 stream: CaptureStream,
254 tx: Sender<ChildEvent>,
255) where
256 R: Read + Send + 'static,
257{
258 thread::spawn(move || {
259 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
260 read_capped(reader, budget, stream.name())
261 }))
262 .unwrap_or_else(|_| {
263 Err(Error::HostError(format!(
264 "exec {} thread panicked",
265 stream.name()
266 )))
267 });
268 let _ = tx.send(ChildEvent::Capture { stream, result });
269 });
270}
271
272fn spawn_writer(
273 mut stdin: std::process::ChildStdin,
274 input: Option<Vec<u8>>,
275 tx: Sender<ChildEvent>,
276) {
277 thread::spawn(move || {
278 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
279 write_stdin(&mut stdin, input)
280 }))
281 .unwrap_or_else(|_| Err(Error::HostError("exec stdin thread panicked".to_owned())));
282 let _ = tx.send(ChildEvent::Stdin(result));
283 });
284}
285
286fn write_stdin(stdin: &mut std::process::ChildStdin, input: Option<Vec<u8>>) -> Result<()> {
287 let Some(input) = input else {
288 return Ok(());
289 };
290 match stdin.write_all(&input) {
291 Ok(()) => Ok(()),
292 Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()),
293 Err(err) => Err(Error::HostError(format!("exec stdin write: {err}"))),
294 }
295}
296
297fn wait_for_completion(
298 child: &mut Child,
299 rx: &Receiver<ChildEvent>,
300 deadline: Instant,
301 stdin_pending: bool,
302) -> std::result::Result<ChildCompletion, WaitError> {
303 let mut status = None;
304 let mut stdout = None;
305 let mut stderr = None;
306 let mut stdin = if stdin_pending { None } else { Some(Ok(())) };
307
308 loop {
309 poll_child_status(child, &mut status)?;
310 drain_child_events(rx, &mut stdout, &mut stderr, &mut stdin)?;
311 if status.is_some() && stdout.is_some() && stderr.is_some() && stdin.is_some() {
312 return Ok(ChildCompletion {
313 status: status.take().expect("status checked above"),
314 stdout: stdout.take().expect("stdout checked above"),
315 stderr: stderr.take().expect("stderr checked above"),
316 stdin: stdin.take().expect("stdin checked above"),
317 });
318 }
319
320 let now = Instant::now();
321 if now >= deadline {
322 return Err(WaitError::Timeout {
323 child_exited: status.is_some(),
324 });
325 }
326
327 match rx.recv_timeout((deadline - now).min(Duration::from_millis(10))) {
328 Ok(event) => record_child_event(event, &mut stdout, &mut stderr, &mut stdin),
329 Err(RecvTimeoutError::Timeout) => {}
330 Err(RecvTimeoutError::Disconnected) if status.is_some() => {
331 return Err(WaitError::Host(Error::HostError(
332 "exec capture thread ended without result".to_owned(),
333 )));
334 }
335 Err(RecvTimeoutError::Disconnected) => {
336 thread::sleep((deadline - now).min(Duration::from_millis(10)));
337 }
338 }
339 }
340}
341
342fn poll_child_status(
343 child: &mut Child,
344 status: &mut Option<ExitStatus>,
345) -> std::result::Result<(), WaitError> {
346 if status.is_some() {
347 return Ok(());
348 }
349 *status = child
350 .try_wait()
351 .map_err(|err| WaitError::Host(Error::HostError(format!("exec wait: {err}"))))?;
352 Ok(())
353}
354
355fn drain_child_events(
356 rx: &Receiver<ChildEvent>,
357 stdout: &mut Option<Result<Vec<u8>>>,
358 stderr: &mut Option<Result<Vec<u8>>>,
359 stdin: &mut Option<Result<()>>,
360) -> std::result::Result<(), WaitError> {
361 loop {
362 match rx.try_recv() {
363 Ok(event) => record_child_event(event, stdout, stderr, stdin),
364 Err(mpsc::TryRecvError::Empty) => return Ok(()),
365 Err(mpsc::TryRecvError::Disconnected) => return Ok(()),
366 }
367 }
368}
369
370fn record_child_event(
371 event: ChildEvent,
372 stdout: &mut Option<Result<Vec<u8>>>,
373 stderr: &mut Option<Result<Vec<u8>>>,
374 stdin: &mut Option<Result<()>>,
375) {
376 match event {
377 ChildEvent::Capture {
378 stream: CaptureStream::Stdout,
379 result,
380 } => *stdout = Some(result),
381 ChildEvent::Capture {
382 stream: CaptureStream::Stderr,
383 result,
384 } => *stderr = Some(result),
385 ChildEvent::Stdin(result) => *stdin = Some(result),
386 }
387}
388
389fn timeout_error(child: &mut Child, timeout_ms: u64, child_exited: bool) -> Error {
390 let kill_result = terminate_timed_out_child(child, child_exited);
391 let wait_result = child.wait();
392 let mut message = format!("exec timed out after {timeout_ms} ms");
393 if let Err(err) = kill_result {
394 message.push_str(&format!("; kill failed: {err}"));
395 }
396 if let Err(err) = wait_result {
397 message.push_str(&format!("; wait failed: {err}"));
398 }
399 Error::HostError(message)
400}
401
402struct ChildCompletion {
403 status: ExitStatus,
404 stdout: Result<Vec<u8>>,
405 stderr: Result<Vec<u8>>,
406 stdin: Result<()>,
407}
408
409enum WaitError {
410 Timeout { child_exited: bool },
411 Host(Error),
412}
413
414#[derive(Clone, Copy)]
415enum CaptureStream {
416 Stdout,
417 Stderr,
418}
419
420impl CaptureStream {
421 fn name(self) -> &'static str {
422 match self {
423 Self::Stdout => "stdout",
424 Self::Stderr => "stderr",
425 }
426 }
427}
428
429enum ChildEvent {
430 Capture {
431 stream: CaptureStream,
432 result: Result<Vec<u8>>,
433 },
434 Stdin(Result<()>),
435}
436
437fn read_capped<R>(
438 mut reader: R,
439 budget: Arc<Mutex<CaptureBudget>>,
440 name: &'static str,
441) -> Result<Vec<u8>>
442where
443 R: Read,
444{
445 let mut captured = Vec::new();
446 let mut chunk = [0_u8; 4096];
447 loop {
448 let read = reader
449 .read(&mut chunk)
450 .map_err(|err| Error::HostError(format!("exec read {name}: {err}")))?;
451 if read == 0 {
452 return Ok(captured);
453 }
454 let keep = {
455 let mut budget = budget
456 .lock()
457 .map_err(|_| Error::PoisonedLock("exec output budget"))?;
458 budget.claim(read)
459 };
460 captured.extend_from_slice(&chunk[..keep]);
461 }
462}
463
464fn exit_code(status: ExitStatus) -> i32 {
465 status.code().unwrap_or(-1)
466}
467
468#[derive(Debug)]
469struct CaptureBudget {
470 remaining: usize,
471 truncated: bool,
472}
473
474impl CaptureBudget {
475 fn new(max_output_bytes: usize) -> Self {
476 Self {
477 remaining: max_output_bytes,
478 truncated: false,
479 }
480 }
481
482 fn claim(&mut self, read: usize) -> usize {
483 let keep = read.min(self.remaining);
484 self.remaining -= keep;
485 if keep < read {
486 self.truncated = true;
487 }
488 keep
489 }
490}