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