1use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18use std::path::Path;
19use std::process::Command as StdCommand;
20use std::sync::{Mutex as StdMutex, PoisonError};
21
22use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
23use leviath_sys::ContainerRunSpec;
24use leviath_tools::ShellExecutor;
25use tokio::process::Command as TokioCommand;
26
27const CONTAINER_SHELL: &str = "sh";
31const CONTAINER_SHELL_FLAG: &str = "-c";
32
33type CmdRunner<'a> = dyn Fn(&[String]) -> Result<(), String> + 'a;
37
38#[derive(Debug, Clone)]
40struct LiveContainer {
41 engine: String,
43 name: String,
44}
45
46#[derive(Debug)]
49pub struct SandboxManager {
50 containers: HashMap<u64, LiveContainer>,
52 by_index: Vec<ToolSandboxConfig>,
54 namespace_ok: bool,
58 current: StdMutex<ToolSandboxConfig>,
61}
62
63fn signature(cfg: &ToolSandboxConfig) -> u64 {
67 let mut h = std::collections::hash_map::DefaultHasher::new();
68 cfg.engine.hash(&mut h);
69 cfg.image.hash(&mut h);
70 cfg.network.hash(&mut h);
71 cfg.mounts.hash(&mut h);
72 h.finish()
73}
74
75fn sanitize(run_id: &str) -> String {
77 run_id
78 .chars()
79 .map(|c| {
80 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
81 c
82 } else {
83 '-'
84 }
85 })
86 .collect()
87}
88
89fn host_command(shell: &str, flag: &str, command: &str, workdir: &Path) -> TokioCommand {
91 let mut c = TokioCommand::new(shell);
92 c.arg(flag).arg(command).current_dir(workdir);
93 c
94}
95
96impl SandboxManager {
97 pub fn build(
104 run_id: &str,
105 by_index: Vec<ToolSandboxConfig>,
106 workdir: &str,
107 entry_index: usize,
108 ) -> Result<Option<Self>, String> {
109 let needs_container = by_index.iter().any(|c| c.kind == SandboxKind::Container);
113 let detected = needs_container
114 .then(leviath_sys::detect_container_engine)
115 .flatten();
116 let namespace_ok = leviath_sys::namespace_supported();
117 Self::build_with(
118 run_id,
119 by_index,
120 workdir,
121 entry_index,
122 detected,
123 namespace_ok,
124 &real_run,
125 )
126 }
127
128 fn build_with(
132 run_id: &str,
133 by_index: Vec<ToolSandboxConfig>,
134 workdir: &str,
135 entry_index: usize,
136 detected: Option<String>,
137 namespace_ok: bool,
138 run: &CmdRunner,
139 ) -> Result<Option<Self>, String> {
140 if by_index.iter().all(|c| !c.is_active()) {
142 return Ok(None);
143 }
144
145 let mut containers: HashMap<u64, LiveContainer> = HashMap::new();
146 for cfg in &by_index {
147 match cfg.kind {
148 SandboxKind::None => {}
149 SandboxKind::Namespace => {
150 if !namespace_ok {
151 unavailable(
152 cfg,
153 "namespace sandbox requires Linux (unshare); this host lacks it",
154 &containers,
155 run,
156 )?;
157 }
158 }
159 SandboxKind::Container => {
160 let sig = signature(cfg);
161 if containers.contains_key(&sig) {
162 continue; }
164 let Some(engine) = cfg.engine.clone().or_else(|| detected.clone()) else {
166 unavailable(
167 cfg,
168 "no container engine found - install docker or podman, \
169 or set `engine` in [sandbox]",
170 &containers,
171 run,
172 )?;
173 continue;
174 };
175 let Some(image) = cfg.image.as_deref() else {
176 unavailable(
177 cfg,
178 "container sandbox requires an `image`",
179 &containers,
180 run,
181 )?;
182 continue;
183 };
184 let name = format!("leviath-{}-{:016x}", sanitize(run_id), sig);
185 let spec = ContainerRunSpec {
186 engine: &engine,
187 image,
188 workdir,
189 network: cfg.network,
190 mounts: &cfg.mounts,
191 name: &name,
192 };
193 match run(&leviath_sys::container_run_argv(&spec)) {
194 Ok(()) => {
195 containers.insert(sig, LiveContainer { engine, name });
196 }
197 Err(stderr) => {
198 let msg = format!(
199 "failed to start container '{image}' via '{engine}': {stderr}"
200 );
201 unavailable(cfg, &msg, &containers, run)?;
202 }
203 }
204 }
205 }
206 }
207
208 let current = by_index.get(entry_index).cloned().unwrap_or_default();
209 Ok(Some(Self {
210 containers,
211 by_index,
212 namespace_ok,
213 current: StdMutex::new(current),
214 }))
215 }
216
217 pub fn set_stage(&self, index: usize) {
220 if let Some(cfg) = self.by_index.get(index) {
221 *self.current.lock().unwrap_or_else(PoisonError::into_inner) = cfg.clone();
222 }
223 }
224
225 pub fn destroy_all(&self) {
228 self.destroy_with(&real_run);
229 }
230
231 fn destroy_with(&self, run: &CmdRunner) {
233 for c in self.containers.values() {
234 let _ = run(&leviath_sys::container_rm_argv(&c.engine, &c.name));
235 }
236 }
237}
238
239fn unavailable(
243 cfg: &ToolSandboxConfig,
244 reason: &str,
245 created: &HashMap<u64, LiveContainer>,
246 run: &CmdRunner,
247) -> Result<(), String> {
248 match cfg.on_unavailable {
249 OnUnavailable::Error => {
250 for c in created.values() {
251 let _ = run(&leviath_sys::container_rm_argv(&c.engine, &c.name));
252 }
253 Err(format!("sandbox unavailable: {reason}"))
254 }
255 OnUnavailable::Warn => {
256 tracing::warn!("sandbox unavailable ({reason}); falling back to host execution");
257 Ok(())
258 }
259 }
260}
261
262fn real_run(argv: &[String]) -> Result<(), String> {
265 let mut cmd = StdCommand::new(&argv[0]);
266 cmd.args(&argv[1..]);
267 leviath_sys::hide_console_window(&mut cmd);
270 let output = cmd.output().map_err(|e| e.to_string())?;
271 if output.status.success() {
272 Ok(())
273 } else {
274 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
275 }
276}
277
278impl ShellExecutor for SandboxManager {
279 fn build_command(
280 &self,
281 shell: &str,
282 flag: &str,
283 command: &str,
284 workdir: &Path,
285 ) -> TokioCommand {
286 let cfg = self
287 .current
288 .lock()
289 .unwrap_or_else(PoisonError::into_inner)
290 .clone();
291 match cfg.kind {
292 SandboxKind::None => host_command(shell, flag, command, workdir),
293 SandboxKind::Namespace => {
294 if self.namespace_ok {
295 let argv = leviath_sys::namespace_argv(shell, flag, command, cfg.network);
296 let mut c = TokioCommand::new(&argv[0]);
297 c.args(&argv[1..]).current_dir(workdir);
298 c
299 } else {
300 host_command(shell, flag, command, workdir)
303 }
304 }
305 SandboxKind::Container => match self.containers.get(&signature(&cfg)) {
306 Some(lc) => {
307 let wd = workdir.to_string_lossy();
308 let argv = leviath_sys::container_exec_argv(
311 &lc.engine,
312 &lc.name,
313 &wd,
314 CONTAINER_SHELL,
315 CONTAINER_SHELL_FLAG,
316 command,
317 );
318 let mut c = TokioCommand::new(&argv[0]);
319 c.args(&argv[1..]);
320 c
321 }
322 None => host_command(shell, flag, command, workdir),
324 },
325 }
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn container_cfg(
334 image: &str,
335 network: bool,
336 on_unavailable: OnUnavailable,
337 ) -> ToolSandboxConfig {
338 ToolSandboxConfig {
339 kind: SandboxKind::Container,
340 image: Some(image.to_string()),
341 network,
342 on_unavailable,
343 ..Default::default()
344 }
345 }
346
347 fn ns_cfg(on_unavailable: OnUnavailable) -> ToolSandboxConfig {
348 ToolSandboxConfig {
349 kind: SandboxKind::Namespace,
350 on_unavailable,
351 ..Default::default()
352 }
353 }
354
355 fn recording_runner(
357 log: &std::sync::Mutex<Vec<Vec<String>>>,
358 ) -> impl Fn(&[String]) -> Result<(), String> + '_ {
359 move |argv| {
360 log.lock().unwrap().push(argv.to_vec());
361 Ok(())
362 }
363 }
364
365 fn ok_runner(_argv: &[String]) -> Result<(), String> {
368 Ok(())
369 }
370
371 #[test]
372 fn all_host_yields_no_manager() {
373 let by_index = vec![ToolSandboxConfig::default(), ToolSandboxConfig::default()];
374 let m = SandboxManager::build_with("run1", by_index, "/work", 0, None, true, &ok_runner)
375 .unwrap();
376 assert!(m.is_none());
377 }
378
379 #[test]
380 fn config_engine_overrides_autodetect() {
381 let log = std::sync::Mutex::new(Vec::new());
384 let cfg = ToolSandboxConfig {
385 kind: SandboxKind::Container,
386 image: Some("alpine".to_string()),
387 engine: Some("nerdctl".to_string()),
388 on_unavailable: OnUnavailable::Error,
389 ..Default::default()
390 };
391 let m = SandboxManager::build_with(
392 "r",
393 vec![cfg],
394 "/w",
395 0,
396 None, true,
398 &recording_runner(&log),
399 )
400 .unwrap()
401 .unwrap();
402 assert_eq!(log.lock().unwrap()[0][0], "nerdctl");
403 assert_eq!(m.containers.values().next().unwrap().engine, "nerdctl");
404 }
405
406 #[test]
407 fn dedups_identical_container_configs() {
408 let log = std::sync::Mutex::new(Vec::new());
409 let cfg = container_cfg("ubuntu:24.04", true, OnUnavailable::Error);
410 let by_index = vec![cfg.clone(), cfg.clone(), cfg];
411 let m = SandboxManager::build_with(
412 "run-1",
413 by_index,
414 "/work",
415 0,
416 Some("docker".to_string()),
417 true,
418 &recording_runner(&log),
419 )
420 .unwrap()
421 .unwrap();
422 assert_eq!(log.lock().unwrap().len(), 1);
424 assert_eq!(m.containers.len(), 1);
425 let argv = &log.lock().unwrap()[0];
426 assert_eq!(argv[0], "docker");
427 assert!(argv.contains(&"ubuntu:24.04".to_string()));
428 }
429
430 #[test]
431 fn distinct_container_configs_each_get_a_container() {
432 let by_index = vec![
435 container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
436 container_cfg("node:22-slim", false, OnUnavailable::Error),
437 ];
438 let m = SandboxManager::build_with(
439 "r",
440 by_index,
441 "/w",
442 0,
443 Some("docker".to_string()),
444 true,
445 &ok_runner,
446 )
447 .unwrap()
448 .unwrap();
449 assert_eq!(m.containers.len(), 2);
450 }
451
452 #[test]
453 fn missing_engine_errors_by_default() {
454 let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
455 let err =
456 SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner).unwrap_err();
457 assert!(err.contains("no container engine"));
458 }
459
460 #[test]
461 fn missing_engine_warns_and_falls_back() {
462 let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Warn)];
463 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
464 .unwrap()
465 .unwrap();
466 assert!(m.containers.is_empty());
468 let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
469 assert_eq!(cmd.as_std().get_program(), "sh");
470 }
471
472 #[test]
473 fn container_without_image_errors() {
474 let cfg = ToolSandboxConfig {
475 kind: SandboxKind::Container,
476 image: None,
477 on_unavailable: OnUnavailable::Error,
478 ..Default::default()
479 };
480 let err = SandboxManager::build_with(
481 "r",
482 vec![cfg],
483 "/w",
484 0,
485 Some("docker".to_string()),
486 true,
487 &ok_runner,
488 )
489 .unwrap_err();
490 assert!(err.contains("requires an `image`"), "got: {err}");
491 }
492
493 #[test]
494 fn container_without_image_warns_and_skips() {
495 let cfg = ToolSandboxConfig {
498 kind: SandboxKind::Container,
499 image: None,
500 on_unavailable: OnUnavailable::Warn,
501 ..Default::default()
502 };
503 let m = SandboxManager::build_with(
504 "r",
505 vec![cfg],
506 "/w",
507 0,
508 Some("docker".to_string()),
509 true,
510 &ok_runner,
511 )
512 .unwrap()
513 .unwrap();
514 assert!(m.containers.is_empty());
515 }
516
517 #[test]
518 fn build_command_host_arm_when_current_stage_is_none() {
519 let by_index = vec![
522 ToolSandboxConfig::default(),
523 container_cfg("ubuntu:24.04", true, OnUnavailable::Warn),
524 ];
525 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
526 .unwrap()
527 .unwrap();
528 let cmd = m.build_command("zsh", "-c", "echo hi", Path::new("/w"));
529 assert_eq!(cmd.as_std().get_program(), "zsh");
530 }
531
532 #[test]
533 fn container_start_failure_errors() {
534 let by_index = vec![container_cfg("bad:image", true, OnUnavailable::Error)];
535 let err = SandboxManager::build_with(
536 "r",
537 by_index,
538 "/w",
539 0,
540 Some("docker".to_string()),
541 true,
542 &|_| Err("no such image".to_string()),
543 )
544 .unwrap_err();
545 assert!(err.contains("failed to start container"));
546 assert!(err.contains("no such image"));
547 }
548
549 #[test]
550 fn error_teardown_removes_already_created_containers() {
551 let log = std::sync::Mutex::new(Vec::<Vec<String>>::new());
554 let run = |argv: &[String]| -> Result<(), String> {
555 log.lock().unwrap().push(argv.to_vec());
556 if argv.contains(&"node:22-slim".to_string()) {
558 Err("boom".to_string())
559 } else {
560 Ok(())
561 }
562 };
563 let by_index = vec![
564 container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
565 container_cfg("node:22-slim", true, OnUnavailable::Error),
566 ];
567 let err = SandboxManager::build_with(
568 "r",
569 by_index,
570 "/w",
571 0,
572 Some("docker".to_string()),
573 true,
574 &run,
575 )
576 .unwrap_err();
577 assert!(err.contains("failed to start container"));
578 let calls = log.lock().unwrap();
579 assert!(
580 calls
581 .iter()
582 .any(|c| c.first().map(String::as_str) == Some("docker")
583 && c.contains(&"rm".to_string()))
584 );
585 }
586
587 #[test]
588 fn namespace_unavailable_errors_by_default() {
589 let by_index = vec![ns_cfg(OnUnavailable::Error)];
590 let err = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
591 .unwrap_err();
592 assert!(err.contains("namespace"));
593 }
594
595 #[test]
596 fn namespace_unavailable_warns_and_falls_back() {
597 let by_index = vec![ns_cfg(OnUnavailable::Warn)];
598 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
599 .unwrap()
600 .unwrap();
601 let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
602 assert_eq!(cmd.as_std().get_program(), "sh");
603 }
604
605 #[test]
606 fn set_stage_switches_current_config() {
607 let by_index = vec![
608 ToolSandboxConfig::default(), container_cfg("ubuntu:24.04", true, OnUnavailable::Warn), ];
611 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
613 .unwrap()
614 .unwrap();
615 assert_eq!(m.current.lock().unwrap().kind, SandboxKind::None);
616 m.set_stage(1);
617 assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
618 m.set_stage(99); assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
620 }
621
622 #[test]
623 fn build_command_container_uses_docker_exec() {
624 let log = std::sync::Mutex::new(Vec::new());
625 let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
626 let m = SandboxManager::build_with(
627 "r",
628 by_index,
629 "/w",
630 0,
631 Some("docker".to_string()),
632 true,
633 &recording_runner(&log),
634 )
635 .unwrap()
636 .unwrap();
637 let cmd = m.build_command("sh", "-c", "ls", Path::new("/w"));
638 assert_eq!(cmd.as_std().get_program(), "docker");
639 let args: Vec<_> = cmd
640 .as_std()
641 .get_args()
642 .map(|a| a.to_string_lossy().to_string())
643 .collect();
644 assert_eq!(args[0], "exec");
645 assert!(args.contains(&"ls".to_string()));
646 }
647
648 #[test]
649 fn build_command_namespace_uses_unshare_when_supported() {
650 let by_index = vec![ns_cfg(OnUnavailable::Error)];
653 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
654 .unwrap()
655 .unwrap();
656 let cmd = m.build_command("sh", "-c", "whoami", Path::new("/w"));
657 assert_eq!(cmd.as_std().get_program(), "unshare");
658 }
659
660 #[test]
661 fn destroy_all_removes_every_container() {
662 let log = std::sync::Mutex::new(Vec::new());
663 let by_index = vec![
664 container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
665 container_cfg("node:22-slim", true, OnUnavailable::Error),
666 ];
667 let m = SandboxManager::build_with(
668 "r",
669 by_index,
670 "/w",
671 0,
672 Some("docker".to_string()),
673 true,
674 &recording_runner(&log),
675 )
676 .unwrap()
677 .unwrap();
678 let rm_log = std::sync::Mutex::new(Vec::new());
679 m.destroy_with(&recording_runner(&rm_log));
680 let calls = rm_log.lock().unwrap();
681 assert_eq!(calls.len(), 2);
682 assert!(calls.iter().all(|c| c.contains(&"rm".to_string())));
683 }
684
685 #[test]
686 fn sanitize_replaces_unsafe_chars() {
687 assert_eq!(sanitize("abc-123_x.y"), "abc-123_x-y");
688 assert_eq!(sanitize("a/b:c d"), "a-b-c-d");
689 }
690
691 #[test]
692 fn destroy_all_with_no_containers_is_a_noop() {
693 let m = SandboxManager::build_with(
696 "r",
697 vec![ns_cfg(OnUnavailable::Warn)],
698 "/w",
699 0,
700 None,
701 false,
702 &ok_runner,
703 )
704 .unwrap()
705 .unwrap();
706 m.destroy_all();
707 assert!(m.containers.is_empty());
708 }
709
710 #[cfg(unix)]
714 #[test]
715 fn real_run_success_nonzero_and_spawn_error_unix() {
716 assert!(real_run(&["true".to_string()]).is_ok());
717 let err = real_run(&[
718 "sh".to_string(),
719 "-c".to_string(),
720 "echo boom 1>&2; exit 1".to_string(),
721 ])
722 .unwrap_err();
723 assert!(err.contains("boom"), "stderr should surface: {err}");
724 assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
725 }
726
727 #[cfg(windows)]
728 #[test]
729 fn real_run_success_nonzero_and_spawn_error_windows() {
730 assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 0".to_string()]).is_ok());
731 assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 1".to_string()]).is_err());
732 assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
733 }
734
735 }