1use 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
51const MAX_OUTPUT_BYTES: usize = 1_024 * 1_024;
53
54#[derive(Debug, Clone)]
70pub struct ProcessConfig {
71 pub rustc_path: String,
73 pub python_path: String,
75 pub node_path: String,
77}
78
79impl Default for ProcessConfig {
80 fn default() -> Self {
81 Self {
82 rustc_path: "rustc".to_string(),
83 python_path: "python3".to_string(),
84 node_path: "node".to_string(),
85 }
86 }
87}
88
89pub struct ProcessBackend {
124 config: ProcessConfig,
125 enforcer: Option<Box<dyn SandboxEnforcer>>,
126 policy: Option<SandboxPolicy>,
127}
128
129impl ProcessBackend {
130 pub fn new(config: ProcessConfig) -> Self {
132 Self { config, enforcer: None, policy: None }
133 }
134
135 pub fn with_sandbox(
144 config: ProcessConfig,
145 enforcer: Box<dyn SandboxEnforcer>,
146 policy: SandboxPolicy,
147 ) -> Self {
148 Self { config, enforcer: Some(enforcer), policy: Some(policy) }
149 }
150}
151
152impl Default for ProcessBackend {
153 fn default() -> Self {
154 Self::new(ProcessConfig::default())
155 }
156}
157
158impl std::fmt::Debug for ProcessBackend {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.debug_struct("ProcessBackend")
162 .field("config", &self.config)
163 .field("enforcer", &self.enforcer.as_ref().map(|e| e.name()))
164 .field("policy", &self.policy)
165 .finish()
166 }
167}
168
169fn truncate_utf8(bytes: Vec<u8>, max_bytes: usize) -> String {
172 if bytes.len() <= max_bytes {
173 return String::from_utf8_lossy(&bytes).into_owned();
174 }
175 let truncated = &bytes[..max_bytes];
176 let mut end = max_bytes;
178 while end > 0 && std::str::from_utf8(&truncated[..end]).is_err() {
179 end -= 1;
180 }
181 std::str::from_utf8(&bytes[..end]).unwrap_or("").to_string()
182}
183
184#[async_trait]
185impl SandboxBackend for ProcessBackend {
186 fn name(&self) -> &str {
187 "process"
188 }
189
190 fn capabilities(&self) -> BackendCapabilities {
191 let has_enforcer = self.enforcer.is_some();
192 let denies_network = self.policy.as_ref().is_some_and(|p| !p.allow_network);
193
194 BackendCapabilities {
195 supported_languages: vec![
196 Language::Rust,
197 Language::Python,
198 Language::JavaScript,
199 Language::TypeScript,
200 Language::Command,
201 ],
202 isolation_class: if has_enforcer {
203 "process+sandbox".to_string()
204 } else {
205 "process".to_string()
206 },
207 enforced_limits: EnforcedLimits {
208 timeout: true,
209 memory: false,
210 network_isolation: has_enforcer && denies_network,
211 filesystem_isolation: has_enforcer,
212 environment_isolation: true,
213 },
214 }
215 }
216
217 #[instrument(
218 skip_all,
219 fields(
220 backend = "process",
221 language = %request.language,
222 exit_code,
223 duration_ms,
224 )
225 )]
226 async fn execute(&self, request: ExecRequest) -> Result<ExecResult, SandboxError> {
227 if let Some(limit) = request.memory_limit_mb {
228 tracing::debug!(
229 memory_limit_mb = limit,
230 "memory limit not enforced by process backend"
231 );
232 }
233
234 match request.language {
235 Language::Rust => self.execute_rust(&request).await,
236 Language::Python => self.execute_python(&request).await,
237 Language::JavaScript | Language::TypeScript => self.execute_javascript(&request).await,
238 Language::Command => self.execute_command(&request).await,
239 Language::Wasm => Err(SandboxError::InvalidRequest(
240 "Wasm execution is not supported by ProcessBackend. Use WasmBackend instead."
241 .to_string(),
242 )),
243 }
244 }
245}
246
247impl ProcessBackend {
248 async fn execute_rust(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
250 let dir = tempfile::tempdir()?;
251 let src_path = dir.path().join("main.rs");
252 let bin_path = dir.path().join("main");
253
254 std::fs::write(&src_path, &request.code)?;
255
256 let compile_output = {
258 let mut cmd = Command::new(&self.config.rustc_path);
259 cmd.arg(&src_path).arg("-o").arg(&bin_path).env_clear().kill_on_drop(true);
260 for (k, v) in &request.env {
261 cmd.env(k, v);
262 }
263 cmd.output().await?
264 };
265
266 if !compile_output.status.success() {
267 let stderr = truncate_utf8(compile_output.stderr, MAX_OUTPUT_BYTES);
268 let stdout = truncate_utf8(compile_output.stdout, MAX_OUTPUT_BYTES);
269 let exit_code = compile_output.status.code().unwrap_or(1);
270 let result =
271 ExecResult { stdout, stderr, exit_code, duration: std::time::Duration::ZERO };
272 Span::current().record("exit_code", exit_code);
273 Span::current().record("duration_ms", 0_u64);
274 return Ok(result);
275 }
276
277 self.run_binary(&bin_path, request).await
279 }
280
281 async fn execute_python(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
283 let dir = tempfile::tempdir()?;
284 let src_path = dir.path().join("script.py");
285 std::fs::write(&src_path, &request.code)?;
286
287 let mut cmd = Command::new(&self.config.python_path);
288 cmd.arg(&src_path);
289 self.run_command(cmd, request).await
290 }
291
292 async fn execute_javascript(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
294 let dir = tempfile::tempdir()?;
295 let src_path = dir.path().join("script.js");
296 std::fs::write(&src_path, &request.code)?;
297
298 let mut cmd = Command::new(&self.config.node_path);
299 cmd.arg(&src_path);
300 self.run_command(cmd, request).await
301 }
302
303 async fn execute_command(&self, request: &ExecRequest) -> Result<ExecResult, SandboxError> {
305 let cmd = if cfg!(windows) {
306 let mut c = Command::new("cmd");
307 c.arg("/C").arg(&request.code);
308 c
309 } else {
310 let mut c = Command::new("sh");
311 c.arg("-c").arg(&request.code);
312 c
313 };
314 self.run_command(cmd, request).await
315 }
316
317 async fn run_binary(
319 &self,
320 bin_path: &std::path::Path,
321 request: &ExecRequest,
322 ) -> Result<ExecResult, SandboxError> {
323 let cmd = Command::new(bin_path);
324 self.run_command(cmd, request).await
325 }
326
327 async fn run_command(
332 &self,
333 cmd: Command,
334 request: &ExecRequest,
335 ) -> Result<ExecResult, SandboxError> {
336 let mut cmd = if let (Some(enforcer), Some(policy)) = (&self.enforcer, &self.policy) {
340 let std_cmd = cmd.as_std();
341 let program = std_cmd.get_program();
342 let args: Vec<OsString> = std_cmd.get_args().map(OsStr::to_owned).collect();
343
344 let wrapped = enforcer.wrap_command(program, &args, policy)?;
345
346 let mut new_cmd = Command::new(&wrapped.program);
347 new_cmd.args(&wrapped.args);
348
349 enforcer.configure_command(&mut new_cmd, policy)?;
351
352 new_cmd
353 } else {
354 cmd
355 };
356
357 cmd.env_clear();
358 for (k, v) in &request.env {
359 cmd.env(k, v);
360 }
361 cmd.kill_on_drop(true);
362
363 cmd.stdout(std::process::Stdio::piped());
364 cmd.stderr(std::process::Stdio::piped());
365
366 if request.stdin.is_some() {
367 cmd.stdin(std::process::Stdio::piped());
368 } else {
369 cmd.stdin(std::process::Stdio::null());
370 }
371
372 let start = Instant::now();
373 let mut child = cmd.spawn()?;
374
375 if let Some(ref input) = request.stdin {
377 if let Some(mut stdin_handle) = child.stdin.take() {
378 stdin_handle.write_all(input.as_bytes()).await?;
379 drop(stdin_handle);
380 }
381 }
382
383 let output = tokio::time::timeout(request.timeout, child.wait_with_output()).await;
385 let duration = start.elapsed();
386
387 match output {
388 Ok(Ok(output)) => {
389 let exit_code = output.status.code().unwrap_or(-1);
390 let stdout = truncate_utf8(output.stdout, MAX_OUTPUT_BYTES);
391 let stderr = truncate_utf8(output.stderr, MAX_OUTPUT_BYTES);
392
393 Span::current().record("exit_code", exit_code);
394 Span::current().record("duration_ms", duration.as_millis() as u64);
395
396 Ok(ExecResult { stdout, stderr, exit_code, duration })
397 }
398 Ok(Err(e)) => {
399 Err(SandboxError::ExecutionFailed(format!("failed to wait for child process: {e}")))
400 }
401 Err(_) => {
402 Span::current().record("duration_ms", duration.as_millis() as u64);
404 Err(SandboxError::Timeout { timeout: request.timeout })
405 }
406 }
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::collections::HashMap;
414 use std::time::Duration;
415
416 fn make_request(language: Language, code: &str) -> ExecRequest {
417 let mut env = HashMap::new();
418 if let Ok(path) = std::env::var("PATH") {
421 env.insert("PATH".to_string(), path);
422 }
423 if let Ok(sr) = std::env::var("SYSTEMROOT") {
425 env.insert("SYSTEMROOT".to_string(), sr);
426 }
427 ExecRequest {
428 language,
429 code: code.to_string(),
430 stdin: None,
431 timeout: Duration::from_secs(30),
432 memory_limit_mb: None,
433 env,
434 }
435 }
436
437 #[tokio::test]
438 async fn test_python_execution() {
439 let backend = ProcessBackend::default();
440 let request = make_request(Language::Python, "print('hello')");
441 let result = backend.execute(request).await.unwrap();
442 assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
443 assert_eq!(result.exit_code, 0);
444 }
445
446 #[tokio::test]
447 async fn test_javascript_execution() {
448 if std::process::Command::new("node").arg("--version").output().is_err() {
450 eprintln!("skipping test_javascript_execution: node not found");
451 return;
452 }
453 let backend = ProcessBackend::default();
454 let request = make_request(Language::JavaScript, "console.log('hello')");
455 let result = backend.execute(request).await.unwrap();
456 assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
457 assert_eq!(result.exit_code, 0);
458 }
459
460 #[tokio::test]
461 async fn test_command_execution() {
462 let backend = ProcessBackend::default();
463 let request = make_request(Language::Command, "echo hello");
464 let result = backend.execute(request).await.unwrap();
465 assert!(result.stdout.contains("hello"), "stdout: {}", result.stdout);
466 assert_eq!(result.exit_code, 0);
467 }
468
469 #[tokio::test]
470 async fn test_timeout_enforcement() {
471 let backend = ProcessBackend::default();
472 let code =
473 if cfg!(windows) { "ping -n 11 127.0.0.1".to_string() } else { "sleep 10".to_string() };
474 let mut request = make_request(Language::Command, &code);
475 request.timeout = Duration::from_secs(1);
476 let result = backend.execute(request).await;
477 assert!(
478 matches!(result, Err(SandboxError::Timeout { .. })),
479 "expected Timeout, got: {result:?}"
480 );
481 }
482
483 #[tokio::test]
484 #[cfg(not(windows))]
485 async fn test_environment_isolation() {
486 let backend = ProcessBackend::default();
487 let mut env = HashMap::new();
488 env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
489 let request = ExecRequest {
490 language: Language::Command,
491 code: "/usr/bin/env".to_string(),
493 stdin: None,
494 timeout: Duration::from_secs(10),
495 memory_limit_mb: None,
496 env,
497 };
498 let result = backend.execute(request).await.unwrap();
499 assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
501 assert!(
503 !result.stdout.contains("HOME="),
504 "HOME should not be inherited: {}",
505 result.stdout
506 );
507 }
508
509 #[tokio::test]
510 #[cfg(windows)]
511 async fn test_environment_isolation() {
512 let backend = ProcessBackend::default();
513 let mut env = HashMap::new();
514 env.insert("MY_TEST_VAR".to_string(), "test_value".to_string());
515 let request = ExecRequest {
516 language: Language::Command,
517 code: "set MY_TEST_VAR".to_string(),
518 stdin: None,
519 timeout: Duration::from_secs(10),
520 memory_limit_mb: None,
521 env,
522 };
523 let result = backend.execute(request).await.unwrap();
524 assert!(result.stdout.contains("MY_TEST_VAR=test_value"), "stdout: {}", result.stdout);
525 }
526
527 #[tokio::test]
528 async fn test_nonzero_exit_code() {
529 let backend = ProcessBackend::default();
530 let code = if cfg!(windows) { "exit /b 42" } else { "exit 42" };
531 let request = make_request(Language::Command, code);
532 let result = backend.execute(request).await.unwrap();
533 assert_eq!(result.exit_code, 42);
534 }
535
536 #[tokio::test]
537 async fn test_wasm_returns_invalid_request() {
538 let backend = ProcessBackend::default();
539 let request = make_request(Language::Wasm, "");
540 let result = backend.execute(request).await;
541 assert!(
542 matches!(result, Err(SandboxError::InvalidRequest(_))),
543 "expected InvalidRequest, got: {result:?}"
544 );
545 }
546
547 #[test]
548 fn test_truncate_utf8_within_limit() {
549 let data = "hello world".as_bytes().to_vec();
550 let result = truncate_utf8(data, 1024);
551 assert_eq!(result, "hello world");
552 }
553
554 #[test]
555 fn test_truncate_utf8_at_boundary() {
556 let data = "café".as_bytes().to_vec(); let result = truncate_utf8(data, 4);
560 assert_eq!(result, "caf");
561 }
562
563 #[test]
564 fn test_capabilities() {
565 let backend = ProcessBackend::default();
566 let caps = backend.capabilities();
567 assert_eq!(caps.isolation_class, "process");
568 assert!(caps.enforced_limits.timeout);
569 assert!(caps.enforced_limits.environment_isolation);
570 assert!(!caps.enforced_limits.memory);
571 assert!(!caps.enforced_limits.network_isolation);
572 assert!(!caps.enforced_limits.filesystem_isolation);
573 assert!(caps.supported_languages.contains(&Language::Rust));
574 assert!(caps.supported_languages.contains(&Language::Python));
575 assert!(caps.supported_languages.contains(&Language::JavaScript));
576 assert!(caps.supported_languages.contains(&Language::TypeScript));
577 assert!(caps.supported_languages.contains(&Language::Command));
578 assert!(!caps.supported_languages.contains(&Language::Wasm));
579 }
580
581 #[test]
582 fn test_name() {
583 let backend = ProcessBackend::default();
584 assert_eq!(backend.name(), "process");
585 }
586
587 #[test]
588 fn test_process_config_default() {
589 let config = ProcessConfig::default();
590 assert_eq!(config.rustc_path, "rustc");
591 assert_eq!(config.python_path, "python3");
592 assert_eq!(config.node_path, "node");
593 }
594}