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