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 pub shell: Option<ShellSpec>,
26 pub front_back: Option<FrontBackSpec>,
28}
29
30#[derive(Debug, Clone, Copy, Default)]
34pub struct ShellSpec {
35 pub detect_program: bool,
38 pub login_arg: bool,
40 pub login_path_probe: bool,
43}
44
45#[derive(Debug, Clone, Copy)]
50pub struct FrontBackSpec {
51 pub char_budget: usize,
53 pub spill: bool,
56}
57
58pub const SPILL_RETAIN_MAX: u64 = 64 * 1024 * 1024;
60
61#[derive(Debug, Clone)]
63pub struct FrontBackCapture {
64 pub front: String,
66 pub back: Option<String>,
68 pub total_bytes: u64,
70 pub spill_path: Option<PathBuf>,
73}
74
75#[derive(Debug, Clone)]
77pub struct ExecOutput {
78 pub stdout: String,
80 pub stderr: String,
82 pub combined: String,
84 pub exit_code: Option<i32>,
86 pub timed_out: bool,
88 pub cancelled: bool,
90 pub truncated: bool,
92 pub duration: Duration,
94 pub front_back: Option<FrontBackCapture>,
96}
97
98#[derive(Debug, thiserror::Error)]
101pub enum ExecError {
102 #[error("failed to spawn shell: {0}")]
104 Spawn(String),
105}
106
107impl Host {
108 pub async fn exec(
117 &self,
118 req: ExecRequest,
119 cancel: &CancellationToken,
120 ) -> Result<ExecOutput, ExecError> {
121 let timeout = req
122 .timeout
123 .unwrap_or(self.limits.default_timeout)
124 .min(self.limits.max_timeout);
125 let program = self.shell_program_for(req.shell);
126 let path_env = match req.shell {
127 Some(spec) if spec.login_path_probe => self.probed_login_path(&program).await,
128 _ => None,
129 };
130 let cmd = self.build_shell_command(&req, &program, path_env.as_deref());
131 self.run_captured(cmd, timeout, cancel, req.front_back)
132 .await
133 }
134
135 fn shell_program_for(&self, spec: Option<ShellSpec>) -> String {
138 if spec.is_some_and(|s| s.detect_program)
139 && let Ok(user_shell) = std::env::var("SHELL")
140 {
141 let base = std::path::Path::new(&user_shell)
142 .file_name()
143 .map(|n| n.to_string_lossy().into_owned());
144 if matches!(base.as_deref(), Some("bash" | "zsh")) {
145 return user_shell;
146 }
147 }
148 self.shell_program.clone()
149 }
150
151 async fn probed_login_path(&self, program: &str) -> Option<String> {
155 self.login_path
156 .get_or_init(|| async {
157 let probe = Command::new(program)
158 .arg("-lc")
159 .arg("printf %s \"$PATH\"")
160 .stdin(Stdio::null())
161 .output();
162 match tokio::time::timeout(Duration::from_secs(5), probe).await {
163 Ok(Ok(out)) if out.status.success() => {
164 let path = String::from_utf8_lossy(&out.stdout).into_owned();
165 (!path.trim().is_empty()).then_some(path)
166 }
167 _ => None,
168 }
169 })
170 .await
171 .clone()
172 }
173
174 pub async fn run_capture(
181 &self,
182 program: &Path,
183 args: &[String],
184 cwd: &Path,
185 timeout: Option<Duration>,
186 cancel: &CancellationToken,
187 ) -> Result<ExecOutput, ExecError> {
188 let timeout = timeout
189 .unwrap_or(self.limits.default_timeout)
190 .min(self.limits.max_timeout);
191 let mut cmd = Command::new(program);
192 cmd.args(args);
193 cmd.current_dir(cwd);
194 cmd.stdin(Stdio::null())
195 .stdout(Stdio::piped())
196 .stderr(Stdio::piped());
197 #[cfg(unix)]
198 cmd.process_group(0);
199 self.run_captured(cmd, timeout, cancel, None).await
200 }
201
202 async fn run_captured(
204 &self,
205 mut cmd: Command,
206 timeout: Duration,
207 cancel: &CancellationToken,
208 front_back: Option<FrontBackSpec>,
209 ) -> Result<ExecOutput, ExecError> {
210 let started = Instant::now();
211 let cap = self.limits.max_output_bytes;
212
213 let mut child = cmd.spawn().map_err(|e| ExecError::Spawn(e.to_string()))?;
214 let pid = child.id();
215
216 let stdout = child.stdout.take();
217 let stderr = child.stderr.take();
218 let (out_task, err_task, combined_task) = if let Some(spec) = front_back {
219 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
224 let tx_err = tx.clone();
225 let out = tokio::spawn(read_capped_forwarding(stdout, cap, tx));
226 let err = tokio::spawn(read_capped_forwarding(stderr, cap, tx_err));
227 let collector = tokio::spawn(collect_front_back(rx, spec));
228 (out, err, Some(collector))
229 } else {
230 (
231 tokio::spawn(read_capped(stdout, cap)),
232 tokio::spawn(read_capped(stderr, cap)),
233 None,
234 )
235 };
236
237 let mut exit_code = None;
238 let mut timed_out = false;
239 let mut cancelled = false;
240 tokio::select! {
241 status = child.wait() => {
242 exit_code = status.ok().and_then(|s| s.code());
243 }
244 () = tokio::time::sleep(timeout) => {
245 timed_out = true;
246 terminate(&mut child, pid, self.limits.kill_grace).await;
247 }
248 () = cancel.cancelled() => {
249 cancelled = true;
250 terminate(&mut child, pid, self.limits.kill_grace).await;
251 }
252 }
253
254 let (stdout, out_trunc) = out_task.await.unwrap_or_else(|_| (Vec::new(), false));
255 let (stderr, err_trunc) = err_task.await.unwrap_or_else(|_| (Vec::new(), false));
256 let front_back_capture = match combined_task {
257 Some(task) => task.await.ok(),
258 None => None,
259 };
260 let stdout = String::from_utf8_lossy(&stdout).into_owned();
261 let stderr = String::from_utf8_lossy(&stderr).into_owned();
262 let combined = combine(&stdout, &stderr);
263
264 Ok(ExecOutput {
265 stdout,
266 stderr,
267 combined,
268 exit_code,
269 timed_out,
270 cancelled,
271 truncated: out_trunc || err_trunc,
272 duration: started.elapsed(),
273 front_back: front_back_capture,
274 })
275 }
276
277 fn build_shell_command(
278 &self,
279 req: &ExecRequest,
280 program: &str,
281 path_env: Option<&str>,
282 ) -> Command {
283 let mut cmd = Command::new(program);
284 let login = req.shell.map_or(self.login_shell, |s| s.login_arg);
285 cmd.arg(if login { "-lc" } else { "-c" });
286 if let Some(path) = path_env {
287 cmd.env("PATH", path);
288 }
289 cmd.arg(&req.command);
290 cmd.current_dir(&req.cwd);
291 cmd.stdin(Stdio::null())
292 .stdout(Stdio::piped())
293 .stderr(Stdio::piped());
294 for (key, value) in &req.env {
295 cmd.env(key, value);
296 }
297 #[cfg(unix)]
300 cmd.process_group(0);
301 cmd
302 }
303}
304
305async fn read_capped<R: AsyncRead + Unpin>(reader: Option<R>, cap: usize) -> (Vec<u8>, bool) {
308 let Some(mut reader) = reader else {
309 return (Vec::new(), false);
310 };
311 let mut buf = Vec::new();
312 let mut chunk = [0u8; 8192];
313 let mut truncated = false;
314 loop {
315 match reader.read(&mut chunk).await {
316 Ok(0) | Err(_) => break,
317 Ok(n) => {
318 buf.extend_from_slice(&chunk[..n]);
319 if buf.len() > cap {
320 let excess = buf.len() - cap;
321 buf.drain(..excess);
322 truncated = true;
323 }
324 }
325 }
326 }
327 (buf, truncated)
328}
329
330async fn read_capped_forwarding<R: AsyncRead + Unpin>(
333 reader: Option<R>,
334 cap: usize,
335 tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
336) -> (Vec<u8>, bool) {
337 let Some(mut reader) = reader else {
338 return (Vec::new(), false);
339 };
340 let mut buf = Vec::new();
341 let mut chunk = [0u8; 8192];
342 let mut truncated = false;
343 loop {
344 match reader.read(&mut chunk).await {
345 Ok(0) | Err(_) => break,
346 Ok(n) => {
347 let _ = tx.send(chunk[..n].to_vec());
348 buf.extend_from_slice(&chunk[..n]);
349 if buf.len() > cap {
350 let excess = buf.len() - cap;
351 buf.drain(..excess);
352 truncated = true;
353 }
354 }
355 }
356 }
357 (buf, truncated)
358}
359
360async fn collect_front_back(
364 mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
365 spec: FrontBackSpec,
366) -> FrontBackCapture {
367 use std::collections::VecDeque;
368 use tokio::io::AsyncWriteExt;
369
370 let front_budget = spec.char_budget / 2;
371 let back_budget = spec.char_budget - front_budget;
372 let mut front: Vec<u8> = Vec::new();
373 let mut back: VecDeque<u8> = VecDeque::new();
374 let mut total: u64 = 0;
375 let mut spill = None;
376 let mut spill_path = None;
377 if spec.spill {
378 let dir = std::env::temp_dir().join("locode").join("exec");
379 if tokio::fs::create_dir_all(&dir).await.is_ok() {
380 static SPILL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
381 let seq = SPILL_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
382 let path = dir.join(format!("cmd-{}-{}.log", std::process::id(), seq));
383 if let Ok(file) = tokio::fs::File::create(&path).await {
384 spill = Some(file);
385 spill_path = Some(path);
386 }
387 }
388 }
389 while let Some(chunk) = rx.recv().await {
390 if let Some(file) = spill.as_mut()
391 && total < SPILL_RETAIN_MAX
392 {
393 let room = usize::try_from(SPILL_RETAIN_MAX - total).unwrap_or(usize::MAX);
394 let write = &chunk[..chunk.len().min(room)];
395 let _ = file.write_all(write).await;
396 }
397 total += chunk.len() as u64;
398 if front.len() < front_budget {
399 let room = front_budget - front.len();
400 front.extend_from_slice(&chunk[..chunk.len().min(room)]);
401 if chunk.len() > room {
402 back.extend(&chunk[room..]);
403 }
404 } else {
405 back.extend(&chunk);
406 }
407 while back.len() > back_budget {
408 back.pop_front();
409 }
410 }
411 if let Some(mut file) = spill {
412 let _ = file.flush().await;
413 }
414 let truncated = total > (front.len() + back.len()) as u64;
415 let front_str = String::from_utf8_lossy(&front).into_owned();
416 let back_bytes: Vec<u8> = back.into_iter().collect();
417 let back_str = String::from_utf8_lossy(&back_bytes).into_owned();
418 if truncated {
419 FrontBackCapture {
420 front: front_str,
421 back: Some(back_str),
422 total_bytes: total,
423 spill_path,
424 }
425 } else {
426 FrontBackCapture {
427 front: format!("{front_str}{back_str}"),
428 back: None,
429 total_bytes: total,
430 spill_path,
431 }
432 }
433}
434
435async fn terminate(child: &mut Child, pid: Option<u32>, grace: Duration) {
438 if group_kill(child, pid, grace).await {
439 return;
440 }
441 let _ = child.start_kill();
442 let _ = child.wait().await;
443}
444
445#[cfg(unix)]
446async fn group_kill(child: &mut Child, pid: Option<u32>, grace: Duration) -> bool {
447 use nix::sys::signal::{Signal, killpg};
448 use nix::unistd::Pid;
449
450 let Some(pid) = pid else { return false };
451 let Ok(raw) = i32::try_from(pid) else {
452 return false;
453 };
454 if raw <= 1 {
457 return false;
458 }
459 let leader = Pid::from_raw(raw);
460 let _ = killpg(leader, Signal::SIGTERM);
461 if tokio::time::timeout(grace, child.wait()).await.is_err() {
462 let _ = killpg(leader, Signal::SIGKILL);
463 let _ = child.wait().await;
464 }
465 true
466}
467
468#[cfg(not(unix))]
469async fn group_kill(_child: &mut Child, _pid: Option<u32>, _grace: Duration) -> bool {
470 false
471}
472
473fn combine(stdout: &str, stderr: &str) -> String {
475 match (stdout.is_empty(), stderr.is_empty()) {
476 (false, false) => format!("{stdout}\n{stderr}"),
477 (false, true) => stdout.to_string(),
478 (true, false) => stderr.to_string(),
479 (true, true) => String::new(),
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use crate::{ExecLimits, Host, HostConfig, PathPolicy, test_host};
487 use std::time::Duration;
488 use tempfile::tempdir;
489
490 fn req(command: &str, cwd: PathBuf) -> ExecRequest {
491 ExecRequest {
492 command: command.to_string(),
493 cwd,
494 timeout: None,
495 env: Vec::new(),
496 shell: None,
497 front_back: None,
498 }
499 }
500
501 fn shell_host(root: &std::path::Path) -> Host {
504 test_host(root, PathPolicy::Jailed, false)
505 }
506
507 #[cfg(unix)]
508 #[tokio::test]
509 async fn captures_stdout_and_exit() {
510 let dir = tempdir().unwrap();
511 let host = shell_host(dir.path());
512 let out = host
513 .exec(
514 req("echo hello", dir.path().into()),
515 &CancellationToken::new(),
516 )
517 .await
518 .unwrap();
519 assert!(out.stdout.contains("hello"));
520 assert_eq!(out.exit_code, Some(0));
521 assert!(!out.timed_out && !out.cancelled);
522 }
523
524 #[cfg(unix)]
525 #[tokio::test]
526 async fn captures_stderr_and_nonzero_exit() {
527 let dir = tempdir().unwrap();
528 let host = shell_host(dir.path());
529 let out = host
530 .exec(
531 req(">&2 echo boom; exit 3", dir.path().into()),
532 &CancellationToken::new(),
533 )
534 .await
535 .unwrap();
536 assert!(out.stderr.contains("boom"));
537 assert_eq!(out.exit_code, Some(3));
538 }
539
540 #[cfg(unix)]
542 #[tokio::test]
543 async fn timeout_kills_sleeper() {
544 let dir = tempdir().unwrap();
545 let mut config = HostConfig::new(dir.path());
546 config.login_shell = false;
547 config.exec = ExecLimits {
548 default_timeout: Duration::from_millis(200),
549 ..ExecLimits::default()
550 };
551 let host = Host::new(config).unwrap();
552 let started = Instant::now();
553 let out = host
554 .exec(
555 req("sleep 30", dir.path().into()),
556 &CancellationToken::new(),
557 )
558 .await
559 .unwrap();
560 assert!(out.timed_out);
561 assert!(out.exit_code.is_none());
562 assert!(
563 started.elapsed() < Duration::from_secs(5),
564 "should not wait for the sleep"
565 );
566 }
567
568 #[cfg(unix)]
570 #[tokio::test]
571 async fn cancellation_kills_running_command() {
572 let dir = tempdir().unwrap();
573 let host = shell_host(dir.path());
574 let cancel = CancellationToken::new();
575 let child_cancel = cancel.clone();
576 tokio::spawn(async move {
577 tokio::time::sleep(Duration::from_millis(50)).await;
578 child_cancel.cancel();
579 });
580 let started = Instant::now();
581 let out = host
582 .exec(req("sleep 30", dir.path().into()), &cancel)
583 .await
584 .unwrap();
585 assert!(out.cancelled);
586 assert!(started.elapsed() < Duration::from_secs(5));
587 }
588
589 #[cfg(unix)]
591 #[tokio::test]
592 async fn output_over_cap_is_truncated() {
593 let dir = tempdir().unwrap();
594 let mut config = HostConfig::new(dir.path());
595 config.login_shell = false;
596 config.exec = ExecLimits {
597 max_output_bytes: 1000,
598 ..ExecLimits::default()
599 };
600 let host = Host::new(config).unwrap();
601 let out = host
603 .exec(
604 req(
605 "for i in $(seq 1 20000); do echo 0123456789; done",
606 dir.path().into(),
607 ),
608 &CancellationToken::new(),
609 )
610 .await
611 .unwrap();
612 assert!(out.truncated);
613 assert!(
614 out.stdout.len() <= 1000 + 16,
615 "kept ~cap bytes, got {}",
616 out.stdout.len()
617 );
618 }
619
620 #[cfg(unix)]
622 #[tokio::test]
623 async fn front_back_retains_head_and_tail_and_spills() {
624 let dir = tempdir().unwrap();
625 let host = shell_host(dir.path());
626 let mut request = req(
627 "for i in $(seq 1 2000); do echo line-$i; done",
628 dir.path().into(),
629 );
630 request.front_back = Some(FrontBackSpec {
631 char_budget: 400,
632 spill: true,
633 });
634 let out = host.exec(request, &CancellationToken::new()).await.unwrap();
635 let capture = out.front_back.expect("capture requested");
636 assert!(
637 capture.front.starts_with("line-1\n"),
638 "front holds the head"
639 );
640 let back = capture.back.expect("truncated -> back present");
641 assert!(
642 back.ends_with("line-2000\n"),
643 "back holds the tail: {back:?}"
644 );
645 assert!(capture.front.len() <= 200 && back.len() <= 200);
646 assert!(capture.total_bytes > 400, "true total, not retained size");
647 let spill = capture.spill_path.expect("spill requested");
648 let spilled = std::fs::read_to_string(&spill).unwrap();
649 assert!(spilled.starts_with("line-1\n") && spilled.ends_with("line-2000\n"));
650 assert_eq!(spilled.len() as u64, capture.total_bytes);
651 let _ = std::fs::remove_file(spill);
652 }
653
654 #[cfg(unix)]
656 #[tokio::test]
657 async fn front_back_small_output_is_untruncated() {
658 let dir = tempdir().unwrap();
659 let host = shell_host(dir.path());
660 let mut request = req("echo tiny", dir.path().into());
661 request.front_back = Some(FrontBackSpec {
662 char_budget: 400,
663 spill: false,
664 });
665 let out = host.exec(request, &CancellationToken::new()).await.unwrap();
666 let capture = out.front_back.expect("capture requested");
667 assert_eq!(capture.front, "tiny\n");
668 assert!(capture.back.is_none(), "nothing cut");
669 assert_eq!(capture.total_bytes, 5);
670 assert!(capture.spill_path.is_none());
671 }
672
673 #[cfg(unix)]
675 #[tokio::test]
676 async fn shell_spec_c_arg_and_path_probe() {
677 let dir = tempdir().unwrap();
678 let host = shell_host(dir.path());
679 let mut request = req("echo $PATH", dir.path().into());
680 request.shell = Some(ShellSpec {
681 detect_program: false,
682 login_arg: false,
683 login_path_probe: true,
684 });
685 let out = host.exec(request, &CancellationToken::new()).await.unwrap();
686 assert_eq!(out.exit_code, Some(0));
687 assert!(!out.stdout.trim().is_empty());
689 let mut request2 = req("echo $PATH", dir.path().into());
692 request2.shell = Some(ShellSpec {
693 detect_program: false,
694 login_arg: false,
695 login_path_probe: true,
696 });
697 let out2 = host
698 .exec(request2, &CancellationToken::new())
699 .await
700 .unwrap();
701 assert_eq!(out.stdout, out2.stdout);
702 }
703
704 #[cfg(unix)]
706 #[tokio::test]
707 async fn null_stdin_does_not_hang() {
708 let dir = tempdir().unwrap();
709 let host = shell_host(dir.path());
710 let out = host
712 .exec(req("cat", dir.path().into()), &CancellationToken::new())
713 .await
714 .unwrap();
715 assert_eq!(out.exit_code, Some(0));
716 }
717}