use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::time::Instant;
use async_trait::async_trait;
use tokio::process::Command;
use tracing::{Span, instrument};
use crate::backend::{BackendCapabilities, EnforcedLimits, SandboxBackend};
use crate::child_io::{collect_output, note_truncation, truncate_utf8};
use crate::error::SandboxError;
use crate::sandbox::{AccessMode, AllowedPath, SandboxEnforcer, SandboxPolicy};
use crate::types::{ExecRequest, ExecResult, Language};
const MAX_OUTPUT_BYTES: usize = 1_024 * 1_024;
const NON_WINDOWS_TOOLCHAIN_ENV_KEYS: &[&str] = &[
"PATH",
"DEVELOPER_DIR",
"SDKROOT",
"HOME",
"TMPDIR",
"RUSTUP_HOME",
"CARGO_HOME",
"RUSTUP_TOOLCHAIN",
];
const WINDOWS_TOOLCHAIN_ENV_KEYS: &[&str] = &[
"PATH",
"LIB",
"LIBPATH",
"INCLUDE",
"SystemRoot",
"TEMP",
"TMP",
"USERPROFILE",
"RUSTUP_HOME",
"RUSTUP_TOOLCHAIN",
];
#[derive(Debug, Clone)]
pub struct ProcessConfig {
pub rustc_path: String,
pub python_path: String,
pub node_path: String,
pub max_output_bytes: usize,
}
impl Default for ProcessConfig {
fn default() -> Self {
Self {
rustc_path: "rustc".to_string(),
python_path: "python3".to_string(),
node_path: "node".to_string(),
max_output_bytes: MAX_OUTPUT_BYTES,
}
}
}
pub struct ProcessBackend {
config: ProcessConfig,
enforcer: Option<Box<dyn SandboxEnforcer>>,
policy: Option<SandboxPolicy>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IsolationClass {
SubprocessOnly,
OsEnforced,
}
fn resolve_program(program: &OsStr) -> Option<std::path::PathBuf> {
let as_path = std::path::Path::new(program);
if as_path.components().count() > 1 {
return None;
}
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var).find_map(|dir| {
let candidate = dir.join(program);
candidate.is_file().then_some(candidate)
})
}
impl ProcessBackend {
pub fn new(config: ProcessConfig) -> Self {
Self { config, enforcer: None, policy: None }
}
pub fn isolation(&self) -> IsolationClass {
match (self.enforcer.is_some(), self.policy.is_some()) {
(true, true) => IsolationClass::OsEnforced,
_ => IsolationClass::SubprocessOnly,
}
}
pub fn with_sandbox(
config: ProcessConfig,
enforcer: Box<dyn SandboxEnforcer>,
policy: SandboxPolicy,
) -> Self {
Self { config, enforcer: Some(enforcer), policy: Some(policy) }
}
}
impl Default for ProcessBackend {
fn default() -> Self {
Self::new(ProcessConfig::default())
}
}
impl std::fmt::Debug for ProcessBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProcessBackend")
.field("config", &self.config)
.field("enforcer", &self.enforcer.as_ref().map(|e| e.name()))
.field("policy", &self.policy)
.finish()
}
}
#[async_trait]
impl SandboxBackend for ProcessBackend {
fn name(&self) -> &str {
"process"
}
fn capabilities(&self) -> BackendCapabilities {
let has_enforcer = self.enforcer.is_some();
let denies_network = self.policy.as_ref().is_some_and(|p| !p.allow_network);
BackendCapabilities {
supported_languages: vec![
Language::Rust,
Language::Python,
Language::JavaScript,
Language::TypeScript,
Language::Command,
],
isolation_class: if has_enforcer {
"process+sandbox".to_string()
} else {
"process".to_string()
},
enforced_limits: EnforcedLimits {
timeout: true,
memory: false,
network_isolation: has_enforcer && denies_network,
filesystem_write_isolation: has_enforcer,
filesystem_read_isolation: has_enforcer
&& cfg!(any(target_os = "linux", target_os = "macos")),
environment_isolation: true,
},
}
}
#[instrument(
skip_all,
fields(
backend = "process",
language = %request.language,
exit_code,
duration_ms,
)
)]
async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
if let Some(limit) = request.memory_limit_mb {
tracing::debug!(
memory_limit_mb = limit,
"memory limit not enforced by process backend"
);
}
match request.language {
Language::Rust => self.execute_rust(&request).await,
Language::Python => self.execute_python(&request).await,
Language::JavaScript | Language::TypeScript => self.execute_javascript(&request).await,
Language::Command => self.execute_command(&request).await,
Language::Wasm => Err(SandboxError::InvalidRequest(
"Wasm execution is not supported by ProcessBackend. Use WasmBackend instead."
.to_string(),
)),
}
}
}
impl ProcessBackend {
async fn execute_rust(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
let dir = tempfile::tempdir()?;
let src_path = dir.path().join("main.rs");
let bin_path = dir.path().join("main");
std::fs::write(&src_path, &request.code)?;
let toolchain_env = Self::toolchain_env();
#[cfg(windows)]
let has_msvc_library_path =
toolchain_env.iter().any(|(key, _)| key.eq_ignore_ascii_case("LIB"));
let compile_result = {
let mut cmd = Command::new(&self.config.rustc_path);
#[cfg(windows)]
cmd.arg("-Clinker=rust-lld");
cmd.arg(&src_path).arg("-o").arg(&bin_path);
self.run_command_with_env(cmd, request, &toolchain_env, Some(dir.path())).await?
};
#[cfg(windows)]
let compile_result = {
let mut result = compile_result;
if result.exit_code != 0 && !has_msvc_library_path {
result.stderr.push_str(
"\nWindows Rust linking requires the MSVC Build Tools and Windows SDK. \
Install the `Desktop development with C++` workload; ProcessBackend could \
not discover its LIB paths from this host.",
);
}
result
};
if compile_result.exit_code != 0 {
Span::current().record("exit_code", compile_result.exit_code);
Span::current().record("duration_ms", compile_result.duration.as_millis() as u64);
return Ok(compile_result);
}
self.run_command(Command::new(&bin_path), request, Some(dir.path())).await
}
async fn execute_python(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
let dir = tempfile::tempdir()?;
let src_path = dir.path().join("script.py");
std::fs::write(&src_path, &request.code)?;
let mut cmd = Command::new(&self.config.python_path);
cmd.arg(&src_path);
self.run_command(cmd, request, Some(dir.path())).await
}
async fn execute_javascript(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
let dir = tempfile::tempdir()?;
let src_path = dir.path().join("script.js");
std::fs::write(&src_path, &request.code)?;
let mut cmd = Command::new(&self.config.node_path);
cmd.arg(&src_path);
self.run_command(cmd, request, Some(dir.path())).await
}
async fn execute_command(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
#[cfg(windows)]
let cmd = {
use std::os::windows::process::CommandExt;
let mut c = Command::new("cmd");
c.arg("/D").arg("/C");
c.as_std_mut().raw_arg(&request.code);
c
};
#[cfg(not(windows))]
let cmd = {
let mut c = Command::new("sh");
c.arg("-c").arg(&request.code);
c
};
self.run_command(cmd, request, None).await
}
async fn run_command(
&self,
cmd: Command,
request: &ExecRequest,
scratch_dir: Option<&Path>,
) -> Result<ExecResult, SandboxError> {
self.run_command_with_env(cmd, request, &[], scratch_dir).await
}
pub fn toolchain_env() -> Vec<(String, OsString)> {
let keys =
if cfg!(windows) { WINDOWS_TOOLCHAIN_ENV_KEYS } else { NON_WINDOWS_TOOLCHAIN_ENV_KEYS };
let environment: Vec<(String, OsString)> = keys
.iter()
.filter_map(|key| std::env::var_os(key).map(|value| ((*key).to_string(), value)))
.collect();
#[cfg(windows)]
let mut environment = environment;
#[cfg(windows)]
if !environment.iter().any(|(key, _)| key.eq_ignore_ascii_case("LIB"))
&& let Some(linker) = find_msvc_tools::find(std::env::consts::ARCH, "link.exe")
{
for (key, value) in linker.get_envs() {
let Some(value) = value else {
continue;
};
let Some(allowed_key) = WINDOWS_TOOLCHAIN_ENV_KEYS
.iter()
.find(|allowed| key.eq_ignore_ascii_case(OsStr::new(allowed)))
else {
continue;
};
if !environment
.iter()
.any(|(existing, _)| existing.eq_ignore_ascii_case(allowed_key))
{
environment.push(((*allowed_key).to_string(), value.to_os_string()));
}
}
}
environment
}
async fn run_command_with_env(
&self,
cmd: Command,
request: &ExecRequest,
extra_env: &[(String, OsString)],
scratch_dir: Option<&Path>,
) -> Result<ExecResult, SandboxError> {
let mut owned_scratch: Option<tempfile::TempDir> = None;
let mut cmd = if let (Some(enforcer), Some(policy)) = (&self.enforcer, &self.policy) {
let scratch = match scratch_dir {
Some(dir) => dir.to_path_buf(),
None => owned_scratch.insert(tempfile::tempdir()?).path().to_path_buf(),
};
let mut policy = policy.clone();
policy
.allowed_paths
.push(AllowedPath { path: scratch.clone(), mode: AccessMode::ReadWrite });
let std_cmd = cmd.as_std();
let args: Vec<OsString> = std_cmd.get_args().map(OsStr::to_owned).collect();
let wrapped = enforcer.wrap_command(std_cmd.get_program(), &args, &policy)?;
let program = resolve_program(&wrapped.program)
.map_or(wrapped.program, std::path::PathBuf::into_os_string);
let mut wrapped_cmd = Command::new(program);
wrapped_cmd.args(&wrapped.args).current_dir(&scratch);
enforcer.configure_command(&mut wrapped_cmd, &policy)?;
wrapped_cmd
} else {
match resolve_program(cmd.as_std().get_program()) {
Some(resolved) => {
let args: Vec<OsString> =
cmd.as_std().get_args().map(OsStr::to_owned).collect();
let mut resolved_cmd = Command::new(resolved);
resolved_cmd.args(&args);
resolved_cmd
}
None => cmd,
}
};
cmd.env_clear();
for (k, v) in extra_env {
cmd.env(k, v);
}
if let Some(policy) = &self.policy {
for (k, v) in &policy.env {
cmd.env(k, v);
}
}
for (k, v) in &request.env {
cmd.env(k, v);
}
cmd.kill_on_drop(true);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.as_std_mut().process_group(0);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
if request.stdin.is_some() {
cmd.stdin(std::process::Stdio::piped());
} else {
cmd.stdin(std::process::Stdio::null());
}
let start = Instant::now();
let child = cmd.spawn()?;
let cap = self.config.max_output_bytes;
let stdin = request.stdin.as_ref().map(|input| input.as_bytes().to_vec());
let output = collect_output(child, stdin, request.timeout, cap).await;
let duration = start.elapsed();
match output {
Ok(Some(captured)) => {
let exit_code = captured.status.code().unwrap_or(-1);
if captured.stdout_truncated || captured.stderr_truncated {
tracing::warn!(
max_output_bytes = cap,
stdout.truncated = captured.stdout_truncated,
stderr.truncated = captured.stderr_truncated,
"sandbox output exceeded the cap and was truncated"
);
}
let stdout =
note_truncation(truncate_utf8(captured.stdout, cap), captured.stdout_truncated);
let stderr =
note_truncation(truncate_utf8(captured.stderr, cap), captured.stderr_truncated);
Span::current().record("exit_code", exit_code);
Span::current().record("duration_ms", duration.as_millis() as u64);
Ok(ExecResult { stdout, stderr, exit_code, duration })
}
Ok(None) => {
Span::current().record("duration_ms", duration.as_millis() as u64);
Err(SandboxError::Timeout { timeout: request.timeout })
}
Err(e) => {
Err(SandboxError::ExecutionFailed(format!("failed to wait for child process: {e}")))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::time::Duration;
fn make_request(language: Language, code: &str) -> ExecRequest {
let mut env = HashMap::new();
if let Ok(path) = std::env::var("PATH") {
env.insert("PATH".to_string(), path);
}
if let Ok(sr) = std::env::var("SYSTEMROOT") {
env.insert("SYSTEMROOT".to_string(), sr);
}
ExecRequest {
language,
code: code.to_string(),
stdin: None,
timeout: Duration::from_secs(30),
memory_limit_mb: None,
env,
}
}
#[tokio::test]
async fn test_python_execution() {
let backend = ProcessBackend::default();
let request = make_request(Language::Python, "print('hello')");
let result = backend.execute(request).await.unwrap();
assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_javascript_execution() {
if std::process::Command::new("node").arg("--version").output().is_err() {
eprintln!("skipping test_javascript_execution: node not found");
return;
}
let backend = ProcessBackend::default();
let request = make_request(Language::JavaScript, "console.log('hello')");
let result = backend.execute(request).await.unwrap();
assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_command_execution() {
let backend = ProcessBackend::default();
let request = make_request(Language::Command, "echo hello");
let result = backend.execute(request).await.unwrap();
assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
#[cfg(windows)]
async fn test_command_supports_quoted_script_paths() {
let directory = tempfile::tempdir().unwrap();
let script = directory.path().join("quoted helper.cmd");
std::fs::write(&script, "@echo quoted-path-ok\r\n").unwrap();
let backend = ProcessBackend::default();
let request = make_request(Language::Command, &format!("\"{}\"", script.display()));
let result = backend.execute(request).await.unwrap();
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert!(result.stdout.contains("quoted-path-ok"), "stdout: {}", result.stdout);
}
#[tokio::test]
async fn test_timeout_enforcement() {
let backend = ProcessBackend::default();
let code =
if cfg!(windows) { "ping -n 11 127.0.0.1".to_string() } else { "sleep 10".to_string() };
let mut request = make_request(Language::Command, &code);
request.timeout = Duration::from_secs(1);
let result = backend.execute(request).await;
assert!(
matches!(result, Err(SandboxError::Timeout { .. })),
"expected Timeout, got: {result:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_timeout_terminates_background_descendants() {
let backend = ProcessBackend::default();
let directory = tempfile::tempdir().unwrap();
let marker = directory.path().join("escaped-child");
let escaped_marker = marker.to_string_lossy().replace('\'', "'\\''");
let code = format!("(sleep 1; touch '{escaped_marker}') & wait");
let mut request = make_request(Language::Command, &code);
request.timeout = Duration::from_millis(100);
let result = backend.execute(request).await;
assert!(matches!(result, Err(SandboxError::Timeout { .. })));
tokio::time::sleep(Duration::from_millis(1_200)).await;
assert!(!marker.exists(), "a background descendant survived the execution timeout");
}
#[tokio::test]
#[cfg(unix)]
async fn test_normal_exit_terminates_background_descendants() {
let backend = ProcessBackend::default();
let directory = tempfile::tempdir().unwrap();
let marker = directory.path().join("escaped-child");
let escaped_marker = marker.to_string_lossy().replace('\'', "'\\''");
let code = format!(
"nohup sh -c 'sleep 1; touch \"$0\"' '{escaped_marker}' >/dev/null 2>&1 & echo done"
);
let result = backend.execute(make_request(Language::Command, &code)).await.unwrap();
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout.trim(), "done");
tokio::time::sleep(Duration::from_millis(1_500)).await;
assert!(!marker.exists(), "a background descendant survived a normal exit");
}
#[tokio::test]
#[cfg(unix)]
async fn test_large_stdin_does_not_deadlock_against_output() {
let backend = ProcessBackend::default();
let mut request = make_request(Language::Command, "head -c 262144 /dev/zero; wc -c");
request.stdin = Some("s".repeat(512 * 1_024));
let result = tokio::time::timeout(Duration::from_secs(60), backend.execute(request))
.await
.expect("the execution deadlocked")
.expect("the execution completes");
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert!(
result.stdout.trim_end().ends_with("524288"),
"stdin was not fully delivered: {:?}",
&result.stdout[result.stdout.len().saturating_sub(32)..]
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_unread_stdin_is_bounded_by_the_timeout() {
let backend = ProcessBackend::default();
let mut request = make_request(Language::Command, "sleep 30");
request.stdin = Some("s".repeat(4 * 1_024 * 1_024));
request.timeout = Duration::from_millis(300);
let started = std::time::Instant::now();
let result = backend.execute(request).await;
assert!(matches!(result, Err(SandboxError::Timeout { .. })), "got: {result:?}");
assert!(started.elapsed() < Duration::from_secs(10), "took {:?}", started.elapsed());
}
#[tokio::test]
#[cfg(not(windows))]
async fn test_environment_isolation() {
let backend = ProcessBackend::default();
let mut env = HashMap::new();
env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
let request = ExecRequest {
language: Language::Command,
code: "/usr/bin/env".to_string(),
stdin: None,
timeout: Duration::from_secs(10),
memory_limit_mb: None,
env,
};
let result = backend.execute(request).await.unwrap();
assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
assert!(
!result.stdout.contains("HOME="),
"HOME should not be inherited: {}",
result.stdout
);
}
#[tokio::test]
#[cfg(windows)]
async fn test_environment_isolation() {
let backend = ProcessBackend::default();
let mut env = HashMap::new();
env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
let request = ExecRequest {
language: Language::Command,
code: "set MY_TEST_VAR".to_string(),
stdin: None,
timeout: Duration::from_secs(10),
memory_limit_mb: None,
env,
};
let result = backend.execute(request).await.unwrap();
assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
}
#[tokio::test]
async fn test_nonzero_exit_code() {
let backend = ProcessBackend::default();
let code = if cfg!(windows) { "exit /b 42" } else { "exit 42" };
let request = make_request(Language::Command, code);
let result = backend.execute(request).await.unwrap();
assert_eq!(result.exit_code, 42);
}
#[tokio::test]
async fn test_wasm_returns_invalid_request() {
let backend = ProcessBackend::default();
let request = make_request(Language::Wasm, "");
let result = backend.execute(request).await;
assert!(
matches!(result, Err(SandboxError::InvalidRequest(_))),
"expected InvalidRequest, got: {result:?}"
);
}
#[test]
fn test_capabilities() {
let backend = ProcessBackend::default();
let caps = backend.capabilities();
assert_eq!(caps.isolation_class, "process");
assert!(caps.enforced_limits.timeout);
assert!(caps.enforced_limits.environment_isolation);
assert!(!caps.enforced_limits.memory);
assert!(!caps.enforced_limits.network_isolation);
assert!(!caps.enforced_limits.filesystem_write_isolation);
assert!(!caps.enforced_limits.filesystem_read_isolation);
assert!(caps.supported_languages.contains(&Language::Rust));
assert!(caps.supported_languages.contains(&Language::Python));
assert!(caps.supported_languages.contains(&Language::JavaScript));
assert!(caps.supported_languages.contains(&Language::TypeScript));
assert!(caps.supported_languages.contains(&Language::Command));
assert!(!caps.supported_languages.contains(&Language::Wasm));
}
#[test]
fn test_name() {
let backend = ProcessBackend::default();
assert_eq!(backend.name(), "process");
}
#[test]
fn test_process_config_default() {
let config = ProcessConfig::default();
assert_eq!(config.rustc_path, "rustc");
assert_eq!(config.python_path, "python3");
assert_eq!(config.node_path, "node");
}
#[test]
fn windows_compiler_environment_is_a_minimal_allowlist() {
assert_eq!(
WINDOWS_TOOLCHAIN_ENV_KEYS,
&[
"PATH",
"LIB",
"LIBPATH",
"INCLUDE",
"SystemRoot",
"TEMP",
"TMP",
"USERPROFILE",
"RUSTUP_HOME",
"RUSTUP_TOOLCHAIN",
]
);
}
}