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