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