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