Skip to main content

uv_cache/
removal.rs

1//! Derived from Cargo's `clean` implementation.
2//! Cargo is dual-licensed under either Apache 2.0 or MIT, at the user's choice.
3//! Source: <https://github.com/rust-lang/cargo/blob/e1ebce1035f9b53bb46a55bd4b0ecf51e24c6458/src/cargo/ops/cargo_clean.rs#L324>
4
5use std::io;
6use std::path::Path;
7
8use crate::CleanReporter;
9
10/// Remove a file or directory and all its contents, returning a [`Removal`] with
11/// the number of files and directories removed, along with a total byte count.
12pub fn rm_rf(path: impl AsRef<Path>) -> io::Result<Removal> {
13    Remover::default().rm_rf(path, false)
14}
15
16/// A builder for a [`Remover`] that can remove files and directories.
17#[derive(Default)]
18pub(crate) struct Remover {
19    reporter: Option<Box<dyn CleanReporter>>,
20}
21
22impl Remover {
23    /// Create a new [`Remover`] with the given reporter.
24    pub(crate) fn new(reporter: Box<dyn CleanReporter>) -> Self {
25        Self {
26            reporter: Some(reporter),
27        }
28    }
29
30    /// Remove a file or directory and all its contents, returning a [`Removal`] with
31    /// the number of files and directories removed, along with a total byte count.
32    pub(crate) fn rm_rf(
33        &self,
34        path: impl AsRef<Path>,
35        skip_locked_file: bool,
36    ) -> io::Result<Removal> {
37        let mut removal = Removal::default();
38        removal.rm_rf(path.as_ref(), self.reporter.as_deref(), skip_locked_file)?;
39        Ok(removal)
40    }
41}
42
43/// A removal operation with statistics on the number of files and directories removed.
44#[derive(Debug, Default)]
45pub struct Removal {
46    /// The number of files removed.
47    pub num_files: u64,
48    /// The number of directories removed.
49    pub num_dirs: u64,
50    /// The total number of bytes removed.
51    ///
52    /// Note: this will both over-count bytes removed for hard-linked files, and under-count
53    /// bytes in general since it's a measure of the exact byte size (as opposed to the block size).
54    pub total_bytes: u64,
55}
56
57impl Removal {
58    /// Recursively remove a file or directory and all its contents.
59    fn rm_rf(
60        &mut self,
61        path: &Path,
62        reporter: Option<&dyn CleanReporter>,
63        skip_locked_file: bool,
64    ) -> io::Result<()> {
65        let path = uv_fs::verbatim_path(path);
66
67        let metadata = match fs_err::symlink_metadata(&path) {
68            Ok(metadata) => metadata,
69            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
70            Err(err) => return Err(err),
71        };
72
73        if !metadata.is_dir() {
74            self.num_files += 1;
75
76            // Remove the file.
77            self.total_bytes += metadata.len();
78            if metadata.is_symlink() {
79                cfg_select! {
80                    windows => {
81                        use std::os::windows::fs::FileTypeExt;
82
83                        if metadata.file_type().is_symlink_dir() {
84                            remove_dir(&path)?;
85                        } else {
86                            remove_file(&path)?;
87                        }
88                    },
89                    _ => {
90                        remove_file(&path)?;
91                    },
92                }
93            } else {
94                remove_file(&path)?;
95            }
96
97            reporter.map(CleanReporter::on_clean);
98
99            return Ok(());
100        }
101
102        for entry in walkdir::WalkDir::new(&path).contents_first(true) {
103            // If we hit a directory that lacks read permissions, try to make it readable.
104            if let Err(ref err) = entry {
105                if err
106                    .io_error()
107                    .is_some_and(|err| err.kind() == io::ErrorKind::PermissionDenied)
108                {
109                    if let Some(dir) = err.path() {
110                        if set_readable(dir).unwrap_or(false) {
111                            // Retry the operation; if we _just_ `self.rm_rf(dir)` and continue,
112                            // `walkdir` may give us duplicate entries for the directory.
113                            return self.rm_rf(&path, reporter, skip_locked_file);
114                        }
115                    }
116                }
117            }
118
119            let entry = entry?;
120
121            // Remove the exclusive lock last.
122            if skip_locked_file
123                && entry.file_name() == ".lock"
124                && entry
125                    .path()
126                    .strip_prefix(&path)
127                    .is_ok_and(|suffix| suffix == Path::new(".lock"))
128            {
129                continue;
130            }
131
132            if entry.file_type().is_symlink() && {
133                #[cfg(windows)]
134                {
135                    use std::os::windows::fs::FileTypeExt;
136                    entry.file_type().is_symlink_dir()
137                }
138                #[cfg(not(windows))]
139                {
140                    false
141                }
142            } {
143                self.num_files += 1;
144                remove_dir(entry.path())?;
145            } else if entry.file_type().is_dir() {
146                // Remove the directory with the exclusive lock last.
147                if skip_locked_file && entry.path() == path.as_ref() {
148                    continue;
149                }
150
151                self.num_dirs += 1;
152
153                // The contents should have been removed by now, but sometimes a race condition is
154                // hit where other files have been added by the OS. Fall back to `remove_dir_all`,
155                // which will remove the directory robustly across platforms.
156                remove_dir_all(entry.path())?;
157            } else {
158                self.num_files += 1;
159
160                // Remove the file.
161                if let Ok(meta) = entry.metadata() {
162                    self.total_bytes += meta.len();
163                }
164                remove_file(entry.path())?;
165            }
166
167            reporter.map(CleanReporter::on_clean);
168        }
169
170        reporter.map(CleanReporter::on_complete);
171
172        Ok(())
173    }
174}
175
176impl std::ops::AddAssign for Removal {
177    fn add_assign(&mut self, other: Self) {
178        self.num_files += other.num_files;
179        self.num_dirs += other.num_dirs;
180        self.total_bytes += other.total_bytes;
181    }
182}
183
184/// If the directory isn't readable by the current user, change the permissions to make it readable.
185#[cfg_attr(windows, allow(unused_variables, clippy::unnecessary_wraps))]
186fn set_readable(path: &Path) -> io::Result<bool> {
187    #[cfg(unix)]
188    {
189        use std::os::unix::fs::PermissionsExt;
190        let mut perms = fs_err::metadata(path)?.permissions();
191        if perms.mode() & 0o500 == 0 {
192            perms.set_mode(perms.mode() | 0o500);
193            fs_err::set_permissions(path, perms)?;
194            return Ok(true);
195        }
196    }
197    Ok(false)
198}
199
200/// If the file is readonly, change the permissions to make it _not_ readonly.
201fn set_not_readonly(path: &Path) -> io::Result<bool> {
202    let mut perms = fs_err::metadata(path)?.permissions();
203    if !perms.readonly() {
204        return Ok(false);
205    }
206
207    // We're about to delete the file, so it's fine to set the permissions to world-writable.
208    #[expect(clippy::permissions_set_readonly_false)]
209    perms.set_readonly(false);
210
211    fs_err::set_permissions(path, perms)?;
212
213    Ok(true)
214}
215
216/// Like [`fs_err::remove_file`], but attempts to change the permissions to force the file to be
217/// deleted (if it is readonly).
218fn remove_file(path: &Path) -> io::Result<()> {
219    match fs_err::remove_file(path) {
220        Ok(()) => Ok(()),
221        Err(err)
222            if err.kind() == io::ErrorKind::PermissionDenied
223                && set_not_readonly(path).unwrap_or(false) =>
224        {
225            fs_err::remove_file(path)
226        }
227        Err(err) => Err(err),
228    }
229}
230
231/// Like [`fs_err::remove_dir`], but attempts to change the permissions to force the directory to
232/// be deleted (if it is readonly).
233fn remove_dir(path: &Path) -> io::Result<()> {
234    match fs_err::remove_dir(path) {
235        Ok(()) => Ok(()),
236        Err(err)
237            if err.kind() == io::ErrorKind::PermissionDenied
238                && set_readable(path).unwrap_or(false) =>
239        {
240            fs_err::remove_dir(path)
241        }
242        Err(err) => Err(err),
243    }
244}
245
246/// Like [`fs_err::remove_dir_all`], but attempts to change the permissions to force the directory
247/// to be deleted (if it is readonly).
248fn remove_dir_all(path: &Path) -> io::Result<()> {
249    match fs_err::remove_dir_all(path) {
250        Ok(()) => Ok(()),
251        Err(err)
252            if err.kind() == io::ErrorKind::PermissionDenied
253                && set_readable(path).unwrap_or(false) =>
254        {
255            fs_err::remove_dir_all(path)
256        }
257        Err(err) => Err(err),
258    }
259}