Skip to main content

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 crate::{Interrupted, RunContext};
78use anyhow::{Context, Result, bail};
79use rayon::prelude::*;
80use std::fs;
81use std::io::Write;
82use std::os::unix::process::CommandExt;
83use std::path::{Path, PathBuf};
84use std::process::{Child, Command, ExitStatus, Output, Stdio};
85use std::sync::Arc;
86use std::sync::atomic::{AtomicBool, Ordering};
87use std::sync::mpsc::RecvTimeoutError;
88use std::time::{Duration, Instant};
89use tracing::{debug, info, info_span, warn};
90
91/// How often to check the shutdown flag while waiting for something else.
92/// This determines the maximum latency between Ctrl+C and response.
93/// 100ms provides responsive feel without excessive polling overhead.
94pub(crate) const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
95
96/// Maximum number of retries when killing processes in a sandbox.
97/// Uses exponential backoff: 64ms, 128ms, 256ms, 512ms, 1024ms = ~2s total.
98pub(crate) const KILL_PROCESSES_MAX_RETRIES: u32 = 5;
99pub(crate) const KILL_PROCESSES_INITIAL_DELAY_MS: u64 = 64;
100
101/*
102 * Poll for child process exit while checking a shutdown flag.  If shutdown
103 * is requested, kill the child and return an error.
104 */
105pub fn wait_with_shutdown(child: &mut Child, shutdown: &AtomicBool) -> Result<ExitStatus> {
106    loop {
107        if shutdown.load(Ordering::SeqCst) {
108            let _ = child.kill();
109            let _ = child.wait();
110            bail!("Interrupted by shutdown");
111        }
112        match child.try_wait()? {
113            Some(status) => return Ok(status),
114            None => std::thread::sleep(SHUTDOWN_POLL_INTERVAL),
115        }
116    }
117}
118
119/*
120 * Wait for child process exit while checking a shutdown flag, returning
121 * the full output (stdout/stderr).  If shutdown is requested, kill the
122 * child and return an error.
123 *
124 * Uses a single helper thread that calls wait_with_output() (which handles
125 * pipe draining correctly via internal threads).  The main thread polls a
126 * channel for results while checking the shutdown flag.  This avoids the
127 * polling latency of try_wait() while still allowing shutdown interruption.
128 */
129pub fn wait_output_with_shutdown(child: Child, shutdown: &AtomicBool) -> Result<Output> {
130    let pid = child.id();
131    let (tx, rx) = std::sync::mpsc::channel();
132
133    std::thread::spawn(move || {
134        let _ = tx.send(child.wait_with_output());
135    });
136
137    loop {
138        if shutdown.load(Ordering::SeqCst) {
139            unsafe {
140                libc::kill(pid as i32, libc::SIGKILL);
141            }
142            let _ = rx.recv();
143            bail!("Interrupted by shutdown");
144        }
145        match rx.recv_timeout(SHUTDOWN_POLL_INTERVAL) {
146            Ok(result) => return result.map_err(Into::into),
147            Err(RecvTimeoutError::Timeout) => continue,
148            Err(RecvTimeoutError::Disconnected) => {
149                bail!("wait thread disconnected unexpectedly");
150            }
151        }
152    }
153}
154
155/// Build sandbox manager.
156#[derive(Clone, Debug, Default)]
157pub struct Sandbox {
158    config: Config,
159}
160
161impl Sandbox {
162    /**
163     * Create a new [`Sandbox`] instance.  This is used even if sandboxes have
164     * not been enabled, as it provides a consistent interface to run commands
165     * through using [`execute`].  If sandboxes are enabled then commands are
166     * executed via `chroot(8)`, otherwise they are executed directly.
167     *
168     * [`execute`]: Sandbox::execute
169     */
170    pub fn new(config: &Config) -> Sandbox {
171        Sandbox {
172            config: config.clone(),
173        }
174    }
175
176    /// Return whether sandboxes have been enabled.
177    ///
178    /// This is based on whether a valid `sandboxes` section has been
179    /// specified in the config file.
180    pub fn enabled(&self) -> bool {
181        self.config.sandboxes().is_some()
182    }
183
184    fn basedir(&self) -> Option<&PathBuf> {
185        self.config.sandboxes().as_ref().map(|s| &s.basedir)
186    }
187
188    /**
189     * Return full path to a sandbox by id.
190     */
191    pub fn path(&self, id: usize) -> PathBuf {
192        let sandbox = &self.config.sandboxes().as_ref().unwrap();
193        let mut p = PathBuf::from(&sandbox.basedir);
194        p.push(id.to_string());
195        p
196    }
197
198    /**
199     * Create a Command that runs in the sandbox (via chroot) if enabled,
200     * or directly if sandboxes are disabled.
201     */
202    pub fn command(&self, id: usize, cmd: &Path) -> Command {
203        let mut c = if self.enabled() {
204            let mut c = Command::new("/usr/sbin/chroot");
205            c.arg(self.path(id)).arg(cmd);
206            c
207        } else {
208            Command::new(cmd)
209        };
210        self.apply_environment(&mut c);
211        c
212    }
213
214    /**
215     * Apply the configured environment settings to a Command.
216     *
217     * If no environment section is configured, the parent environment is
218     * inherited unchanged.  If a section is present, `clear` defaults to
219     * true and only explicitly configured variables are available.
220     */
221    fn apply_environment(&self, cmd: &mut Command) {
222        let Some(env) = self.config.environment() else {
223            return;
224        };
225        if env.clear {
226            cmd.env_clear();
227            for name in &env.inherit {
228                if let Ok(value) = std::env::var(name) {
229                    cmd.env(name, value);
230                }
231            }
232        }
233        for (name, value) in &env.set {
234            cmd.env(name, value);
235        }
236    }
237
238    /**
239     * Kill all processes in a sandbox by id.
240     * This is used for graceful shutdown on Ctrl+C.
241     */
242    pub fn kill_processes_by_id(&self, id: usize) {
243        if !self.enabled() {
244            return;
245        }
246        let sandbox = self.path(id);
247        if sandbox.exists() {
248            let span = info_span!("kill_processes", sandbox_id = id);
249            let _guard = span.enter();
250            self.kill_processes(&sandbox);
251        }
252    }
253
254    /**
255     * Return full path to a specified mount point in a sandbox.
256     * The returned path is guaranteed to be within the sandbox directory.
257     */
258    fn mountpath(&self, id: usize, mnt: &PathBuf) -> PathBuf {
259        /*
260         * Note that .push() on a PathBuf will replace the path if
261         * it is absolute, so we need to trim any leading "/".
262         */
263        let mut p = self.path(id);
264        match mnt.strip_prefix("/") {
265            Ok(s) => p.push(s),
266            Err(_) => p.push(mnt),
267        };
268        p
269    }
270
271    /**
272     * Verify that a path is safely contained within the sandbox.
273     * This prevents path traversal attacks via ".." or symlinks.
274     * Returns error if the path escapes the sandbox boundary.
275     */
276    fn verify_path_in_sandbox(&self, id: usize, path: &Path) -> Result<()> {
277        let sandbox_root = self.path(id);
278        // Canonicalize both paths to resolve any ".." or symlinks
279        // Note: canonicalize requires the path to exist, so we check
280        // the parent directory for paths that don't exist yet
281        let canonical_sandbox = sandbox_root.canonicalize().unwrap_or(sandbox_root.clone());
282
283        // For the target path, try to canonicalize what exists
284        let canonical_path = if path.exists() {
285            path.canonicalize()?
286        } else {
287            // Path doesn't exist yet, check its parent
288            if let Some(parent) = path.parent() {
289                if parent.exists() {
290                    let canonical_parent = parent.canonicalize()?;
291                    if !canonical_parent.starts_with(&canonical_sandbox) {
292                        bail!(
293                            "Path escapes sandbox: {} is not within {}",
294                            path.display(),
295                            sandbox_root.display()
296                        );
297                    }
298                }
299            }
300            return Ok(());
301        };
302
303        if !canonical_path.starts_with(&canonical_sandbox) {
304            bail!(
305                "Path escapes sandbox: {} resolves to {} which is not within {}",
306                path.display(),
307                canonical_path.display(),
308                canonical_sandbox.display()
309            );
310        }
311        Ok(())
312    }
313
314    /*
315     * Marker directory functions for sandbox lifecycle management.
316     *
317     * Each sandbox has a .bob directory that marks it as bob-managed.
318     * Inside .bob, a "completed" directory indicates the sandbox was set up
319     * successfully.  This two-stage marker allows detecting incomplete sandboxes.
320     */
321    fn bobmarker(&self, id: usize) -> PathBuf {
322        self.path(id).join(".bob")
323    }
324
325    fn completedpath(&self, id: usize) -> PathBuf {
326        self.bobmarker(id).join("completed")
327    }
328
329    fn create_marker(&self, id: usize) -> Result<()> {
330        let path = self.bobmarker(id);
331        fs::create_dir(&path).with_context(|| format!("Failed to create {}", path.display()))?;
332        Ok(())
333    }
334
335    fn mark_complete(&self, id: usize) -> Result<()> {
336        let path = self.completedpath(id);
337        fs::create_dir(&path).with_context(|| format!("Failed to create {}", path.display()))?;
338        Ok(())
339    }
340
341    fn is_bob_sandbox(&self, id: usize) -> bool {
342        self.bobmarker(id).exists()
343    }
344
345    fn is_sandbox_complete(&self, id: usize) -> bool {
346        self.completedpath(id).exists()
347    }
348
349    /**
350     * Discover all bob-managed sandboxes by scanning basedir.
351     * Returns sorted list of sandbox IDs.
352     */
353    fn discover_sandboxes(&self) -> Result<Vec<usize>> {
354        let Some(basedir) = self.basedir() else {
355            return Ok(vec![]);
356        };
357        if !basedir.exists() {
358            return Ok(vec![]);
359        }
360        let mut ids = Vec::new();
361        let entries = fs::read_dir(basedir)
362            .with_context(|| format!("Failed to read {}", basedir.display()))?;
363        for entry in entries {
364            let entry = entry?;
365            let path = entry.path();
366            if !path.is_dir() {
367                continue;
368            }
369            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
370                continue;
371            };
372            let Ok(id) = name.parse::<usize>() else {
373                continue;
374            };
375            if self.is_bob_sandbox(id) {
376                ids.push(id);
377            }
378        }
379        ids.sort();
380        Ok(ids)
381    }
382
383    /**
384     * Create a single sandbox by id.
385     * If the sandbox already exists and is valid (has lock), this is a no-op.
386     */
387    pub fn create(&self, id: usize) -> Result<()> {
388        let sandbox = self.path(id);
389        if sandbox.exists() {
390            if self.is_sandbox_complete(id) {
391                return Ok(());
392            }
393            bail!(
394                "Sandbox exists but is incomplete: {}.\n\
395                 Run 'bob util sandbox destroy' first.",
396                sandbox.display()
397            );
398        }
399        fs::create_dir_all(&sandbox)
400            .with_context(|| format!("Failed to create {}", sandbox.display()))?;
401        self.create_marker(id)?;
402        self.perform_actions(id)?;
403        self.mark_complete(id)?;
404        Ok(())
405    }
406
407    /**
408     * Execute a script file with supplied environment variables and optional
409     * stdin data.
410     *
411     * If protected is true, the process is placed in its own process group
412     * to isolate it from terminal signals (Ctrl+C). Use this for cleanup
413     * scripts that must complete even during shutdown.
414     */
415    pub fn execute(
416        &self,
417        id: usize,
418        script: &Path,
419        envs: Vec<(String, String)>,
420        stdin_data: Option<&str>,
421        protected: bool,
422    ) -> Result<Child> {
423        use std::io::Write;
424
425        let mut cmd = self.command(id, script);
426        cmd.current_dir("/");
427
428        for (key, val) in envs {
429            cmd.env(key, val);
430        }
431
432        if stdin_data.is_some() {
433            cmd.stdin(Stdio::piped());
434        }
435
436        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
437
438        if protected {
439            cmd.process_group(0);
440        }
441
442        let mut child = cmd.spawn()?;
443
444        if let Some(data) = stdin_data {
445            if let Some(mut stdin) = child.stdin.take() {
446                stdin.write_all(data.as_bytes())?;
447            }
448        }
449
450        Ok(child)
451    }
452
453    /**
454     * Execute inline script content via /bin/sh.
455     */
456    pub fn execute_script(
457        &self,
458        id: usize,
459        content: &str,
460        envs: Vec<(String, String)>,
461    ) -> Result<Child> {
462        use std::io::Write;
463
464        let mut cmd = self.command(id, Path::new("/bin/sh"));
465        cmd.current_dir("/").arg("-s");
466
467        for (key, val) in envs {
468            cmd.env(key, val);
469        }
470
471        let mut child = cmd
472            .stdin(Stdio::piped())
473            .stdout(Stdio::piped())
474            .stderr(Stdio::piped())
475            .spawn()?;
476
477        if let Some(mut stdin) = child.stdin.take() {
478            stdin.write_all(content.as_bytes())?;
479        }
480
481        Ok(child)
482    }
483
484    /**
485     * Execute a command directly without shell interpretation.
486     */
487    pub fn execute_command<I, S>(
488        &self,
489        id: usize,
490        cmd: &Path,
491        args: I,
492        envs: Vec<(String, String)>,
493    ) -> Result<Child>
494    where
495        I: IntoIterator<Item = S>,
496        S: AsRef<std::ffi::OsStr>,
497    {
498        let mut command = self.command(id, cmd);
499        command.args(args);
500        for (key, val) in envs {
501            command.env(key, val);
502        }
503        command
504            .stdin(Stdio::null())
505            .stdout(Stdio::piped())
506            .stderr(Stdio::piped())
507            .spawn()
508            .map_err(Into::into)
509    }
510
511    /**
512     * Run the pre-build script if configured.
513     * Returns Ok(true) if script ran successfully or wasn't configured,
514     * Ok(false) if script failed.
515     */
516    pub fn run_pre_build(
517        &self,
518        id: usize,
519        config: &Config,
520        envs: Vec<(String, String)>,
521    ) -> Result<bool> {
522        if let Some(script) = config.script("pre-build") {
523            info!(script = %script.display(), "Running pre-build script");
524            let child = self.execute(id, script, envs, None, false)?;
525            let output = child.wait_with_output()?;
526            if output.status.success() {
527                info!(script = %script.display(), result = "success", "Finished running pre-build script");
528            } else {
529                let stderr = String::from_utf8_lossy(&output.stderr);
530                let stdout = String::from_utf8_lossy(&output.stdout);
531                warn!(
532                    script = %script.display(),
533                    result = "failed",
534                    stdout = %stdout.trim(),
535                    stderr = %stderr.trim(),
536                    "Finished running pre-build script"
537                );
538                return Ok(false);
539            }
540        }
541        Ok(true)
542    }
543
544    /**
545     * Run the post-build script if configured.
546     * Returns Ok(true) if script ran successfully or wasn't configured,
547     * Ok(false) if script failed.
548     *
549     * Post-build scripts run with signal protection (process_group(0)) to
550     * ensure cleanup completes even during shutdown from Ctrl+C.
551     */
552    pub fn run_post_build(
553        &self,
554        id: usize,
555        config: &Config,
556        envs: Vec<(String, String)>,
557    ) -> Result<bool> {
558        if let Some(script) = config.script("post-build") {
559            info!(script = %script.display(), "Running post-build script");
560            // Use protected=true to ensure cleanup completes during shutdown
561            let child = self.execute(id, script, envs, None, true)?;
562            let output = child.wait_with_output()?;
563            if output.status.success() {
564                info!(script = %script.display(), result = "success", "Finished running post-build script");
565            } else {
566                let stderr = String::from_utf8_lossy(&output.stderr);
567                let stdout = String::from_utf8_lossy(&output.stdout);
568                warn!(
569                    script = %script.display(),
570                    result = "failed",
571                    stdout = %stdout.trim(),
572                    stderr = %stderr.trim(),
573                    "Finished running post-build script"
574                );
575                return Ok(false);
576            }
577        }
578        Ok(true)
579    }
580
581    /**
582     * Destroy a single sandbox by id.
583     */
584    pub fn destroy(&self, id: usize) -> anyhow::Result<()> {
585        let sandbox = self.path(id);
586        if !sandbox.exists() {
587            return Ok(());
588        }
589        /*
590         * First, remove the "completed" marker to indicate this sandbox is no
591         * longer available for builds.  If cleanup fails after this point,
592         * the sandbox remains discoverable (has .bob) but is marked incomplete.
593         */
594        let completeddir = self.completedpath(id);
595        if completeddir.exists() {
596            self.remove_empty_hierarchy(&completeddir)?;
597        }
598        self.reverse_actions(id)?;
599        /*
600         * Kill any remaining processes before removing the sandbox directory.
601         * Per-mount killing already happened in reverse_actions(), but this
602         * catches anything that slipped through.
603         */
604        self.kill_processes(&sandbox);
605        /*
606         * Remove all sandbox contents EXCEPT .bob.  If removal fails due to
607         * unexpected files, the .bob marker remains and the sandbox can still
608         * be discovered for retry.
609         */
610        if sandbox.exists() {
611            let bobmarker = self.bobmarker(id);
612            let entries = fs::read_dir(&sandbox)
613                .with_context(|| format!("Failed to read {}", sandbox.display()))?;
614            for entry in entries {
615                let entry = entry?;
616                let path = entry.path();
617                if path == bobmarker {
618                    continue;
619                }
620                self.remove_empty_hierarchy(&path)?;
621            }
622            /*
623             * All other contents removed successfully.  Remove .bob as the
624             * absolute last step - this is what makes the sandbox no longer
625             * discoverable.
626             */
627            if bobmarker.exists() {
628                self.remove_empty_hierarchy(&bobmarker)?;
629            }
630            self.remove_empty_hierarchy(&sandbox)?;
631        }
632        Ok(())
633    }
634
635    /**
636     * Create all sandboxes in parallel, rolling back on failure.
637     */
638    pub fn create_all(&self, count: usize) -> Result<()> {
639        if count == 1 {
640            print!("Creating sandbox...");
641        } else {
642            print!("Creating {} sandboxes...", count);
643        }
644        let _ = std::io::stdout().flush();
645        let start = Instant::now();
646        let results: Vec<(usize, Result<()>)> = (0..count)
647            .into_par_iter()
648            .map(|i| (i, self.create(i)))
649            .collect();
650        let mut first_error: Option<anyhow::Error> = None;
651        let mut sandbox_ids: Vec<usize> = Vec::new();
652        for (i, result) in results {
653            sandbox_ids.push(i);
654            if let Err(e) = result {
655                if first_error.is_none() {
656                    first_error = Some(e.context(format!("sandbox {}", i)));
657                }
658            }
659        }
660        if let Some(e) = first_error {
661            println!();
662            for i in &sandbox_ids {
663                if let Err(destroy_err) = self.destroy(*i) {
664                    eprintln!("Warning: failed to destroy sandbox {}: {}", i, destroy_err);
665                }
666            }
667            return Err(e);
668        }
669        println!(" done ({:.1}s)", start.elapsed().as_secs_f32());
670        Ok(())
671    }
672
673    /**
674     * Destroy all discovered sandboxes in parallel.  Runs post-build cleanup
675     * on each sandbox first, then destroys them.  Continue on errors to ensure
676     * all sandboxes are attempted, printing each error as it occurs.
677     */
678    pub fn destroy_all(&self) -> Result<()> {
679        let sandboxes = self.discover_sandboxes()?;
680        if sandboxes.is_empty() {
681            return Ok(());
682        }
683        let envs = self.config.script_env(None);
684        for &id in &sandboxes {
685            if self.path(id).exists() {
686                match self.run_post_build(id, &self.config, envs.clone()) {
687                    Ok(true) => {}
688                    Ok(false) => {
689                        warn!("post-build script failed for sandbox {}", id)
690                    }
691                    Err(e) => {
692                        warn!(error = %e, sandbox = id, "post-build script error")
693                    }
694                }
695            }
696        }
697        if sandboxes.len() == 1 {
698            print!("Destroying sandbox...");
699        } else {
700            print!("Destroying {} sandboxes...", sandboxes.len());
701        }
702        let _ = std::io::stdout().flush();
703        let start = Instant::now();
704        let results: Vec<(usize, Result<()>)> = sandboxes
705            .into_par_iter()
706            .map(|i| (i, self.destroy(i)))
707            .collect();
708        let mut failed = 0;
709        for (i, result) in results {
710            if let Err(e) = result {
711                if failed == 0 {
712                    println!();
713                }
714                eprintln!("sandbox {}: {:#}", i, e);
715                failed += 1;
716            }
717        }
718        if failed == 0 {
719            println!(" done ({:.1}s)", start.elapsed().as_secs_f32());
720            Ok(())
721        } else {
722            Err(anyhow::anyhow!(
723                "Failed to destroy {} sandbox{}.\n\
724                 Remove unexpected files, then run 'bob util sandbox destroy'.",
725                failed,
726                if failed == 1 { "" } else { "es" }
727            ))
728        }
729    }
730
731    /**
732     * List all discovered sandboxes.
733     */
734    pub fn list_all(&self) -> Result<()> {
735        for id in self.discover_sandboxes()? {
736            let sandbox = self.path(id);
737            if self.is_sandbox_complete(id) {
738                println!("{}", sandbox.display())
739            } else {
740                println!("{} (incomplete)", sandbox.display())
741            }
742        }
743        Ok(())
744    }
745
746    /**
747     * Count discovered sandboxes (complete or incomplete).
748     */
749    pub fn count_existing(&self) -> Result<usize> {
750        Ok(self.discover_sandboxes()?.len())
751    }
752
753    /*
754     * Remove any empty directories from a mount point up to the root of the
755     * sandbox.
756     */
757    fn remove_empty_dirs(&self, id: usize, mountpoint: &Path) {
758        for p in mountpoint.ancestors() {
759            /*
760             * Sanity check we are within the chroot.
761             */
762            if !p.starts_with(self.path(id)) {
763                break;
764            }
765            /*
766             * Go up to next parent if this path does not exist.
767             */
768            if !p.exists() {
769                continue;
770            }
771            /*
772             * Otherwise attempt to remove.  If this fails then skip any
773             * parent directories.
774             */
775            if fs::remove_dir(p).is_err() {
776                break;
777            }
778        }
779    }
780
781    /// Remove a directory hierarchy only if it contains nothing but empty
782    /// directories and symlinks. Walks depth-first. Removes symlinks and
783    /// empty directories. Fails if any regular files, device nodes, pipes,
784    /// sockets, or other non-removable entries are encountered.
785    #[allow(clippy::only_used_in_recursion)]
786    fn remove_empty_hierarchy(&self, path: &Path) -> Result<()> {
787        // Use symlink_metadata to not follow symlinks
788        let meta = fs::symlink_metadata(path)?;
789
790        if meta.is_symlink() {
791            // Symlinks can be removed
792            fs::remove_file(path).map_err(|e| {
793                anyhow::anyhow!("Failed to remove symlink {}: {}", path.display(), e)
794            })?;
795            return Ok(());
796        }
797
798        if !meta.is_dir() {
799            // Regular file, device node, pipe, socket, etc. - fail
800            bail!(
801                "Cannot remove sandbox: non-directory exists at {}",
802                path.display()
803            );
804        }
805
806        // It's a directory - process contents first (depth-first)
807        let entries =
808            fs::read_dir(path).with_context(|| format!("Failed to read {}", path.display()))?;
809        for entry in entries {
810            let entry = entry?;
811            self.remove_empty_hierarchy(&entry.path())?;
812        }
813
814        // Directory should now be empty, remove it
815        fs::remove_dir(path).map_err(|e| {
816            anyhow::anyhow!(
817                "Failed to remove directory {}: {}. Directory may not be empty.",
818                path.display(),
819                e
820            )
821        })
822    }
823
824    ///
825    /// Iterate over the supplied array of actions in order.  If at any
826    /// point a problem is encountered we immediately bail.
827    ///
828    fn perform_actions(&self, id: usize) -> Result<()> {
829        let Some(sandbox) = &self.config.sandboxes() else {
830            bail!("Internal error: trying to perform actions when sandboxes disabled.");
831        };
832        for action in sandbox.actions.iter() {
833            action.validate()?;
834            let action_type = action.action_type()?;
835
836            // For mount/copy actions, dest defaults to src (src is more readable)
837            let src = action.src().or(action.dest());
838            let dest = action
839                .dest()
840                .or(action.src())
841                .map(|d| self.mountpath(id, d));
842            if let Some(ref dest_path) = dest {
843                self.verify_path_in_sandbox(id, dest_path)?;
844            }
845
846            let mut opts = vec![];
847            if let Some(o) = action.opts() {
848                for opt in o.split(' ').collect::<Vec<&str>>() {
849                    opts.push(opt);
850                }
851            }
852
853            let status = match action_type {
854                ActionType::Mount => {
855                    let fs_type = action.fs_type()?;
856                    let src =
857                        src.ok_or_else(|| anyhow::anyhow!("mount action requires src or dest"))?;
858                    let dest = dest.ok_or_else(|| anyhow::anyhow!("mount action requires dest"))?;
859                    if action.ifexists() && !src.exists() {
860                        debug!(
861                            sandbox = id,
862                            action = "mount",
863                            fs = ?fs_type,
864                            src = %src.display(),
865                            "Skipped (source does not exist)"
866                        );
867                        continue;
868                    }
869                    debug!(
870                        sandbox = id,
871                        action = "mount",
872                        fs = ?fs_type,
873                        src = %src.display(),
874                        dest = %dest.display(),
875                        "Mounting"
876                    );
877                    match fs_type {
878                        FSType::Bind => self.mount_bindfs(src, &dest, &opts)?,
879                        FSType::Dev => self.mount_devfs(src, &dest, &opts)?,
880                        FSType::Fd => self.mount_fdfs(src, &dest, &opts)?,
881                        FSType::Nfs => self.mount_nfs(src, &dest, &opts)?,
882                        FSType::Proc => self.mount_procfs(src, &dest, &opts)?,
883                        FSType::Tmp => self.mount_tmpfs(src, &dest, &opts)?,
884                    }
885                }
886                ActionType::Copy => {
887                    let src =
888                        src.ok_or_else(|| anyhow::anyhow!("copy action requires src or dest"))?;
889                    let dest = dest.ok_or_else(|| anyhow::anyhow!("copy action requires dest"))?;
890                    debug!(
891                        sandbox = id,
892                        action = "copy",
893                        src = %src.display(),
894                        dest = %dest.display(),
895                        "Copying"
896                    );
897                    if let Some(parent) = dest.parent() {
898                        fs::create_dir_all(parent)
899                            .with_context(|| format!("Failed to create {}", parent.display()))?;
900                    }
901                    copy_dir::copy_dir(src, &dest).with_context(|| {
902                        format!("Failed to copy {} to {}", src.display(), dest.display())
903                    })?;
904                    None
905                }
906                ActionType::Cmd => {
907                    if let Some(create_cmd) = action.create_cmd() {
908                        debug!(
909                            sandbox = id,
910                            action = "cmd",
911                            cmd = create_cmd,
912                            chroot = action.chroot(),
913                            "Running create command"
914                        );
915                        self.run_action_cmd(id, create_cmd, action.chroot())?
916                    } else {
917                        None
918                    }
919                }
920                ActionType::Symlink => {
921                    let src = action
922                        .src()
923                        .ok_or_else(|| anyhow::anyhow!("symlink action requires src"))?;
924                    let dest = action
925                        .dest()
926                        .ok_or_else(|| anyhow::anyhow!("symlink action requires dest"))?;
927                    let dest_path = self.mountpath(id, dest);
928                    debug!(
929                        sandbox = id,
930                        action = "symlink",
931                        src = %src.display(),
932                        dest = %dest_path.display(),
933                        "Creating symlink"
934                    );
935                    if let Some(parent) = dest_path.parent() {
936                        if !parent.exists() {
937                            fs::create_dir_all(parent).with_context(|| {
938                                format!("Failed to create {}", parent.display())
939                            })?;
940                        }
941                    }
942                    std::os::unix::fs::symlink(src, &dest_path).with_context(|| {
943                        format!(
944                            "Failed to create symlink {} -> {}",
945                            dest_path.display(),
946                            src.display()
947                        )
948                    })?;
949                    None
950                }
951            };
952            if let Some(s) = status {
953                if !s.success() {
954                    bail!("Sandbox action failed");
955                }
956            }
957        }
958        Ok(())
959    }
960
961    /**
962     * Run a custom action command.
963     *
964     * When `chroot` is false (default), the command runs on the host system
965     * with the sandbox root as the working directory.
966     *
967     * When `chroot` is true, the command runs inside the sandbox via chroot
968     * with `/` as the working directory.
969     *
970     * Stdio is captured to prevent commands like `su` from trying to interact
971     * with the terminal. Output is logged on failure.
972     */
973    fn run_action_cmd(
974        &self,
975        id: usize,
976        cmd: &str,
977        chroot: bool,
978    ) -> Result<Option<std::process::ExitStatus>> {
979        let sandbox_path = self.path(id);
980        let output = if chroot {
981            let mut c = Command::new("/usr/sbin/chroot");
982            self.apply_environment(&mut c);
983            c.arg(&sandbox_path)
984                .arg("/bin/sh")
985                .arg("-c")
986                .arg(cmd)
987                .stdin(Stdio::null())
988                .stdout(Stdio::piped())
989                .stderr(Stdio::piped())
990                .process_group(0);
991            c.output()?
992        } else {
993            let mut c = Command::new("/bin/sh");
994            self.apply_environment(&mut c);
995            c.arg("-c")
996                .arg(cmd)
997                .env("bob_sandbox_path", &sandbox_path)
998                .current_dir(&sandbox_path)
999                .stdin(Stdio::null())
1000                .stdout(Stdio::piped())
1001                .stderr(Stdio::piped())
1002                .process_group(0);
1003            c.output()?
1004        };
1005
1006        if !output.status.success() {
1007            let stdout = String::from_utf8_lossy(&output.stdout);
1008            let stderr = String::from_utf8_lossy(&output.stderr);
1009            if !stdout.is_empty() {
1010                warn!(cmd, stdout = %stdout.trim(), "Action command output");
1011            }
1012            if !stderr.is_empty() {
1013                warn!(cmd, stderr = %stderr.trim(), "Action command error");
1014            }
1015        }
1016
1017        Ok(Some(output.status))
1018    }
1019
1020    fn reverse_actions(&self, id: usize) -> anyhow::Result<()> {
1021        let Some(sandbox) = &self.config.sandboxes() else {
1022            bail!("Internal error: trying to reverse actions when sandboxes disabled.");
1023        };
1024        for action in sandbox.actions.iter().rev() {
1025            let action_type = action.action_type()?;
1026            // dest defaults to src if not specified
1027            let dest = action
1028                .dest()
1029                .or(action.src())
1030                .map(|d| self.mountpath(id, d));
1031
1032            match action_type {
1033                ActionType::Cmd => {
1034                    if let Some(destroy_cmd) = action.destroy_cmd() {
1035                        debug!(
1036                            sandbox = id,
1037                            action = "cmd",
1038                            cmd = destroy_cmd,
1039                            chroot = action.chroot(),
1040                            "Running destroy command"
1041                        );
1042                        let status = self.run_action_cmd(id, destroy_cmd, action.chroot())?;
1043                        if let Some(s) = status {
1044                            if !s.success() {
1045                                bail!("Failed to run destroy command: exit code {:?}", s.code());
1046                            }
1047                        }
1048                    }
1049                }
1050                ActionType::Copy => {
1051                    let Some(mntdest) = dest else { continue };
1052                    if !mntdest.exists() {
1053                        self.remove_empty_dirs(id, &mntdest);
1054                        continue;
1055                    }
1056                    if fs::remove_dir(&mntdest).is_ok() {
1057                        continue;
1058                    }
1059                    /*
1060                     * Use remove_dir_recursive which fails if non-empty
1061                     * directories remain, rather than blindly deleting.
1062                     * First verify the path is within the sandbox.
1063                     */
1064                    debug!(
1065                        sandbox = id,
1066                        action = "copy",
1067                        dest = %mntdest.display(),
1068                        "Removing copied directory"
1069                    );
1070                    self.verify_path_in_sandbox(id, &mntdest)?;
1071                    self.remove_dir_recursive(&mntdest)?;
1072                    self.remove_empty_dirs(id, &mntdest);
1073                }
1074                ActionType::Symlink => {
1075                    let Some(mntdest) = dest else { continue };
1076                    if mntdest.is_symlink() {
1077                        debug!(
1078                            sandbox = id,
1079                            action = "symlink",
1080                            dest = %mntdest.display(),
1081                            "Removing symlink"
1082                        );
1083                        fs::remove_file(&mntdest)?;
1084                    }
1085                    self.remove_empty_dirs(id, &mntdest);
1086                }
1087                ActionType::Mount => {
1088                    let Some(mntdest) = dest else { continue };
1089                    let fs_type = action.fs_type()?;
1090
1091                    let src = action.src().or(action.dest());
1092                    if let Some(src) = src {
1093                        if action.ifexists() && !src.exists() {
1094                            continue;
1095                        }
1096                    }
1097
1098                    /*
1099                     * If the mount point itself does not exist then do not try to
1100                     * unmount it, but do try to clean up any empty parent
1101                     * directories up to the root.
1102                     */
1103                    if !mntdest.exists() {
1104                        self.remove_empty_dirs(id, &mntdest);
1105                        continue;
1106                    }
1107
1108                    /*
1109                     * Before trying to unmount, try just removing the directory,
1110                     * in case it was never mounted in the first place.  Avoids
1111                     * errors trying to unmount a file system that isn't mounted.
1112                     */
1113                    if fs::remove_dir(&mntdest).is_ok() {
1114                        continue;
1115                    }
1116
1117                    /*
1118                     * Kill any processes using this mount point before
1119                     * attempting to unmount.
1120                     */
1121                    self.kill_processes_for_path(&mntdest);
1122
1123                    /*
1124                     * Unmount the filesystem.  Check return codes and bail on
1125                     * failure - it is critical that all mounts are successfully
1126                     * unmounted before we attempt to remove the sandbox directory.
1127                     */
1128                    debug!(
1129                        sandbox = id,
1130                        action = "mount",
1131                        fs = ?fs_type,
1132                        dest = %mntdest.display(),
1133                        "Unmounting"
1134                    );
1135                    let status = match fs_type {
1136                        FSType::Bind => self.unmount_bindfs(&mntdest)?,
1137                        FSType::Dev => self.unmount_devfs(&mntdest)?,
1138                        FSType::Fd => self.unmount_fdfs(&mntdest)?,
1139                        FSType::Nfs => self.unmount_nfs(&mntdest)?,
1140                        FSType::Proc => self.unmount_procfs(&mntdest)?,
1141                        FSType::Tmp => self.unmount_tmpfs(&mntdest)?,
1142                    };
1143                    if let Some(s) = status {
1144                        if !s.success() {
1145                            bail!("Failed to unmount {}", mntdest.display());
1146                        }
1147                    }
1148                    self.remove_empty_dirs(id, &mntdest);
1149                }
1150            }
1151        }
1152        Ok(())
1153    }
1154
1155    /**
1156     * Recursively remove a directory by walking it depth-first and removing
1157     * files and empty directories.  Unlike remove_dir_all, this will fail
1158     * if it encounters a non-empty directory that cannot be removed, which
1159     * would indicate an active mount point.
1160     *
1161     * IMPORTANT: This function explicitly does NOT follow symlinks to avoid
1162     * deleting files outside the sandbox via symlink attacks.
1163     */
1164    #[allow(clippy::only_used_in_recursion)]
1165    fn remove_dir_recursive(&self, path: &Path) -> Result<()> {
1166        // Use symlink_metadata to check type WITHOUT following symlinks
1167        let meta = fs::symlink_metadata(path)
1168            .with_context(|| format!("Failed to stat {}", path.display()))?;
1169        if meta.is_symlink() {
1170            // Remove the symlink itself, don't follow it
1171            fs::remove_file(path)
1172                .with_context(|| format!("Failed to remove {}", path.display()))?;
1173            return Ok(());
1174        }
1175        if !meta.is_dir() {
1176            fs::remove_file(path)
1177                .with_context(|| format!("Failed to remove {}", path.display()))?;
1178            return Ok(());
1179        }
1180        let entries =
1181            fs::read_dir(path).with_context(|| format!("Failed to read {}", path.display()))?;
1182        for entry in entries {
1183            let entry = entry?;
1184            let entry_path = entry.path();
1185            // Use file_type() from DirEntry which doesn't follow symlinks
1186            let file_type = entry.file_type()?;
1187            if file_type.is_symlink() {
1188                // Remove symlink itself, don't follow
1189                fs::remove_file(&entry_path)
1190                    .with_context(|| format!("Failed to remove {}", entry_path.display()))?;
1191            } else if file_type.is_dir() {
1192                self.remove_dir_recursive(&entry_path)?;
1193            } else {
1194                fs::remove_file(&entry_path)
1195                    .with_context(|| format!("Failed to remove {}", entry_path.display()))?;
1196            }
1197        }
1198        fs::remove_dir(path).with_context(|| format!("Failed to remove {}", path.display()))?;
1199        Ok(())
1200    }
1201}
1202
1203/**
1204 * RAII scope for sandbox lifecycle management.
1205 *
1206 * Creates sandboxes on demand via `ensure()`, destroys them on drop.
1207 * This ensures sandboxes are always cleaned up, even on error paths.
1208 * If sandboxes are disabled, all operations are no-ops.
1209 */
1210#[derive(Debug)]
1211pub struct SandboxScope {
1212    sandbox: Sandbox,
1213    count: usize,
1214    ctx: RunContext,
1215}
1216
1217impl SandboxScope {
1218    /**
1219     * Create a new scope with no sandboxes.
1220     *
1221     * Use `ensure()` to create sandboxes when needed.
1222     */
1223    pub fn new(sandbox: Sandbox, ctx: RunContext) -> Self {
1224        Self {
1225            sandbox,
1226            count: 0,
1227            ctx,
1228        }
1229    }
1230
1231    /**
1232     * Ensure sandboxes 0..n exist.
1233     *
1234     * Creates any missing sandboxes in parallel. If sandboxes are disabled
1235     * or n <= current count, this is a no-op.
1236     *
1237     * On error or interrupt, newly created sandboxes are rolled back but
1238     * previously existing sandboxes remain (they'll be cleaned up on drop).
1239     */
1240    pub fn ensure(&mut self, n: usize) -> Result<()> {
1241        if !self.sandbox.enabled() || n <= self.count {
1242            return Ok(());
1243        }
1244        let to_create = n - self.count;
1245        if to_create == 1 {
1246            print!("Creating sandbox...");
1247        } else {
1248            print!("Creating {} sandboxes...", to_create);
1249        }
1250        let _ = std::io::stdout().flush();
1251        let start = Instant::now();
1252        let results: Vec<(usize, Result<()>)> = (self.count..n)
1253            .into_par_iter()
1254            .map(|i| (i, self.sandbox.create(i)))
1255            .collect();
1256
1257        // Check for interrupt - roll back newly created sandboxes
1258        if self.ctx.shutdown.load(Ordering::SeqCst) {
1259            for (i, result) in &results {
1260                if result.is_ok() {
1261                    let _ = self.sandbox.destroy(*i);
1262                }
1263            }
1264            return Err(Interrupted.into());
1265        }
1266
1267        let mut first_error: Option<anyhow::Error> = None;
1268        let mut sandbox_ids: Vec<usize> = Vec::new();
1269        for (i, result) in results {
1270            sandbox_ids.push(i);
1271            if let Err(e) = result {
1272                if first_error.is_none() {
1273                    first_error = Some(e.context(format!("sandbox {}", i)));
1274                }
1275            }
1276        }
1277        if let Some(e) = first_error {
1278            println!();
1279            for i in &sandbox_ids {
1280                if let Err(destroy_err) = self.sandbox.destroy(*i) {
1281                    eprintln!("Warning: failed to destroy sandbox {}: {}", i, destroy_err);
1282                }
1283            }
1284            return Err(e);
1285        }
1286        println!(" done ({:.1}s)", start.elapsed().as_secs_f32());
1287        self.count = n;
1288        Ok(())
1289    }
1290
1291    /// Access the underlying sandbox for operations.
1292    pub fn sandbox(&self) -> &Sandbox {
1293        &self.sandbox
1294    }
1295
1296    /// Return whether sandboxes are enabled.
1297    pub fn enabled(&self) -> bool {
1298        self.sandbox.enabled()
1299    }
1300
1301    /// Access the shutdown flag.
1302    pub fn shutdown(&self) -> &Arc<AtomicBool> {
1303        &self.ctx.shutdown
1304    }
1305}
1306
1307impl Drop for SandboxScope {
1308    fn drop(&mut self) {
1309        if self.sandbox.enabled() && self.count > 0 {
1310            if let Err(e) = self.sandbox.destroy_all() {
1311                eprintln!("Warning: failed to destroy sandboxes: {}", e);
1312            }
1313        }
1314    }
1315}