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}