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}