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 = leviath_sys::child_command_async(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 let output = cmd.output().map_err(|e| e.to_string())?;
270 if output.status.success() {
271 Ok(())
272 } else {
273 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
274 }
275}
276
277impl ShellExecutor for SandboxManager {
278 fn build_command(
279 &self,
280 shell: &str,
281 flag: &str,
282 command: &str,
283 workdir: &Path,
284 ) -> TokioCommand {
285 let cfg = self
286 .current
287 .lock()
288 .unwrap_or_else(PoisonError::into_inner)
289 .clone();
290 match cfg.kind {
291 SandboxKind::None => host_command(shell, flag, command, workdir),
292 SandboxKind::Namespace => {
293 if self.namespace_ok {
294 let argv = leviath_sys::namespace_argv(shell, flag, command, cfg.network);
295 let mut c = leviath_sys::child_command_async(&argv[0]);
296 c.args(&argv[1..]).current_dir(workdir);
297 c
298 } else {
299 host_command(shell, flag, command, workdir)
302 }
303 }
304 SandboxKind::Container => match self.containers.get(&signature(&cfg)) {
305 Some(lc) => {
306 let wd = workdir.to_string_lossy();
307 let argv = leviath_sys::container_exec_argv(
310 &lc.engine,
311 &lc.name,
312 &wd,
313 CONTAINER_SHELL,
314 CONTAINER_SHELL_FLAG,
315 command,
316 );
317 let mut c = leviath_sys::child_command_async(&argv[0]);
318 c.args(&argv[1..]);
319 c
320 }
321 None => host_command(shell, flag, command, workdir),
323 },
324 }
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 fn container_cfg(
333 image: &str,
334 network: bool,
335 on_unavailable: OnUnavailable,
336 ) -> ToolSandboxConfig {
337 ToolSandboxConfig {
338 kind: SandboxKind::Container,
339 image: Some(image.to_string()),
340 network,
341 on_unavailable,
342 ..Default::default()
343 }
344 }
345
346 fn ns_cfg(on_unavailable: OnUnavailable) -> ToolSandboxConfig {
347 ToolSandboxConfig {
348 kind: SandboxKind::Namespace,
349 on_unavailable,
350 ..Default::default()
351 }
352 }
353
354 fn recording_runner(
356 log: &std::sync::Mutex<Vec<Vec<String>>>,
357 ) -> impl Fn(&[String]) -> Result<(), String> + '_ {
358 move |argv| {
359 log.lock().unwrap().push(argv.to_vec());
360 Ok(())
361 }
362 }
363
364 fn ok_runner(_argv: &[String]) -> Result<(), String> {
367 Ok(())
368 }
369
370 #[test]
371 fn all_host_yields_no_manager() {
372 let by_index = vec![ToolSandboxConfig::default(), ToolSandboxConfig::default()];
373 let m = SandboxManager::build_with("run1", by_index, "/work", 0, None, true, &ok_runner)
374 .unwrap();
375 assert!(m.is_none());
376 }
377
378 #[test]
379 fn config_engine_overrides_autodetect() {
380 let log = std::sync::Mutex::new(Vec::new());
383 let cfg = ToolSandboxConfig {
384 kind: SandboxKind::Container,
385 image: Some("alpine".to_string()),
386 engine: Some("nerdctl".to_string()),
387 on_unavailable: OnUnavailable::Error,
388 ..Default::default()
389 };
390 let m = SandboxManager::build_with(
391 "r",
392 vec![cfg],
393 "/w",
394 0,
395 None, true,
397 &recording_runner(&log),
398 )
399 .unwrap()
400 .unwrap();
401 assert_eq!(log.lock().unwrap()[0][0], "nerdctl");
402 assert_eq!(m.containers.values().next().unwrap().engine, "nerdctl");
403 }
404
405 #[test]
406 fn dedups_identical_container_configs() {
407 let log = std::sync::Mutex::new(Vec::new());
408 let cfg = container_cfg("ubuntu:24.04", true, OnUnavailable::Error);
409 let by_index = vec![cfg.clone(), cfg.clone(), cfg];
410 let m = SandboxManager::build_with(
411 "run-1",
412 by_index,
413 "/work",
414 0,
415 Some("docker".to_string()),
416 true,
417 &recording_runner(&log),
418 )
419 .unwrap()
420 .unwrap();
421 assert_eq!(log.lock().unwrap().len(), 1);
423 assert_eq!(m.containers.len(), 1);
424 let argv = &log.lock().unwrap()[0];
425 assert_eq!(argv[0], "docker");
426 assert!(argv.contains(&"ubuntu:24.04".to_string()));
427 }
428
429 #[test]
430 fn distinct_container_configs_each_get_a_container() {
431 let by_index = vec![
434 container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
435 container_cfg("node:22-slim", false, OnUnavailable::Error),
436 ];
437 let m = SandboxManager::build_with(
438 "r",
439 by_index,
440 "/w",
441 0,
442 Some("docker".to_string()),
443 true,
444 &ok_runner,
445 )
446 .unwrap()
447 .unwrap();
448 assert_eq!(m.containers.len(), 2);
449 }
450
451 #[test]
452 fn missing_engine_errors_by_default() {
453 let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
454 let err =
455 SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner).unwrap_err();
456 assert!(err.contains("no container engine"));
457 }
458
459 #[test]
460 fn missing_engine_warns_and_falls_back() {
461 let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Warn)];
462 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
463 .unwrap()
464 .unwrap();
465 assert!(m.containers.is_empty());
467 let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
468 assert_eq!(cmd.as_std().get_program(), "sh");
469 }
470
471 #[test]
472 fn container_without_image_errors() {
473 let cfg = ToolSandboxConfig {
474 kind: SandboxKind::Container,
475 image: None,
476 on_unavailable: OnUnavailable::Error,
477 ..Default::default()
478 };
479 let err = SandboxManager::build_with(
480 "r",
481 vec![cfg],
482 "/w",
483 0,
484 Some("docker".to_string()),
485 true,
486 &ok_runner,
487 )
488 .unwrap_err();
489 assert!(err.contains("requires an `image`"), "got: {err}");
490 }
491
492 #[test]
493 fn container_without_image_warns_and_skips() {
494 let cfg = ToolSandboxConfig {
497 kind: SandboxKind::Container,
498 image: None,
499 on_unavailable: OnUnavailable::Warn,
500 ..Default::default()
501 };
502 let m = SandboxManager::build_with(
503 "r",
504 vec![cfg],
505 "/w",
506 0,
507 Some("docker".to_string()),
508 true,
509 &ok_runner,
510 )
511 .unwrap()
512 .unwrap();
513 assert!(m.containers.is_empty());
514 }
515
516 #[test]
517 fn build_command_host_arm_when_current_stage_is_none() {
518 let by_index = vec![
521 ToolSandboxConfig::default(),
522 container_cfg("ubuntu:24.04", true, OnUnavailable::Warn),
523 ];
524 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
525 .unwrap()
526 .unwrap();
527 let cmd = m.build_command("zsh", "-c", "echo hi", Path::new("/w"));
528 assert_eq!(cmd.as_std().get_program(), "zsh");
529 }
530
531 #[test]
532 fn container_start_failure_errors() {
533 let by_index = vec![container_cfg("bad:image", true, OnUnavailable::Error)];
534 let err = SandboxManager::build_with(
535 "r",
536 by_index,
537 "/w",
538 0,
539 Some("docker".to_string()),
540 true,
541 &|_| Err("no such image".to_string()),
542 )
543 .unwrap_err();
544 assert!(err.contains("failed to start container"));
545 assert!(err.contains("no such image"));
546 }
547
548 #[test]
549 fn error_teardown_removes_already_created_containers() {
550 let log = std::sync::Mutex::new(Vec::<Vec<String>>::new());
553 let run = |argv: &[String]| -> Result<(), String> {
554 log.lock().unwrap().push(argv.to_vec());
555 if argv.contains(&"node:22-slim".to_string()) {
557 Err("boom".to_string())
558 } else {
559 Ok(())
560 }
561 };
562 let by_index = vec![
563 container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
564 container_cfg("node:22-slim", true, OnUnavailable::Error),
565 ];
566 let err = SandboxManager::build_with(
567 "r",
568 by_index,
569 "/w",
570 0,
571 Some("docker".to_string()),
572 true,
573 &run,
574 )
575 .unwrap_err();
576 assert!(err.contains("failed to start container"));
577 let calls = log.lock().unwrap();
578 assert!(
579 calls
580 .iter()
581 .any(|c| c.first().map(String::as_str) == Some("docker")
582 && c.contains(&"rm".to_string()))
583 );
584 }
585
586 #[test]
587 fn namespace_unavailable_errors_by_default() {
588 let by_index = vec![ns_cfg(OnUnavailable::Error)];
589 let err = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
590 .unwrap_err();
591 assert!(err.contains("namespace"));
592 }
593
594 #[test]
595 fn namespace_unavailable_warns_and_falls_back() {
596 let by_index = vec![ns_cfg(OnUnavailable::Warn)];
597 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, false, &ok_runner)
598 .unwrap()
599 .unwrap();
600 let cmd = m.build_command("sh", "-c", "echo hi", Path::new("/w"));
601 assert_eq!(cmd.as_std().get_program(), "sh");
602 }
603
604 #[test]
605 fn set_stage_switches_current_config() {
606 let by_index = vec![
607 ToolSandboxConfig::default(), container_cfg("ubuntu:24.04", true, OnUnavailable::Warn), ];
610 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
612 .unwrap()
613 .unwrap();
614 assert_eq!(m.current.lock().unwrap().kind, SandboxKind::None);
615 m.set_stage(1);
616 assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
617 m.set_stage(99); assert_eq!(m.current.lock().unwrap().kind, SandboxKind::Container);
619 }
620
621 #[test]
622 fn build_command_container_uses_docker_exec() {
623 let log = std::sync::Mutex::new(Vec::new());
624 let by_index = vec![container_cfg("ubuntu:24.04", true, OnUnavailable::Error)];
625 let m = SandboxManager::build_with(
626 "r",
627 by_index,
628 "/w",
629 0,
630 Some("docker".to_string()),
631 true,
632 &recording_runner(&log),
633 )
634 .unwrap()
635 .unwrap();
636 let cmd = m.build_command("sh", "-c", "ls", Path::new("/w"));
637 assert_eq!(cmd.as_std().get_program(), "docker");
638 let args: Vec<_> = cmd
639 .as_std()
640 .get_args()
641 .map(|a| a.to_string_lossy().to_string())
642 .collect();
643 assert_eq!(args[0], "exec");
644 assert!(args.contains(&"ls".to_string()));
645 }
646
647 #[test]
648 fn build_command_namespace_uses_unshare_when_supported() {
649 let by_index = vec![ns_cfg(OnUnavailable::Error)];
652 let m = SandboxManager::build_with("r", by_index, "/w", 0, None, true, &ok_runner)
653 .unwrap()
654 .unwrap();
655 let cmd = m.build_command("sh", "-c", "whoami", Path::new("/w"));
656 assert_eq!(cmd.as_std().get_program(), "unshare");
657 }
658
659 #[test]
660 fn destroy_all_removes_every_container() {
661 let log = std::sync::Mutex::new(Vec::new());
662 let by_index = vec![
663 container_cfg("ubuntu:24.04", true, OnUnavailable::Error),
664 container_cfg("node:22-slim", true, OnUnavailable::Error),
665 ];
666 let m = SandboxManager::build_with(
667 "r",
668 by_index,
669 "/w",
670 0,
671 Some("docker".to_string()),
672 true,
673 &recording_runner(&log),
674 )
675 .unwrap()
676 .unwrap();
677 let rm_log = std::sync::Mutex::new(Vec::new());
678 m.destroy_with(&recording_runner(&rm_log));
679 let calls = rm_log.lock().unwrap();
680 assert_eq!(calls.len(), 2);
681 assert!(calls.iter().all(|c| c.contains(&"rm".to_string())));
682 }
683
684 #[test]
685 fn sanitize_replaces_unsafe_chars() {
686 assert_eq!(sanitize("abc-123_x.y"), "abc-123_x-y");
687 assert_eq!(sanitize("a/b:c d"), "a-b-c-d");
688 }
689
690 #[test]
691 fn destroy_all_with_no_containers_is_a_noop() {
692 let m = SandboxManager::build_with(
695 "r",
696 vec![ns_cfg(OnUnavailable::Warn)],
697 "/w",
698 0,
699 None,
700 false,
701 &ok_runner,
702 )
703 .unwrap()
704 .unwrap();
705 m.destroy_all();
706 assert!(m.containers.is_empty());
707 }
708
709 #[cfg(unix)]
713 #[test]
714 fn real_run_success_nonzero_and_spawn_error_unix() {
715 assert!(real_run(&["true".to_string()]).is_ok());
716 let err = real_run(&[
717 "sh".to_string(),
718 "-c".to_string(),
719 "echo boom 1>&2; exit 1".to_string(),
720 ])
721 .unwrap_err();
722 assert!(err.contains("boom"), "stderr should surface: {err}");
723 assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
724 }
725
726 #[cfg(windows)]
727 #[test]
728 fn real_run_success_nonzero_and_spawn_error_windows() {
729 assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 0".to_string()]).is_ok());
730 assert!(real_run(&["cmd".to_string(), "/C".to_string(), "exit 1".to_string()]).is_err());
731 assert!(real_run(&["leviath-no-such-binary-xyz".to_string()]).is_err());
732 }
733
734 }