Skip to main content

verificar/oracle/
sandbox.rs

1//! Sandboxed Python execution for safe code evaluation
2//!
3//! Provides secure execution of untrusted Python code with:
4//! - Import restrictions (no os, subprocess, socket, etc.)
5//! - Resource limits (time, output size)
6//! - Isolated environment (no env vars, no site-packages)
7//!
8//! # Security Model
9//!
10//! The sandbox uses multiple layers of protection:
11//! 1. **Python isolation flags**: `-I -E -S` for isolated mode
12//! 2. **Import restrictions**: Custom import hook blocks dangerous modules
13//! 3. **Builtin restrictions**: Removes dangerous builtins (eval, exec, open, etc.)
14//! 4. **Time limits**: Process timeout with forced kill
15//! 5. **Output limits**: Truncate excessive output
16//!
17//! # Example
18//!
19//! ```rust,ignore
20//! use verificar::oracle::SandboxedPythonExecutor;
21//!
22//! let executor = SandboxedPythonExecutor::new();
23//! let result = executor.execute("print(1 + 1)", "", 1000)?;
24//! assert_eq!(result.stdout.trim(), "2");
25//! ```
26
27use std::process::{Command, Stdio};
28use std::time::{Duration, Instant};
29
30use crate::{Error, Language, Result};
31
32use super::executor::Executor;
33use super::ExecutionResult;
34
35/// Sandboxed Python executor with security restrictions
36#[derive(Debug, Clone)]
37pub struct SandboxedPythonExecutor {
38    /// Path to Python interpreter
39    interpreter: String,
40    /// Maximum output size in bytes
41    max_output_bytes: usize,
42    /// Blocked module names
43    blocked_modules: Vec<String>,
44    /// Whether to allow file I/O
45    allow_file_io: bool,
46}
47
48impl Default for SandboxedPythonExecutor {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl SandboxedPythonExecutor {
55    /// Default blocked modules for safe execution
56    const DEFAULT_BLOCKED_MODULES: &'static [&'static str] = &[
57        // System/process access
58        "os",
59        "subprocess",
60        "sys",
61        "shutil",
62        "pathlib",
63        "glob",
64        "tempfile",
65        // Network access
66        "socket",
67        "http",
68        "urllib",
69        "requests",
70        "aiohttp",
71        "ftplib",
72        "smtplib",
73        "ssl",
74        // Code execution/compilation
75        "code",
76        "codeop",
77        "compile",
78        "importlib",
79        "runpy",
80        "ast",
81        "dis",
82        "inspect",
83        // Dangerous internals
84        "ctypes",
85        "cffi",
86        "multiprocessing",
87        "threading",
88        "concurrent",
89        "_thread",
90        "gc",
91        "resource",
92        "signal",
93        // File operations
94        "io",
95        "builtins",
96        "pickle",
97        "shelve",
98        "dbm",
99        "sqlite3",
100        // Misc dangerous
101        "pty",
102        "tty",
103        "termios",
104        "fcntl",
105        "mmap",
106    ];
107
108    /// Create a new sandboxed Python executor with default restrictions
109    #[must_use]
110    pub fn new() -> Self {
111        Self {
112            interpreter: "python3".to_string(),
113            max_output_bytes: 64 * 1024, // 64KB max output
114            blocked_modules: Self::DEFAULT_BLOCKED_MODULES
115                .iter()
116                .map(|&s| s.to_string())
117                .collect(),
118            allow_file_io: false,
119        }
120    }
121
122    /// Create executor with custom interpreter path
123    #[must_use]
124    pub fn with_interpreter(mut self, interpreter: impl Into<String>) -> Self {
125        self.interpreter = interpreter.into();
126        self
127    }
128
129    /// Set maximum output size
130    #[must_use]
131    pub fn with_max_output(mut self, bytes: usize) -> Self {
132        self.max_output_bytes = bytes;
133        self
134    }
135
136    /// Add additional blocked modules
137    #[must_use]
138    pub fn with_blocked_modules(mut self, modules: &[&str]) -> Self {
139        for module in modules {
140            if !self.blocked_modules.contains(&(*module).to_string()) {
141                self.blocked_modules.push((*module).to_string());
142            }
143        }
144        self
145    }
146
147    /// Allow file I/O operations (not recommended for untrusted code)
148    #[must_use]
149    pub fn with_file_io(mut self, allow: bool) -> Self {
150        self.allow_file_io = allow;
151        self
152    }
153
154    /// Check if Python interpreter is available
155    #[must_use]
156    pub fn is_available(&self) -> bool {
157        Command::new(&self.interpreter)
158            .arg("--version")
159            .stdout(Stdio::null())
160            .stderr(Stdio::null())
161            .status()
162            .is_ok()
163    }
164
165    /// Generate the sandbox wrapper code that restricts imports and builtins
166    #[allow(clippy::uninlined_format_args)]
167    fn sandbox_wrapper(&self) -> String {
168        let blocked_list = self
169            .blocked_modules
170            .iter()
171            .map(|m| format!("'{m}'"))
172            .collect::<Vec<_>>()
173            .join(", ");
174
175        let open_restriction = if self.allow_file_io {
176            ""
177        } else {
178            "_sandbox_builtins.open = None\n"
179        };
180
181        format!(
182            r#"
183import sys as _sandbox_sys
184import builtins as _sandbox_builtins
185
186# Block dangerous modules
187_sandbox_blocked = set([{blocked_list}])
188
189# Remove already-loaded blocked modules from sys.modules
190for _sandbox_mod in list(_sandbox_sys.modules.keys()):
191    _sandbox_base = _sandbox_mod.split('.')[0]
192    if _sandbox_base in _sandbox_blocked and _sandbox_base != 'sys':
193        del _sandbox_sys.modules[_sandbox_mod]
194
195# Create a restricted __import__ function using default argument to capture values
196_sandbox_orig_import = _sandbox_builtins.__import__
197
198def _sandbox_import(name, globals=None, locals=None, fromlist=(), level=0,
199                    _blocked=_sandbox_blocked, _orig=_sandbox_orig_import):
200    base = name.split('.')[0]
201    if base in _blocked:
202        raise ImportError(f"Module '{{name}}' is not allowed in sandbox")
203    return _orig(name, globals, locals, fromlist, level)
204
205# Replace builtins.__import__ (this is what import statements actually use)
206_sandbox_builtins.__import__ = _sandbox_import
207
208# Restrict dangerous builtins
209_sandbox_builtins.eval = None
210_sandbox_builtins.exec = None
211_sandbox_builtins.compile = None
212_sandbox_builtins.breakpoint = None
213_sandbox_builtins.help = None
214{open_restriction}
215# Capture user input lines
216_sandbox_user_input = []
217_sandbox_builtins.input = lambda *a, _inp=_sandbox_user_input: _inp.pop(0) if _inp else ''
218
219# Clean up sandbox setup from namespace
220del _sandbox_mod, _sandbox_base, _sandbox_sys, _sandbox_builtins, _sandbox_orig_import, _sandbox_blocked
221
222# User code follows:
223"#,
224            blocked_list = blocked_list,
225            open_restriction = open_restriction
226        )
227    }
228
229    /// Wrap user code with sandbox restrictions
230    fn wrap_code(&self, code: &str, input: &str) -> String {
231        let mut wrapper = self.sandbox_wrapper();
232
233        // Add input handling - inject into _sandbox_user_input before cleanup
234        if !input.is_empty() {
235            // Need to insert input BEFORE the del statement
236            // Find and replace the _sandbox_user_input = [] line
237            let input_lines: Vec<_> = input
238                .lines()
239                .map(|l| format!("'{}'", l.replace('\'', "\\'")))
240                .collect();
241            let input_init = format!("_sandbox_user_input = [{}]\n", input_lines.join(", "));
242            wrapper = wrapper.replace("_sandbox_user_input = []\n", &input_init);
243        }
244
245        // Add user code (sandbox is already set up)
246        wrapper.push_str(code);
247
248        wrapper
249    }
250}
251
252impl Executor for SandboxedPythonExecutor {
253    fn execute(&self, code: &str, input: &str, timeout_ms: u64) -> Result<ExecutionResult> {
254        use std::sync::atomic::{AtomicU64, Ordering};
255        static COUNTER: AtomicU64 = AtomicU64::new(0);
256
257        let start = Instant::now();
258
259        // Create a unique temp file
260        let temp_dir = std::env::temp_dir();
261        let unique_id = COUNTER.fetch_add(1, Ordering::SeqCst);
262        let temp_file = temp_dir.join(format!(
263            "verificar_sandbox_{}_{}.py",
264            std::process::id(),
265            unique_id
266        ));
267
268        let sandboxed_code = self.wrap_code(code, input);
269
270        std::fs::write(&temp_file, &sandboxed_code)
271            .map_err(|e| Error::Verification(format!("Failed to write sandbox file: {e}")))?;
272
273        let mut cmd = Command::new(&self.interpreter);
274        cmd.arg("-I") // Isolated mode: don't add user site directory, ignore PYTHON* env vars
275            .arg("-E") // Ignore PYTHON* environment variables
276            .arg("-S") // Don't import site module
277            .arg("-u") // Unbuffered output
278            .arg(&temp_file)
279            .stdin(Stdio::null()) // No stdin (we handle input via code)
280            .stdout(Stdio::piped())
281            .stderr(Stdio::piped())
282            .env_clear(); // Clear all environment variables
283
284        // Spawn process
285        let child = cmd.spawn().map_err(|e| {
286            let _ = std::fs::remove_file(&temp_file);
287            Error::Verification(format!("Failed to spawn sandboxed Python: {e}"))
288        })?;
289
290        // Wait with timeout
291        let timeout = Duration::from_millis(timeout_ms);
292        let output = match wait_with_timeout(child, timeout) {
293            Ok(output) => output,
294            Err(e) => {
295                let _ = std::fs::remove_file(&temp_file);
296                return Err(e);
297            }
298        };
299
300        // Clean up
301        let _ = std::fs::remove_file(&temp_file);
302
303        let duration_ms = start.elapsed().as_millis() as u64;
304
305        // Truncate output if too large
306        let stdout = truncate_output(&output.stdout, self.max_output_bytes);
307        let stderr = truncate_output(&output.stderr, self.max_output_bytes);
308
309        Ok(ExecutionResult {
310            stdout,
311            stderr,
312            exit_code: output.status.code().unwrap_or(-1),
313            duration_ms,
314        })
315    }
316
317    fn language(&self) -> Language {
318        Language::Python
319    }
320}
321
322/// Wait for process with timeout (duplicated to avoid circular dependency)
323fn wait_with_timeout(
324    mut child: std::process::Child,
325    timeout: Duration,
326) -> Result<std::process::Output> {
327    use std::io::Read;
328    use std::sync::mpsc;
329    use std::thread;
330
331    let stdout_handle = child.stdout.take();
332    let stderr_handle = child.stderr.take();
333
334    let stdout_thread = thread::spawn(move || {
335        let mut buf = Vec::new();
336        if let Some(mut stdout) = stdout_handle {
337            let _ = stdout.read_to_end(&mut buf);
338        }
339        buf
340    });
341
342    let stderr_thread = thread::spawn(move || {
343        let mut buf = Vec::new();
344        if let Some(mut stderr) = stderr_handle {
345            let _ = stderr.read_to_end(&mut buf);
346        }
347        buf
348    });
349
350    let (tx, rx) = mpsc::channel();
351    let wait_thread = thread::spawn(move || {
352        let result = child.wait();
353        let _ = tx.send(result);
354        child
355    });
356
357    match rx.recv_timeout(timeout) {
358        Ok(Ok(status)) => {
359            let _ = wait_thread.join();
360            let stdout = stdout_thread.join().unwrap_or_default();
361            let stderr = stderr_thread.join().unwrap_or_default();
362            Ok(std::process::Output {
363                status,
364                stdout,
365                stderr,
366            })
367        }
368        Ok(Err(e)) => {
369            let _ = wait_thread.join();
370            let _ = stdout_thread.join();
371            let _ = stderr_thread.join();
372            Err(Error::Verification(format!("Wait error: {e}")))
373        }
374        Err(mpsc::RecvTimeoutError::Timeout) => {
375            if let Ok(mut child) = wait_thread.join() {
376                let _ = child.kill();
377                let _ = child.wait();
378            }
379            let _ = stdout_thread.join();
380            let _ = stderr_thread.join();
381            Err(Error::Verification(
382                "Sandbox execution timed out".to_string(),
383            ))
384        }
385        Err(mpsc::RecvTimeoutError::Disconnected) => {
386            let _ = wait_thread.join();
387            let _ = stdout_thread.join();
388            let _ = stderr_thread.join();
389            Err(Error::Verification(
390                "Sandbox thread disconnected".to_string(),
391            ))
392        }
393    }
394}
395
396/// Truncate output to maximum size with message
397fn truncate_output(data: &[u8], max_bytes: usize) -> String {
398    let s = String::from_utf8_lossy(data);
399    if s.len() <= max_bytes {
400        s.to_string()
401    } else {
402        let truncated: String = s.chars().take(max_bytes).collect();
403        format!("{truncated}\n... [output truncated at {max_bytes} bytes]")
404    }
405}
406
407/// Sandbox configuration options
408#[derive(Debug, Clone)]
409pub struct SandboxConfig {
410    /// Maximum execution time in milliseconds
411    pub timeout_ms: u64,
412    /// Maximum output size in bytes
413    pub max_output_bytes: usize,
414    /// Maximum memory usage (not enforced in pure Rust, advisory only)
415    pub max_memory_bytes: usize,
416    /// Allow network access (always false for security)
417    pub allow_network: bool,
418    /// Allow file system access
419    pub allow_filesystem: bool,
420    /// Additional blocked modules
421    pub blocked_modules: Vec<String>,
422}
423
424impl Default for SandboxConfig {
425    fn default() -> Self {
426        Self {
427            timeout_ms: 5000,
428            max_output_bytes: 64 * 1024,
429            max_memory_bytes: 128 * 1024 * 1024, // 128MB advisory
430            allow_network: false,
431            allow_filesystem: false,
432            blocked_modules: Vec::new(),
433        }
434    }
435}
436
437impl SandboxConfig {
438    /// Create a strict sandbox configuration
439    #[must_use]
440    pub fn strict() -> Self {
441        Self {
442            timeout_ms: 1000,
443            max_output_bytes: 16 * 1024,
444            max_memory_bytes: 64 * 1024 * 1024,
445            allow_network: false,
446            allow_filesystem: false,
447            blocked_modules: Vec::new(),
448        }
449    }
450
451    /// Create a lenient sandbox for testing (still secure, but more permissive limits)
452    #[must_use]
453    pub fn lenient() -> Self {
454        Self {
455            timeout_ms: 30000,
456            max_output_bytes: 1024 * 1024,
457            max_memory_bytes: 512 * 1024 * 1024,
458            allow_network: false,
459            allow_filesystem: false,
460            blocked_modules: Vec::new(),
461        }
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    fn skip_if_no_python(executor: &SandboxedPythonExecutor) -> bool {
470        if !executor.is_available() {
471            eprintln!("Python not available, skipping test");
472            true
473        } else {
474            false
475        }
476    }
477
478    #[test]
479    fn test_sandbox_basic_execution() {
480        let executor = SandboxedPythonExecutor::new();
481        if skip_if_no_python(&executor) {
482            return;
483        }
484
485        let result = executor
486            .execute("print(1 + 1)", "", 5000)
487            .expect("execution should succeed");
488
489        assert_eq!(result.stdout.trim(), "2");
490        assert_eq!(result.exit_code, 0);
491    }
492
493    #[test]
494    fn test_sandbox_blocks_os_import() {
495        let executor = SandboxedPythonExecutor::new();
496        if skip_if_no_python(&executor) {
497            return;
498        }
499
500        let result = executor
501            .execute("import os\nprint(os.getcwd())", "", 5000)
502            .expect("execution should succeed");
503
504        assert_ne!(result.exit_code, 0);
505        assert!(result.stderr.contains("not allowed") || result.stderr.contains("ImportError"));
506    }
507
508    #[test]
509    fn test_sandbox_blocks_subprocess() {
510        let executor = SandboxedPythonExecutor::new();
511        if skip_if_no_python(&executor) {
512            return;
513        }
514
515        let result = executor
516            .execute("import subprocess\nsubprocess.run(['ls'])", "", 5000)
517            .expect("execution should succeed");
518
519        assert_ne!(result.exit_code, 0);
520        assert!(result.stderr.contains("not allowed") || result.stderr.contains("ImportError"));
521    }
522
523    #[test]
524    fn test_sandbox_blocks_socket() {
525        let executor = SandboxedPythonExecutor::new();
526        if skip_if_no_python(&executor) {
527            return;
528        }
529
530        let result = executor
531            .execute("import socket", "", 5000)
532            .expect("execution should succeed");
533
534        assert_ne!(result.exit_code, 0);
535    }
536
537    #[test]
538    fn test_sandbox_blocks_eval() {
539        let executor = SandboxedPythonExecutor::new();
540        if skip_if_no_python(&executor) {
541            return;
542        }
543
544        let result = executor
545            .execute("eval('1+1')", "", 5000)
546            .expect("execution should succeed");
547
548        assert_ne!(result.exit_code, 0);
549    }
550
551    #[test]
552    fn test_sandbox_blocks_exec() {
553        let executor = SandboxedPythonExecutor::new();
554        if skip_if_no_python(&executor) {
555            return;
556        }
557
558        let result = executor
559            .execute("exec('print(1)')", "", 5000)
560            .expect("execution should succeed");
561
562        assert_ne!(result.exit_code, 0);
563    }
564
565    #[test]
566    fn test_sandbox_allows_safe_math() {
567        let executor = SandboxedPythonExecutor::new();
568        if skip_if_no_python(&executor) {
569            return;
570        }
571
572        let result = executor
573            .execute("import math\nprint(math.sqrt(16))", "", 5000)
574            .expect("execution should succeed");
575
576        assert_eq!(result.stdout.trim(), "4.0");
577        assert_eq!(result.exit_code, 0);
578    }
579
580    #[test]
581    fn test_sandbox_allows_safe_builtins() {
582        let executor = SandboxedPythonExecutor::new();
583        if skip_if_no_python(&executor) {
584            return;
585        }
586
587        let code = r#"
588x = [1, 2, 3, 4, 5]
589print(sum(x))
590print(len(x))
591print(max(x))
592print(min(x))
593"#;
594        let result = executor
595            .execute(code, "", 5000)
596            .expect("execution should succeed");
597
598        let lines: Vec<_> = result.stdout.trim().lines().collect();
599        assert_eq!(lines, vec!["15", "5", "5", "1"]);
600    }
601
602    #[test]
603    #[ignore] // Takes too long for CI - run with `cargo test -- --ignored`
604    fn test_sandbox_timeout() {
605        let executor = SandboxedPythonExecutor::new();
606        if skip_if_no_python(&executor) {
607            return;
608        }
609
610        let result = executor.execute("while True: pass", "", 500);
611
612        assert!(result.is_err());
613        let err = result.unwrap_err().to_string();
614        assert!(err.contains("timed out") || err.contains("timeout"));
615    }
616
617    #[test]
618    fn test_sandbox_output_truncation() {
619        let executor = SandboxedPythonExecutor::new().with_max_output(100);
620        if skip_if_no_python(&executor) {
621            return;
622        }
623
624        let result = executor
625            .execute("print('x' * 1000)", "", 5000)
626            .expect("execution should succeed");
627
628        assert!(result.stdout.len() <= 150); // 100 + truncation message
629        assert!(result.stdout.contains("truncated"));
630    }
631
632    #[test]
633    fn test_sandbox_input_handling() {
634        let executor = SandboxedPythonExecutor::new();
635        if skip_if_no_python(&executor) {
636            return;
637        }
638
639        let code = r#"
640name = input()
641print(f"Hello, {name}!")
642"#;
643        let result = executor
644            .execute(code, "World", 5000)
645            .expect("execution should succeed");
646
647        assert_eq!(result.stdout.trim(), "Hello, World!");
648    }
649
650    #[test]
651    fn test_sandbox_multiple_inputs() {
652        let executor = SandboxedPythonExecutor::new();
653        if skip_if_no_python(&executor) {
654            return;
655        }
656
657        let code = r#"
658a = int(input())
659b = int(input())
660print(a + b)
661"#;
662        let result = executor
663            .execute(code, "3\n4", 5000)
664            .expect("execution should succeed");
665
666        assert_eq!(result.stdout.trim(), "7");
667    }
668
669    #[test]
670    fn test_sandbox_config_strict() {
671        let config = SandboxConfig::strict();
672        assert_eq!(config.timeout_ms, 1000);
673        assert!(!config.allow_network);
674        assert!(!config.allow_filesystem);
675    }
676
677    #[test]
678    fn test_sandbox_config_lenient() {
679        let config = SandboxConfig::lenient();
680        assert_eq!(config.timeout_ms, 30000);
681        assert!(!config.allow_network);
682    }
683
684    #[test]
685    fn test_sandbox_config_default() {
686        let config = SandboxConfig::default();
687        assert_eq!(config.timeout_ms, 5000);
688        assert_eq!(config.max_output_bytes, 64 * 1024);
689        assert_eq!(config.max_memory_bytes, 128 * 1024 * 1024);
690        assert!(!config.allow_network);
691        assert!(!config.allow_filesystem);
692        assert!(config.blocked_modules.is_empty());
693    }
694
695    #[test]
696    fn test_sandbox_config_debug() {
697        let config = SandboxConfig::default();
698        let debug = format!("{:?}", config);
699        assert!(debug.contains("SandboxConfig"));
700    }
701
702    #[test]
703    fn test_sandbox_config_clone() {
704        let config = SandboxConfig::strict();
705        let cloned = config.clone();
706        assert_eq!(cloned.timeout_ms, config.timeout_ms);
707    }
708
709    #[test]
710    fn test_sandboxed_executor_with_interpreter() {
711        let executor = SandboxedPythonExecutor::new().with_interpreter("python3.11");
712        assert_eq!(executor.interpreter, "python3.11");
713    }
714
715    #[test]
716    fn test_sandboxed_executor_with_max_output() {
717        let executor = SandboxedPythonExecutor::new().with_max_output(1024);
718        assert_eq!(executor.max_output_bytes, 1024);
719    }
720
721    #[test]
722    fn test_sandboxed_executor_with_blocked_modules() {
723        let executor = SandboxedPythonExecutor::new()
724            .with_blocked_modules(&["custom_module", "another_module"]);
725        assert!(executor
726            .blocked_modules
727            .contains(&"custom_module".to_string()));
728        assert!(executor
729            .blocked_modules
730            .contains(&"another_module".to_string()));
731    }
732
733    #[test]
734    fn test_sandboxed_executor_with_file_io() {
735        let executor = SandboxedPythonExecutor::new().with_file_io(true);
736        assert!(executor.allow_file_io);
737    }
738
739    #[test]
740    fn test_sandboxed_executor_default() {
741        let executor = SandboxedPythonExecutor::default();
742        assert_eq!(executor.interpreter, "python3");
743        assert_eq!(executor.max_output_bytes, 64 * 1024);
744        assert!(!executor.allow_file_io);
745    }
746
747    #[test]
748    fn test_sandboxed_executor_language() {
749        let executor = SandboxedPythonExecutor::new();
750        assert_eq!(executor.language(), Language::Python);
751    }
752
753    #[test]
754    fn test_sandboxed_executor_debug() {
755        let executor = SandboxedPythonExecutor::new();
756        let debug = format!("{:?}", executor);
757        assert!(debug.contains("SandboxedPythonExecutor"));
758    }
759
760    #[test]
761    fn test_truncate_output_short() {
762        let data = b"hello world";
763        let result = truncate_output(data, 100);
764        assert_eq!(result, "hello world");
765    }
766
767    #[test]
768    fn test_truncate_output_long() {
769        let data = b"hello world this is a longer string";
770        let result = truncate_output(data, 10);
771        assert!(result.len() <= 50); // Truncated + message
772        assert!(result.contains("truncated"));
773    }
774
775    #[test]
776    fn test_sandbox_with_file_io_enabled() {
777        let executor = SandboxedPythonExecutor::new().with_file_io(true);
778        if skip_if_no_python(&executor) {
779            return;
780        }
781
782        // With file I/O enabled, open should work (but we don't actually write files)
783        // Just test that the sandbox generates different code
784        let code = "print('file io test')";
785        let result = executor
786            .execute(code, "", 5000)
787            .expect("execution should succeed");
788
789        assert_eq!(result.stdout.trim(), "file io test");
790    }
791
792    #[test]
793    fn test_sandbox_blocks_open_by_default() {
794        let executor = SandboxedPythonExecutor::new();
795        if skip_if_no_python(&executor) {
796            return;
797        }
798
799        // Default should block open()
800        let code = "open('/etc/passwd', 'r')";
801        let result = executor
802            .execute(code, "", 5000)
803            .expect("execution should succeed");
804
805        // Should fail because open is disabled
806        assert_ne!(result.exit_code, 0);
807    }
808
809    #[test]
810    fn test_sandbox_blocks_ctypes() {
811        let executor = SandboxedPythonExecutor::new();
812        if skip_if_no_python(&executor) {
813            return;
814        }
815
816        let result = executor
817            .execute("import ctypes", "", 5000)
818            .expect("execution should succeed");
819
820        assert_ne!(result.exit_code, 0);
821    }
822
823    #[test]
824    fn test_sandbox_blocks_sys() {
825        let executor = SandboxedPythonExecutor::new();
826        if skip_if_no_python(&executor) {
827            return;
828        }
829
830        let result = executor
831            .execute("import sys\nprint(sys.executable)", "", 5000)
832            .expect("execution should succeed");
833
834        // sys is blocked
835        assert_ne!(result.exit_code, 0);
836    }
837
838    #[test]
839    fn test_sandbox_empty_input() {
840        let executor = SandboxedPythonExecutor::new();
841        if skip_if_no_python(&executor) {
842            return;
843        }
844
845        // With empty input, input() should return empty string
846        let code = "x = input()\nprint(f'got: [{x}]')";
847        let result = executor
848            .execute(code, "", 5000)
849            .expect("execution should succeed");
850
851        assert_eq!(result.stdout.trim(), "got: []");
852    }
853
854    #[test]
855    fn test_sandbox_input_with_quotes() {
856        let executor = SandboxedPythonExecutor::new();
857        if skip_if_no_python(&executor) {
858            return;
859        }
860
861        // Test that quotes in input are properly escaped
862        let code = "x = input()\nprint(f'got: {x}')";
863        let result = executor
864            .execute(code, "hello'world", 5000)
865            .expect("execution should succeed");
866
867        assert!(result.stdout.contains("hello'world") || result.exit_code == 0);
868    }
869
870    #[test]
871    fn test_sandbox_is_available() {
872        let executor = SandboxedPythonExecutor::new();
873        // Just check it returns something
874        let _ = executor.is_available();
875    }
876
877    #[test]
878    fn test_sandbox_with_custom_blocked_module_execution() {
879        let executor = SandboxedPythonExecutor::new().with_blocked_modules(&["json"]);
880        if skip_if_no_python(&executor) {
881            return;
882        }
883
884        // json is normally allowed, but we blocked it
885        let result = executor
886            .execute("import json\nprint(json.dumps({}))", "", 5000)
887            .expect("execution should succeed");
888
889        assert_ne!(result.exit_code, 0);
890    }
891}