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}