1use std::sync::LazyLock;
5use std::time::Instant;
6
7use async_trait::async_trait;
8use regex::Regex;
9use tokio::process::Command;
10
11use super::{CodeSandbox, Language, RunResult, SandboxError};
12
13const BLOCKED_PYTHON_IMPORTS: &[&str] = &[
23 "os",
24 "subprocess",
25 "sys",
26 "shutil",
27 "signal",
28 "ctypes",
29 "multiprocessing",
30 "socket",
31 "http.server",
32 "xmlrpc",
33 "pickle",
34 "shelve",
35 "importlib",
36 "code",
37 "codeop",
38 "compileall",
39 "pty",
40 "commands",
41 "pdb",
42 "webbrowser",
43];
44
45fn contains_dangerous_python_import(code: &str) -> Option<String> {
47 for line in code.lines() {
48 let trimmed = line.trim();
49 if trimmed.starts_with('#') {
50 continue;
51 }
52 let code_part = trimmed.split('#').next().unwrap_or(trimmed);
53 if code_part.contains("import") {
54 for blocked in BLOCKED_PYTHON_IMPORTS {
55 if code_part.contains(&format!("import {}", blocked))
56 || code_part.contains(&format!("from {} ", blocked))
57 || code_part.contains(&format!("from {}.", blocked))
58 || code_part.contains(&format!("from {}import", blocked))
59 {
60 return Some(blocked.to_string());
61 }
62 }
63 }
64 }
65 None
66}
67
68const BLOCKED_JS_MODULES: &[&str] = &[
77 "fs",
78 "child_process",
79 "net",
80 "dgram",
81 "tls",
82 "http",
83 "https",
84 "http2",
85 "worker_threads",
86 "vm",
87 "cluster",
88 "repl",
89 "readline",
90 "os",
91];
92
93static BLOCKED_JS_CALL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
99 Regex::new(r"\b(?:process\.|eval\s*\(|Function\s*\(|globalThis|spawn\s*\(|exec\s*\()")
100 .expect("static JS regex literal must compile")
101});
102
103fn contains_dangerous_javascript(code: &str) -> Option<String> {
105 for line in code.lines() {
106 let trimmed = line.trim();
107 if trimmed.starts_with("//") {
108 continue;
109 }
110 let code_part = trimmed.split("//").next().unwrap_or(trimmed);
111 for m in BLOCKED_JS_MODULES {
113 if code_part.contains(&format!("require('{}')", m))
114 || code_part.contains(&format!("require(\"{}\")", m))
115 {
116 return Some(format!("require('{}')", m));
117 }
118 }
119 if let Some(m) = BLOCKED_JS_CALL_REGEX.find(code_part) {
121 return Some(m.as_str().to_string());
122 }
123 }
124 None
125}
126
127pub struct LocalSandbox {
129 python_path: String,
130 node_path: String,
131}
132
133impl LocalSandbox {
134 pub fn new() -> Self {
136 Self {
137 python_path: Self::find_python(),
138 node_path: "node".to_string(),
139 }
140 }
141
142 pub fn with_python_path(mut self, path: impl Into<String>) -> Self {
144 self.python_path = path.into();
145 self
146 }
147
148 pub fn with_node_path(mut self, path: impl Into<String>) -> Self {
150 self.node_path = path.into();
151 self
152 }
153
154 fn find_python() -> String {
156 for candidate in &["python3", "python"] {
157 if std::process::Command::new(candidate)
158 .arg("--version")
159 .output()
160 .is_ok()
161 {
162 return candidate.to_string();
163 }
164 }
165 "python3".to_string()
166 }
167
168 fn build_command(&self, code: &str, language: Language) -> Result<Command, SandboxError> {
170 match language {
171 Language::Python => {
172 if let Some(blocked) = contains_dangerous_python_import(code) {
173 return Err(SandboxError::Runtime(format!(
174 "Code contains dangerous import: '{}'. \
175 Blocked by the noise-filter blacklist (note: this is not a \
176 security boundary; untrusted code must run in a real sandbox).",
177 blocked
178 )));
179 }
180 let mut cmd = Command::new(&self.python_path);
181 cmd.arg("-c").arg(code);
182 Ok(cmd)
183 }
184 Language::JavaScript => {
185 if let Some(dangerous) = contains_dangerous_javascript(code) {
186 return Err(SandboxError::Runtime(format!(
187 "Code contains dangerous Node.js API: '{}'. \
188 Blocked by the noise-filter blacklist (note: this is not a \
189 security boundary; untrusted code must run in a real sandbox).",
190 dangerous
191 )));
192 }
193 let mut cmd = Command::new(&self.node_path);
194 cmd.arg("-e").arg(code);
195 Ok(cmd)
196 }
197 Language::Rust => Err(SandboxError::UnsupportedLanguage(
198 "Rust compilation is not supported by LocalSandbox".to_string(),
199 )),
200 }
201 }
202}
203
204impl Default for LocalSandbox {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210#[async_trait]
211impl CodeSandbox for LocalSandbox {
212 async fn run(
213 &self,
214 code: &str,
215 language: Language,
216 timeout_ms: u64,
217 ) -> Result<RunResult, SandboxError> {
218 let mut cmd = self.build_command(code, language)?;
219
220 let start = Instant::now();
221
222 let result =
223 tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), cmd.output())
224 .await
225 .map_err(|_| SandboxError::Timeout(timeout_ms))?
226 .map_err(|e| {
227 SandboxError::Runtime(format!("failed to execute subprocess: {}", e))
228 })?;
229
230 let execution_time_ms = start.elapsed().as_millis() as u64;
231
232 let stdout = String::from_utf8_lossy(&result.stdout).to_string();
233 let stderr = String::from_utf8_lossy(&result.stderr).to_string();
234 let exit_code = result.status.code().unwrap_or(-1);
235
236 Ok(RunResult {
237 stdout,
238 stderr,
239 exit_code,
240 execution_time_ms,
241 })
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_local_sandbox_default() {
251 let sandbox = LocalSandbox::default();
252 assert!(!sandbox.python_path.is_empty());
253 assert_eq!(sandbox.node_path, "node");
254 }
255
256 #[test]
257 fn test_local_sandbox_custom_paths() {
258 let sandbox = LocalSandbox::new()
259 .with_python_path("/usr/bin/python3.11")
260 .with_node_path("/usr/local/bin/node");
261 assert_eq!(sandbox.python_path, "/usr/bin/python3.11");
262 assert_eq!(sandbox.node_path, "/usr/local/bin/node");
263 }
264
265 #[test]
266 fn test_rust_unsupported() {
267 let sandbox = LocalSandbox::new();
268 let result = sandbox.build_command("fn main(){}", Language::Rust);
269 assert!(result.is_err());
270 match result.unwrap_err() {
271 SandboxError::UnsupportedLanguage(msg) => {
272 assert!(msg.contains("Rust"));
273 }
274 other => panic!("expected UnsupportedLanguage, got: {:?}", other),
275 }
276 }
277
278 #[test]
279 fn test_dangerous_python_import_detection() {
280 assert!(contains_dangerous_python_import("import os").is_some());
281 assert!(contains_dangerous_python_import("from sys import path").is_some());
282 assert!(contains_dangerous_python_import("import subprocess").is_some());
283 assert!(contains_dangerous_python_import("import math").is_none());
284 assert!(contains_dangerous_python_import("import json").is_none());
285 assert!(contains_dangerous_python_import("from datetime import datetime").is_none());
286 assert!(contains_dangerous_python_import("# import os").is_none());
287 }
288
289 #[test]
290 fn test_dangerous_javascript_detection() {
291 assert!(contains_dangerous_javascript("require('fs').readFileSync('/etc/passwd')").is_some());
293 assert!(contains_dangerous_javascript("require(\"child_process\")").is_some());
294 assert!(contains_dangerous_javascript("const net = require('net')").is_some());
295 assert!(contains_dangerous_javascript("process.env").is_some());
297 assert!(contains_dangerous_javascript("eval('console.log(1)')").is_some());
298 assert!(contains_dangerous_javascript("new Function('x', 'return x')").is_some());
299 assert!(contains_dangerous_javascript("globalThis.foo = 1").is_some());
300 assert!(contains_dangerous_javascript("console.log(1 + 2)").is_none());
302 assert!(contains_dangerous_javascript("JSON.parse(x).map(v => v * 2)").is_none());
303 assert!(contains_dangerous_javascript("// require('fs')").is_none());
304 assert!(contains_dangerous_javascript("evaluate(fn).execute()").is_none());
305 }
306
307 #[tokio::test]
308 async fn test_javascript_dangerous_api_blocked() {
309 let sandbox = LocalSandbox::new();
310 let result = sandbox
311 .run(
312 "require('fs').readFileSync('/etc/passwd')",
313 Language::JavaScript,
314 10_000,
315 )
316 .await;
317 assert!(result.is_err());
318 let err = result.unwrap_err().to_string();
319 assert!(
320 err.contains("dangerous Node.js API"),
321 "Expected dangerous Node.js API error, got: {}",
322 err
323 );
324 }
325
326 #[tokio::test]
327 async fn test_python_dangerous_import_blocked() {
328 let sandbox = LocalSandbox::new();
329 let result = sandbox
330 .run("import os; print(os.getcwd())", Language::Python, 10_000)
331 .await;
332 assert!(result.is_err());
333 let err = result.unwrap_err().to_string();
334 assert!(
335 err.contains("dangerous import"),
336 "Expected dangerous import error, got: {}",
337 err
338 );
339 }
340
341 #[tokio::test]
342 async fn test_python_execution_if_available() {
343 let sandbox = LocalSandbox::new();
344 let result = sandbox.run("print(1 + 2)", Language::Python, 10_000).await;
345
346 match result {
347 Ok(run_result) => {
348 if run_result.exit_code == 0 {
349 assert!(
350 run_result.stdout.trim() == "3",
351 "expected '3', got '{}'",
352 run_result.stdout.trim()
353 );
354 }
355 }
356 Err(SandboxError::Runtime(msg)) => {
357 eprintln!("Python not available (expected in some CI): {}", msg);
358 }
359 Err(other) => panic!("unexpected error: {:?}", other),
360 }
361 }
362
363 #[tokio::test]
364 async fn test_javascript_execution_if_available() {
365 let sandbox = LocalSandbox::new();
366 let result = sandbox
367 .run("console.log(1 + 2)", Language::JavaScript, 10_000)
368 .await;
369
370 match result {
371 Ok(run_result) => {
372 if run_result.exit_code == 0 {
373 assert!(
374 run_result.stdout.trim() == "3",
375 "expected '3', got '{}'",
376 run_result.stdout.trim()
377 );
378 }
379 }
380 Err(SandboxError::Runtime(msg)) => {
381 eprintln!("Node.js not available (expected in some CI): {}", msg);
382 }
383 Err(SandboxError::Timeout(ms)) => {
389 eprintln!("Node.js present but too slow in this CI environment ({ms}ms)");
390 }
391 Err(other) => panic!("unexpected error: {:?}", other),
392 }
393 }
394
395 #[tokio::test]
396 async fn test_rust_execution_unsupported() {
397 let sandbox = LocalSandbox::new();
398 let result = sandbox.run("fn main(){}", Language::Rust, 10_000).await;
399 assert!(result.is_err());
400 match result.unwrap_err() {
401 SandboxError::UnsupportedLanguage(_) => {}
402 other => panic!("expected UnsupportedLanguage, got: {:?}", other),
403 }
404 }
405
406 #[tokio::test]
407 async fn test_execution_timeout() {
408 let sandbox = LocalSandbox::new();
409 let result = sandbox
410 .run("import time; time.sleep(10)", Language::Python, 100)
411 .await;
412
413 match result {
414 Err(SandboxError::Timeout(ms)) => {
415 assert_eq!(ms, 100);
416 }
417 Ok(_) => {
418 }
420 Err(other) => panic!("expected Timeout, got: {:?}", other),
421 }
422 }
423
424 #[tokio::test]
425 async fn test_execution_time_is_recorded() {
426 let sandbox = LocalSandbox::new();
427 let result = sandbox.run("print('hi')", Language::Python, 10_000).await;
428
429 if let Ok(run_result) = result {
430 assert!(run_result.execution_time_ms < 10_000);
431 }
432 }
433
434 #[tokio::test]
435 async fn test_stderr_captured() {
436 let sandbox = LocalSandbox::new();
437 let result = sandbox
438 .run(
439 "import sys; print('error', file=sys.stderr)",
440 Language::Python,
441 10_000,
442 )
443 .await;
444
445 if let Ok(run_result) = result {
446 if run_result.exit_code == 0 {
447 assert!(
448 run_result.stderr.contains("error"),
449 "stderr should contain 'error', got: '{}'",
450 run_result.stderr
451 );
452 }
453 }
454 }
455}