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 tracing::debug;
9
10use crate::CleanReporter;
11
12/// The storage accounting used when removing cache entries.
13#[derive(Debug, Clone, Copy, Default)]
14pub enum RemovalMode {
15    /// Report the logical size of the removed files.
16    #[default]
17    Logical,
18    /// Report the exclusively owned physical storage reclaimed by the removed files.
19    Physical,
20}
21
22/// A builder for a [`Remover`] that can remove files and directories.
23#[derive(Default)]
24pub(crate) struct Remover {
25    reporter: Option<Box<dyn CleanReporter>>,
26    removal_mode: RemovalMode,
27}
28
29impl Remover {
30    /// Create a new [`Remover`] with the given reporter.
31    pub(crate) fn new(reporter: Box<dyn CleanReporter>) -> Self {
32        Self {
33            reporter: Some(reporter),
34            ..Self::default()
35        }
36    }
37
38    /// Set the storage accounting used before each file is removed.
39    pub(crate) fn with_removal_mode(mut self, removal_mode: RemovalMode) -> Self {
40        self.removal_mode = removal_mode;
41        self
42    }
43
44    /// Remove a file or directory and all its contents, returning a [`Removal`] with
45    /// the number of files and directories removed, along with a total byte count.
46    pub(crate) fn rm_rf(
47        &self,
48        path: impl AsRef<Path>,
49        skip_locked_file: bool,
50    ) -> io::Result<Removal> {
51        let mut removal = Removal::new(self.removal_mode);
52        removal.rm_rf(path.as_ref(), self.reporter.as_deref(), skip_locked_file)?;
53        Ok(removal)
54    }
55}
56
57/// A removal operation with statistics on the number of files and directories removed.
58#[derive(Debug, Default)]
59pub struct Removal {
60    /// The number of files removed.
61    pub num_files: u64,
62    /// The number of directories removed.
63    pub num_dirs: u64,
64    /// The logical number of bytes removed.
65    ///
66    /// Note: this will both over-count bytes removed for hard-linked files, and under-count
67    /// bytes in general since it's a measure of the exact byte size (as opposed to the block size).
68    pub logical_bytes: u64,
69    /// The exclusively owned physical file data reclaimed by the removal, when available.
70    pub physical_bytes: Option<u64>,
71    /// Whether any removed entries could not be measured, making the physical count a lower bound.
72    pub physical_bytes_incomplete: bool,
73}
74
75impl Removal {
76    /// Create an empty removal summary with the requested storage accounting.
77    pub(crate) fn new(removal_mode: RemovalMode) -> Self {
78        Self {
79            physical_bytes: match removal_mode {
80                RemovalMode::Logical => None,
81                RemovalMode::Physical => Some(0),
82            },
83            ..Self::default()
84        }
85    }
86
87    /// Account for a file while its current sharing state can still be inspected.
88    fn add_file(&mut self, path: &Path, metadata: &std::fs::Metadata) {
89        self.logical_bytes += metadata.len();
90
91        if let Some(physical_bytes) = self.physical_bytes {
92            match uv_fs::physical_space(path, metadata) {
93                Ok(physical) => {
94                    self.physical_bytes = Some(physical_bytes.saturating_add(physical));
95                }
96                Err(error) => {
97                    debug!(
98                        "Failed to measure physical space for {}: {error}",
99                        path.display()
100                    );
101                    self.physical_bytes_incomplete = true;
102                }
103            }
104        }
105    }
106
107    /// Recursively remove a file or directory and all its contents.
108    fn rm_rf(
109        &mut self,
110        path: &Path,
111        reporter: Option<&dyn CleanReporter>,
112        skip_locked_file: bool,
113    ) -> io::Result<()> {
114        let path = uv_fs::verbatim_path(path);
115
116        let metadata = match fs_err::symlink_metadata(&path) {
117            Ok(metadata) => metadata,
118            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
119            Err(err) => return Err(err),
120        };
121
122        if !metadata.is_dir() {
123            self.num_files += 1;
124
125            // Remove the file.
126            self.add_file(&path, &metadata);
127            if metadata.is_symlink() {
128                cfg_select! {
129                    windows => {
130                        use std::os::windows::fs::FileTypeExt;
131
132                        if metadata.file_type().is_symlink_dir() {
133                            remove_dir(&path)?;
134                        } else {
135                            remove_file(&path)?;
136                        }
137                    },
138                    _ => {
139                        remove_file(&path)?;
140                    },
141                }
142            } else {
143                remove_file(&path)?;
144            }
145
146            reporter.map(CleanReporter::on_clean);
147
148            return Ok(());
149        }
150
151        for entry in walkdir::WalkDir::new(&path).contents_first(true) {
152            // If we hit a directory that lacks read permissions, try to make it readable.
153            if let Err(ref err) = entry {
154                if err
155                    .io_error()
156                    .is_some_and(|err| err.kind() == io::ErrorKind::PermissionDenied)
157                {
158                    if let Some(dir) = err.path() {
159                        if set_readable(dir).unwrap_or(false) {
160                            // Retry the operation; if we _just_ `self.rm_rf(dir)` and continue,
161                            // `walkdir` may give us duplicate entries for the directory.
162                            return self.rm_rf(&path, reporter, skip_locked_file);
163                        }
164                    }
165                }
166            }
167
168            let entry = entry?;
169
170            // Remove the exclusive lock last.
171            if skip_locked_file
172                && entry.file_name() == ".lock"
173                && entry
174                    .path()
175                    .strip_prefix(&path)
176                    .is_ok_and(|suffix| suffix == Path::new(".lock"))
177            {
178                continue;
179            }
180
181            if entry.file_type().is_symlink() && {
182                #[cfg(windows)]
183                {
184                    use std::os::windows::fs::FileTypeExt;
185                    entry.file_type().is_symlink_dir()
186                }
187                #[cfg(not(windows))]
188                {
189                    false
190                }
191            } {
192                self.num_files += 1;
193                remove_dir(entry.path())?;
194            } else if entry.file_type().is_dir() {
195                // Remove the directory with the exclusive lock last.
196                if skip_locked_file && entry.path() == path.as_ref() {
197                    continue;
198                }
199
200                self.num_dirs += 1;
201
202                // The contents should have been removed by now, but sometimes a race condition is
203                // hit where other files have been added by the OS. Fall back to `remove_dir_all`,
204                // which will remove the directory robustly across platforms.
205                remove_dir_all(entry.path())?;
206            } else {
207                self.num_files += 1;
208
209                // Remove the file.
210                if let Ok(metadata) = entry.metadata() {
211                    self.add_file(entry.path(), &metadata);
212                } else if self.physical_bytes.is_some() {
213                    self.physical_bytes_incomplete = true;
214                }
215                remove_file(entry.path())?;
216            }
217
218            reporter.map(CleanReporter::on_clean);
219        }
220
221        reporter.map(CleanReporter::on_complete);
222
223        Ok(())
224    }
225}
226
227impl std::ops::AddAssign for Removal {
228    fn add_assign(&mut self, other: Self) {
229        self.num_files += other.num_files;
230        self.num_dirs += other.num_dirs;
231        self.logical_bytes += other.logical_bytes;
232        self.physical_bytes = self
233            .physical_bytes
234            .zip(other.physical_bytes)
235            .map(|(left, right)| left.saturating_add(right));
236        self.physical_bytes_incomplete |= other.physical_bytes_incomplete;
237    }
238}
239
240/// If the directory isn't readable by the current user, change the permissions to make it readable.
241#[cfg_attr(windows, allow(unused_variables, clippy::unnecessary_wraps))]
242fn set_readable(path: &Path) -> io::Result<bool> {
243    #[cfg(unix)]
244    {
245        use std::os::unix::fs::PermissionsExt;
246        let mut perms = fs_err::metadata(path)?.permissions();
247        if perms.mode() & 0o500 == 0 {
248            perms.set_mode(perms.mode() | 0o500);
249            fs_err::set_permissions(path, perms)?;
250            return Ok(true);
251        }
252    }
253    Ok(false)
254}
255
256/// If the file is readonly, change the permissions to make it _not_ readonly.
257fn set_not_readonly(path: &Path) -> io::Result<bool> {
258    let mut perms = fs_err::metadata(path)?.permissions();
259    if !perms.readonly() {
260        return Ok(false);
261    }
262
263    // We're about to delete the file, so it's fine to set the permissions to world-writable.
264    #[expect(clippy::permissions_set_readonly_false)]
265    perms.set_readonly(false);
266
267    fs_err::set_permissions(path, perms)?;
268
269    Ok(true)
270}
271
272/// Like [`fs_err::remove_file`], but attempts to change the permissions to force the file to be
273/// deleted (if it is readonly).
274fn remove_file(path: &Path) -> io::Result<()> {
275    match fs_err::remove_file(path) {
276        Ok(()) => Ok(()),
277        Err(err)
278            if err.kind() == io::ErrorKind::PermissionDenied
279                && set_not_readonly(path).unwrap_or(false) =>
280        {
281            fs_err::remove_file(path)
282        }
283        Err(err) => Err(err),
284    }
285}
286
287/// Like [`fs_err::remove_dir`], but attempts to change the permissions to force the directory to
288/// be deleted (if it is readonly).
289fn remove_dir(path: &Path) -> io::Result<()> {
290    match fs_err::remove_dir(path) {
291        Ok(()) => Ok(()),
292        Err(err)
293            if err.kind() == io::ErrorKind::PermissionDenied
294                && set_readable(path).unwrap_or(false) =>
295        {
296            fs_err::remove_dir(path)
297        }
298        Err(err) => Err(err),
299    }
300}
301
302/// Like [`fs_err::remove_dir_all`], but attempts to change the permissions to force the directory
303/// to be deleted (if it is readonly).
304fn remove_dir_all(path: &Path) -> io::Result<()> {
305    match fs_err::remove_dir_all(path) {
306        Ok(()) => Ok(()),
307        Err(err)
308            if err.kind() == io::ErrorKind::PermissionDenied
309                && set_readable(path).unwrap_or(false) =>
310        {
311            fs_err::remove_dir_all(path)
312        }
313        Err(err) => Err(err),
314    }
315}