1use std::time::Instant;
5
6use async_trait::async_trait;
7use tokio::process::Command;
8
9use super::{CodeSandbox, Language, RunResult, SandboxError};
10
11const BLOCKED_PYTHON_IMPORTS: &[&str] = &[
18 "os",
19 "subprocess",
20 "sys",
21 "shutil",
22 "signal",
23 "ctypes",
24 "multiprocessing",
25 "socket",
26 "http.server",
27 "xmlrpc",
28 "pickle",
29 "shelve",
30 "importlib",
31 "code",
32 "codeop",
33 "compileall",
34 "pty",
35 "commands",
36 "pdb",
37 "webbrowser",
38];
39
40fn contains_dangerous_python_import(code: &str) -> Option<String> {
42 for line in code.lines() {
43 let trimmed = line.trim();
44 if trimmed.starts_with('#') {
45 continue;
46 }
47 let code_part = trimmed.split('#').next().unwrap_or(trimmed);
48 if code_part.contains("import") {
49 for blocked in BLOCKED_PYTHON_IMPORTS {
50 if code_part.contains(&format!("import {}", blocked))
51 || code_part.contains(&format!("from {} ", blocked))
52 || code_part.contains(&format!("from {}.", blocked))
53 || code_part.contains(&format!("from {}import", blocked))
54 {
55 return Some(blocked.to_string());
56 }
57 }
58 }
59 }
60 None
61}
62
63pub struct LocalSandbox {
65 python_path: String,
66 node_path: String,
67}
68
69impl LocalSandbox {
70 pub fn new() -> Self {
72 Self {
73 python_path: Self::find_python(),
74 node_path: "node".to_string(),
75 }
76 }
77
78 pub fn with_python_path(mut self, path: impl Into<String>) -> Self {
80 self.python_path = path.into();
81 self
82 }
83
84 pub fn with_node_path(mut self, path: impl Into<String>) -> Self {
86 self.node_path = path.into();
87 self
88 }
89
90 fn find_python() -> String {
92 for candidate in &["python3", "python"] {
93 if std::process::Command::new(candidate)
94 .arg("--version")
95 .output()
96 .is_ok()
97 {
98 return candidate.to_string();
99 }
100 }
101 "python3".to_string()
102 }
103
104 fn build_command(&self, code: &str, language: Language) -> Result<Command, SandboxError> {
106 match language {
107 Language::Python => {
108 if let Some(blocked) = contains_dangerous_python_import(code) {
109 return Err(SandboxError::Runtime(format!(
110 "Code contains dangerous import: '{}'. \
111 Blocked by the noise-filter blacklist (note: this is not a \
112 security boundary; untrusted code must run in a real sandbox).",
113 blocked
114 )));
115 }
116 let mut cmd = Command::new(&self.python_path);
117 cmd.arg("-c").arg(code);
118 Ok(cmd)
119 }
120 Language::JavaScript => {
121 let mut cmd = Command::new(&self.node_path);
122 cmd.arg("-e").arg(code);
123 Ok(cmd)
124 }
125 Language::Rust => Err(SandboxError::UnsupportedLanguage(
126 "Rust compilation is not supported by LocalSandbox".to_string(),
127 )),
128 }
129 }
130}
131
132impl Default for LocalSandbox {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138#[async_trait]
139impl CodeSandbox for LocalSandbox {
140 async fn run(
141 &self,
142 code: &str,
143 language: Language,
144 timeout_ms: u64,
145 ) -> Result<RunResult, SandboxError> {
146 let mut cmd = self.build_command(code, language)?;
147
148 let start = Instant::now();
149
150 let result =
151 tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), cmd.output())
152 .await
153 .map_err(|_| SandboxError::Timeout(timeout_ms))?
154 .map_err(|e| {
155 SandboxError::Runtime(format!("failed to execute subprocess: {}", e))
156 })?;
157
158 let execution_time_ms = start.elapsed().as_millis() as u64;
159
160 let stdout = String::from_utf8_lossy(&result.stdout).to_string();
161 let stderr = String::from_utf8_lossy(&result.stderr).to_string();
162 let exit_code = result.status.code().unwrap_or(-1);
163
164 Ok(RunResult {
165 stdout,
166 stderr,
167 exit_code,
168 execution_time_ms,
169 })
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_local_sandbox_default() {
179 let sandbox = LocalSandbox::default();
180 assert!(!sandbox.python_path.is_empty());
181 assert_eq!(sandbox.node_path, "node");
182 }
183
184 #[test]
185 fn test_local_sandbox_custom_paths() {
186 let sandbox = LocalSandbox::new()
187 .with_python_path("/usr/bin/python3.11")
188 .with_node_path("/usr/local/bin/node");
189 assert_eq!(sandbox.python_path, "/usr/bin/python3.11");
190 assert_eq!(sandbox.node_path, "/usr/local/bin/node");
191 }
192
193 #[test]
194 fn test_rust_unsupported() {
195 let sandbox = LocalSandbox::new();
196 let result = sandbox.build_command("fn main(){}", Language::Rust);
197 assert!(result.is_err());
198 match result.unwrap_err() {
199 SandboxError::UnsupportedLanguage(msg) => {
200 assert!(msg.contains("Rust"));
201 }
202 other => panic!("expected UnsupportedLanguage, got: {:?}", other),
203 }
204 }
205
206 #[test]
207 fn test_dangerous_python_import_detection() {
208 assert!(contains_dangerous_python_import("import os").is_some());
209 assert!(contains_dangerous_python_import("from sys import path").is_some());
210 assert!(contains_dangerous_python_import("import subprocess").is_some());
211 assert!(contains_dangerous_python_import("import math").is_none());
212 assert!(contains_dangerous_python_import("import json").is_none());
213 assert!(contains_dangerous_python_import("from datetime import datetime").is_none());
214 assert!(contains_dangerous_python_import("# import os").is_none());
215 }
216
217 #[tokio::test]
218 async fn test_python_dangerous_import_blocked() {
219 let sandbox = LocalSandbox::new();
220 let result = sandbox
221 .run("import os; print(os.getcwd())", Language::Python, 10_000)
222 .await;
223 assert!(result.is_err());
224 let err = result.unwrap_err().to_string();
225 assert!(
226 err.contains("dangerous import"),
227 "Expected dangerous import error, got: {}",
228 err
229 );
230 }
231
232 #[tokio::test]
233 async fn test_python_execution_if_available() {
234 let sandbox = LocalSandbox::new();
235 let result = sandbox.run("print(1 + 2)", Language::Python, 10_000).await;
236
237 match result {
238 Ok(run_result) => {
239 if run_result.exit_code == 0 {
240 assert!(
241 run_result.stdout.trim() == "3",
242 "expected '3', got '{}'",
243 run_result.stdout.trim()
244 );
245 }
246 }
247 Err(SandboxError::Runtime(msg)) => {
248 eprintln!("Python not available (expected in some CI): {}", msg);
249 }
250 Err(other) => panic!("unexpected error: {:?}", other),
251 }
252 }
253
254 #[tokio::test]
255 async fn test_javascript_execution_if_available() {
256 let sandbox = LocalSandbox::new();
257 let result = sandbox
258 .run("console.log(1 + 2)", Language::JavaScript, 10_000)
259 .await;
260
261 match result {
262 Ok(run_result) => {
263 if run_result.exit_code == 0 {
264 assert!(
265 run_result.stdout.trim() == "3",
266 "expected '3', got '{}'",
267 run_result.stdout.trim()
268 );
269 }
270 }
271 Err(SandboxError::Runtime(msg)) => {
272 eprintln!("Node.js not available (expected in some CI): {}", msg);
273 }
274 Err(other) => panic!("unexpected error: {:?}", other),
275 }
276 }
277
278 #[tokio::test]
279 async fn test_rust_execution_unsupported() {
280 let sandbox = LocalSandbox::new();
281 let result = sandbox.run("fn main(){}", Language::Rust, 10_000).await;
282 assert!(result.is_err());
283 match result.unwrap_err() {
284 SandboxError::UnsupportedLanguage(_) => {}
285 other => panic!("expected UnsupportedLanguage, got: {:?}", other),
286 }
287 }
288
289 #[tokio::test]
290 async fn test_execution_timeout() {
291 let sandbox = LocalSandbox::new();
292 let result = sandbox
293 .run("import time; time.sleep(10)", Language::Python, 100)
294 .await;
295
296 match result {
297 Err(SandboxError::Timeout(ms)) => {
298 assert_eq!(ms, 100);
299 }
300 Ok(_) => {
301 }
303 Err(other) => panic!("expected Timeout, got: {:?}", other),
304 }
305 }
306
307 #[tokio::test]
308 async fn test_execution_time_is_recorded() {
309 let sandbox = LocalSandbox::new();
310 let result = sandbox.run("print('hi')", Language::Python, 10_000).await;
311
312 if let Ok(run_result) = result {
313 assert!(run_result.execution_time_ms < 10_000);
314 }
315 }
316
317 #[tokio::test]
318 async fn test_stderr_captured() {
319 let sandbox = LocalSandbox::new();
320 let result = sandbox
321 .run(
322 "import sys; print('error', file=sys.stderr)",
323 Language::Python,
324 10_000,
325 )
326 .await;
327
328 if let Ok(run_result) = result {
329 if run_result.exit_code == 0 {
330 assert!(
331 run_result.stderr.contains("error"),
332 "stderr should contain 'error', got: '{}'",
333 run_result.stderr
334 );
335 }
336 }
337 }
338}