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