1use std::collections::HashMap;
2#[cfg(unix)]
3use std::os::unix::process::CommandExt;
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct SandboxResult {
8 pub stdout: String,
9 pub stderr: String,
10 pub exit_code: i32,
11 pub language: String,
12 pub duration_ms: u64,
13}
14
15const TIMEOUT_SECS: u64 = 30;
16const MAX_OUTPUT_BYTES: usize = 32_768;
17const MAX_CODE_BYTES: usize = 256 * 1024;
20
21pub fn execute(language: &str, code: &str, timeout_secs: Option<u64>) -> SandboxResult {
28 execute_in(language, code, timeout_secs, None)
29}
30
31pub fn execute_in(
39 language: &str,
40 code: &str,
41 timeout_secs: Option<u64>,
42 cwd: Option<&std::path::Path>,
43) -> SandboxResult {
44 if code.len() > MAX_CODE_BYTES {
45 return SandboxResult {
46 stdout: String::new(),
47 stderr: format!(
48 "Code exceeds the {MAX_CODE_BYTES}-byte limit ({} bytes). Split it into smaller scripts.",
49 code.len()
50 ),
51 exit_code: 1,
52 language: language.to_string(),
53 duration_ms: 0,
54 };
55 }
56
57 let timeout = timeout_secs.unwrap_or(TIMEOUT_SECS);
58 let start = std::time::Instant::now();
59
60 let Some(runtime) = resolve_runtime(language) else {
61 return SandboxResult {
62 stdout: String::new(),
63 stderr: format!(
64 "Unsupported language: {language}. Supported: javascript, typescript, python, shell, ruby, go, rust, php, perl, r, elixir"
65 ),
66 exit_code: 1,
67 language: language.to_string(),
68 duration_ms: 0,
69 };
70 };
71
72 let sandbox_level = std::env::var("LEAN_CTX_SANDBOX_LEVEL")
73 .ok()
74 .and_then(|v| v.parse::<u8>().ok())
75 .unwrap_or_else(|| crate::core::config::Config::load().sandbox_level);
76
77 if sandbox_level >= 1 && cfg!(target_os = "macos") {
78 let result = seatbelt_execute(&runtime, code, timeout, cwd);
79 let duration_ms = start.elapsed().as_millis() as u64;
80 return match result {
81 Ok((stdout, stderr, exit_code)) => SandboxResult {
82 stdout: truncate_output(&stdout),
83 stderr: truncate_smart(&stderr, 2048),
84 exit_code,
85 language: language.to_string(),
86 duration_ms,
87 },
88 Err(e) => SandboxResult {
89 stdout: String::new(),
90 stderr: format!("Seatbelt execution error: {e}"),
91 exit_code: 1,
92 language: language.to_string(),
93 duration_ms,
94 },
95 };
96 } else if sandbox_level >= 1 {
97 #[cfg(target_os = "linux")]
98 {
99 let result = landlock_execute(&runtime, code, timeout, cwd);
100 let duration_ms = start.elapsed().as_millis() as u64;
101 return match result {
102 Ok((stdout, stderr, exit_code)) => SandboxResult {
103 stdout: truncate_output(&stdout),
104 stderr: truncate_smart(&stderr, 2048),
105 exit_code,
106 language: language.to_string(),
107 duration_ms,
108 },
109 Err(e) => SandboxResult {
110 stdout: String::new(),
111 stderr: format!("Landlock execution error: {e}"),
112 exit_code: 1,
113 language: language.to_string(),
114 duration_ms,
115 },
116 };
117 }
118
119 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
123 {
124 return sandbox_unavailable(language, start);
125 }
126 }
127
128 let result = if runtime.needs_temp_file {
129 execute_with_file(&runtime, code, timeout, cwd)
130 } else {
131 execute_with_stdin(&runtime, code, timeout, cwd)
132 };
133
134 let duration_ms = start.elapsed().as_millis() as u64;
135
136 match result {
137 Ok((stdout, stderr, code)) => SandboxResult {
138 stdout: truncate_output(&stdout),
139 stderr: truncate_smart(&stderr, 2048),
140 exit_code: code,
141 language: language.to_string(),
142 duration_ms,
143 },
144 Err(e) => SandboxResult {
145 stdout: String::new(),
146 stderr: format!("Execution error: {e}"),
147 exit_code: 1,
148 language: language.to_string(),
149 duration_ms,
150 },
151 }
152}
153
154pub fn batch_execute(items: &[(String, String)]) -> Vec<SandboxResult> {
155 items
156 .iter()
157 .map(|(lang, code)| execute(lang, code, None))
158 .collect()
159}
160
161#[must_use]
165pub fn is_shell_language(language: &str) -> bool {
166 matches!(language.to_lowercase().as_str(), "shell" | "bash" | "sh")
167}
168
169#[cfg_attr(any(target_os = "macos", target_os = "linux"), allow(dead_code))]
171fn sandbox_unavailable(language: &str, start: std::time::Instant) -> SandboxResult {
172 SandboxResult {
173 stdout: String::new(),
174 stderr: "sandbox_level >= 1 is configured but no OS sandbox is available on this \
175 platform; refusing to run the code unsandboxed. Set sandbox_level = 0 to \
176 accept unsandboxed execution."
177 .to_string(),
178 exit_code: 1,
179 language: language.to_string(),
180 duration_ms: start.elapsed().as_millis() as u64,
181 }
182}
183
184struct RuntimeConfig {
185 command: String,
186 args: Vec<String>,
187 needs_temp_file: bool,
188 file_extension: String,
189 env: HashMap<String, String>,
190}
191
192pub fn canonical_language(language: &str) -> Option<&'static str> {
195 Some(match language.to_lowercase().as_str() {
196 "javascript" | "js" | "node" => "javascript",
197 "typescript" | "ts" => "typescript",
198 "python" | "py" => "python",
199 "shell" | "bash" | "sh" => "shell",
200 "ruby" | "rb" => "ruby",
201 "go" | "golang" => "go",
202 "rust" | "rs" => "rust",
203 "php" => "php",
204 "perl" | "pl" => "perl",
205 "r" => "r",
206 "elixir" | "ex" => "elixir",
207 _ => return None,
208 })
209}
210
211fn resolve_runtime(language: &str) -> Option<RuntimeConfig> {
212 match canonical_language(language)? {
213 "javascript" => Some(RuntimeConfig {
214 command: find_binary(&["bun", "node"])?,
215 args: vec!["-e".to_string()],
216 needs_temp_file: false,
217 file_extension: "js".to_string(),
218 env: HashMap::new(),
219 }),
220 "typescript" => Some(RuntimeConfig {
221 command: find_binary(&["bun", "npx"])?,
222 args: if which_exists("bun") {
223 vec!["-e".to_string()]
224 } else {
225 vec!["tsx".to_string(), "-e".to_string()]
226 },
227 needs_temp_file: false,
228 file_extension: "ts".to_string(),
229 env: HashMap::new(),
230 }),
231 "python" => Some(RuntimeConfig {
232 command: find_binary(&["python3", "python"])?,
233 args: vec!["-c".to_string()],
234 needs_temp_file: false,
235 file_extension: "py".to_string(),
236 env: HashMap::from([("PYTHONDONTWRITEBYTECODE".into(), "1".into())]),
237 }),
238 lang if is_shell_language(lang) => {
239 #[cfg(target_os = "windows")]
240 {
241 Some(RuntimeConfig {
242 command: "cmd".to_string(),
243 args: vec!["/C".to_string()],
244 needs_temp_file: false,
245 file_extension: "bat".to_string(),
246 env: HashMap::new(),
247 })
248 }
249 #[cfg(not(target_os = "windows"))]
250 {
251 Some(RuntimeConfig {
252 command: find_binary(&["bash", "sh"])?,
253 args: vec!["-c".to_string()],
254 needs_temp_file: false,
255 file_extension: "sh".to_string(),
256 env: HashMap::new(),
257 })
258 }
259 }
260 "ruby" => Some(RuntimeConfig {
261 command: find_binary(&["ruby"])?,
262 args: vec!["-e".to_string()],
263 needs_temp_file: false,
264 file_extension: "rb".to_string(),
265 env: HashMap::new(),
266 }),
267 "go" => Some(RuntimeConfig {
268 command: find_binary(&["go"])?,
269 args: vec!["run".to_string()],
270 needs_temp_file: true,
271 file_extension: "go".to_string(),
272 env: HashMap::new(),
273 }),
274 "rust" => Some(RuntimeConfig {
275 command: "rustc_script".to_string(),
276 args: vec![],
277 needs_temp_file: true,
278 file_extension: "rs".to_string(),
279 env: HashMap::new(),
280 }),
281 "php" => Some(RuntimeConfig {
282 command: find_binary(&["php"])?,
283 args: vec!["-r".to_string()],
284 needs_temp_file: false,
285 file_extension: "php".to_string(),
286 env: HashMap::new(),
287 }),
288 "perl" => Some(RuntimeConfig {
289 command: find_binary(&["perl"])?,
290 args: vec!["-e".to_string()],
291 needs_temp_file: false,
292 file_extension: "pl".to_string(),
293 env: HashMap::new(),
294 }),
295 "r" => Some(RuntimeConfig {
296 command: find_binary(&["Rscript"])?,
297 args: vec!["-e".to_string()],
298 needs_temp_file: false,
299 file_extension: "R".to_string(),
300 env: HashMap::new(),
301 }),
302 "elixir" => Some(RuntimeConfig {
303 command: find_binary(&["elixir"])?,
304 args: vec!["-e".to_string()],
305 needs_temp_file: false,
306 file_extension: "exs".to_string(),
307 env: HashMap::new(),
308 }),
309 _ => None,
310 }
311}
312
313fn seatbelt_execute(
314 runtime: &RuntimeConfig,
315 code: &str,
316 timeout: u64,
317 cwd: Option<&std::path::Path>,
318) -> Result<(String, String, i32), String> {
319 let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
320 let _ = std::fs::create_dir_all(&tmp_dir);
321
322 let env_pairs: Vec<(String, String)> = runtime
323 .env
324 .iter()
325 .map(|(k, v)| (k.clone(), v.clone()))
326 .collect();
327
328 if runtime.needs_temp_file {
329 let suffix = format!(".{}", runtime.file_extension);
330 let tmp = tempfile::Builder::new()
331 .prefix("exec_")
332 .suffix(&suffix)
333 .tempfile_in(&tmp_dir)
334 .map_err(|e| format!("Failed to create temp file: {e}"))?;
335 let file_path = tmp.into_temp_path();
336 std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
337
338 let mut allowed = vec![file_path.to_path_buf()];
339 if let Some(dir) = cwd {
344 allowed.push(dir.to_path_buf());
345 }
346 let allowed_refs: Vec<&std::path::Path> =
347 allowed.iter().map(std::path::PathBuf::as_path).collect();
348 let file_str = file_path.to_string_lossy().to_string();
349
350 let mut args: Vec<&str> = runtime
351 .args
352 .iter()
353 .map(std::string::String::as_str)
354 .collect();
355 args.push(&file_str);
356
357 let result = super::sandbox_seatbelt::execute_sandboxed(
358 &runtime.command,
359 &args,
360 &allowed_refs,
361 &env_pairs,
362 timeout,
363 cwd,
364 );
365 let _ = std::fs::remove_file(&file_path);
366 result
367 } else {
368 let mut args: Vec<&str> = runtime
369 .args
370 .iter()
371 .map(std::string::String::as_str)
372 .collect();
373 args.push(code);
374 let allowed: Vec<std::path::PathBuf> =
375 cwd.map(|d| vec![d.to_path_buf()]).unwrap_or_default();
376 let allowed_refs: Vec<&std::path::Path> =
377 allowed.iter().map(std::path::PathBuf::as_path).collect();
378 super::sandbox_seatbelt::execute_sandboxed(
379 &runtime.command,
380 &args,
381 &allowed_refs,
382 &env_pairs,
383 timeout,
384 cwd,
385 )
386 }
387}
388
389#[cfg(target_os = "linux")]
390fn landlock_execute(
391 runtime: &RuntimeConfig,
392 code: &str,
393 timeout: u64,
394 cwd: Option<&std::path::Path>,
395) -> Result<(String, String, i32), String> {
396 let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
397 let _ = std::fs::create_dir_all(&tmp_dir);
398
399 let env_pairs: Vec<(String, String)> = runtime
400 .env
401 .iter()
402 .map(|(k, v)| (k.clone(), v.clone()))
403 .collect();
404
405 if runtime.needs_temp_file {
406 let suffix = format!(".{}", runtime.file_extension);
407 let tmp = tempfile::Builder::new()
408 .prefix("exec_")
409 .suffix(&suffix)
410 .tempfile_in(&tmp_dir)
411 .map_err(|e| format!("Failed to create temp file: {e}"))?;
412 let file_path = tmp.into_temp_path();
413 std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
414
415 let mut allowed = vec![file_path.to_path_buf()];
416 if let Some(dir) = cwd {
417 allowed.push(dir.to_path_buf());
418 }
419 let allowed_refs: Vec<&std::path::Path> =
420 allowed.iter().map(std::path::PathBuf::as_path).collect();
421 let file_str = file_path.to_string_lossy().to_string();
422
423 let mut args: Vec<&str> = runtime
424 .args
425 .iter()
426 .map(std::string::String::as_str)
427 .collect();
428 args.push(&file_str);
429
430 let result = super::sandbox_landlock::execute_sandboxed(
431 &runtime.command,
432 &args,
433 &allowed_refs,
434 &env_pairs,
435 timeout,
436 cwd,
437 );
438 let _ = std::fs::remove_file(&file_path);
439 result
440 } else {
441 let mut args: Vec<&str> = runtime
442 .args
443 .iter()
444 .map(std::string::String::as_str)
445 .collect();
446 args.push(code);
447 let allowed: Vec<std::path::PathBuf> =
448 cwd.map(|d| vec![d.to_path_buf()]).unwrap_or_default();
449 let allowed_refs: Vec<&std::path::Path> =
450 allowed.iter().map(std::path::PathBuf::as_path).collect();
451 super::sandbox_landlock::execute_sandboxed(
452 &runtime.command,
453 &args,
454 &allowed_refs,
455 &env_pairs,
456 timeout,
457 cwd,
458 )
459 }
460}
461
462const SANDBOX_ENV_ALLOWLIST: &[&str] = &[
463 "PATH",
464 "HOME",
465 "USER",
466 "LANG",
467 "LC_ALL",
468 "TERM",
469 "TMPDIR",
470 "TMP",
471 "TEMP",
472 "SYSTEMROOT",
473 "WINDIR",
474 "SystemDrive",
475];
476
477fn apply_sandbox_env(cmd: &mut Command, runtime: &RuntimeConfig) {
478 cmd.env_clear();
479 for key in SANDBOX_ENV_ALLOWLIST {
480 if let Ok(val) = std::env::var(key) {
481 cmd.env(key, val);
482 }
483 }
484 for (k, v) in &runtime.env {
485 cmd.env(k, v);
486 }
487 cmd.env("LEAN_CTX_SANDBOX", "1");
488}
489
490fn apply_cwd(cmd: &mut Command, cwd: Option<&std::path::Path>) -> Result<(), String> {
498 let Some(dir) = cwd else { return Ok(()) };
499 if !dir.is_dir() {
500 return Err(format!(
501 "working directory does not exist: {}",
502 dir.display()
503 ));
504 }
505 cmd.current_dir(dir);
506 Ok(())
507}
508
509fn execute_with_stdin(
510 runtime: &RuntimeConfig,
511 code: &str,
512 timeout: u64,
513 cwd: Option<&std::path::Path>,
514) -> Result<(String, String, i32), String> {
515 let mut cmd = Command::new(&runtime.command);
516 apply_cwd(&mut cmd, cwd)?;
517 for arg in &runtime.args {
518 cmd.arg(arg);
519 }
520 cmd.arg(code);
521 apply_sandbox_env(&mut cmd, runtime);
522 run_runtime_command(cmd, timeout)
523}
524
525fn run_runtime_command(mut cmd: Command, timeout: u64) -> Result<(String, String, i32), String> {
529 #[cfg(unix)]
530 {
531 use crate::core::protected_execution::{Mode, launch_mode};
532 match launch_mode()? {
533 Mode::Brokered(client) => {
534 let executable = resolve_program_path(&cmd)?;
535 let executable = executable.to_str().ok_or("runtime path is not UTF-8")?;
536 let args: Vec<&str> = cmd
537 .get_args()
538 .map(|arg| arg.to_str().ok_or("runtime argument is not UTF-8"))
539 .collect::<Result<_, _>>()?;
540 let env: Vec<(String, String)> = cmd
541 .get_envs()
542 .filter_map(|(key, value)| value.map(|value| (key, value)))
543 .map(|(key, value)| {
544 Ok((
545 key.to_str()
546 .ok_or("runtime environment key is not UTF-8")?
547 .to_owned(),
548 value
549 .to_str()
550 .ok_or("runtime environment value is not UTF-8")?
551 .to_owned(),
552 ))
553 })
554 .collect::<Result<_, String>>()?;
555 return client.session_program(
556 executable,
557 &args,
558 &env,
559 timeout,
560 cmd.get_current_dir(),
561 );
562 }
563 Mode::Sandboxed(_) => {
564 return Err("runtime execution must use the supervised program channel".into());
565 }
566 Mode::Direct => {}
567 }
568 }
569 #[cfg(not(unix))]
570 let _protected = crate::core::protected_store::protected_child_prefix()?;
571 cmd.stdin(std::process::Stdio::null());
575 cmd.stdout(std::process::Stdio::piped());
576 cmd.stderr(std::process::Stdio::piped());
577 #[cfg(unix)]
578 cmd.process_group(0);
579
580 let program = cmd.get_program().to_string_lossy().into_owned();
581 let output = wait_with_timeout(&mut cmd, timeout, |e| {
582 format!("Failed to spawn {program}: {e}")
583 })?;
584 Ok((
585 crate::shell::decode_output(&output.stdout),
586 crate::shell::decode_output(&output.stderr),
587 crate::shell::exit_status::exit_code(output.status),
588 ))
589}
590
591#[cfg(unix)]
592fn resolve_program_path(cmd: &Command) -> Result<std::path::PathBuf, String> {
593 use std::os::unix::fs::PermissionsExt;
594 let program = std::path::Path::new(cmd.get_program());
595 if program.is_absolute() {
596 return Ok(program.to_path_buf());
597 }
598 let cwd = cmd
599 .get_current_dir()
600 .map(std::path::Path::to_path_buf)
601 .unwrap_or(std::env::current_dir().map_err(|_| "runtime working directory unavailable")?);
602 if program.components().count() > 1 {
603 return Ok(cwd.join(program));
604 }
605 let path = cmd
606 .get_envs()
607 .find(|(key, _)| *key == "PATH")
608 .and_then(|(_, value)| value.map(std::ffi::OsStr::to_os_string))
609 .or_else(|| std::env::var_os("PATH"))
610 .ok_or("runtime PATH unavailable")?;
611 for directory in std::env::split_paths(&path) {
612 let directory = if directory.is_absolute() {
613 directory
614 } else {
615 cwd.join(directory)
616 };
617 let candidate = directory.join(program);
618 if candidate
619 .metadata()
620 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
621 {
622 return Ok(candidate);
624 }
625 }
626 Err("runtime executable unavailable".into())
627}
628
629fn execute_with_file(
630 runtime: &RuntimeConfig,
631 code: &str,
632 timeout: u64,
633 cwd: Option<&std::path::Path>,
634) -> Result<(String, String, i32), String> {
635 let tmp_dir = std::env::temp_dir().join("lean-ctx-sandbox");
636 let _ = std::fs::create_dir_all(&tmp_dir);
637
638 let suffix = format!(".{}", runtime.file_extension);
639 let tmp = tempfile::Builder::new()
640 .prefix("exec_")
641 .suffix(&suffix)
642 .tempfile_in(&tmp_dir)
643 .map_err(|e| format!("Failed to create temp file: {e}"))?;
644 let file_path = tmp.into_temp_path();
645
646 std::fs::write(&file_path, code).map_err(|e| format!("Failed to write temp file: {e}"))?;
647
648 let result = if runtime.command == "rustc_script" {
649 execute_rust(&file_path, timeout, cwd)
650 } else {
651 let mut cmd = Command::new(&runtime.command);
652 apply_cwd(&mut cmd, cwd)?;
653 for arg in &runtime.args {
654 cmd.arg(arg);
655 }
656 cmd.arg(&file_path);
657 apply_sandbox_env(&mut cmd, runtime);
658 run_runtime_command(cmd, timeout)
659 };
660
661 let _ = std::fs::remove_file(&file_path);
662 result
663}
664
665fn execute_rust(
666 source_path: &std::path::Path,
667 timeout: u64,
668 cwd: Option<&std::path::Path>,
669) -> Result<(String, String, i32), String> {
670 let binary_path = source_path.with_extension("");
671
672 let mut compile_cmd = Command::new("rustc");
673 compile_cmd.arg(source_path).arg("-o").arg(&binary_path);
674 compile_cmd.env_clear();
675 for key in SANDBOX_ENV_ALLOWLIST {
676 if let Ok(val) = std::env::var(key) {
677 compile_cmd.env(key, val);
678 }
679 }
680 compile_cmd.env("LEAN_CTX_SANDBOX", "1");
681
682 let (_, stderr, code) = run_runtime_command(compile_cmd, timeout)?;
683
684 if code != 0 {
685 let _ = std::fs::remove_file(&binary_path);
686 return Ok((String::new(), stderr, code));
687 }
688
689 let mut run_cmd = Command::new(&binary_path);
690 apply_cwd(&mut run_cmd, cwd)?;
691 run_cmd.env_clear();
692 for key in SANDBOX_ENV_ALLOWLIST {
693 if let Ok(val) = std::env::var(key) {
694 run_cmd.env(key, val);
695 }
696 }
697 run_cmd.env("LEAN_CTX_SANDBOX", "1");
698 let result = run_runtime_command(run_cmd, timeout);
699 let _ = std::fs::remove_file(&binary_path);
700 result
701}
702
703#[cfg(unix)]
706fn let_go_of_guard(guard: &mut Option<crate::shell::containment::CallGuard>) {
707 if let Some(guard) = guard.take() {
708 let ended = guard.finish();
709 if ended != crate::shell::containment::GuardOutcome::Ended {
710 tracing::warn!("sandbox run: the end of its process group is {ended:?}");
711 }
712 }
713}
714
715fn wait_with_timeout(
716 cmd: &mut Command,
717 timeout_secs: u64,
718 spawn_error: impl FnOnce(std::io::Error) -> String,
719) -> Result<std::process::Output, String> {
720 crate::shell::process_tree::ProcessTree::prepare(cmd);
723 #[cfg(unix)]
731 let (mut child, mut guard) = {
732 use crate::shell::containment::Refusal;
733 match crate::shell::containment::start_guarded_child(cmd) {
734 Ok(started) => started,
735 Err(Refusal::NotStarted(error)) => return Err(spawn_error(error)),
736 Err(Refusal::NotLeader) => {
737 return Err(
738 "Execution could not be contained: the process does not lead a group of its own"
739 .to_string(),
740 );
741 }
742 Err(Refusal::NoGuard(reason)) => {
743 return Err(format!("Execution could not be contained: {reason}"));
744 }
745 }
746 };
747 #[cfg(not(unix))]
748 let mut child = cmd.spawn().map_err(spawn_error)?;
749 let tree = crate::shell::process_tree::ProcessTree::start(&mut child)?;
750 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
751
752 loop {
753 match child.try_wait() {
754 Ok(Some(_)) => {
755 tree.release();
756 #[cfg(unix)]
760 let_go_of_guard(&mut guard);
761 return child.wait_with_output().map_err(|e| e.to_string());
762 }
763 Ok(None) => {
764 if std::time::Instant::now() > deadline {
765 tree.kill(&mut child);
766 #[cfg(unix)]
767 let_go_of_guard(&mut guard);
768 if let Ok(mut output) = child.wait_with_output() {
772 output.stderr.extend_from_slice(
773 format!("\n[lean-ctx] Execution timed out after {timeout_secs}s")
774 .as_bytes(),
775 );
776 return Ok(output);
777 }
778 return Err(format!("Execution timed out after {timeout_secs}s"));
779 }
780 std::thread::sleep(std::time::Duration::from_millis(50));
781 }
782 Err(e) => {
783 tree.kill(&mut child);
784 #[cfg(unix)]
785 let_go_of_guard(&mut guard);
786 let _ = child.wait();
787 return Err(e.to_string());
788 }
789 }
790 }
791}
792
793fn find_binary(candidates: &[&str]) -> Option<String> {
794 for name in candidates {
795 if which_exists(name) {
796 return Some(name.to_string());
797 }
798 }
799 None
800}
801
802fn which_exists(name: &str) -> bool {
803 #[cfg(target_os = "windows")]
804 let check_cmd = Command::new("where")
805 .arg(name)
806 .stdout(std::process::Stdio::null())
807 .stderr(std::process::Stdio::null())
808 .status();
809
810 #[cfg(not(target_os = "windows"))]
811 let check_cmd = Command::new("which")
812 .arg(name)
813 .stdout(std::process::Stdio::null())
814 .stderr(std::process::Stdio::null())
815 .status();
816
817 check_cmd.is_ok_and(|s| s.success())
818}
819
820fn truncate_output(output: &str) -> String {
821 if output.len() <= MAX_OUTPUT_BYTES {
822 return output.to_string();
823 }
824 crate::core::recovery::with_original_pointer(
825 "ctx_execute",
826 "stdout",
827 output,
828 truncate_smart(output, MAX_OUTPUT_BYTES),
829 )
830}
831
832fn truncate_smart(output: &str, max_bytes: usize) -> String {
833 if output.len() <= max_bytes {
834 return output.to_string();
835 }
836
837 let lines: Vec<&str> = output.lines().collect();
838 let total_lines = lines.len();
839
840 let head_count = (total_lines * 60) / 100;
841 let tail_count = total_lines - head_count;
842
843 let head: Vec<&str> = lines.iter().take(head_count).copied().collect();
844 let tail: Vec<&str> = lines
845 .iter()
846 .skip(total_lines - tail_count)
847 .copied()
848 .collect();
849
850 let head_text = head.join("\n");
851 let tail_text = tail.join("\n");
852
853 if head_text.len() + tail_text.len() + 100 > max_bytes {
854 let half = max_bytes / 2;
855 let h = &output[..output.floor_char_boundary(half.min(output.len()))];
856 let t_start = output.ceil_char_boundary(output.len().saturating_sub(half));
857 let t = &output[t_start..];
858 let skipped = output.len() - h.len() - t.len();
859 return format!("{h}\n\n... [{skipped} bytes truncated — showing head + tail] ...\n\n{t}");
860 }
861
862 let skipped_lines = total_lines - head_count - tail_count;
863 let skipped_bytes = output.len() - head_text.len() - tail_text.len();
864 format!(
865 "{head_text}\n\n... [{skipped_lines} lines / {skipped_bytes} bytes truncated — showing first {head_count} + last {tail_count} lines] ...\n\n{tail_text}"
866 )
867}
868
869pub fn supported_languages() -> &'static [&'static str] {
870 &[
871 "javascript",
872 "typescript",
873 "python",
874 "shell",
875 "ruby",
876 "go",
877 "rust",
878 "php",
879 "perl",
880 "r",
881 "elixir",
882 ]
883}
884
885#[cfg(test)]
886pub mod tests {
887 use super::*;
888
889 fn python_available() -> bool {
890 find_binary(&["python3", "python"]).is_some()
891 }
892
893 #[cfg(target_os = "macos")]
894 #[test]
895 fn level_zero_interpreters_and_compilers_require_the_protected_channel() {
896 let _lock = crate::core::data_dir::test_env_lock();
897 let isolation = crate::core::data_dir::isolated_data_dir();
898 let _session =
899 crate::core::protected_store::pin_synthetic_session(isolation.path()).unwrap();
900 let runtime = RuntimeConfig {
901 command: "/bin/sh".into(),
902 args: vec!["-c".into()],
903 needs_temp_file: false,
904 file_extension: "sh".into(),
905 env: HashMap::new(),
906 };
907 let stdin_error = execute_with_stdin(&runtime, "printf bypass", 1, None).unwrap_err();
908 assert!(
909 stdin_error.contains("supervised execution channel"),
910 "{stdin_error}"
911 );
912 let file_error = execute_with_file(&runtime, "printf bypass", 1, None).unwrap_err();
913 assert!(
914 file_error.contains("supervised execution channel"),
915 "{file_error}"
916 );
917 let source = tempfile::Builder::new().suffix(".rs").tempfile().unwrap();
918 std::fs::write(source.path(), "fn main() {}\n").unwrap();
919 let rust_error = execute_rust(source.path(), 1, None).unwrap_err();
920 assert!(
921 rust_error.contains("supervised execution channel"),
922 "{rust_error}"
923 );
924 assert!(!source.path().with_extension("").exists());
925 }
926
927 #[test]
928 fn execute_python_hello() {
929 let _lock = crate::core::data_dir::test_env_lock();
930 if !python_available() {
931 return;
932 }
933 let result = execute("python", "print('hello sandbox')", None);
934 assert_eq!(result.exit_code, 0);
935 assert!(result.stdout.contains("hello sandbox"));
936 }
937
938 #[test]
943 #[cfg(not(target_os = "windows"))]
944 fn execute_in_resolves_relative_paths_against_the_given_directory() {
945 let _lock = crate::core::data_dir::test_env_lock();
946 let dir = tempfile::tempdir().expect("tempdir");
947 std::fs::write(dir.path().join("only-here.txt"), "x").expect("write marker");
948
949 let scoped = execute_in("shell", "cat only-here.txt", None, Some(dir.path()));
950 assert_eq!(scoped.exit_code, 0, "stderr: {}", scoped.stderr);
951 assert!(scoped.stdout.contains('x'), "{scoped:?}");
952
953 let unscoped = execute_in("shell", "cat only-here.txt", None, None);
956 assert_ne!(
957 unscoped.exit_code, 0,
958 "precondition: the marker is only reachable from the given directory"
959 );
960 }
961
962 #[test]
965 #[cfg(not(target_os = "windows"))]
966 fn execute_in_reports_a_missing_directory_instead_of_falling_back() {
967 let _lock = crate::core::data_dir::test_env_lock();
968 let dir = tempfile::tempdir().expect("tempdir");
969 let missing = dir.path().join("does-not-exist");
970
971 let result = execute_in("shell", "pwd", None, Some(&missing));
972 assert_ne!(result.exit_code, 0, "{result:?}");
973 assert!(
974 result.stderr.contains("working directory does not exist"),
975 "{result:?}"
976 );
977 }
978
979 #[test]
980 #[cfg(not(target_os = "windows"))]
981 fn execute_shell_echo() {
982 let _lock = crate::core::data_dir::test_env_lock();
983 let result = execute("shell", "echo 'test output'", None);
984 assert_eq!(result.exit_code, 0);
985 assert!(result.stdout.contains("test output"));
986 }
987
988 #[test]
991 #[cfg(unix)]
992 fn a_run_ends_what_its_snippet_left_running() {
993 crate::shell::containment::tests::run_external_fixture(
994 module_path!(),
995 "left_running_fixture",
996 );
997 }
998
999 #[test]
1004 #[ignore = "fixture body of a_run_ends_what_its_snippet_left_running"]
1005 #[cfg(unix)]
1006 fn left_running_fixture() {
1007 let fixture = crate::shell::containment::tests::external_fixture();
1008 let _lock = crate::core::data_dir::test_env_lock();
1009 let result = execute(
1010 "shell",
1011 "sleep 20 >/dev/null 2>&1 &\necho \"LEFT $!\"",
1012 Some(20),
1013 );
1014 assert_eq!(result.exit_code, 0, "{}", result.stderr);
1015 let pid: libc::pid_t = result
1016 .stdout
1017 .lines()
1018 .find_map(|line| line.strip_prefix("LEFT "))
1019 .expect("the snippet names its background child")
1020 .trim()
1021 .parse()
1022 .unwrap();
1023 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1026 while unsafe { libc::kill(pid, 0) } == 0 {
1028 assert!(
1029 std::time::Instant::now() < deadline,
1030 "a background child of the snippet outlived the run"
1031 );
1032 std::thread::sleep(std::time::Duration::from_millis(20));
1033 }
1034 fixture.complete();
1035 }
1036
1037 #[test]
1041 #[cfg(unix)]
1042 fn a_run_never_starts_before_its_guard_is_ready() {
1043 crate::shell::containment::tests::run_external_fixture(
1044 module_path!(),
1045 "run_caller_killed_fixture",
1046 );
1047 }
1048
1049 #[test]
1052 #[ignore = "fixture body of a_run_never_starts_before_its_guard_is_ready"]
1053 #[cfg(unix)]
1054 fn run_caller_killed_fixture() {
1055 let fixture = crate::shell::containment::tests::external_fixture();
1056 crate::shell::containment::tests::caller_killed_before_its_guard(
1057 module_path!(),
1058 "run_caller_standin",
1059 );
1060 fixture.complete();
1061 }
1062
1063 #[test]
1065 #[ignore = "stand-in caller of run_caller_killed_fixture"]
1066 #[cfg(unix)]
1067 fn run_caller_standin() {
1068 use std::os::unix::process::CommandExt as _;
1069 let marker = crate::shell::containment::tests::caller_standin_marker();
1070 let mut command = std::process::Command::new("sh");
1071 command
1072 .args([
1073 "-c",
1074 &format!("echo ran > '{}'; sleep 60", marker.display()),
1075 ])
1076 .stdin(std::process::Stdio::null())
1077 .stdout(std::process::Stdio::piped())
1078 .stderr(std::process::Stdio::piped())
1079 .process_group(0);
1080 let _ = super::wait_with_timeout(&mut command, 60, |error| error.to_string());
1081 }
1082
1083 #[test]
1084 fn execute_unsupported_language() {
1085 let result = execute("brainfuck", "++++", None);
1086 assert_eq!(result.exit_code, 1);
1087 assert!(result.stderr.contains("Unsupported language"));
1088 }
1089
1090 #[test]
1091 fn execute_rejects_oversized_code() {
1092 let huge = "a".repeat(MAX_CODE_BYTES + 1);
1093 let result = execute("python", &huge, None);
1094 assert_eq!(result.exit_code, 1);
1095 assert!(result.stderr.contains("exceeds the"));
1096 }
1097
1098 #[cfg(windows)]
1099 #[test]
1100 fn sandbox_env_preserves_system_drive() {
1101 let expected =
1102 std::env::var_os("SystemDrive").expect("SystemDrive must be defined on Windows");
1103
1104 let runtime = RuntimeConfig {
1105 command: "cmd".to_string(),
1106 args: Vec::new(),
1107 needs_temp_file: false,
1108 file_extension: "bat".to_string(),
1109 env: HashMap::new(),
1110 };
1111
1112 let mut cmd = Command::new("cmd");
1113 apply_sandbox_env(&mut cmd, &runtime);
1114
1115 let preserved = cmd.get_envs().any(|(key, value)| {
1116 key.to_string_lossy().eq_ignore_ascii_case("SystemDrive")
1117 && value == Some(expected.as_os_str())
1118 });
1119
1120 assert!(
1121 preserved,
1122 "Windows sandbox child environment must preserve SystemDrive"
1123 );
1124 }
1125
1126 #[test]
1127 fn execute_python_error() {
1128 let _lock = crate::core::data_dir::test_env_lock();
1129 if !python_available() {
1130 return;
1131 }
1132 let result = execute("python", "raise ValueError('test error')", None);
1133 assert_ne!(result.exit_code, 0);
1134 assert!(result.stderr.contains("ValueError"));
1135 }
1136
1137 #[test]
1138 fn execute_with_timeout() {
1139 let _lock = crate::core::data_dir::test_env_lock();
1140 if !python_available() {
1141 return;
1142 }
1143 let result = execute("python", "import time; time.sleep(60)", Some(1));
1144 assert_ne!(result.exit_code, 0);
1145 assert!(
1146 result.stderr.contains("timed out"),
1147 "stderr should mention timeout: {}",
1148 result.stderr
1149 );
1150 }
1151
1152 #[test]
1154 fn timeout_preserves_partial_output() {
1155 let _lock = crate::core::data_dir::test_env_lock();
1156 if !python_available() {
1157 return;
1158 }
1159 let result = execute(
1160 "python",
1161 "import sys; sys.stdout.write('PARTIAL_1504'); sys.stdout.flush(); import time; time.sleep(60)",
1162 Some(2),
1163 );
1164 assert_ne!(result.exit_code, 0);
1165 assert!(
1166 result.stdout.contains("PARTIAL_1504"),
1167 "partial stdout before timeout must be preserved, got: {:?}",
1168 result.stdout
1169 );
1170 assert!(
1171 result.stderr.contains("timed out"),
1172 "stderr should mention timeout: {}",
1173 result.stderr
1174 );
1175 }
1176
1177 #[test]
1178 fn truncate_preserves_head_and_tail() {
1179 let lines: Vec<String> = (0..100)
1180 .map(|i| format!("line {i}: some content here"))
1181 .collect();
1182 let output = lines.join("\n");
1183 let truncated = truncate_smart(&output, 500);
1184 assert!(truncated.contains("line 0:"));
1185 assert!(truncated.contains("line 99:"));
1186 assert!(truncated.contains("truncated"));
1187 }
1188
1189 #[test]
1190 fn supported_languages_list() {
1191 let langs = supported_languages();
1192 assert!(langs.contains(&"python"));
1193 assert!(langs.contains(&"javascript"));
1194 assert!(langs.contains(&"rust"));
1195 assert_eq!(langs.len(), 11);
1196 }
1197
1198 #[test]
1199 fn sandbox_env_is_set() {
1200 let _lock = crate::core::data_dir::test_env_lock();
1201 if !python_available() {
1202 return;
1203 }
1204 let result = execute(
1205 "python",
1206 "import os; print(os.environ.get('LEAN_CTX_SANDBOX', 'missing'))",
1207 None,
1208 );
1209 assert_eq!(result.exit_code, 0);
1210 assert!(result.stdout.contains('1'));
1211 }
1212
1213 #[test]
1214 #[cfg(not(target_os = "windows"))]
1215 fn batch_execute_multiple() {
1216 let _lock = crate::core::data_dir::test_env_lock();
1217 let items = vec![
1218 ("python".to_string(), "print(1+1)".to_string()),
1219 ("shell".to_string(), "echo hello".to_string()),
1220 ];
1221 let results = batch_execute(&items);
1222 assert_eq!(results.len(), 2);
1223 assert!(results[0].stdout.contains('2'));
1224 assert!(results[1].stdout.contains("hello"));
1225 }
1226}