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(windows)]
80                {
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                #[cfg(not(windows))]
91                {
92                    remove_file(&path)?;
93                }
94            } else {
95                remove_file(&path)?;
96            }
97
98            reporter.map(CleanReporter::on_clean);
99
100            return Ok(());
101        }
102
103        for entry in walkdir::WalkDir::new(&path).contents_first(true) {
104            // If we hit a directory that lacks read permissions, try to make it readable.
105            if let Err(ref err) = entry {
106                if err
107                    .io_error()
108                    .is_some_and(|err| err.kind() == io::ErrorKind::PermissionDenied)
109                {
110                    if let Some(dir) = err.path() {
111                        if set_readable(dir).unwrap_or(false) {
112                            // Retry the operation; if we _just_ `self.rm_rf(dir)` and continue,
113                            // `walkdir` may give us duplicate entries for the directory.
114                            return self.rm_rf(&path, reporter, skip_locked_file);
115                        }
116                    }
117                }
118            }
119
120            let entry = entry?;
121
122            // Remove the exclusive lock last.
123            if skip_locked_file
124                && entry.file_name() == ".lock"
125                && entry
126                    .path()
127                    .strip_prefix(&path)
128                    .is_ok_and(|suffix| suffix == Path::new(".lock"))
129            {
130                continue;
131            }
132
133            if entry.file_type().is_symlink() && {
134                #[cfg(windows)]
135                {
136                    use std::os::windows::fs::FileTypeExt;
137                    entry.file_type().is_symlink_dir()
138                }
139                #[cfg(not(windows))]
140                {
141                    false
142                }
143            } {
144                self.num_files += 1;
145                remove_dir(entry.path())?;
146            } else if entry.file_type().is_dir() {
147                // Remove the directory with the exclusive lock last.
148                if skip_locked_file && entry.path() == path.as_ref() {
149                    continue;
150                }
151
152                self.num_dirs += 1;
153
154                // The contents should have been removed by now, but sometimes a race condition is
155                // hit where other files have been added by the OS. Fall back to `remove_dir_all`,
156                // which will remove the directory robustly across platforms.
157                remove_dir_all(entry.path())?;
158            } else {
159                self.num_files += 1;
160
161                // Remove the file.
162                if let Ok(meta) = entry.metadata() {
163                    self.total_bytes += meta.len();
164                }
165                remove_file(entry.path())?;
166            }
167
168            reporter.map(CleanReporter::on_clean);
169        }
170
171        reporter.map(CleanReporter::on_complete);
172
173        Ok(())
174    }
175}
176
177impl std::ops::AddAssign for Removal {
178    fn add_assign(&mut self, other: Self) {
179        self.num_files += other.num_files;
180        self.num_dirs += other.num_dirs;
181        self.total_bytes += other.total_bytes;
182    }
183}
184
185/// If the directory isn't readable by the current user, change the permissions to make it readable.
186#[cfg_attr(windows, allow(unused_variables, clippy::unnecessary_wraps))]
187fn set_readable(path: &Path) -> io::Result<bool> {
188    #[cfg(unix)]
189    {
190        use std::os::unix::fs::PermissionsExt;
191        let mut perms = fs_err::metadata(path)?.permissions();
192        if perms.mode() & 0o500 == 0 {
193            perms.set_mode(perms.mode() | 0o500);
194            fs_err::set_permissions(path, perms)?;
195            return Ok(true);
196        }
197    }
198    Ok(false)
199}
200
201/// If the file is readonly, change the permissions to make it _not_ readonly.
202fn set_not_readonly(path: &Path) -> io::Result<bool> {
203    let mut perms = fs_err::metadata(path)?.permissions();
204    if !perms.readonly() {
205        return Ok(false);
206    }
207
208    // We're about to delete the file, so it's fine to set the permissions to world-writable.
209    #[expect(clippy::permissions_set_readonly_false)]
210    perms.set_readonly(false);
211
212    fs_err::set_permissions(path, perms)?;
213
214    Ok(true)
215}
216
217/// Like [`fs_err::remove_file`], but attempts to change the permissions to force the file to be
218/// deleted (if it is readonly).
219fn remove_file(path: &Path) -> io::Result<()> {
220    match fs_err::remove_file(path) {
221        Ok(()) => Ok(()),
222        Err(err)
223            if err.kind() == io::ErrorKind::PermissionDenied
224                && set_not_readonly(path).unwrap_or(false) =>
225        {
226            fs_err::remove_file(path)
227        }
228        Err(err) => Err(err),
229    }
230}
231
232/// Like [`fs_err::remove_dir`], but attempts to change the permissions to force the directory to
233/// be deleted (if it is readonly).
234fn remove_dir(path: &Path) -> io::Result<()> {
235    match fs_err::remove_dir(path) {
236        Ok(()) => Ok(()),
237        Err(err)
238            if err.kind() == io::ErrorKind::PermissionDenied
239                && set_readable(path).unwrap_or(false) =>
240        {
241            fs_err::remove_dir(path)
242        }
243        Err(err) => Err(err),
244    }
245}
246
247/// Like [`fs_err::remove_dir_all`], but attempts to change the permissions to force the directory
248/// to be deleted (if it is readonly).
249fn remove_dir_all(path: &Path) -> io::Result<()> {
250    match fs_err::remove_dir_all(path) {
251        Ok(()) => Ok(()),
252        Err(err)
253            if err.kind() == io::ErrorKind::PermissionDenied
254                && set_readable(path).unwrap_or(false) =>
255        {
256            fs_err::remove_dir_all(path)
257        }
258        Err(err) => Err(err),
259    }
260}