use std::ffi::{OsStr, OsString};
use std::time::Instant;
use async_trait::async_trait;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tracing::{Span, instrument};
use crate::backend::{BackendCapabilities, EnforcedLimits, SandboxBackend};
use crate::error::SandboxError;
use crate::sandbox::{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()
}
}
fn truncate_utf8(bytes: Vec<u8>, max_bytes: usize) -> String {
if bytes.len() <= max_bytes {
return String::from_utf8_lossy(&bytes).into_owned();
}
let truncated = &bytes[..max_bytes];
let mut end = max_bytes;
while end > 0 && std::str::from_utf8(&truncated[..end]).is_err() {
end -= 1;
}
std::str::from_utf8(&bytes[..end]).unwrap_or("").to_string()
}
fn note_truncation(mut text: String, discarded: bool) -> String {
if discarded {
text.push_str("\n... (truncated: output exceeded the configured limit)");
}
text
}
async fn read_capped<R>(mut reader: R, cap: usize) -> std::io::Result<(Vec<u8>, bool)>
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut retained = Vec::new();
let mut chunk = [0u8; 8192];
let mut discarded = false;
loop {
let read = reader.read(&mut chunk).await?;
if read == 0 {
break;
}
let room = cap.saturating_sub(retained.len());
if room == 0 {
discarded = true;
continue;
}
let take = room.min(read);
retained.extend_from_slice(&chunk[..take]);
if take < read {
discarded = true;
}
}
Ok((retained, discarded))
}
#[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!(target_os = "linux"),
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).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_binary(&bin_path, request).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).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).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).await
}
async fn run_binary(
&self,
bin_path: &std::path::Path,
request: &ExecRequest,
) -> Result<ExecResult, SandboxError> {
let cmd = Command::new(bin_path);
self.run_command(cmd, request).await
}
async fn run_command(
&self,
cmd: Command,
request: &ExecRequest,
) -> Result<ExecResult, SandboxError> {
self.run_command_with_env(cmd, request, &[]).await
}
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)],
) -> Result<ExecResult, SandboxError> {
let mut cmd = if let (Some(enforcer), Some(policy)) = (&self.enforcer, &self.policy) {
let std_cmd = cmd.as_std();
let program = std_cmd.get_program();
let args: Vec<OsString> = std_cmd.get_args().map(OsStr::to_owned).collect();
let wrapped = enforcer.wrap_command(program, &args, policy)?;
let mut new_cmd = Command::new(&wrapped.program);
new_cmd.args(&wrapped.args);
enforcer.configure_command(&mut new_cmd, policy)?;
new_cmd
} else {
cmd
};
{
let program = cmd.as_std().get_program().to_owned();
if let Some(resolved) = resolve_program(&program) {
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);
cmd = resolved_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 mut child = cmd.spawn()?;
#[cfg(unix)]
let process_group = child.id().map(|id| id as i32);
if let Some(ref input) = request.stdin
&& let Some(mut stdin_handle) = child.stdin.take()
{
stdin_handle.write_all(input.as_bytes()).await?;
drop(stdin_handle);
}
let cap = self.config.max_output_bytes;
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_reader = tokio::spawn(async move {
match stdout_pipe {
Some(pipe) => read_capped(pipe, cap).await,
None => Ok((Vec::new(), false)),
}
});
let stderr_reader = tokio::spawn(async move {
match stderr_pipe {
Some(pipe) => read_capped(pipe, cap).await,
None => Ok((Vec::new(), false)),
}
});
let output = tokio::time::timeout(request.timeout, async {
let status = child.wait().await?;
let (stdout, stdout_discarded) =
stdout_reader.await.map_err(std::io::Error::other)??;
let (stderr, stderr_discarded) =
stderr_reader.await.map_err(std::io::Error::other)??;
Ok::<_, std::io::Error>((status, stdout, stdout_discarded, stderr, stderr_discarded))
})
.await;
let duration = start.elapsed();
match output {
Ok(Ok((status, stdout_bytes, stdout_discarded, stderr_bytes, stderr_discarded))) => {
let exit_code = status.code().unwrap_or(-1);
if stdout_discarded || stderr_discarded {
tracing::warn!(
max_output_bytes = cap,
stdout.truncated = stdout_discarded,
stderr.truncated = stderr_discarded,
"sandbox output exceeded the cap and was truncated"
);
}
let cap = self.config.max_output_bytes;
let stdout = note_truncation(truncate_utf8(stdout_bytes, cap), stdout_discarded);
let stderr = note_truncation(truncate_utf8(stderr_bytes, cap), stderr_discarded);
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(Err(e)) => {
Err(SandboxError::ExecutionFailed(format!("failed to wait for child process: {e}")))
}
Err(_) => {
#[cfg(unix)]
if let Some(group) = process_group {
unsafe {
libc::kill(-group, libc::SIGKILL);
}
}
Span::current().record("duration_ms", duration.as_millis() as u64);
Err(SandboxError::Timeout { timeout: request.timeout })
}
}
}
}
#[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(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:?}"
);
}
#[tokio::test]
async fn read_capped_retains_at_most_the_cap() {
let cap = 4_096;
let source = vec![b'x'; cap * 256];
let (retained, discarded) = read_capped(&source[..], cap).await.expect("reads");
assert_eq!(retained.len(), cap, "retained buffer must stop at the cap");
assert!(discarded, "the overflow must be reported as discarded");
}
#[tokio::test]
async fn read_capped_retains_everything_under_the_cap() {
let source = vec![b'y'; 100];
let (retained, discarded) = read_capped(&source[..], 4_096).await.expect("reads");
assert_eq!(retained, source);
assert!(!discarded);
}
#[tokio::test]
async fn read_capped_handles_the_exact_boundary() {
let cap = 8_192;
let source = vec![b'z'; cap];
let (retained, discarded) = read_capped(&source[..], cap).await.expect("reads");
assert_eq!(retained.len(), cap);
assert!(!discarded, "reaching the cap exactly discards nothing");
}
#[test]
fn test_truncate_utf8_within_limit() {
let data = "hello world".as_bytes().to_vec();
let result = truncate_utf8(data, 1024);
assert_eq!(result, "hello world");
}
#[test]
fn test_truncate_utf8_at_boundary() {
let data = "café".as_bytes().to_vec(); let result = truncate_utf8(data, 4);
assert_eq!(result, "caf");
}
#[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",
]
);
}
}