bob/
sandbox.rs

1/*
2 * Copyright (c) 2025 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/bob",
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/bob/0`
63//! - `/data/chroot/bob/1`
64//! - `/data/chroot/bob/2`
65//! - `/data/chroot/bob/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 std::fs;
79use std::path::{Path, PathBuf};
80use std::process::{Child, Command, Stdio};
81
82/// Build sandbox manager.
83///
84/// Provides methods to create, execute commands in, and destroy sandboxes.
85/// The sandbox implementation is platform-specific but the interface is uniform.
86///
87/// # Example
88///
89/// ```no_run
90/// # use bob::{Config, Sandbox};
91/// # use std::path::Path;
92/// # fn example() -> anyhow::Result<()> {
93/// let config = Config::load(None, false)?;
94/// let sandbox = Sandbox::new(&config);
95///
96/// if sandbox.enabled() {
97///     sandbox.create(0)?;  // Create sandbox 0
98///
99///     // Execute a script in the sandbox
100///     let child = sandbox.execute(
101///         0,
102///         Path::new("/path/to/script"),
103///         vec![("KEY".to_string(), "value".to_string())],
104///         None,
105///         None,
106///     )?;
107///     let output = child.wait_with_output()?;
108///
109///     sandbox.destroy(0)?;
110/// }
111/// # Ok(())
112/// # }
113/// ```
114#[derive(Clone, Debug, Default)]
115pub struct Sandbox {
116    config: Config,
117}
118
119impl Sandbox {
120    /**
121     * Create a new [`Sandbox`] instance.  This is used even if sandboxes have
122     * not been enabled, as it provides a consistent interface to run commands
123     * through using [`execute`].  If sandboxes are enabled then commands are
124     * executed via `chroot(8)`, otherwise they are executed directly.
125     *
126     * [`execute`]: Sandbox::execute
127     */
128    pub fn new(config: &Config) -> Sandbox {
129        Sandbox { config: config.clone() }
130    }
131
132    /// Return whether sandboxes have been enabled.
133    ///
134    /// This is based on whether a valid `sandboxes` section has been
135    /// specified in the config file.
136    pub fn enabled(&self) -> bool {
137        self.config.sandboxes().is_some()
138    }
139
140    /**
141     * Return full path to a sandbox by id.
142     */
143    pub fn path(&self, id: usize) -> PathBuf {
144        let sandbox = &self.config.sandboxes().as_ref().unwrap();
145        let mut p = PathBuf::from(&sandbox.basedir);
146        p.push(id.to_string());
147        p
148    }
149
150    /**
151     * Create a Command that runs in the sandbox (via chroot) if enabled,
152     * or directly if sandboxes are disabled.
153     */
154    pub fn command(&self, id: usize, cmd: &Path) -> Command {
155        if self.enabled() {
156            let mut c = Command::new("/usr/sbin/chroot");
157            c.arg(self.path(id)).arg(cmd);
158            c
159        } else {
160            Command::new(cmd)
161        }
162    }
163
164    /**
165     * Kill all processes in a sandbox by id.
166     * This is used for graceful shutdown on Ctrl+C.
167     */
168    pub fn kill_processes_by_id(&self, id: usize) {
169        if !self.enabled() {
170            return;
171        }
172        let sandbox = self.path(id);
173        if sandbox.exists() {
174            self.kill_processes(&sandbox);
175        }
176    }
177
178    /**
179     * Return full path to a specified mount point in a sandbox.
180     * The returned path is guaranteed to be within the sandbox directory.
181     */
182    fn mountpath(&self, id: usize, mnt: &PathBuf) -> PathBuf {
183        /*
184         * Note that .push() on a PathBuf will replace the path if
185         * it is absolute, so we need to trim any leading "/".
186         */
187        let mut p = self.path(id);
188        match mnt.strip_prefix("/") {
189            Ok(s) => p.push(s),
190            Err(_) => p.push(mnt),
191        };
192        p
193    }
194
195    /**
196     * Verify that a path is safely contained within the sandbox.
197     * This prevents path traversal attacks via ".." or symlinks.
198     * Returns error if the path escapes the sandbox boundary.
199     */
200    fn verify_path_in_sandbox(&self, id: usize, path: &Path) -> Result<()> {
201        let sandbox_root = self.path(id);
202        // Canonicalize both paths to resolve any ".." or symlinks
203        // Note: canonicalize requires the path to exist, so we check
204        // the parent directory for paths that don't exist yet
205        let canonical_sandbox =
206            sandbox_root.canonicalize().unwrap_or(sandbox_root.clone());
207
208        // For the target path, try to canonicalize what exists
209        let canonical_path = if path.exists() {
210            path.canonicalize()?
211        } else {
212            // Path doesn't exist yet, check its parent
213            if let Some(parent) = path.parent() {
214                if parent.exists() {
215                    let canonical_parent = parent.canonicalize()?;
216                    if !canonical_parent.starts_with(&canonical_sandbox) {
217                        bail!(
218                            "Path escapes sandbox: {} is not within {}",
219                            path.display(),
220                            sandbox_root.display()
221                        );
222                    }
223                }
224            }
225            return Ok(());
226        };
227
228        if !canonical_path.starts_with(&canonical_sandbox) {
229            bail!(
230                "Path escapes sandbox: {} resolves to {} which is not within {}",
231                path.display(),
232                canonical_path.display(),
233                canonical_sandbox.display()
234            );
235        }
236        Ok(())
237    }
238
239    /*
240     * Functions to create/destroy lock directory inside a sandbox to
241     * indicate that it has successfully been created.  An empty directory
242     * is used as it provides a handy way to guarantee(?) atomicity.
243     */
244    fn lockpath(&self, id: usize) -> PathBuf {
245        let mut p = self.path(id);
246        p.push(".created");
247        p
248    }
249    fn create_lock(&self, id: usize) -> Result<()> {
250        Ok(fs::create_dir(self.lockpath(id))?)
251    }
252    fn delete_lock(&self, id: usize) -> Result<()> {
253        let lockdir = self.lockpath(id);
254        if lockdir.exists() {
255            fs::remove_dir(self.lockpath(id))?
256        }
257        Ok(())
258    }
259
260    /**
261     * Create a single sandbox by id.
262     * If the sandbox already exists and is valid (has lock), this is a no-op.
263     */
264    pub fn create(&self, id: usize) -> Result<()> {
265        let sandbox = self.path(id);
266        if sandbox.exists() {
267            if self.lockpath(id).exists() {
268                // Sandbox already exists and is valid
269                return Ok(());
270            }
271            bail!(
272                "Sandbox exists but is incomplete: {}. Destroy it first.",
273                sandbox.display()
274            );
275        }
276        fs::create_dir_all(&sandbox)?;
277        self.perform_actions(id)?;
278        self.create_lock(id)?;
279        Ok(())
280    }
281
282    /**
283     * Execute a script file with supplied environment variables and optional
284     * stdin data. If status_fd is provided, it will be passed to the child
285     * process via the bob_status_fd environment variable.
286     */
287    pub fn execute(
288        &self,
289        id: usize,
290        script: &Path,
291        mut envs: Vec<(String, String)>,
292        stdin_data: Option<&str>,
293        status_fd: Option<i32>,
294    ) -> Result<Child> {
295        use std::io::Write;
296
297        let mut cmd = self.command(id, script);
298        cmd.current_dir("/");
299
300        if let Some(fd) = status_fd {
301            envs.push(("bob_status_fd".to_string(), fd.to_string()));
302        }
303
304        for (key, val) in envs {
305            cmd.env(key, val);
306        }
307
308        if stdin_data.is_some() {
309            cmd.stdin(Stdio::piped());
310        }
311
312        // Script handles its own output redirection to log files
313        cmd.stdout(Stdio::null()).stderr(Stdio::null());
314
315        let mut child = cmd.spawn()?;
316
317        if let Some(data) = stdin_data {
318            if let Some(mut stdin) = child.stdin.take() {
319                stdin.write_all(data.as_bytes())?;
320            }
321        }
322
323        Ok(child)
324    }
325
326    /**
327     * Execute inline script content via /bin/sh.
328     */
329    pub fn execute_script(
330        &self,
331        id: usize,
332        content: &str,
333        envs: Vec<(String, String)>,
334    ) -> Result<Child> {
335        use std::io::Write;
336
337        let mut cmd = self.command(id, Path::new("/bin/sh"));
338        cmd.current_dir("/").arg("-s");
339
340        for (key, val) in envs {
341            cmd.env(key, val);
342        }
343
344        let mut child = cmd
345            .stdin(Stdio::piped())
346            .stdout(Stdio::piped())
347            .stderr(Stdio::piped())
348            .spawn()?;
349
350        if let Some(mut stdin) = child.stdin.take() {
351            stdin.write_all(content.as_bytes())?;
352        }
353
354        Ok(child)
355    }
356
357    /**
358     * Destroy a single sandbox by id.
359     */
360    pub fn destroy(&self, id: usize) -> anyhow::Result<()> {
361        let sandbox = self.path(id);
362        if !sandbox.exists() {
363            return Ok(());
364        }
365        self.kill_processes(&sandbox);
366        self.delete_lock(id)?;
367        self.reverse_actions(id)?;
368        /*
369         * After unmounting, try to remove the sandbox directory.  Use
370         * remove_empty_hierarchy which only removes empty directories.
371         * If any files remain, it will fail - this is intentional as it
372         * likely means a mount is still active or cleanup actions are
373         * missing from the config.
374         */
375        if sandbox.exists() {
376            self.remove_empty_hierarchy(&sandbox)?;
377        }
378        Ok(())
379    }
380
381    /**
382     * Create all sandboxes.
383     */
384    pub fn create_all(&self, count: usize) -> Result<()> {
385        for i in 0..count {
386            self.create(i)?;
387        }
388        Ok(())
389    }
390
391    /**
392     * Destroy all sandboxes.  Continue on errors to ensure all sandboxes
393     * are attempted, printing each error as it occurs.
394     */
395    pub fn destroy_all(&self, count: usize) -> Result<()> {
396        let mut failed = 0;
397        for i in 0..count {
398            if let Err(e) = self.destroy(i) {
399                eprintln!("sandbox {}: {}", i, e);
400                failed += 1;
401            }
402        }
403        if failed == 0 {
404            Ok(())
405        } else {
406            Err(anyhow::anyhow!(
407                "Failed to destroy {} sandbox{}\nRemove unexpected files, then run 'bob util sandbox destroy'",
408                failed,
409                if failed == 1 { "" } else { "es" }
410            ))
411        }
412    }
413
414    /**
415     * List all sandboxes.
416     */
417    pub fn list_all(&self, count: usize) {
418        for i in 0..count {
419            let sandbox = self.path(i);
420            if sandbox.exists() {
421                if self.lockpath(i).exists() {
422                    println!("{}", sandbox.display())
423                } else {
424                    println!("{} (incomplete)", sandbox.display())
425                }
426            }
427        }
428    }
429
430    /*
431     * Remove any empty directories from a mount point up to the root of the
432     * sandbox.
433     */
434    fn remove_empty_dirs(&self, id: usize, mountpoint: &Path) {
435        for p in mountpoint.ancestors() {
436            /*
437             * Sanity check we are within the chroot.
438             */
439            if !p.starts_with(self.path(id)) {
440                break;
441            }
442            /*
443             * Go up to next parent if this path does not exist.
444             */
445            if !p.exists() {
446                continue;
447            }
448            /*
449             * Otherwise attempt to remove.  If this fails then skip any
450             * parent directories.
451             */
452            if fs::remove_dir(p).is_err() {
453                break;
454            }
455        }
456    }
457
458    /// Remove a directory hierarchy only if it contains nothing but empty
459    /// directories and symlinks. Walks depth-first. Removes symlinks and
460    /// empty directories. Fails if any regular files, device nodes, pipes,
461    /// sockets, or other non-removable entries are encountered.
462    #[allow(clippy::only_used_in_recursion)]
463    fn remove_empty_hierarchy(&self, path: &Path) -> Result<()> {
464        // Use symlink_metadata to not follow symlinks
465        let meta = fs::symlink_metadata(path)?;
466
467        if meta.is_symlink() {
468            // Symlinks can be removed
469            fs::remove_file(path).map_err(|e| {
470                anyhow::anyhow!(
471                    "Failed to remove symlink {}: {}",
472                    path.display(),
473                    e
474                )
475            })?;
476            return Ok(());
477        }
478
479        if !meta.is_dir() {
480            // Regular file, device node, pipe, socket, etc. - fail
481            bail!(
482                "Cannot remove sandbox: non-directory exists at {}",
483                path.display()
484            );
485        }
486
487        // It's a directory - process contents first (depth-first)
488        for entry in fs::read_dir(path)? {
489            let entry = entry?;
490            self.remove_empty_hierarchy(&entry.path())?;
491        }
492
493        // Directory should now be empty, remove it
494        fs::remove_dir(path).map_err(|e| {
495            anyhow::anyhow!(
496                "Failed to remove directory {}: {}. Directory may not be empty.",
497                path.display(),
498                e
499            )
500        })
501    }
502
503    ///
504    /// Iterate over the supplied array of actions in order.  If at any
505    /// point a problem is encountered we immediately bail.
506    ///
507    fn perform_actions(&self, id: usize) -> Result<()> {
508        let Some(sandbox) = &self.config.sandboxes() else {
509            bail!(
510                "Internal error: trying to perform actions when sandboxes disabled."
511            );
512        };
513        for action in sandbox.actions.iter() {
514            action.validate()?;
515            let action_type = action.action_type()?;
516
517            // For mount/copy actions, dest defaults to src (src is more readable)
518            let src = action.src().or(action.dest());
519            let dest =
520                action.dest().or(action.src()).map(|d| self.mountpath(id, d));
521            if let Some(ref dest_path) = dest {
522                self.verify_path_in_sandbox(id, dest_path)?;
523            }
524
525            let mut opts = vec![];
526            if let Some(o) = action.opts() {
527                for opt in o.split(' ').collect::<Vec<&str>>() {
528                    opts.push(opt);
529                }
530            }
531
532            let status = match action_type {
533                ActionType::Mount => {
534                    let fs_type = action.fs_type()?;
535                    let src = src.ok_or_else(|| {
536                        anyhow::anyhow!("mount action requires src or dest")
537                    })?;
538                    let dest = dest.ok_or_else(|| {
539                        anyhow::anyhow!("mount action requires dest")
540                    })?;
541                    if action.ifexists() && !src.exists() {
542                        continue;
543                    }
544                    match fs_type {
545                        FSType::Bind => self.mount_bindfs(src, &dest, &opts)?,
546                        FSType::Dev => self.mount_devfs(src, &dest, &opts)?,
547                        FSType::Fd => self.mount_fdfs(src, &dest, &opts)?,
548                        FSType::Nfs => self.mount_nfs(src, &dest, &opts)?,
549                        FSType::Proc => self.mount_procfs(src, &dest, &opts)?,
550                        FSType::Tmp => self.mount_tmpfs(src, &dest, &opts)?,
551                    }
552                }
553                ActionType::Copy => {
554                    let src = src.ok_or_else(|| {
555                        anyhow::anyhow!("copy action requires src or dest")
556                    })?;
557                    let dest = dest.ok_or_else(|| {
558                        anyhow::anyhow!("copy action requires dest")
559                    })?;
560                    copy_dir::copy_dir(src, &dest)?;
561                    None
562                }
563                ActionType::Cmd => {
564                    if let Some(create_cmd) = action.create_cmd() {
565                        self.run_action_cmd(id, create_cmd, action.cwd())?
566                    } else {
567                        None
568                    }
569                }
570                ActionType::Symlink => {
571                    let src = action.src().ok_or_else(|| {
572                        anyhow::anyhow!("symlink action requires src")
573                    })?;
574                    let dest = action.dest().ok_or_else(|| {
575                        anyhow::anyhow!("symlink action requires dest")
576                    })?;
577                    let dest_path = self.mountpath(id, dest);
578                    // Create parent directory if needed
579                    if let Some(parent) = dest_path.parent() {
580                        if !parent.exists() {
581                            fs::create_dir_all(parent)?;
582                        }
583                    }
584                    std::os::unix::fs::symlink(src, &dest_path)?;
585                    None
586                }
587            };
588            if let Some(s) = status {
589                if !s.success() {
590                    bail!("Sandbox action failed");
591                }
592            }
593        }
594        Ok(())
595    }
596
597    /// Run a custom action command.
598    /// The command is run via /bin/sh -c with environment variables set.
599    /// If cwd is specified, the directory is created if it doesn't exist.
600    fn run_action_cmd(
601        &self,
602        id: usize,
603        cmd: &str,
604        cwd: Option<&PathBuf>,
605    ) -> Result<Option<std::process::ExitStatus>> {
606        let sandbox_path = self.path(id);
607        let work_dir = if let Some(c) = cwd {
608            self.mountpath(id, c)
609        } else {
610            sandbox_path.clone()
611        };
612        self.verify_path_in_sandbox(id, &work_dir)?;
613
614        // Create the working directory if it doesn't exist
615        if !work_dir.exists() {
616            fs::create_dir_all(&work_dir)?;
617        }
618
619        let status = Command::new("/bin/sh")
620            .arg("-c")
621            .arg(cmd)
622            .current_dir(&work_dir)
623            .status()?;
624
625        Ok(Some(status))
626    }
627
628    fn reverse_actions(&self, id: usize) -> anyhow::Result<()> {
629        let Some(sandbox) = &self.config.sandboxes() else {
630            bail!(
631                "Internal error: trying to reverse actions when sandboxes disabled."
632            );
633        };
634        for action in sandbox.actions.iter().rev() {
635            let action_type = action.action_type()?;
636            // dest defaults to src if not specified
637            let dest =
638                action.dest().or(action.src()).map(|d| self.mountpath(id, d));
639
640            match action_type {
641                ActionType::Cmd => {
642                    // For cmd actions, we run the destroy command
643                    if let Some(destroy_cmd) = action.destroy_cmd() {
644                        let status =
645                            self.run_action_cmd(id, destroy_cmd, action.cwd())?;
646                        if let Some(s) = status {
647                            if !s.success() {
648                                bail!(
649                                    "Failed to run destroy command: exit code {:?}",
650                                    s.code()
651                                );
652                            }
653                        }
654                    }
655                    // Clean up cwd directory if it was created
656                    if let Some(cwd) = action.cwd() {
657                        let cwd_path = self.mountpath(id, cwd);
658                        self.remove_empty_dirs(id, &cwd_path);
659                    }
660                }
661                ActionType::Copy => {
662                    // Copied directories need to be removed
663                    let Some(mntdest) = dest else { continue };
664                    if !mntdest.exists() {
665                        self.remove_empty_dirs(id, &mntdest);
666                        continue;
667                    }
668                    if fs::remove_dir(&mntdest).is_ok() {
669                        continue;
670                    }
671                    /*
672                     * Use remove_dir_recursive which fails if non-empty
673                     * directories remain, rather than blindly deleting.
674                     * First verify the path is within the sandbox.
675                     */
676                    self.verify_path_in_sandbox(id, &mntdest)?;
677                    self.remove_dir_recursive(&mntdest)?;
678                    self.remove_empty_dirs(id, &mntdest);
679                }
680                ActionType::Symlink => {
681                    // Remove the symlink
682                    let Some(mntdest) = dest else { continue };
683                    if mntdest.is_symlink() {
684                        fs::remove_file(&mntdest)?;
685                    }
686                    self.remove_empty_dirs(id, &mntdest);
687                }
688                ActionType::Mount => {
689                    // For mount actions, we need to unmount
690                    let Some(mntdest) = dest else { continue };
691                    let fs_type = action.fs_type()?;
692
693                    // If ifexists was set and src doesn't exist, the mount was skipped
694                    let src = action.src().or(action.dest());
695                    if let Some(src) = src {
696                        if action.ifexists() && !src.exists() {
697                            continue;
698                        }
699                    }
700
701                    /*
702                     * If the mount point itself does not exist then do not try to
703                     * unmount it, but do try to clean up any empty parent
704                     * directories up to the root.
705                     */
706                    if !mntdest.exists() {
707                        self.remove_empty_dirs(id, &mntdest);
708                        continue;
709                    }
710
711                    /*
712                     * Before trying to unmount, try just removing the directory,
713                     * in case it was never mounted in the first place.  Avoids
714                     * errors trying to unmount a file system that isn't mounted.
715                     */
716                    if fs::remove_dir(&mntdest).is_ok() {
717                        continue;
718                    }
719
720                    /*
721                     * Unmount the filesystem.  Check return codes and bail on
722                     * failure - it is critical that all mounts are successfully
723                     * unmounted before we attempt to remove the sandbox directory.
724                     */
725                    let status = match fs_type {
726                        FSType::Bind => self.unmount_bindfs(&mntdest)?,
727                        FSType::Dev => self.unmount_devfs(&mntdest)?,
728                        FSType::Fd => self.unmount_fdfs(&mntdest)?,
729                        FSType::Nfs => self.unmount_nfs(&mntdest)?,
730                        FSType::Proc => self.unmount_procfs(&mntdest)?,
731                        FSType::Tmp => self.unmount_tmpfs(&mntdest)?,
732                    };
733                    if let Some(s) = status {
734                        if !s.success() {
735                            bail!("Failed to unmount {}", mntdest.display());
736                        }
737                    }
738                    self.remove_empty_dirs(id, &mntdest);
739                }
740            }
741        }
742        Ok(())
743    }
744
745    /**
746     * Recursively remove a directory by walking it depth-first and removing
747     * files and empty directories.  Unlike remove_dir_all, this will fail
748     * if it encounters a non-empty directory that cannot be removed, which
749     * would indicate an active mount point.
750     *
751     * IMPORTANT: This function explicitly does NOT follow symlinks to avoid
752     * deleting files outside the sandbox via symlink attacks.
753     */
754    #[allow(clippy::only_used_in_recursion)]
755    fn remove_dir_recursive(&self, path: &Path) -> Result<()> {
756        // Use symlink_metadata to check type WITHOUT following symlinks
757        let meta = fs::symlink_metadata(path)?;
758        if meta.is_symlink() {
759            // Remove the symlink itself, don't follow it
760            fs::remove_file(path)?;
761            return Ok(());
762        }
763        if !meta.is_dir() {
764            fs::remove_file(path)?;
765            return Ok(());
766        }
767        for entry in fs::read_dir(path)? {
768            let entry = entry?;
769            let entry_path = entry.path();
770            // Use file_type() from DirEntry which doesn't follow symlinks
771            let file_type = entry.file_type()?;
772            if file_type.is_symlink() {
773                // Remove symlink itself, don't follow
774                fs::remove_file(&entry_path)?;
775            } else if file_type.is_dir() {
776                self.remove_dir_recursive(&entry_path)?;
777            } else {
778                fs::remove_file(&entry_path)?;
779            }
780        }
781        fs::remove_dir(path)?;
782        Ok(())
783    }
784}