bob/sandbox.rs
1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17//! Sandbox creation and management.
18//!
19//! This module provides the [`Sandbox`] struct for creating isolated build
20//! environments using chroot. The implementation varies by platform but
21//! presents a uniform interface.
22//!
23//! # Platform Support
24//!
25//! | Platform | Implementation |
26//! |----------|---------------|
27//! | Linux | Mount namespaces + chroot |
28//! | macOS | bindfs/devfs + chroot |
29//! | NetBSD | Native mounts + chroot |
30//! | illumos/Solaris | Platform mounts + chroot |
31//!
32//! # Sandbox Lifecycle
33//!
34//! 1. **Create**: Set up the sandbox directory and perform configured actions
35//! 2. **Execute**: Run build scripts inside the sandbox via chroot
36//! 3. **Destroy**: Reverse actions and clean up the sandbox directory
37//!
38//! # Configuration
39//!
40//! Sandboxes are configured in the `sandboxes` section of the Lua config file.
41//! See the [`action`](crate::action) module for available actions.
42//!
43//! ```lua
44//! sandboxes = {
45//! basedir = "/data/chroot",
46//! actions = {
47//! { action = "mount", fs = "proc", dir = "/proc" },
48//! { action = "mount", fs = "dev", dir = "/dev" },
49//! { action = "mount", fs = "bind", dir = "/usr/bin", opts = "ro" },
50//! { action = "copy", dir = "/etc" },
51//! },
52//! }
53//! ```
54//!
55//! # Multiple Sandboxes
56//!
57//! Multiple sandboxes can be created for parallel builds. Each sandbox is
58//! identified by an integer ID (0, 1, 2, ...) and created as a subdirectory
59//! of `basedir`.
60//!
61//! With `build_threads = 4`, sandboxes are created at:
62//! - `/data/chroot/0`
63//! - `/data/chroot/1`
64//! - `/data/chroot/2`
65//! - `/data/chroot/3`
66#[cfg(target_os = "linux")]
67mod sandbox_linux;
68#[cfg(target_os = "macos")]
69mod sandbox_macos;
70#[cfg(target_os = "netbsd")]
71mod sandbox_netbsd;
72#[cfg(any(target_os = "illumos", target_os = "solaris"))]
73mod sandbox_sunos;
74
75use crate::action::{ActionType, FSType};
76use crate::config::Config;
77use anyhow::{Result, bail};
78use rayon::prelude::*;
79use std::fs;
80use std::io::Write;
81use std::os::unix::process::CommandExt;
82use std::path::{Path, PathBuf};
83use std::process::{Child, Command, ExitStatus, Output, Stdio};
84use std::sync::atomic::{AtomicBool, Ordering};
85use std::sync::mpsc::RecvTimeoutError;
86use std::time::{Duration, Instant};
87use tracing::{debug, info, info_span, warn};
88
89/// How often to check the shutdown flag while waiting for something else.
90/// This determines the maximum latency between Ctrl+C and response.
91/// 100ms provides responsive feel without excessive polling overhead.
92pub(crate) const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
93
94/// Maximum number of retries when killing processes in a sandbox.
95/// Uses exponential backoff: 64ms, 128ms, 256ms, 512ms, 1024ms = ~2s total.
96pub(crate) const KILL_PROCESSES_MAX_RETRIES: u32 = 5;
97pub(crate) const KILL_PROCESSES_INITIAL_DELAY_MS: u64 = 64;
98
99/*
100 * Poll for child process exit while checking a shutdown flag. If shutdown
101 * is requested, kill the child and return an error.
102 */
103pub fn wait_with_shutdown(
104 child: &mut Child,
105 shutdown: &AtomicBool,
106) -> Result<ExitStatus> {
107 loop {
108 if shutdown.load(Ordering::SeqCst) {
109 let _ = child.kill();
110 let _ = child.wait();
111 bail!("Interrupted by shutdown");
112 }
113 match child.try_wait()? {
114 Some(status) => return Ok(status),
115 None => std::thread::sleep(SHUTDOWN_POLL_INTERVAL),
116 }
117 }
118}
119
120/*
121 * Wait for child process exit while checking a shutdown flag, returning
122 * the full output (stdout/stderr). If shutdown is requested, kill the
123 * child and return an error.
124 *
125 * Uses a single helper thread that calls wait_with_output() (which handles
126 * pipe draining correctly via internal threads). The main thread polls a
127 * channel for results while checking the shutdown flag. This avoids the
128 * polling latency of try_wait() while still allowing shutdown interruption.
129 */
130pub fn wait_output_with_shutdown(
131 child: Child,
132 shutdown: &AtomicBool,
133) -> Result<Output> {
134 let pid = child.id();
135 let (tx, rx) = std::sync::mpsc::channel();
136
137 std::thread::spawn(move || {
138 let _ = tx.send(child.wait_with_output());
139 });
140
141 loop {
142 if shutdown.load(Ordering::SeqCst) {
143 unsafe {
144 libc::kill(pid as i32, libc::SIGKILL);
145 }
146 let _ = rx.recv();
147 bail!("Interrupted by shutdown");
148 }
149 match rx.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
150 Ok(result) => return result.map_err(Into::into),
151 Err(RecvTimeoutError::Timeout) => continue,
152 Err(RecvTimeoutError::Disconnected) => {
153 bail!("wait thread disconnected unexpectedly");
154 }
155 }
156 }
157}
158
159/// Build sandbox manager.
160#[derive(Clone, Debug, Default)]
161pub struct Sandbox {
162 config: Config,
163}
164
165impl Sandbox {
166 /**
167 * Create a new [`Sandbox`] instance. This is used even if sandboxes have
168 * not been enabled, as it provides a consistent interface to run commands
169 * through using [`execute`]. If sandboxes are enabled then commands are
170 * executed via `chroot(8)`, otherwise they are executed directly.
171 *
172 * [`execute`]: Sandbox::execute
173 */
174 pub fn new(config: &Config) -> Sandbox {
175 Sandbox { config: config.clone() }
176 }
177
178 /// Return whether sandboxes have been enabled.
179 ///
180 /// This is based on whether a valid `sandboxes` section has been
181 /// specified in the config file.
182 pub fn enabled(&self) -> bool {
183 self.config.sandboxes().is_some()
184 }
185
186 /**
187 * Return full path to a sandbox by id.
188 */
189 pub fn path(&self, id: usize) -> PathBuf {
190 let sandbox = &self.config.sandboxes().as_ref().unwrap();
191 let mut p = PathBuf::from(&sandbox.basedir);
192 p.push(id.to_string());
193 p
194 }
195
196 /**
197 * Create a Command that runs in the sandbox (via chroot) if enabled,
198 * or directly if sandboxes are disabled.
199 */
200 pub fn command(&self, id: usize, cmd: &Path) -> Command {
201 if self.enabled() {
202 let mut c = Command::new("/usr/sbin/chroot");
203 c.arg(self.path(id)).arg(cmd);
204 c
205 } else {
206 Command::new(cmd)
207 }
208 }
209
210 /**
211 * Kill all processes in a sandbox by id.
212 * This is used for graceful shutdown on Ctrl+C.
213 */
214 pub fn kill_processes_by_id(&self, id: usize) {
215 if !self.enabled() {
216 return;
217 }
218 let sandbox = self.path(id);
219 if sandbox.exists() {
220 let span = info_span!("kill_processes", sandbox_id = id);
221 let _guard = span.enter();
222 self.kill_processes(&sandbox);
223 }
224 }
225
226 /**
227 * Return full path to a specified mount point in a sandbox.
228 * The returned path is guaranteed to be within the sandbox directory.
229 */
230 fn mountpath(&self, id: usize, mnt: &PathBuf) -> PathBuf {
231 /*
232 * Note that .push() on a PathBuf will replace the path if
233 * it is absolute, so we need to trim any leading "/".
234 */
235 let mut p = self.path(id);
236 match mnt.strip_prefix("/") {
237 Ok(s) => p.push(s),
238 Err(_) => p.push(mnt),
239 };
240 p
241 }
242
243 /**
244 * Verify that a path is safely contained within the sandbox.
245 * This prevents path traversal attacks via ".." or symlinks.
246 * Returns error if the path escapes the sandbox boundary.
247 */
248 fn verify_path_in_sandbox(&self, id: usize, path: &Path) -> Result<()> {
249 let sandbox_root = self.path(id);
250 // Canonicalize both paths to resolve any ".." or symlinks
251 // Note: canonicalize requires the path to exist, so we check
252 // the parent directory for paths that don't exist yet
253 let canonical_sandbox =
254 sandbox_root.canonicalize().unwrap_or(sandbox_root.clone());
255
256 // For the target path, try to canonicalize what exists
257 let canonical_path = if path.exists() {
258 path.canonicalize()?
259 } else {
260 // Path doesn't exist yet, check its parent
261 if let Some(parent) = path.parent() {
262 if parent.exists() {
263 let canonical_parent = parent.canonicalize()?;
264 if !canonical_parent.starts_with(&canonical_sandbox) {
265 bail!(
266 "Path escapes sandbox: {} is not within {}",
267 path.display(),
268 sandbox_root.display()
269 );
270 }
271 }
272 }
273 return Ok(());
274 };
275
276 if !canonical_path.starts_with(&canonical_sandbox) {
277 bail!(
278 "Path escapes sandbox: {} resolves to {} which is not within {}",
279 path.display(),
280 canonical_path.display(),
281 canonical_sandbox.display()
282 );
283 }
284 Ok(())
285 }
286
287 /*
288 * Functions to create/destroy lock directory inside a sandbox to
289 * indicate that it has successfully been created. An empty directory
290 * is used as it provides a handy way to guarantee(?) atomicity.
291 */
292 fn lockpath(&self, id: usize) -> PathBuf {
293 let mut p = self.path(id);
294 p.push(".created");
295 p
296 }
297 fn create_lock(&self, id: usize) -> Result<()> {
298 Ok(fs::create_dir(self.lockpath(id))?)
299 }
300 fn delete_lock(&self, id: usize) -> Result<()> {
301 let lockdir = self.lockpath(id);
302 if lockdir.exists() {
303 fs::remove_dir(self.lockpath(id))?
304 }
305 Ok(())
306 }
307
308 /**
309 * Create a single sandbox by id.
310 * If the sandbox already exists and is valid (has lock), this is a no-op.
311 */
312 pub fn create(&self, id: usize) -> Result<()> {
313 let sandbox = self.path(id);
314 if sandbox.exists() {
315 if self.lockpath(id).exists() {
316 // Sandbox already exists and is valid
317 return Ok(());
318 }
319 bail!(
320 "Sandbox exists but is incomplete: {}.\n\
321 Run 'bob util sandbox destroy' first.",
322 sandbox.display()
323 );
324 }
325 fs::create_dir_all(&sandbox)?;
326 self.perform_actions(id)?;
327 self.create_lock(id)?;
328 Ok(())
329 }
330
331 /**
332 * Execute a script file with supplied environment variables and optional
333 * stdin data.
334 *
335 * If protected is true, the process is placed in its own process group
336 * to isolate it from terminal signals (Ctrl+C). Use this for cleanup
337 * scripts that must complete even during shutdown.
338 */
339 pub fn execute(
340 &self,
341 id: usize,
342 script: &Path,
343 envs: Vec<(String, String)>,
344 stdin_data: Option<&str>,
345 protected: bool,
346 ) -> Result<Child> {
347 use std::io::Write;
348
349 let mut cmd = self.command(id, script);
350 cmd.current_dir("/");
351
352 for (key, val) in envs {
353 cmd.env(key, val);
354 }
355
356 if stdin_data.is_some() {
357 cmd.stdin(Stdio::piped());
358 }
359
360 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
361
362 if protected {
363 cmd.process_group(0);
364 }
365
366 let mut child = cmd.spawn()?;
367
368 if let Some(data) = stdin_data {
369 if let Some(mut stdin) = child.stdin.take() {
370 stdin.write_all(data.as_bytes())?;
371 }
372 }
373
374 Ok(child)
375 }
376
377 /**
378 * Execute inline script content via /bin/sh.
379 */
380 pub fn execute_script(
381 &self,
382 id: usize,
383 content: &str,
384 envs: Vec<(String, String)>,
385 ) -> Result<Child> {
386 use std::io::Write;
387
388 let mut cmd = self.command(id, Path::new("/bin/sh"));
389 cmd.current_dir("/").arg("-s");
390
391 for (key, val) in envs {
392 cmd.env(key, val);
393 }
394
395 let mut child = cmd
396 .stdin(Stdio::piped())
397 .stdout(Stdio::piped())
398 .stderr(Stdio::piped())
399 .spawn()?;
400
401 if let Some(mut stdin) = child.stdin.take() {
402 stdin.write_all(content.as_bytes())?;
403 }
404
405 Ok(child)
406 }
407
408 /**
409 * Execute a command directly without shell interpretation.
410 */
411 pub fn execute_command<I, S>(
412 &self,
413 id: usize,
414 cmd: &Path,
415 args: I,
416 envs: Vec<(String, String)>,
417 ) -> Result<Child>
418 where
419 I: IntoIterator<Item = S>,
420 S: AsRef<std::ffi::OsStr>,
421 {
422 let mut command = self.command(id, cmd);
423 command.args(args);
424 for (key, val) in envs {
425 command.env(key, val);
426 }
427 command
428 .stdin(Stdio::null())
429 .stdout(Stdio::piped())
430 .stderr(Stdio::piped())
431 .spawn()
432 .map_err(Into::into)
433 }
434
435 /**
436 * Run the pre-build script if configured.
437 * Returns Ok(true) if script ran successfully or wasn't configured,
438 * Ok(false) if script failed.
439 */
440 pub fn run_pre_build(
441 &self,
442 id: usize,
443 config: &Config,
444 envs: Vec<(String, String)>,
445 ) -> Result<bool> {
446 if let Some(script) = config.script("pre-build") {
447 info!(script = %script.display(), "Running pre-build script");
448 let child = self.execute(id, script, envs, None, false)?;
449 let output = child.wait_with_output()?;
450 if output.status.success() {
451 info!(script = %script.display(), result = "success", "Finished running pre-build script");
452 } else {
453 let stderr = String::from_utf8_lossy(&output.stderr);
454 let stdout = String::from_utf8_lossy(&output.stdout);
455 warn!(
456 script = %script.display(),
457 result = "failed",
458 stdout = %stdout.trim(),
459 stderr = %stderr.trim(),
460 "Finished running pre-build script"
461 );
462 return Ok(false);
463 }
464 }
465 Ok(true)
466 }
467
468 /**
469 * Run the post-build script if configured.
470 * Returns Ok(true) if script ran successfully or wasn't configured,
471 * Ok(false) if script failed.
472 *
473 * Post-build scripts run with signal protection (process_group(0)) to
474 * ensure cleanup completes even during shutdown from Ctrl+C.
475 */
476 pub fn run_post_build(
477 &self,
478 id: usize,
479 config: &Config,
480 envs: Vec<(String, String)>,
481 ) -> Result<bool> {
482 if let Some(script) = config.script("post-build") {
483 info!(script = %script.display(), "Running post-build script");
484 // Use protected=true to ensure cleanup completes during shutdown
485 let child = self.execute(id, script, envs, None, true)?;
486 let output = child.wait_with_output()?;
487 if output.status.success() {
488 info!(script = %script.display(), result = "success", "Finished running post-build script");
489 } else {
490 let stderr = String::from_utf8_lossy(&output.stderr);
491 let stdout = String::from_utf8_lossy(&output.stdout);
492 warn!(
493 script = %script.display(),
494 result = "failed",
495 stdout = %stdout.trim(),
496 stderr = %stderr.trim(),
497 "Finished running post-build script"
498 );
499 return Ok(false);
500 }
501 }
502 Ok(true)
503 }
504
505 /**
506 * Destroy a single sandbox by id.
507 */
508 pub fn destroy(&self, id: usize) -> anyhow::Result<()> {
509 let sandbox = self.path(id);
510 if !sandbox.exists() {
511 return Ok(());
512 }
513 self.kill_processes(&sandbox);
514 self.delete_lock(id)?;
515 self.reverse_actions(id)?;
516 /*
517 * After unmounting, try to remove the sandbox directory. Use
518 * remove_empty_hierarchy which only removes empty directories.
519 * If any files remain, it will fail - this is intentional as it
520 * likely means a mount is still active or cleanup actions are
521 * missing from the config.
522 */
523 if sandbox.exists() {
524 self.remove_empty_hierarchy(&sandbox)?;
525 }
526 Ok(())
527 }
528
529 /**
530 * Create all sandboxes in parallel, rolling back on failure.
531 */
532 pub fn create_all(&self, count: usize) -> Result<()> {
533 if count == 1 {
534 print!("Creating sandbox...");
535 } else {
536 print!("Creating {} sandboxes...", count);
537 }
538 let _ = std::io::stdout().flush();
539 let start = Instant::now();
540 let results: Vec<(usize, Result<()>)> =
541 (0..count).into_par_iter().map(|i| (i, self.create(i))).collect();
542 let mut first_error: Option<anyhow::Error> = None;
543 for (i, result) in &results {
544 if let Err(e) = result {
545 if first_error.is_none() {
546 first_error = Some(anyhow::anyhow!("sandbox {}: {}", i, e));
547 }
548 }
549 }
550 if let Some(e) = first_error {
551 println!();
552 for (i, _) in &results {
553 if let Err(destroy_err) = self.destroy(*i) {
554 eprintln!(
555 "Warning: failed to destroy sandbox {}: {}",
556 i, destroy_err
557 );
558 }
559 }
560 return Err(e);
561 }
562 println!(" done ({:.1}s)", start.elapsed().as_secs_f32());
563 Ok(())
564 }
565
566 /**
567 * Destroy all sandboxes in parallel. Continue on errors to ensure all
568 * sandboxes are attempted, printing each error as it occurs.
569 */
570 pub fn destroy_all(&self, count: usize) -> Result<()> {
571 let existing = self.count_existing(count);
572 if existing == 0 {
573 return Ok(());
574 }
575 if existing == 1 {
576 print!("Destroying sandbox...");
577 } else {
578 print!("Destroying {} sandboxes...", existing);
579 }
580 let _ = std::io::stdout().flush();
581 let start = Instant::now();
582 let results: Vec<(usize, Result<()>)> =
583 (0..count).into_par_iter().map(|i| (i, self.destroy(i))).collect();
584 let mut failed = 0;
585 for (i, result) in results {
586 if let Err(e) = result {
587 if failed == 0 {
588 println!();
589 }
590 eprintln!("sandbox {}: {}", i, e);
591 failed += 1;
592 }
593 }
594 if failed == 0 {
595 println!(" done ({:.1}s)", start.elapsed().as_secs_f32());
596 Ok(())
597 } else {
598 Err(anyhow::anyhow!(
599 "Failed to destroy {} sandbox{}.\n\
600 Remove unexpected files, then run 'bob util sandbox destroy'.",
601 failed,
602 if failed == 1 { "" } else { "es" }
603 ))
604 }
605 }
606
607 /**
608 * List all sandboxes.
609 */
610 pub fn list_all(&self, count: usize) {
611 for i in 0..count {
612 let sandbox = self.path(i);
613 if sandbox.exists() {
614 if self.lockpath(i).exists() {
615 println!("{}", sandbox.display())
616 } else {
617 println!("{} (incomplete)", sandbox.display())
618 }
619 }
620 }
621 }
622
623 /**
624 * Count existing sandboxes (complete or incomplete).
625 */
626 pub fn count_existing(&self, count: usize) -> usize {
627 (0..count).filter(|i| self.path(*i).exists()).count()
628 }
629
630 /*
631 * Remove any empty directories from a mount point up to the root of the
632 * sandbox.
633 */
634 fn remove_empty_dirs(&self, id: usize, mountpoint: &Path) {
635 for p in mountpoint.ancestors() {
636 /*
637 * Sanity check we are within the chroot.
638 */
639 if !p.starts_with(self.path(id)) {
640 break;
641 }
642 /*
643 * Go up to next parent if this path does not exist.
644 */
645 if !p.exists() {
646 continue;
647 }
648 /*
649 * Otherwise attempt to remove. If this fails then skip any
650 * parent directories.
651 */
652 if fs::remove_dir(p).is_err() {
653 break;
654 }
655 }
656 }
657
658 /// Remove a directory hierarchy only if it contains nothing but empty
659 /// directories and symlinks. Walks depth-first. Removes symlinks and
660 /// empty directories. Fails if any regular files, device nodes, pipes,
661 /// sockets, or other non-removable entries are encountered.
662 #[allow(clippy::only_used_in_recursion)]
663 fn remove_empty_hierarchy(&self, path: &Path) -> Result<()> {
664 // Use symlink_metadata to not follow symlinks
665 let meta = fs::symlink_metadata(path)?;
666
667 if meta.is_symlink() {
668 // Symlinks can be removed
669 fs::remove_file(path).map_err(|e| {
670 anyhow::anyhow!(
671 "Failed to remove symlink {}: {}",
672 path.display(),
673 e
674 )
675 })?;
676 return Ok(());
677 }
678
679 if !meta.is_dir() {
680 // Regular file, device node, pipe, socket, etc. - fail
681 bail!(
682 "Cannot remove sandbox: non-directory exists at {}",
683 path.display()
684 );
685 }
686
687 // It's a directory - process contents first (depth-first)
688 for entry in fs::read_dir(path)? {
689 let entry = entry?;
690 self.remove_empty_hierarchy(&entry.path())?;
691 }
692
693 // Directory should now be empty, remove it
694 fs::remove_dir(path).map_err(|e| {
695 anyhow::anyhow!(
696 "Failed to remove directory {}: {}. Directory may not be empty.",
697 path.display(),
698 e
699 )
700 })
701 }
702
703 ///
704 /// Iterate over the supplied array of actions in order. If at any
705 /// point a problem is encountered we immediately bail.
706 ///
707 fn perform_actions(&self, id: usize) -> Result<()> {
708 let Some(sandbox) = &self.config.sandboxes() else {
709 bail!(
710 "Internal error: trying to perform actions when sandboxes disabled."
711 );
712 };
713 for action in sandbox.actions.iter() {
714 action.validate()?;
715 let action_type = action.action_type()?;
716
717 // For mount/copy actions, dest defaults to src (src is more readable)
718 let src = action.src().or(action.dest());
719 let dest =
720 action.dest().or(action.src()).map(|d| self.mountpath(id, d));
721 if let Some(ref dest_path) = dest {
722 self.verify_path_in_sandbox(id, dest_path)?;
723 }
724
725 let mut opts = vec![];
726 if let Some(o) = action.opts() {
727 for opt in o.split(' ').collect::<Vec<&str>>() {
728 opts.push(opt);
729 }
730 }
731
732 let status = match action_type {
733 ActionType::Mount => {
734 let fs_type = action.fs_type()?;
735 let src = src.ok_or_else(|| {
736 anyhow::anyhow!("mount action requires src or dest")
737 })?;
738 let dest = dest.ok_or_else(|| {
739 anyhow::anyhow!("mount action requires dest")
740 })?;
741 if action.ifexists() && !src.exists() {
742 debug!(
743 sandbox = id,
744 action = "mount",
745 fs = ?fs_type,
746 src = %src.display(),
747 "Skipped (source does not exist)"
748 );
749 continue;
750 }
751 debug!(
752 sandbox = id,
753 action = "mount",
754 fs = ?fs_type,
755 src = %src.display(),
756 dest = %dest.display(),
757 "Mounting"
758 );
759 match fs_type {
760 FSType::Bind => self.mount_bindfs(src, &dest, &opts)?,
761 FSType::Dev => self.mount_devfs(src, &dest, &opts)?,
762 FSType::Fd => self.mount_fdfs(src, &dest, &opts)?,
763 FSType::Nfs => self.mount_nfs(src, &dest, &opts)?,
764 FSType::Proc => self.mount_procfs(src, &dest, &opts)?,
765 FSType::Tmp => self.mount_tmpfs(src, &dest, &opts)?,
766 }
767 }
768 ActionType::Copy => {
769 let src = src.ok_or_else(|| {
770 anyhow::anyhow!("copy action requires src or dest")
771 })?;
772 let dest = dest.ok_or_else(|| {
773 anyhow::anyhow!("copy action requires dest")
774 })?;
775 debug!(
776 sandbox = id,
777 action = "copy",
778 src = %src.display(),
779 dest = %dest.display(),
780 "Copying"
781 );
782 copy_dir::copy_dir(src, &dest)?;
783 None
784 }
785 ActionType::Cmd => {
786 if let Some(create_cmd) = action.create_cmd() {
787 debug!(
788 sandbox = id,
789 action = "cmd",
790 cmd = create_cmd,
791 chroot = action.chroot(),
792 "Running create command"
793 );
794 self.run_action_cmd(id, create_cmd, action.chroot())?
795 } else {
796 None
797 }
798 }
799 ActionType::Symlink => {
800 let src = action.src().ok_or_else(|| {
801 anyhow::anyhow!("symlink action requires src")
802 })?;
803 let dest = action.dest().ok_or_else(|| {
804 anyhow::anyhow!("symlink action requires dest")
805 })?;
806 let dest_path = self.mountpath(id, dest);
807 debug!(
808 sandbox = id,
809 action = "symlink",
810 src = %src.display(),
811 dest = %dest_path.display(),
812 "Creating symlink"
813 );
814 if let Some(parent) = dest_path.parent() {
815 if !parent.exists() {
816 fs::create_dir_all(parent)?;
817 }
818 }
819 std::os::unix::fs::symlink(src, &dest_path)?;
820 None
821 }
822 };
823 if let Some(s) = status {
824 if !s.success() {
825 bail!("Sandbox action failed");
826 }
827 }
828 }
829 Ok(())
830 }
831
832 /// Run a custom action command.
833 ///
834 /// When `chroot` is false (default), the command runs on the host system
835 /// with the sandbox root as the working directory.
836 ///
837 /// When `chroot` is true, the command runs inside the sandbox via chroot
838 /// with `/` as the working directory.
839 fn run_action_cmd(
840 &self,
841 id: usize,
842 cmd: &str,
843 chroot: bool,
844 ) -> Result<Option<std::process::ExitStatus>> {
845 if chroot {
846 let status = Command::new("/usr/sbin/chroot")
847 .arg(self.path(id))
848 .arg("/bin/sh")
849 .arg("-c")
850 .arg(cmd)
851 .process_group(0)
852 .status()?;
853
854 Ok(Some(status))
855 } else {
856 let status = Command::new("/bin/sh")
857 .arg("-c")
858 .arg(cmd)
859 .current_dir(self.path(id))
860 .process_group(0)
861 .status()?;
862
863 Ok(Some(status))
864 }
865 }
866
867 fn reverse_actions(&self, id: usize) -> anyhow::Result<()> {
868 let Some(sandbox) = &self.config.sandboxes() else {
869 bail!(
870 "Internal error: trying to reverse actions when sandboxes disabled."
871 );
872 };
873 for action in sandbox.actions.iter().rev() {
874 let action_type = action.action_type()?;
875 // dest defaults to src if not specified
876 let dest =
877 action.dest().or(action.src()).map(|d| self.mountpath(id, d));
878
879 match action_type {
880 ActionType::Cmd => {
881 if let Some(destroy_cmd) = action.destroy_cmd() {
882 debug!(
883 sandbox = id,
884 action = "cmd",
885 cmd = destroy_cmd,
886 chroot = action.chroot(),
887 "Running destroy command"
888 );
889 let status = self.run_action_cmd(
890 id,
891 destroy_cmd,
892 action.chroot(),
893 )?;
894 if let Some(s) = status {
895 if !s.success() {
896 bail!(
897 "Failed to run destroy command: exit code {:?}",
898 s.code()
899 );
900 }
901 }
902 }
903 }
904 ActionType::Copy => {
905 let Some(mntdest) = dest else { continue };
906 if !mntdest.exists() {
907 self.remove_empty_dirs(id, &mntdest);
908 continue;
909 }
910 if fs::remove_dir(&mntdest).is_ok() {
911 continue;
912 }
913 /*
914 * Use remove_dir_recursive which fails if non-empty
915 * directories remain, rather than blindly deleting.
916 * First verify the path is within the sandbox.
917 */
918 debug!(
919 sandbox = id,
920 action = "copy",
921 dest = %mntdest.display(),
922 "Removing copied directory"
923 );
924 self.verify_path_in_sandbox(id, &mntdest)?;
925 self.remove_dir_recursive(&mntdest)?;
926 self.remove_empty_dirs(id, &mntdest);
927 }
928 ActionType::Symlink => {
929 let Some(mntdest) = dest else { continue };
930 if mntdest.is_symlink() {
931 debug!(
932 sandbox = id,
933 action = "symlink",
934 dest = %mntdest.display(),
935 "Removing symlink"
936 );
937 fs::remove_file(&mntdest)?;
938 }
939 self.remove_empty_dirs(id, &mntdest);
940 }
941 ActionType::Mount => {
942 let Some(mntdest) = dest else { continue };
943 let fs_type = action.fs_type()?;
944
945 let src = action.src().or(action.dest());
946 if let Some(src) = src {
947 if action.ifexists() && !src.exists() {
948 continue;
949 }
950 }
951
952 /*
953 * If the mount point itself does not exist then do not try to
954 * unmount it, but do try to clean up any empty parent
955 * directories up to the root.
956 */
957 if !mntdest.exists() {
958 self.remove_empty_dirs(id, &mntdest);
959 continue;
960 }
961
962 /*
963 * Before trying to unmount, try just removing the directory,
964 * in case it was never mounted in the first place. Avoids
965 * errors trying to unmount a file system that isn't mounted.
966 */
967 if fs::remove_dir(&mntdest).is_ok() {
968 continue;
969 }
970
971 /*
972 * Unmount the filesystem. Check return codes and bail on
973 * failure - it is critical that all mounts are successfully
974 * unmounted before we attempt to remove the sandbox directory.
975 */
976 debug!(
977 sandbox = id,
978 action = "mount",
979 fs = ?fs_type,
980 dest = %mntdest.display(),
981 "Unmounting"
982 );
983 let status = match fs_type {
984 FSType::Bind => self.unmount_bindfs(&mntdest)?,
985 FSType::Dev => self.unmount_devfs(&mntdest)?,
986 FSType::Fd => self.unmount_fdfs(&mntdest)?,
987 FSType::Nfs => self.unmount_nfs(&mntdest)?,
988 FSType::Proc => self.unmount_procfs(&mntdest)?,
989 FSType::Tmp => self.unmount_tmpfs(&mntdest)?,
990 };
991 if let Some(s) = status {
992 if !s.success() {
993 bail!("Failed to unmount {}", mntdest.display());
994 }
995 }
996 self.remove_empty_dirs(id, &mntdest);
997 }
998 }
999 }
1000 Ok(())
1001 }
1002
1003 /**
1004 * Recursively remove a directory by walking it depth-first and removing
1005 * files and empty directories. Unlike remove_dir_all, this will fail
1006 * if it encounters a non-empty directory that cannot be removed, which
1007 * would indicate an active mount point.
1008 *
1009 * IMPORTANT: This function explicitly does NOT follow symlinks to avoid
1010 * deleting files outside the sandbox via symlink attacks.
1011 */
1012 #[allow(clippy::only_used_in_recursion)]
1013 fn remove_dir_recursive(&self, path: &Path) -> Result<()> {
1014 // Use symlink_metadata to check type WITHOUT following symlinks
1015 let meta = fs::symlink_metadata(path)?;
1016 if meta.is_symlink() {
1017 // Remove the symlink itself, don't follow it
1018 fs::remove_file(path)?;
1019 return Ok(());
1020 }
1021 if !meta.is_dir() {
1022 fs::remove_file(path)?;
1023 return Ok(());
1024 }
1025 for entry in fs::read_dir(path)? {
1026 let entry = entry?;
1027 let entry_path = entry.path();
1028 // Use file_type() from DirEntry which doesn't follow symlinks
1029 let file_type = entry.file_type()?;
1030 if file_type.is_symlink() {
1031 // Remove symlink itself, don't follow
1032 fs::remove_file(&entry_path)?;
1033 } else if file_type.is_dir() {
1034 self.remove_dir_recursive(&entry_path)?;
1035 } else {
1036 fs::remove_file(&entry_path)?;
1037 }
1038 }
1039 fs::remove_dir(path)?;
1040 Ok(())
1041 }
1042}
1043
1044/// RAII scope for multiple sandboxes (used by build).
1045///
1046/// Creates sandboxes on construction, destroys them on drop.
1047/// This ensures sandboxes are always cleaned up, even on error paths.
1048/// If sandboxes are disabled, this is a no-op.
1049#[derive(Debug)]
1050pub struct SandboxScope {
1051 sandbox: Sandbox,
1052 count: usize,
1053}
1054
1055impl SandboxScope {
1056 /// Create a new scope, creating `count` sandboxes if enabled.
1057 pub fn new(sandbox: Sandbox, count: usize) -> Result<Self> {
1058 if sandbox.enabled() {
1059 sandbox.create_all(count)?;
1060 }
1061 Ok(Self { sandbox, count })
1062 }
1063
1064 /// Access the underlying sandbox for operations.
1065 pub fn sandbox(&self) -> &Sandbox {
1066 &self.sandbox
1067 }
1068
1069 /// Return whether sandboxes are enabled.
1070 pub fn enabled(&self) -> bool {
1071 self.sandbox.enabled()
1072 }
1073}
1074
1075impl Drop for SandboxScope {
1076 fn drop(&mut self) {
1077 if self.sandbox.enabled() {
1078 if let Err(e) = self.sandbox.destroy_all(self.count) {
1079 eprintln!("Warning: failed to destroy sandboxes: {}", e);
1080 }
1081 }
1082 }
1083}
1084
1085/// RAII scope for a single sandbox (used by scan).
1086///
1087/// Creates sandbox 0 on construction, destroys it on drop.
1088/// If sandboxes are disabled, this is a no-op.
1089#[derive(Debug)]
1090pub struct SingleSandboxScope {
1091 sandbox: Sandbox,
1092}
1093
1094impl SingleSandboxScope {
1095 /// Create a new scope, creating sandbox 0 if enabled.
1096 pub fn new(sandbox: Sandbox) -> Result<Self> {
1097 if sandbox.enabled() {
1098 print!("Creating sandbox...");
1099 let _ = std::io::stdout().flush();
1100 let start = Instant::now();
1101 sandbox.create(0)?;
1102 println!(" done ({:.1}s)", start.elapsed().as_secs_f32());
1103 }
1104 Ok(Self { sandbox })
1105 }
1106
1107 /// Access the underlying sandbox for operations.
1108 pub fn sandbox(&self) -> &Sandbox {
1109 &self.sandbox
1110 }
1111
1112 /// Return whether sandboxes are enabled.
1113 pub fn enabled(&self) -> bool {
1114 self.sandbox.enabled()
1115 }
1116}
1117
1118impl Drop for SingleSandboxScope {
1119 fn drop(&mut self) {
1120 if self.sandbox.enabled() {
1121 print!("Destroying sandbox...");
1122 let _ = std::io::stdout().flush();
1123 let start = Instant::now();
1124 if let Err(e) = self.sandbox.destroy(0) {
1125 println!();
1126 eprintln!("Warning: failed to destroy sandbox: {}", e);
1127 } else {
1128 println!(" done ({:.1}s)", start.elapsed().as_secs_f32());
1129 }
1130 }
1131 }
1132}