Skip to main content

adk_sandbox/
process.rs

1//! [`ProcessBackend`] — subprocess-based code execution via `tokio::process::Command`.
2//!
3//! This backend spawns child processes to execute code in various languages.
4//! It enforces timeout and environment isolation but does **not** enforce
5//! memory limits, network isolation, or filesystem isolation.
6//!
7//! # Supported Languages
8//!
9//! | Language   | Execution Strategy                                    |
10//! |------------|-------------------------------------------------------|
11//! | Rust       | Write to temp file → compile with `rustc` → run binary |
12//! | Python     | Write to temp file → run with `python3`               |
13//! | JavaScript | Write to temp file → run with `node`                  |
14//! | TypeScript | Write to temp file → run with `node` (same as JS)     |
15//! | Command    | Execute code as `sh -c "<code>"`                      |
16//! | Wasm       | Not supported — use `WasmBackend` instead            |
17//!
18//! # Example
19//!
20//! ```rust,ignore
21//! use adk_sandbox::{ProcessBackend, ExecRequest, Language, SandboxBackend};
22//! use std::time::Duration;
23//! use std::collections::HashMap;
24//!
25//! let backend = ProcessBackend::default();
26//! let request = ExecRequest {
27//!     language: Language::Python,
28//!     code: "print('hello')".to_string(),
29//!     stdin: None,
30//!     timeout: Duration::from_secs(30),
31//!     memory_limit_mb: None,
32//!     env: HashMap::new(),
33//! };
34//! let result = backend.execute(request).await?;
35//! assert_eq!(result.stdout.trim(), "hello");
36//! ```
37
38use std::ffi::{OsStr, OsString};
39use std::time::Instant;
40
41use async_trait::async_trait;
42use tokio::io::AsyncWriteExt;
43use tokio::process::Command;
44use tracing::{Span, instrument};
45
46use crate::backend::{BackendCapabilities, EnforcedLimits, SandboxBackend};
47use crate::error::SandboxError;
48use crate::sandbox::{SandboxEnforcer, SandboxPolicy};
49use crate::types::{ExecRequest, ExecResult, Language};
50
51/// Maximum output size in bytes (1 MB).
52const MAX_OUTPUT_BYTES: usize = 1_024 * 1_024;
53
54/// Host variables exposed only while compiling Rust on non-Windows platforms.
55const NON_WINDOWS_TOOLCHAIN_ENV_KEYS: &[&str] = &[
56    "PATH",
57    "DEVELOPER_DIR",
58    "SDKROOT",
59    "HOME",
60    "TMPDIR",
61    "RUSTUP_HOME",
62    "CARGO_HOME",
63    "RUSTUP_TOOLCHAIN",
64];
65
66/// Host variables exposed only while compiling Rust with the MSVC toolchain.
67///
68/// `LIB` is the linker's library search path. The remaining Windows-specific
69/// values support temporary files, system DLL discovery, and rustup's default
70/// toolchain location without copying the full developer-shell environment.
71const WINDOWS_TOOLCHAIN_ENV_KEYS: &[&str] = &[
72    "PATH",
73    "LIB",
74    "LIBPATH",
75    "INCLUDE",
76    "SystemRoot",
77    "TEMP",
78    "TMP",
79    "USERPROFILE",
80    "RUSTUP_HOME",
81    "RUSTUP_TOOLCHAIN",
82];
83
84/// Configuration for [`ProcessBackend`].
85///
86/// Provides paths to language runtimes. Defaults use bare command names
87/// that rely on `PATH` resolution.
88///
89/// # Example
90///
91/// ```rust
92/// use adk_sandbox::ProcessConfig;
93///
94/// let config = ProcessConfig {
95///     rustc_path: "/usr/local/bin/rustc".to_string(),
96///     ..ProcessConfig::default()
97/// };
98/// ```
99#[derive(Debug, Clone)]
100pub struct ProcessConfig {
101    /// Path to the Rust compiler. Default: `"rustc"`.
102    pub rustc_path: String,
103    /// Path to the Python 3 interpreter. Default: `"python3"`.
104    pub python_path: String,
105    /// Path to the Node.js runtime. Default: `"node"`.
106    pub node_path: String,
107    /// Maximum bytes retained from each of stdout and stderr. Default: 1 MiB.
108    ///
109    /// The limit is applied as the pipes are read, so it bounds memory rather than only the
110    /// reported output. Excess is drained and discarded, and the returned text carries a
111    /// truncation notice.
112    pub max_output_bytes: usize,
113}
114
115impl Default for ProcessConfig {
116    fn default() -> Self {
117        Self {
118            rustc_path: "rustc".to_string(),
119            python_path: "python3".to_string(),
120            node_path: "node".to_string(),
121            max_output_bytes: MAX_OUTPUT_BYTES,
122        }
123    }
124}
125
126/// Subprocess-based sandbox backend.
127///
128/// Executes code by spawning child processes with `tokio::process::Command`.
129/// Enforces timeout via `tokio::time::timeout` and environment isolation
130/// via `env_clear()`. Optionally enforces filesystem and network isolation
131/// when a [`SandboxEnforcer`] is configured via [`with_sandbox()`](Self::with_sandbox).
132///
133/// # Example
134///
135/// ```rust
136/// use adk_sandbox::{ProcessBackend, SandboxBackend};
137///
138/// let backend = ProcessBackend::default();
139/// assert_eq!(backend.name(), "process");
140/// ```
141///
142/// # With OS-level sandbox
143///
144/// ```rust,ignore
145/// use adk_sandbox::{ProcessBackend, ProcessConfig, SandboxPolicyBuilder, get_enforcer};
146///
147/// let enforcer = get_enforcer()?;
148/// let policy = SandboxPolicyBuilder::new()
149///     .allow_read("/usr/lib")
150///     .allow_read_write("/tmp/work")
151///     .build();
152///
153/// let backend = ProcessBackend::with_sandbox(
154///     ProcessConfig::default(),
155///     enforcer,
156///     policy,
157/// );
158/// assert!(backend.capabilities().enforced_limits.filesystem_write_isolation);
159/// ```
160pub struct ProcessBackend {
161    config: ProcessConfig,
162    enforcer: Option<Box<dyn SandboxEnforcer>>,
163    policy: Option<SandboxPolicy>,
164}
165
166/// How much isolation a backend actually provides.
167///
168/// Reported so a caller can tell the two apart rather than assuming the stronger one
169/// because the crate is named `adk-sandbox`.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum IsolationClass {
172    /// A child process with a cleared environment, a timeout, and its own process group.
173    ///
174    /// The OS applies no further restriction: the code can read the host filesystem and
175    /// reach the network. This is what [`ProcessBackend::default`] provides.
176    SubprocessOnly,
177    /// A child process wrapped by an OS enforcer — Seatbelt, bubblewrap, or AppContainer
178    /// — under a [`SandboxPolicy`].
179    OsEnforced,
180}
181
182/// Resolve a bare program name to an absolute path using the caller's `PATH`.
183///
184/// Returns `None` when the name already contains a path separator, or when nothing on
185/// `PATH` matches — in which case the command is left as it was so the spawn error still
186/// names the program the caller asked for.
187fn resolve_program(program: &OsStr) -> Option<std::path::PathBuf> {
188    let as_path = std::path::Path::new(program);
189    if as_path.components().count() > 1 {
190        return None;
191    }
192
193    let path_var = std::env::var_os("PATH")?;
194    std::env::split_paths(&path_var).find_map(|dir| {
195        let candidate = dir.join(program);
196        candidate.is_file().then_some(candidate)
197    })
198}
199
200impl ProcessBackend {
201    /// Creates a new `ProcessBackend` with the given configuration.
202    ///
203    /// The result is [`IsolationClass::SubprocessOnly`] until an enforcer and policy are
204    /// attached; see [`ProcessBackend::isolation`].
205    pub fn new(config: ProcessConfig) -> Self {
206        Self { config, enforcer: None, policy: None }
207    }
208
209    /// How much isolation this backend applies.
210    ///
211    /// Check this before treating execution as sandboxed. Without an enforcer *and* a
212    /// policy, execution is subprocess isolation only.
213    pub fn isolation(&self) -> IsolationClass {
214        match (self.enforcer.is_some(), self.policy.is_some()) {
215            (true, true) => IsolationClass::OsEnforced,
216            _ => IsolationClass::SubprocessOnly,
217        }
218    }
219
220    /// Creates a new `ProcessBackend` with OS-level sandbox enforcement.
221    ///
222    /// All executions through this backend will be sandboxed with the given
223    /// policy. The enforcer wraps commands with platform-specific restrictions
224    /// (Seatbelt on macOS, bubblewrap on Linux, AppContainer on Windows).
225    ///
226    /// If different tools need different policies, create multiple
227    /// `ProcessBackend` instances.
228    pub fn with_sandbox(
229        config: ProcessConfig,
230        enforcer: Box<dyn SandboxEnforcer>,
231        policy: SandboxPolicy,
232    ) -> Self {
233        Self { config, enforcer: Some(enforcer), policy: Some(policy) }
234    }
235}
236
237impl Default for ProcessBackend {
238    fn default() -> Self {
239        Self::new(ProcessConfig::default())
240    }
241}
242
243// ProcessBackend can't derive Debug because Box<dyn SandboxEnforcer> doesn't impl Debug.
244impl std::fmt::Debug for ProcessBackend {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        f.debug_struct("ProcessBackend")
247            .field("config", &self.config)
248            .field("enforcer", &self.enforcer.as_ref().map(|e| e.name()))
249            .field("policy", &self.policy)
250            .finish()
251    }
252}
253
254/// Truncates a byte buffer to at most `max_bytes`, ensuring the result is
255/// valid UTF-8 by backing off to the nearest char boundary.
256fn truncate_utf8(bytes: Vec<u8>, max_bytes: usize) -> String {
257    if bytes.len() <= max_bytes {
258        return String::from_utf8_lossy(&bytes).into_owned();
259    }
260    let truncated = &bytes[..max_bytes];
261    // Walk backwards to find a valid UTF-8 boundary.
262    let mut end = max_bytes;
263    while end > 0 && std::str::from_utf8(&truncated[..end]).is_err() {
264        end -= 1;
265    }
266    std::str::from_utf8(&bytes[..end]).unwrap_or("").to_string()
267}
268
269/// Appends a truncation notice when output was discarded.
270///
271/// A model that receives silently-cut output has no way to know it is incomplete, so the notice
272/// travels with the data rather than only appearing in a log. Mirrors the convention in
273/// adk-python's `tools/environment` toolset.
274fn note_truncation(mut text: String, discarded: bool) -> String {
275    if discarded {
276        text.push_str("\n... (truncated: output exceeded the configured limit)");
277    }
278    text
279}
280
281/// Reads `reader` to EOF, accumulating at most `cap` bytes.
282///
283/// Bytes past `cap` are read and discarded rather than left in the pipe. Stopping the read
284/// would block the child on a full pipe buffer and stall it until the execution timeout, so
285/// the drain continues even though the data is thrown away.
286///
287/// Returns the retained bytes and whether anything was discarded.
288async fn read_capped<R>(mut reader: R, cap: usize) -> std::io::Result<(Vec<u8>, bool)>
289where
290    R: tokio::io::AsyncRead + Unpin,
291{
292    use tokio::io::AsyncReadExt;
293
294    let mut retained = Vec::new();
295    let mut chunk = [0u8; 8192];
296    let mut discarded = false;
297
298    loop {
299        let read = reader.read(&mut chunk).await?;
300        if read == 0 {
301            break;
302        }
303        let room = cap.saturating_sub(retained.len());
304        if room == 0 {
305            discarded = true;
306            continue;
307        }
308        let take = room.min(read);
309        retained.extend_from_slice(&chunk[..take]);
310        if take < read {
311            discarded = true;
312        }
313    }
314
315    Ok((retained, discarded))
316}
317
318#[async_trait]
319impl SandboxBackend for ProcessBackend {
320    fn name(&self) -> &str {
321        "process"
322    }
323
324    fn capabilities(&self) -> BackendCapabilities {
325        let has_enforcer = self.enforcer.is_some();
326        let denies_network = self.policy.as_ref().is_some_and(|p| !p.allow_network);
327
328        BackendCapabilities {
329            supported_languages: vec![
330                Language::Rust,
331                Language::Python,
332                Language::JavaScript,
333                Language::TypeScript,
334                Language::Command,
335            ],
336            isolation_class: if has_enforcer {
337                "process+sandbox".to_string()
338            } else {
339                "process".to_string()
340            },
341            enforced_limits: EnforcedLimits {
342                timeout: true,
343                memory: false,
344                network_isolation: has_enforcer && denies_network,
345                filesystem_write_isolation: has_enforcer,
346                // The macOS profile denies writes, network, and fork but leaves reads
347                // open, so read isolation is not claimed there. Linux bubblewrap builds
348                // a filesystem namespace, which does confine reads.
349                filesystem_read_isolation: has_enforcer && cfg!(target_os = "linux"),
350                environment_isolation: true,
351            },
352        }
353    }
354
355    #[instrument(
356        skip_all,
357        fields(
358            backend = "process",
359            language = %request.language,
360            exit_code,
361            duration_ms,
362        )
363    )]
364    async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
365        if let Some(limit) = request.memory_limit_mb {
366            tracing::debug!(
367                memory_limit_mb = limit,
368                "memory limit not enforced by process backend"
369            );
370        }
371
372        match request.language {
373            Language::Rust => self.execute_rust(&request).await,
374            Language::Python => self.execute_python(&request).await,
375            Language::JavaScript | Language::TypeScript => self.execute_javascript(&request).await,
376            Language::Command => self.execute_command(&request).await,
377            Language::Wasm => Err(SandboxError::InvalidRequest(
378                "Wasm execution is not supported by ProcessBackend. Use WasmBackend instead."
379                    .to_string(),
380            )),
381        }
382    }
383}
384
385impl ProcessBackend {
386    /// Executes Rust code: write to temp file → compile with rustc → run binary.
387    async fn execute_rust(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
388        let dir = tempfile::tempdir()?;
389        let src_path = dir.path().join("main.rs");
390        let bin_path = dir.path().join("main");
391
392        std::fs::write(&src_path, &request.code)?;
393
394        // Compile through the same path as execution. Building the command here and
395        // calling `output()` directly skipped the enforcer, the timeout, and the
396        // process group — and Rust compilation is not inert: `include_str!` and
397        // procedural macros read files and can run arbitrary code at compile time, so
398        // the compiler needs the same boundary as the binary it produces.
399        let toolchain_env = Self::toolchain_env();
400        #[cfg(windows)]
401        let has_msvc_library_path =
402            toolchain_env.iter().any(|(key, _)| key.eq_ignore_ascii_case("LIB"));
403
404        let compile_result = {
405            let mut cmd = Command::new(&self.config.rustc_path);
406            // Cargo applies the workspace's rust-lld setting, but this direct rustc
407            // invocation does not read .cargo/config.toml. Hosted Windows runners put
408            // Git's GNU `link.exe` ahead of MSVC on PATH, so naming rust-lld prevents
409            // rustc from launching the unrelated Unix utility.
410            #[cfg(windows)]
411            cmd.arg("-Clinker=rust-lld");
412            cmd.arg(&src_path).arg("-o").arg(&bin_path);
413            self.run_command_with_env(cmd, request, &toolchain_env).await?
414        };
415
416        #[cfg(windows)]
417        let compile_result = {
418            let mut result = compile_result;
419            if result.exit_code != 0 && !has_msvc_library_path {
420                result.stderr.push_str(
421                    "\nWindows Rust linking requires the MSVC Build Tools and Windows SDK. \
422                     Install the `Desktop development with C++` workload; ProcessBackend could \
423                     not discover its LIB paths from this host.",
424                );
425            }
426            result
427        };
428
429        if compile_result.exit_code != 0 {
430            Span::current().record("exit_code", compile_result.exit_code);
431            Span::current().record("duration_ms", compile_result.duration.as_millis() as u64);
432            return Ok(compile_result);
433        }
434
435        // Run the compiled binary
436        self.run_binary(&bin_path, request).await
437    }
438
439    /// Executes Python code: write to temp file → run with python3.
440    async fn execute_python(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
441        let dir = tempfile::tempdir()?;
442        let src_path = dir.path().join("script.py");
443        std::fs::write(&src_path, &request.code)?;
444
445        let mut cmd = Command::new(&self.config.python_path);
446        cmd.arg(&src_path);
447        self.run_command(cmd, request).await
448    }
449
450    /// Executes JavaScript code: write to temp file → run with node.
451    async fn execute_javascript(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
452        let dir = tempfile::tempdir()?;
453        let src_path = dir.path().join("script.js");
454        std::fs::write(&src_path, &request.code)?;
455
456        let mut cmd = Command::new(&self.config.node_path);
457        cmd.arg(&src_path);
458        self.run_command(cmd, request).await
459    }
460
461    /// Executes a raw shell command via the platform shell.
462    async fn execute_command(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
463        #[cfg(windows)]
464        let cmd = {
465            use std::os::windows::process::CommandExt;
466
467            let mut c = Command::new("cmd");
468            c.arg("/D").arg("/C");
469            // `cmd.exe` does not follow CommandLineToArgvW escaping. In particular,
470            // Command::arg turns embedded quotes into `\"`, which makes a quoted
471            // executable path part of the program name. The command is already an
472            // explicitly requested shell program, so pass it with cmd's own syntax.
473            c.as_std_mut().raw_arg(&request.code);
474            c
475        };
476        #[cfg(not(windows))]
477        let cmd = {
478            let mut c = Command::new("sh");
479            c.arg("-c").arg(&request.code);
480            c
481        };
482        self.run_command(cmd, request).await
483    }
484
485    /// Runs a compiled binary with timeout, env isolation, and stdin piping.
486    async fn run_binary(
487        &self,
488        bin_path: &std::path::Path,
489        request: &ExecRequest,
490    ) -> Result<ExecResult, SandboxError> {
491        let cmd = Command::new(bin_path);
492        self.run_command(cmd, request).await
493    }
494
495    /// Shared execution logic: env isolation, stdin piping, timeout, output capture.
496    ///
497    /// When a [`SandboxEnforcer`] is configured, the command is wrapped with
498    /// platform-specific sandbox restrictions before spawning.
499    async fn run_command(
500        &self,
501        cmd: Command,
502        request: &ExecRequest,
503    ) -> Result<ExecResult, SandboxError> {
504        self.run_command_with_env(cmd, request, &[]).await
505    }
506
507    /// Variables a compiler needs to find its own tools.
508    ///
509    /// `rustc` shells out to a platform linker and resolves it through the environment.
510    /// The MSVC linker also reads `LIB` to find the Windows and C runtime libraries.
511    /// With the environment cleared it cannot link at all, so compilation gets a small
512    /// platform-specific allowlist. On Windows, missing values are discovered from the
513    /// installed Visual Studio Build Tools and Windows SDK.
514    ///
515    /// This widens what the compile phase can see compared with the run phase. An OS
516    /// enforcer is what constrains it; see [`ProcessBackend::isolation`].
517    fn toolchain_env() -> Vec<(String, OsString)> {
518        // RUSTUP_TOOLCHAIN matters as much as RUSTUP_HOME: `rustc` on PATH is usually a rustup
519        // shim, and without it the shim ignores the caller's selection and resolves
520        // `rust-toolchain.toml` instead. That either compiles with a different toolchain than the
521        // caller intended, or — when the pinned one is not installed — tries to download it and
522        // fails against the sandbox's network denial, reporting "syncing channel updates" from
523        // what looks like a compile error.
524        let keys =
525            if cfg!(windows) { WINDOWS_TOOLCHAIN_ENV_KEYS } else { NON_WINDOWS_TOOLCHAIN_ENV_KEYS };
526
527        let environment: Vec<(String, OsString)> = keys
528            .iter()
529            .filter_map(|key| std::env::var_os(key).map(|value| ((*key).to_string(), value)))
530            .collect();
531
532        #[cfg(windows)]
533        let mut environment = environment;
534
535        #[cfg(windows)]
536        if !environment.iter().any(|(key, _)| key.eq_ignore_ascii_case("LIB"))
537            && let Some(linker) = find_msvc_tools::find(std::env::consts::ARCH, "link.exe")
538        {
539            for (key, value) in linker.get_envs() {
540                let Some(value) = value else {
541                    continue;
542                };
543                let Some(allowed_key) = WINDOWS_TOOLCHAIN_ENV_KEYS
544                    .iter()
545                    .find(|allowed| key.eq_ignore_ascii_case(OsStr::new(allowed)))
546                else {
547                    continue;
548                };
549                if !environment
550                    .iter()
551                    .any(|(existing, _)| existing.eq_ignore_ascii_case(allowed_key))
552                {
553                    environment.push(((*allowed_key).to_string(), value.to_os_string()));
554                }
555            }
556        }
557
558        environment
559    }
560
561    /// Shared execution logic, with `extra_env` applied below policy and request values.
562    async fn run_command_with_env(
563        &self,
564        cmd: Command,
565        request: &ExecRequest,
566        extra_env: &[(String, OsString)],
567    ) -> Result<ExecResult, SandboxError> {
568        // If a sandbox enforcer is configured, wrap the command.
569        // We extract the program and args from the pre-built Command,
570        // pass them through the enforcer, and create a new Command.
571        let mut cmd = if let (Some(enforcer), Some(policy)) = (&self.enforcer, &self.policy) {
572            let std_cmd = cmd.as_std();
573            let program = std_cmd.get_program();
574            let args: Vec<OsString> = std_cmd.get_args().map(OsStr::to_owned).collect();
575
576            let wrapped = enforcer.wrap_command(program, &args, policy)?;
577
578            let mut new_cmd = Command::new(&wrapped.program);
579            new_cmd.args(&wrapped.args);
580
581            // Apply any post-construction configuration (e.g., Windows AppContainer)
582            enforcer.configure_command(&mut new_cmd, policy)?;
583
584            new_cmd
585        } else {
586            cmd
587        };
588
589        // Resolve a bare program name against the caller's PATH *before* clearing the
590        // environment. Clearing first leaves the child with no PATH, and program
591        // resolution then fails with ENOENT — so a backend configured with `"rustc"`,
592        // `"python3"`, or `"node"` could not execute anything at all.
593        {
594            let program = cmd.as_std().get_program().to_owned();
595            if let Some(resolved) = resolve_program(&program) {
596                let args: Vec<OsString> = cmd.as_std().get_args().map(OsStr::to_owned).collect();
597                let mut resolved_cmd = Command::new(resolved);
598                resolved_cmd.args(&args);
599                cmd = resolved_cmd;
600            }
601        }
602
603        // Environment precedence: the policy supplies defaults for every execution, and
604        // the request overrides them per call. `SandboxPolicy::env` was previously
605        // ignored entirely, so a policy that set variables silently supplied none.
606        cmd.env_clear();
607        for (k, v) in extra_env {
608            cmd.env(k, v);
609        }
610        if let Some(policy) = &self.policy {
611            for (k, v) in &policy.env {
612                cmd.env(k, v);
613            }
614        }
615        for (k, v) in &request.env {
616            cmd.env(k, v);
617        }
618        cmd.kill_on_drop(true);
619
620        // Give each execution its own process group. `kill_on_drop` only
621        // targets the immediate child, which is not enough for shell tools:
622        // compilers, scripts, and background jobs can otherwise survive a
623        // timeout. Descendants inherit this group unless they deliberately
624        // detach, so the timeout path can terminate the execution tree.
625        #[cfg(unix)]
626        {
627            use std::os::unix::process::CommandExt;
628            cmd.as_std_mut().process_group(0);
629        }
630
631        cmd.stdout(std::process::Stdio::piped());
632        cmd.stderr(std::process::Stdio::piped());
633
634        if request.stdin.is_some() {
635            cmd.stdin(std::process::Stdio::piped());
636        } else {
637            cmd.stdin(std::process::Stdio::null());
638        }
639
640        let start = Instant::now();
641        let mut child = cmd.spawn()?;
642        #[cfg(unix)]
643        let process_group = child.id().map(|id| id as i32);
644
645        // Pipe stdin if provided
646        if let Some(ref input) = request.stdin
647            && let Some(mut stdin_handle) = child.stdin.take()
648        {
649            stdin_handle.write_all(input.as_bytes()).await?;
650            drop(stdin_handle);
651        }
652
653        // Read both pipes concurrently with the cap applied as the bytes arrive. Buffering the
654        // whole output first and truncating afterwards let a process allocate without bound
655        // before the limit was consulted, so the cap did not limit memory at all.
656        let cap = self.config.max_output_bytes;
657        let stdout_pipe = child.stdout.take();
658        let stderr_pipe = child.stderr.take();
659        let stdout_reader = tokio::spawn(async move {
660            match stdout_pipe {
661                Some(pipe) => read_capped(pipe, cap).await,
662                None => Ok((Vec::new(), false)),
663            }
664        });
665        let stderr_reader = tokio::spawn(async move {
666            match stderr_pipe {
667                Some(pipe) => read_capped(pipe, cap).await,
668                None => Ok((Vec::new(), false)),
669            }
670        });
671
672        let output = tokio::time::timeout(request.timeout, async {
673            let status = child.wait().await?;
674            let (stdout, stdout_discarded) =
675                stdout_reader.await.map_err(std::io::Error::other)??;
676            let (stderr, stderr_discarded) =
677                stderr_reader.await.map_err(std::io::Error::other)??;
678            Ok::<_, std::io::Error>((status, stdout, stdout_discarded, stderr, stderr_discarded))
679        })
680        .await;
681        let duration = start.elapsed();
682
683        match output {
684            Ok(Ok((status, stdout_bytes, stdout_discarded, stderr_bytes, stderr_discarded))) => {
685                let exit_code = status.code().unwrap_or(-1);
686                if stdout_discarded || stderr_discarded {
687                    tracing::warn!(
688                        max_output_bytes = cap,
689                        stdout.truncated = stdout_discarded,
690                        stderr.truncated = stderr_discarded,
691                        "sandbox output exceeded the cap and was truncated"
692                    );
693                }
694                let cap = self.config.max_output_bytes;
695                let stdout = note_truncation(truncate_utf8(stdout_bytes, cap), stdout_discarded);
696                let stderr = note_truncation(truncate_utf8(stderr_bytes, cap), stderr_discarded);
697
698                Span::current().record("exit_code", exit_code);
699                Span::current().record("duration_ms", duration.as_millis() as u64);
700
701                Ok(ExecResult { stdout, stderr, exit_code, duration })
702            }
703            Ok(Err(e)) => {
704                Err(SandboxError::ExecutionFailed(format!("failed to wait for child process: {e}")))
705            }
706            Err(_) => {
707                // Timeout — terminate the Unix process group before
708                // `kill_on_drop` cleans up the immediate child. This prevents
709                // background descendants from escaping the execution limit.
710                #[cfg(unix)]
711                if let Some(group) = process_group {
712                    // SAFETY: `group` is the positive PID returned for the
713                    // child we just placed in a new process group. A negative
714                    // PID asks kill(2) to signal that process group only.
715                    unsafe {
716                        libc::kill(-group, libc::SIGKILL);
717                    }
718                }
719                Span::current().record("duration_ms", duration.as_millis() as u64);
720                Err(SandboxError::Timeout { timeout: request.timeout })
721            }
722        }
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use std::collections::HashMap;
730    use std::time::Duration;
731
732    fn make_request(language: Language, code: &str) -> ExecRequest {
733        let mut env = HashMap::new();
734        // ProcessBackend clears the environment (REQ-SBX-023), so tests that
735        // invoke interpreters by name need PATH to resolve them.
736        if let Ok(path) = std::env::var("PATH") {
737            env.insert("PATH".to_string(), path);
738        }
739        // Windows processes need SYSTEMROOT for DLL loading and basic operation.
740        if let Ok(sr) = std::env::var("SYSTEMROOT") {
741            env.insert("SYSTEMROOT".to_string(), sr);
742        }
743        ExecRequest {
744            language,
745            code: code.to_string(),
746            stdin: None,
747            timeout: Duration::from_secs(30),
748            memory_limit_mb: None,
749            env,
750        }
751    }
752
753    #[tokio::test]
754    async fn test_python_execution() {
755        let backend = ProcessBackend::default();
756        let request = make_request(Language::Python, "print('hello')");
757        let result = backend.execute(request).await.unwrap();
758        assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
759        assert_eq!(result.exit_code, 0);
760    }
761
762    #[tokio::test]
763    async fn test_javascript_execution() {
764        // Skip if node is not available (e.g. minimal CI images)
765        if std::process::Command::new("node").arg("--version").output().is_err() {
766            eprintln!("skipping test_javascript_execution: node not found");
767            return;
768        }
769        let backend = ProcessBackend::default();
770        let request = make_request(Language::JavaScript, "console.log('hello')");
771        let result = backend.execute(request).await.unwrap();
772        assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
773        assert_eq!(result.exit_code, 0);
774    }
775
776    #[tokio::test]
777    async fn test_command_execution() {
778        let backend = ProcessBackend::default();
779        let request = make_request(Language::Command, "echo hello");
780        let result = backend.execute(request).await.unwrap();
781        assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
782        assert_eq!(result.exit_code, 0);
783    }
784
785    #[tokio::test]
786    #[cfg(windows)]
787    async fn test_command_supports_quoted_script_paths() {
788        let directory = tempfile::tempdir().unwrap();
789        let script = directory.path().join("quoted helper.cmd");
790        std::fs::write(&script, "@echo quoted-path-ok\r\n").unwrap();
791
792        let backend = ProcessBackend::default();
793        let request = make_request(Language::Command, &format!("\"{}\"", script.display()));
794        let result = backend.execute(request).await.unwrap();
795
796        assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
797        assert!(result.stdout.contains("quoted-path-ok"), "stdout: {}", result.stdout);
798    }
799
800    #[tokio::test]
801    async fn test_timeout_enforcement() {
802        let backend = ProcessBackend::default();
803        let code =
804            if cfg!(windows) { "ping -n 11 127.0.0.1".to_string() } else { "sleep 10".to_string() };
805        let mut request = make_request(Language::Command, &code);
806        request.timeout = Duration::from_secs(1);
807        let result = backend.execute(request).await;
808        assert!(
809            matches!(result, Err(SandboxError::Timeout { .. })),
810            "expected Timeout, got: {result:?}"
811        );
812    }
813
814    #[tokio::test]
815    #[cfg(unix)]
816    async fn test_timeout_terminates_background_descendants() {
817        let backend = ProcessBackend::default();
818        let directory = tempfile::tempdir().unwrap();
819        let marker = directory.path().join("escaped-child");
820        let escaped_marker = marker.to_string_lossy().replace('\'', "'\\''");
821        let code = format!("(sleep 1; touch '{escaped_marker}') & wait");
822        let mut request = make_request(Language::Command, &code);
823        request.timeout = Duration::from_millis(100);
824
825        let result = backend.execute(request).await;
826        assert!(matches!(result, Err(SandboxError::Timeout { .. })));
827        tokio::time::sleep(Duration::from_millis(1_200)).await;
828        assert!(!marker.exists(), "a background descendant survived the execution timeout");
829    }
830
831    #[tokio::test]
832    #[cfg(not(windows))]
833    async fn test_environment_isolation() {
834        let backend = ProcessBackend::default();
835        let mut env = HashMap::new();
836        env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
837        let request = ExecRequest {
838            language: Language::Command,
839            // Use absolute path to env since PATH won't be set
840            code: "/usr/bin/env".to_string(),
841            stdin: None,
842            timeout: Duration::from_secs(10),
843            memory_limit_mb: None,
844            env,
845        };
846        let result = backend.execute(request).await.unwrap();
847        // The only env var should be MY_TEST_VAR
848        assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
849        // Common inherited vars like HOME should NOT be present
850        assert!(
851            !result.stdout.contains("HOME="),
852            "HOME should not be inherited: {}",
853            result.stdout
854        );
855    }
856
857    #[tokio::test]
858    #[cfg(windows)]
859    async fn test_environment_isolation() {
860        let backend = ProcessBackend::default();
861        let mut env = HashMap::new();
862        env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
863        let request = ExecRequest {
864            language: Language::Command,
865            code: "set MY_TEST_VAR".to_string(),
866            stdin: None,
867            timeout: Duration::from_secs(10),
868            memory_limit_mb: None,
869            env,
870        };
871        let result = backend.execute(request).await.unwrap();
872        assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
873    }
874
875    #[tokio::test]
876    async fn test_nonzero_exit_code() {
877        let backend = ProcessBackend::default();
878        let code = if cfg!(windows) { "exit /b 42" } else { "exit 42" };
879        let request = make_request(Language::Command, code);
880        let result = backend.execute(request).await.unwrap();
881        assert_eq!(result.exit_code, 42);
882    }
883
884    #[tokio::test]
885    async fn test_wasm_returns_invalid_request() {
886        let backend = ProcessBackend::default();
887        let request = make_request(Language::Wasm, "");
888        let result = backend.execute(request).await;
889        assert!(
890            matches!(result, Err(SandboxError::InvalidRequest(_))),
891            "expected InvalidRequest, got: {result:?}"
892        );
893    }
894
895    /// `read_capped` must retain at most `cap` bytes regardless of how much arrives.
896    ///
897    /// This is the property the streaming read exists for, and it is not observable from
898    /// `ExecResult`: `truncate_utf8` caps the *reported* string either way, so an end-to-end
899    /// test passes even when the whole stream was buffered first. Asserting on the retained
900    /// buffer is what distinguishes bounded memory from a bounded report.
901    #[tokio::test]
902    async fn read_capped_retains_at_most_the_cap() {
903        let cap = 4_096;
904        // 256x the cap, so a buffering implementation would allocate 1 MiB here.
905        let source = vec![b'x'; cap * 256];
906
907        let (retained, discarded) = read_capped(&source[..], cap).await.expect("reads");
908
909        assert_eq!(retained.len(), cap, "retained buffer must stop at the cap");
910        assert!(discarded, "the overflow must be reported as discarded");
911    }
912
913    /// Everything is retained when the stream is smaller than the cap, and nothing is flagged.
914    #[tokio::test]
915    async fn read_capped_retains_everything_under_the_cap() {
916        let source = vec![b'y'; 100];
917
918        let (retained, discarded) = read_capped(&source[..], 4_096).await.expect("reads");
919
920        assert_eq!(retained, source);
921        assert!(!discarded);
922    }
923
924    /// A stream landing exactly on the cap is not reported as truncated.
925    #[tokio::test]
926    async fn read_capped_handles_the_exact_boundary() {
927        let cap = 8_192;
928        let source = vec![b'z'; cap];
929
930        let (retained, discarded) = read_capped(&source[..], cap).await.expect("reads");
931
932        assert_eq!(retained.len(), cap);
933        assert!(!discarded, "reaching the cap exactly discards nothing");
934    }
935
936    #[test]
937    fn test_truncate_utf8_within_limit() {
938        let data = "hello world".as_bytes().to_vec();
939        let result = truncate_utf8(data, 1024);
940        assert_eq!(result, "hello world");
941    }
942
943    #[test]
944    fn test_truncate_utf8_at_boundary() {
945        // Multi-byte UTF-8: "é" is 2 bytes (0xC3 0xA9)
946        let data = "café".as_bytes().to_vec(); // 5 bytes: c a f 0xC3 0xA9
947        // Truncate at 4 bytes — would split the "é"
948        let result = truncate_utf8(data, 4);
949        assert_eq!(result, "caf");
950    }
951
952    #[test]
953    fn test_capabilities() {
954        let backend = ProcessBackend::default();
955        let caps = backend.capabilities();
956        assert_eq!(caps.isolation_class, "process");
957        assert!(caps.enforced_limits.timeout);
958        assert!(caps.enforced_limits.environment_isolation);
959        assert!(!caps.enforced_limits.memory);
960        assert!(!caps.enforced_limits.network_isolation);
961        assert!(!caps.enforced_limits.filesystem_write_isolation);
962        assert!(!caps.enforced_limits.filesystem_read_isolation);
963        assert!(caps.supported_languages.contains(&Language::Rust));
964        assert!(caps.supported_languages.contains(&Language::Python));
965        assert!(caps.supported_languages.contains(&Language::JavaScript));
966        assert!(caps.supported_languages.contains(&Language::TypeScript));
967        assert!(caps.supported_languages.contains(&Language::Command));
968        assert!(!caps.supported_languages.contains(&Language::Wasm));
969    }
970
971    #[test]
972    fn test_name() {
973        let backend = ProcessBackend::default();
974        assert_eq!(backend.name(), "process");
975    }
976
977    #[test]
978    fn test_process_config_default() {
979        let config = ProcessConfig::default();
980        assert_eq!(config.rustc_path, "rustc");
981        assert_eq!(config.python_path, "python3");
982        assert_eq!(config.node_path, "node");
983    }
984
985    #[test]
986    fn windows_compiler_environment_is_a_minimal_allowlist() {
987        assert_eq!(
988            WINDOWS_TOOLCHAIN_ENV_KEYS,
989            &[
990                "PATH",
991                "LIB",
992                "LIBPATH",
993                "INCLUDE",
994                "SystemRoot",
995                "TEMP",
996                "TMP",
997                "USERPROFILE",
998                "RUSTUP_HOME",
999                "RUSTUP_TOOLCHAIN",
1000            ]
1001        );
1002    }
1003}