Skip to main content

uv_cache/
lib.rs

1use std::fmt::{Display, Formatter};
2use std::io;
3use std::io::Write;
4use std::ops::Deref;
5use std::path::{Path, PathBuf};
6use std::str::FromStr;
7use std::sync::Arc;
8
9use rustc_hash::FxHashMap;
10use tracing::{debug, warn};
11
12use uv_cache_info::Timestamp;
13use uv_fs::{LockedFile, LockedFileError, LockedFileMode, Simplified, cachedir, directories};
14use uv_normalize::PackageName;
15use uv_pypi_types::ResolutionMetadata;
16
17pub use crate::by_timestamp::CachedByTimestamp;
18#[cfg(feature = "clap")]
19pub use crate::cli::CacheArgs;
20use crate::removal::Remover;
21pub use crate::removal::{Removal, RemovalAccounting};
22pub use crate::wheel::WheelCache;
23use crate::wheel::WheelCacheKind;
24pub use archive::{ArchiveFileId, ArchiveId};
25
26mod archive;
27mod by_timestamp;
28#[cfg(feature = "clap")]
29mod cli;
30mod removal;
31mod wheel;
32
33/// The version of the archive bucket.
34///
35/// Must be kept in-sync with the version in [`CacheBucket::to_str`].
36pub const ARCHIVE_VERSION: u8 = 0;
37
38/// Error locking a cache entry or shard
39#[derive(Debug, thiserror::Error)]
40pub enum Error {
41    #[error(transparent)]
42    Io(#[from] io::Error),
43    #[error("Failed to initialize cache at `{}`", _0.user_display())]
44    Init(PathBuf, #[source] io::Error),
45    #[error("Could not make the path absolute")]
46    Absolute(#[source] io::Error),
47    #[error("Could not acquire lock")]
48    Acquire(#[from] LockedFileError),
49}
50
51/// A [`CacheEntry`] which may or may not exist yet.
52#[derive(Debug, Clone)]
53pub struct CacheEntry(PathBuf);
54
55impl CacheEntry {
56    /// Create a new [`CacheEntry`] from a directory and a file name.
57    pub fn new(dir: impl Into<PathBuf>, file: impl AsRef<Path>) -> Self {
58        Self(dir.into().join(file))
59    }
60
61    /// Create a new [`CacheEntry`] from a path.
62    pub fn from_path(path: impl Into<PathBuf>) -> Self {
63        Self(path.into())
64    }
65
66    /// Return the cache entry's parent directory.
67    pub fn shard(&self) -> CacheShard {
68        CacheShard(self.dir().to_path_buf())
69    }
70
71    /// Convert the [`CacheEntry`] into a [`PathBuf`].
72    #[inline]
73    pub fn into_path_buf(self) -> PathBuf {
74        self.0
75    }
76
77    /// Return the path to the [`CacheEntry`].
78    #[inline]
79    pub fn path(&self) -> &Path {
80        &self.0
81    }
82
83    /// Return the cache entry's parent directory.
84    #[inline]
85    pub fn dir(&self) -> &Path {
86        self.0.parent().expect("Cache entry has no parent")
87    }
88
89    /// Create a new [`CacheEntry`] with the given file name.
90    #[must_use]
91    pub fn with_file(&self, file: impl AsRef<Path>) -> Self {
92        Self(self.dir().join(file))
93    }
94
95    /// Acquire the [`CacheEntry`] as an exclusive lock.
96    pub async fn lock(&self) -> Result<LockedFile, Error> {
97        fs_err::create_dir_all(self.dir())?;
98        Ok(LockedFile::acquire(
99            self.path(),
100            LockedFileMode::Exclusive,
101            self.path().display(),
102        )
103        .await?)
104    }
105}
106
107impl AsRef<Path> for CacheEntry {
108    fn as_ref(&self) -> &Path {
109        &self.0
110    }
111}
112
113/// A subdirectory within the cache.
114#[derive(Debug, Clone)]
115pub struct CacheShard(PathBuf);
116
117impl CacheShard {
118    /// Return a [`CacheEntry`] within this shard.
119    pub fn entry(&self, file: impl AsRef<Path>) -> CacheEntry {
120        CacheEntry::new(&self.0, file)
121    }
122
123    /// Return a [`CacheShard`] within this shard.
124    #[must_use]
125    pub fn shard(&self, dir: impl AsRef<Path>) -> Self {
126        Self(self.0.join(dir.as_ref()))
127    }
128
129    /// Acquire the cache entry as an exclusive lock.
130    pub async fn lock(&self) -> Result<LockedFile, Error> {
131        fs_err::create_dir_all(self.as_ref())?;
132        Ok(LockedFile::acquire(
133            self.join(".lock"),
134            LockedFileMode::Exclusive,
135            self.display(),
136        )
137        .await?)
138    }
139
140    /// Return the [`CacheShard`] as a [`PathBuf`].
141    pub fn into_path_buf(self) -> PathBuf {
142        self.0
143    }
144}
145
146impl AsRef<Path> for CacheShard {
147    fn as_ref(&self) -> &Path {
148        &self.0
149    }
150}
151
152impl Deref for CacheShard {
153    type Target = Path;
154
155    fn deref(&self) -> &Self::Target {
156        &self.0
157    }
158}
159
160/// The main cache abstraction.
161///
162/// While the cache is active, it holds a read (shared) lock that prevents cache cleaning
163#[derive(Debug, Clone)]
164pub struct Cache {
165    /// The cache directory.
166    root: PathBuf,
167    /// The refresh strategy to use when reading from the cache.
168    refresh: Refresh,
169    /// Ensure that `uv cache` operations don't remove items from the cache that are used by another
170    /// uv process. Drop the lock before a temporary directory so it can be removed on Windows.
171    lock_file: Option<Arc<LockedFile>>,
172    /// A temporary cache directory, if the user requested `--no-cache`.
173    ///
174    /// Included to ensure that the temporary directory exists for the length of the operation, but
175    /// is dropped at the end as appropriate.
176    temp_dir: Option<Arc<tempfile::TempDir>>,
177    /// The storage accounting used when removing cache entries.
178    removal_accounting: RemovalAccounting,
179}
180
181impl Cache {
182    /// A persistent cache directory at `root`.
183    pub fn from_path(root: impl Into<PathBuf>) -> Self {
184        Self {
185            root: root.into(),
186            refresh: Refresh::None(Timestamp::now()),
187            temp_dir: None,
188            lock_file: None,
189            removal_accounting: RemovalAccounting::Coarse,
190        }
191    }
192
193    /// Create a temporary cache directory.
194    pub fn temp() -> Result<Self, io::Error> {
195        let temp_dir = tempfile::tempdir()?;
196        Ok(Self {
197            root: temp_dir.path().to_path_buf(),
198            refresh: Refresh::None(Timestamp::now()),
199            temp_dir: Some(Arc::new(temp_dir)),
200            lock_file: None,
201            removal_accounting: RemovalAccounting::Coarse,
202        })
203    }
204
205    /// Set the [`Refresh`] policy for the cache.
206    #[must_use]
207    pub fn with_refresh(self, refresh: Refresh) -> Self {
208        Self { refresh, ..self }
209    }
210
211    /// Set the storage accounting used when removing cache entries.
212    ///
213    /// Falls back to [`RemovalAccounting::Coarse`] when fine-grained accounting is unsupported.
214    #[must_use]
215    pub fn with_removal_accounting(self, removal_accounting: RemovalAccounting) -> Self {
216        let removal_accounting = match removal_accounting {
217            RemovalAccounting::Fine if !uv_fs::supports_fine_grained_accounting() => {
218                RemovalAccounting::Coarse
219            }
220            removal_accounting => removal_accounting,
221        };
222        Self {
223            removal_accounting,
224            ..self
225        }
226    }
227
228    /// Create an empty removal summary using the cache's configured accounting.
229    pub fn removal(&self) -> Removal {
230        Removal::new(self.removal_accounting)
231    }
232
233    /// Acquire a lock that allows removing entries from the cache.
234    pub async fn with_exclusive_lock(self) -> Result<Self, LockedFileError> {
235        let Self {
236            root,
237            refresh,
238            temp_dir,
239            lock_file,
240            removal_accounting,
241        } = self;
242
243        // Release the existing lock, avoid deadlocks from a cloned cache.
244        if let Some(lock_file) = lock_file {
245            drop(
246                Arc::try_unwrap(lock_file).expect(
247                    "cloning the cache before acquiring an exclusive lock causes a deadlock",
248                ),
249            );
250        }
251        let lock_file = LockedFile::acquire(
252            root.join(".lock"),
253            LockedFileMode::Exclusive,
254            root.simplified_display(),
255        )
256        .await?;
257
258        Ok(Self {
259            root,
260            refresh,
261            temp_dir,
262            lock_file: Some(Arc::new(lock_file)),
263            removal_accounting,
264        })
265    }
266
267    /// Acquire a lock that allows removing entries from the cache, if available.
268    ///
269    /// If the lock is not immediately available, returns [`Err`] with self.
270    pub fn with_exclusive_lock_no_wait(self) -> Result<Self, Self> {
271        let Self {
272            root,
273            refresh,
274            temp_dir,
275            lock_file,
276            removal_accounting,
277        } = self;
278
279        match LockedFile::acquire_no_wait(
280            root.join(".lock"),
281            LockedFileMode::Exclusive,
282            root.simplified_display(),
283        ) {
284            Some(lock_file) => Ok(Self {
285                root,
286                refresh,
287                temp_dir,
288                lock_file: Some(Arc::new(lock_file)),
289                removal_accounting,
290            }),
291            None => Err(Self {
292                root,
293                refresh,
294                lock_file,
295                temp_dir,
296                removal_accounting,
297            }),
298        }
299    }
300
301    /// Return the root of the cache.
302    pub fn root(&self) -> &Path {
303        &self.root
304    }
305
306    /// The folder for a specific cache bucket
307    pub fn bucket(&self, cache_bucket: CacheBucket) -> PathBuf {
308        self.root.join(cache_bucket.to_str())
309    }
310
311    /// Compute an entry in the cache.
312    pub fn shard(&self, cache_bucket: CacheBucket, dir: impl AsRef<Path>) -> CacheShard {
313        CacheShard(self.bucket(cache_bucket).join(dir.as_ref()))
314    }
315
316    /// Compute an entry in the cache.
317    pub fn entry(
318        &self,
319        cache_bucket: CacheBucket,
320        dir: impl AsRef<Path>,
321        file: impl AsRef<Path>,
322    ) -> CacheEntry {
323        CacheEntry::new(self.bucket(cache_bucket).join(dir), file)
324    }
325
326    /// Return the path to an archive in the cache.
327    pub fn archive(&self, id: &ArchiveId) -> PathBuf {
328        self.bucket(CacheBucket::Archive).join(id)
329    }
330
331    /// Return the path to an archive file in the cache.
332    pub fn archive_file(&self, id: &ArchiveFileId) -> PathBuf {
333        self.bucket(CacheBucket::Files).join(id)
334    }
335
336    /// Create a temporary directory to be used as a Python virtual environment.
337    pub fn venv_dir(&self) -> io::Result<tempfile::TempDir> {
338        fs_err::create_dir_all(self.bucket(CacheBucket::Builds))?;
339        tempfile::tempdir_in(self.bucket(CacheBucket::Builds))
340    }
341
342    /// Create a temporary directory to be used for executing PEP 517 source distribution builds.
343    pub fn build_dir(&self) -> io::Result<tempfile::TempDir> {
344        fs_err::create_dir_all(self.bucket(CacheBucket::Builds))?;
345        tempfile::tempdir_in(self.bucket(CacheBucket::Builds))
346    }
347
348    /// Returns `true` if a cache entry must be revalidated given the [`Refresh`] policy.
349    pub fn must_revalidate_package(&self, package: &PackageName) -> bool {
350        match &self.refresh {
351            Refresh::None(_) => false,
352            Refresh::All(_) => true,
353            Refresh::Packages(packages, _, _) => packages.contains(package),
354        }
355    }
356
357    /// Returns `true` if a cache entry must be revalidated given the [`Refresh`] policy.
358    pub fn must_revalidate_path(&self, path: &Path) -> bool {
359        match &self.refresh {
360            Refresh::None(_) => false,
361            Refresh::All(_) => true,
362            Refresh::Packages(_, paths, _) => paths
363                .iter()
364                .any(|target| same_file::is_same_file(path, target).unwrap_or(false)),
365        }
366    }
367
368    /// Returns the [`Freshness`] for a cache entry, validating it against the [`Refresh`] policy.
369    ///
370    /// A cache entry is considered fresh if it was created after the cache itself was
371    /// initialized, or if the [`Refresh`] policy does not require revalidation.
372    pub fn freshness(
373        &self,
374        entry: &CacheEntry,
375        package: Option<&PackageName>,
376        path: Option<&Path>,
377    ) -> io::Result<Freshness> {
378        // Grab the cutoff timestamp, if it's relevant.
379        let timestamp = match &self.refresh {
380            Refresh::None(_) => return Ok(Freshness::Fresh),
381            Refresh::All(timestamp) => timestamp,
382            Refresh::Packages(packages, paths, timestamp) => {
383                if package.is_none_or(|package| packages.contains(package))
384                    || path.is_some_and(|path| {
385                        paths
386                            .iter()
387                            .any(|target| same_file::is_same_file(path, target).unwrap_or(false))
388                    })
389                {
390                    timestamp
391                } else {
392                    return Ok(Freshness::Fresh);
393                }
394            }
395        };
396
397        match fs_err::metadata(entry.path()) {
398            Ok(metadata) => {
399                if Timestamp::from_metadata(&metadata) >= *timestamp {
400                    Ok(Freshness::Fresh)
401                } else {
402                    Ok(Freshness::Stale)
403                }
404            }
405            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Freshness::Missing),
406            Err(err) => Err(err),
407        }
408    }
409
410    /// Persist a temporary directory to the artifact store, returning its unique ID.
411    pub async fn persist(
412        &self,
413        temp_dir: impl AsRef<Path>,
414        path: impl AsRef<Path>,
415    ) -> io::Result<ArchiveId> {
416        // Create a unique ID for the artifact.
417        let id = ArchiveId::new();
418
419        // Move the temporary directory into the directory store.
420        let archive_entry = self.entry(CacheBucket::Archive, "", &id);
421        fs_err::create_dir_all(archive_entry.dir())?;
422        uv_fs::rename_with_retry(temp_dir.as_ref(), archive_entry.path()).await?;
423
424        // Create a symlink to the directory store.
425        fs_err::create_dir_all(path.as_ref().parent().expect("Cache entry to have parent"))?;
426        self.create_link(&id, path.as_ref())?;
427
428        Ok(id)
429    }
430
431    /// Persist a temporary directory to the artifact store under a caller-selected ID.
432    ///
433    /// If another writer has already persisted the same ID, discard `temp_dir` and link `path` to
434    /// the existing archive entry. The ID must therefore uniquely identify the directory contents.
435    pub async fn persist_with_id(
436        &self,
437        temp_dir: tempfile::TempDir,
438        path: impl AsRef<Path>,
439        id: ArchiveId,
440    ) -> io::Result<ArchiveId> {
441        // Move the temporary directory into the directory store.
442        let archive_entry = self.entry(CacheBucket::Archive, "", &id);
443        fs_err::create_dir_all(archive_entry.dir())?;
444        if let Err(err) = uv_fs::rename_with_retry(temp_dir.path(), archive_entry.path()).await {
445            if !archive_entry.path().is_dir() {
446                return Err(err);
447            }
448        }
449
450        // Create a symlink to the directory store.
451        fs_err::create_dir_all(path.as_ref().parent().expect("Cache entry to have parent"))?;
452        self.create_link(&id, path.as_ref())?;
453
454        Ok(id)
455    }
456
457    /// Returns `true` if the [`Cache`] is temporary.
458    pub fn is_temporary(&self) -> bool {
459        self.temp_dir.is_some()
460    }
461
462    /// Populate the cache scaffold.
463    fn create_base_files(root: &PathBuf) -> io::Result<()> {
464        // Add the CACHEDIR.TAG.
465        cachedir::ensure_tag(root)?;
466
467        // Add the .gitignore.
468        match fs_err::OpenOptions::new()
469            .write(true)
470            .create_new(true)
471            .open(root.join(".gitignore"))
472        {
473            Ok(mut file) => file.write_all(b"*")?,
474            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
475            Err(err) => return Err(err),
476        }
477
478        // Add an empty .gitignore to the build bucket, to ensure that the cache's own .gitignore
479        // doesn't interfere with source distribution builds. Build backends (like hatchling) will
480        // traverse upwards to look for .gitignore files.
481        fs_err::create_dir_all(root.join(CacheBucket::SourceDistributions.to_str()))?;
482        match fs_err::OpenOptions::new()
483            .write(true)
484            .create_new(true)
485            .open(
486                root.join(CacheBucket::SourceDistributions.to_str())
487                    .join(".gitignore"),
488            ) {
489            Ok(_) => {}
490            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
491            Err(err) => return Err(err),
492        }
493
494        // Add a phony .git, if it doesn't exist, to ensure that the cache isn't considered to be
495        // part of a Git repository. (Some packages will include Git metadata (like a hash) in the
496        // built version if they're in a Git repository, but the cache should be viewed as an
497        // isolated store.).
498        // We have to put this below the gitignore. Otherwise, if the build backend uses the rust
499        // ignore crate it will walk up to the top level .gitignore and ignore its python source
500        // files.
501        let phony_git = root
502            .join(CacheBucket::SourceDistributions.to_str())
503            .join(".git");
504        match fs_err::OpenOptions::new()
505            .create(true)
506            .write(true)
507            .open(&phony_git)
508        {
509            Ok(_) => {}
510            // Handle read-only caches including sandboxed environments.
511            Err(err) if err.kind() == io::ErrorKind::ReadOnlyFilesystem => {
512                if !phony_git.exists() {
513                    return Err(err);
514                }
515            }
516            Err(err) => return Err(err),
517        }
518
519        Ok(())
520    }
521
522    /// Initialize the [`Cache`].
523    pub async fn init(self) -> Result<Self, Error> {
524        let root = &self.root;
525
526        fs_err::create_dir_all(root).map_err(|err| Error::Init(root.clone(), err))?;
527
528        // Block cache removal operations from interfering.
529        let lock_file = match LockedFile::acquire(
530            root.join(".lock"),
531            LockedFileMode::Shared,
532            root.simplified_display(),
533        )
534        .await
535        {
536            Ok(lock_file) => Some(Arc::new(lock_file)),
537            Err(err)
538                if err
539                    .as_io_error()
540                    .is_some_and(|err| err.kind() == io::ErrorKind::Unsupported) =>
541            {
542                warn!(
543                    "Shared locking is not supported by the current platform or filesystem, \
544                        reduced parallel process safety with `uv cache clean` and `uv cache prune`."
545                );
546                None
547            }
548            Err(err) => return Err(err.into()),
549        };
550
551        Self::create_base_files(root).map_err(|err| Error::Init(root.clone(), err))?;
552        Ok(Self {
553            root: std::path::absolute(root).map_err(Error::Absolute)?,
554            lock_file,
555            ..self
556        })
557    }
558
559    /// Initialize the [`Cache`], assuming that there are no other uv processes running.
560    pub fn init_no_wait(self) -> Result<Option<Self>, Error> {
561        let root = &self.root;
562
563        fs_err::create_dir_all(root).map_err(|err| Error::Init(root.clone(), err))?;
564
565        // Block cache removal operations from interfering.
566        let Some(lock_file) = LockedFile::acquire_no_wait(
567            root.join(".lock"),
568            LockedFileMode::Shared,
569            root.simplified_display(),
570        ) else {
571            return Ok(None);
572        };
573        Self::create_base_files(root).map_err(|err| Error::Init(root.clone(), err))?;
574        Ok(Some(Self {
575            root: std::path::absolute(root).map_err(Error::Absolute)?,
576            lock_file: Some(Arc::new(lock_file)),
577            ..self
578        }))
579    }
580
581    /// Clear the cache, removing all entries.
582    pub fn clear(self, reporter: Box<dyn CleanReporter>) -> Result<Removal, io::Error> {
583        // Waiters may have already opened the lock. Retain its path and directory so they acquire
584        // the same lock as new uv processes. Resolve only the root: never follow links inside it.
585        let root = fs_err::canonicalize(&self.root)?;
586        Remover::new(reporter)
587            .with_removal_accounting(self.removal_accounting)
588            .rm_rf(&root, true)
589    }
590
591    /// Remove a package from the cache.
592    ///
593    /// Unreferenced file objects are removed separately by [`Cache::prune_archive_files`].
594    ///
595    /// Returns the number of entries removed from the cache.
596    pub fn remove(&self, name: &PackageName) -> io::Result<Removal> {
597        // Collect the set of referenced archives.
598        let references = self.find_archive_references()?;
599
600        // Remove any entries for the package from the cache.
601        let mut summary = self.removal();
602        for bucket in CacheBucket::iter() {
603            summary += bucket.remove(self, name)?;
604        }
605
606        if references.is_empty() {
607            return Ok(summary);
608        }
609
610        // Only remove targets in the archive bucket. Cache entries may contain unexpected links
611        // to paths outside the cache.
612        let archive_root = fs_err::canonicalize(&self.root)?.join(CacheBucket::Archive.to_str());
613
614        // Remove any archives that are no longer referenced.
615        for (target, references) in references {
616            if target.starts_with(&archive_root) && references.iter().all(|path| !path.exists()) {
617                debug!("Removing dangling cache entry: {}", target.display());
618                summary += self.remove_path(target)?;
619            }
620        }
621
622        Ok(summary)
623    }
624
625    /// Remove file objects with no hardlinks outside the files bucket.
626    ///
627    /// Archives refer to these objects via hardlinks, independently of the installation link mode.
628    /// Installed copies and reflinks remain valid when the cached file is removed, so they do not
629    /// need to keep the file object alive.
630    pub fn prune_archive_files(&self) -> Result<Removal, io::Error> {
631        let root = self.bucket(CacheBucket::Files);
632        if !root.exists() {
633            return Ok(self.removal());
634        }
635
636        let mut summary = self.removal();
637        let mut directories = Vec::new();
638        let mut entries = walkdir::WalkDir::new(&root).min_depth(1).into_iter();
639        while let Some(entry) = entries.next() {
640            let entry = entry?;
641            if entry.file_type().is_file() {
642                match uv_fs::hardlink_count(entry.path()) {
643                    Ok(1) => summary += self.remove_path(entry.path())?,
644                    Ok(_) => {}
645                    Err(err) if err.kind() == io::ErrorKind::NotFound => {}
646                    Err(err) => return Err(err),
647                }
648            } else if entry.file_type().is_dir() {
649                if let Some(files) = uv_fs::files_with_one_hardlink(entry.path())? {
650                    entries.skip_current_dir();
651                    for file in files {
652                        summary += self.remove_path(file)?;
653                    }
654                }
655                directories.push(entry.into_path());
656            }
657        }
658        // The walk visits parents first so the bulk path can skip their contents.
659        // Remove directories in reverse order so children are removed before parents.
660        for directory in directories.into_iter().rev() {
661            match fs_err::remove_dir(directory) {
662                Ok(()) => {
663                    summary.num_dirs += 1;
664                }
665                Err(err)
666                    if matches!(
667                        err.kind(),
668                        io::ErrorKind::DirectoryNotEmpty | io::ErrorKind::NotFound
669                    ) => {}
670                Err(err) => return Err(err),
671            }
672        }
673
674        Ok(summary)
675    }
676
677    /// Prune dangling cache entries and cached environments.
678    pub fn prune(&self, ci: bool) -> Result<Removal, io::Error> {
679        let mut summary = self.removal();
680
681        // First, remove any top-level directories that are unused. These typically represent
682        // outdated cache buckets (e.g., `wheels-v0`, when latest is `wheels-v1`).
683        for entry in fs_err::read_dir(&self.root)? {
684            let entry = entry?;
685            let metadata = entry.metadata()?;
686
687            if entry.file_name() == "CACHEDIR.TAG"
688                || entry.file_name() == ".gitignore"
689                || entry.file_name() == ".git"
690                || entry.file_name() == ".lock"
691            {
692                continue;
693            }
694
695            if metadata.is_dir() {
696                // If the directory is not a cache bucket, remove it.
697                if CacheBucket::iter().all(|bucket| entry.file_name() != bucket.to_str()) {
698                    let path = entry.path();
699                    debug!("Removing dangling cache bucket: {}", path.display());
700                    summary += self.remove_path(path)?;
701                }
702            } else {
703                // If the file is not a marker file, remove it.
704                let path = entry.path();
705                debug!("Removing dangling cache bucket: {}", path.display());
706                summary += self.remove_path(path)?;
707            }
708        }
709
710        // Second, remove all cached environments. Centralized project environments can be
711        // referenced by `.venv` links, but are recreated when next needed.
712        match fs_err::read_dir(self.bucket(CacheBucket::Environments)) {
713            Ok(entries) => {
714                for entry in entries {
715                    let entry = entry?;
716                    let path = entry.path();
717                    debug!("Removing cached environment: {}", path.display());
718                    summary += self.remove_path(path)?;
719                }
720            }
721            Err(err) if err.kind() == io::ErrorKind::NotFound => (),
722            Err(err) => return Err(err),
723        }
724
725        // Third, remove all temporary build environments.
726        match fs_err::read_dir(self.bucket(CacheBucket::Builds)) {
727            Ok(entries) => {
728                for entry in entries {
729                    let entry = entry?;
730                    let path = entry.path();
731                    debug!("Removing temporary build environment: {}", path.display());
732                    summary += self.remove_path(path)?;
733                }
734            }
735            Err(err) if err.kind() == io::ErrorKind::NotFound => (),
736            Err(err) => return Err(err),
737        }
738
739        // Fourth, if enabled, remove all unzipped wheels, leaving only the wheel archives.
740        if ci {
741            // Remove the entire pre-built wheel cache, since every entry is an unzipped wheel.
742            match fs_err::read_dir(self.bucket(CacheBucket::Wheels)) {
743                Ok(entries) => {
744                    for entry in entries {
745                        let entry = entry?;
746                        let path = entry.path();
747                        if path.is_dir() {
748                            debug!("Removing unzipped wheel entry: {}", path.display());
749                            summary += self.remove_path(path)?;
750                        }
751                    }
752                }
753                Err(err) if err.kind() == io::ErrorKind::NotFound => (),
754                Err(err) => return Err(err),
755            }
756
757            let source_distributions = self.bucket(CacheBucket::SourceDistributions);
758            if source_distributions.try_exists()? {
759                for entry in walkdir::WalkDir::new(source_distributions) {
760                    let entry = entry?;
761
762                    // If the directory contains a `metadata.msgpack`, then it's a built wheel revision.
763                    if !entry.file_type().is_dir() {
764                        continue;
765                    }
766
767                    if !entry.path().join("metadata.msgpack").exists() {
768                        continue;
769                    }
770
771                    // Remove everything except the built wheel archive and the metadata.
772                    for entry in fs_err::read_dir(entry.path())? {
773                        let entry = entry?;
774                        let path = entry.path();
775
776                        // Retain the resolved metadata (`metadata.msgpack`).
777                        if path
778                            .file_name()
779                            .is_some_and(|file_name| file_name == "metadata.msgpack")
780                        {
781                            continue;
782                        }
783
784                        // Visit nested build settings shards separately so their metadata survives.
785                        if entry.file_name() != "src"
786                            && entry.file_type()?.is_dir()
787                            && path.join("metadata.msgpack").exists()
788                        {
789                            continue;
790                        }
791
792                        // Retain any built wheel archives.
793                        if path
794                            .extension()
795                            .is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
796                        {
797                            continue;
798                        }
799
800                        debug!("Removing unzipped built wheel entry: {}", path.display());
801                        summary += self.remove_path(path)?;
802                    }
803                }
804            }
805        }
806
807        // Fifth, remove any unused archives (by searching for archives that are not symlinked).
808        let references = self.find_archive_references()?;
809
810        match fs_err::read_dir(self.bucket(CacheBucket::Archive)) {
811            Ok(entries) => {
812                for entry in entries {
813                    let entry = entry?;
814                    let path = entry.path();
815                    let target = fs_err::canonicalize(&path)?;
816                    if !references.contains_key(&target) {
817                        debug!("Removing dangling cache archive: {}", path.display());
818                        summary += self.remove_path(path)?;
819                    }
820                }
821            }
822            Err(err) if err.kind() == io::ErrorKind::NotFound => (),
823            Err(err) => return Err(err),
824        }
825
826        summary += self.prune_archive_files()?;
827
828        Ok(summary)
829    }
830
831    /// Remove a cache path using the cache's configured storage accounting.
832    pub fn remove_path(&self, path: impl AsRef<Path>) -> io::Result<Removal> {
833        Remover::default()
834            .with_removal_accounting(self.removal_accounting)
835            .rm_rf(path, false)
836    }
837
838    /// Find all references to entries in the archive bucket.
839    ///
840    /// Archive entries are often referenced by symlinks in other cache buckets. This method
841    /// searches for all such references.
842    ///
843    /// Returns a map from archive path to paths that reference it.
844    fn find_archive_references(&self) -> Result<FxHashMap<PathBuf, Vec<PathBuf>>, io::Error> {
845        let mut references = FxHashMap::<PathBuf, Vec<PathBuf>>::default();
846        for bucket in [CacheBucket::SourceDistributions, CacheBucket::Wheels] {
847            let bucket_path = self.bucket(bucket);
848            if bucket_path.is_dir() {
849                let walker = walkdir::WalkDir::new(&bucket_path).into_iter();
850                for entry in walker.filter_entry(|entry| {
851                    !(
852                        // As an optimization, ignore any `.lock`, `.whl`, `.msgpack`, `.rev`, or
853                        // `.http` files, along with the `src` directory, which represents the
854                        // unpacked source distribution.
855                        entry.file_name() == "src"
856                            || entry.file_name() == ".lock"
857                            || entry.file_name() == ".gitignore"
858                            || entry.path().extension().is_some_and(|ext| {
859                                ext.eq_ignore_ascii_case("lock")
860                                    || ext.eq_ignore_ascii_case("whl")
861                                    || ext.eq_ignore_ascii_case("http")
862                                    || ext.eq_ignore_ascii_case("rev")
863                                    || ext.eq_ignore_ascii_case("msgpack")
864                            })
865                    )
866                }) {
867                    let entry = entry?;
868
869                    // On Unix, archive references use symlinks.
870                    if cfg!(unix) {
871                        if !entry.file_type().is_symlink() {
872                            continue;
873                        }
874                    }
875
876                    // On Windows, archive references are files containing structured data.
877                    if cfg!(windows) {
878                        if !entry.file_type().is_file() {
879                            continue;
880                        }
881                    }
882
883                    if let Ok(target) = self.resolve_link(entry.path()) {
884                        references
885                            .entry(target)
886                            .or_default()
887                            .push(entry.path().to_path_buf());
888                    }
889                }
890            }
891        }
892        Ok(references)
893    }
894
895    /// Create a link to a directory in the archive bucket.
896    ///
897    /// On Windows, we write structured data ([`Link`]) to a file containing the archive ID and
898    /// version. On Unix, we create a symlink to the target directory.
899    #[cfg(windows)]
900    #[expect(clippy::unused_self)]
901    fn create_link(&self, id: &ArchiveId, dst: impl AsRef<Path>) -> io::Result<()> {
902        // Serialize the link.
903        let link = Link::new(id.clone());
904        let contents = link.to_string();
905
906        // First, attempt to create a file at the location, but fail if it already exists.
907        match fs_err::OpenOptions::new()
908            .write(true)
909            .create_new(true)
910            .open(dst.as_ref())
911        {
912            Ok(mut file) => {
913                // Write the target path to the file.
914                file.write_all(contents.as_bytes())?;
915                Ok(())
916            }
917            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
918                uv_fs::write_atomic_sync(dst, contents.as_bytes())
919            }
920            Err(err) => Err(err),
921        }
922    }
923
924    /// Resolve an archive link, returning the fully-resolved path.
925    ///
926    /// Returns an error if the link target does not exist.
927    #[cfg(windows)]
928    pub fn resolve_link(&self, path: impl AsRef<Path>) -> io::Result<PathBuf> {
929        // Deserialize the link.
930        let contents = fs_err::read_to_string(path.as_ref())?;
931        let link = Link::from_str(&contents)?;
932
933        // Ignore stale links.
934        if link.version != ARCHIVE_VERSION {
935            return Err(io::Error::new(
936                io::ErrorKind::NotFound,
937                "The link target does not exist.",
938            ));
939        }
940
941        // Reconstruct the path.
942        let path = self.archive(&link.id);
943        path.canonicalize()
944    }
945
946    /// Create a link to a directory in the archive bucket.
947    ///
948    /// On Windows, we write structured data ([`Link`]) to a file containing the archive ID and
949    /// version. On Unix, we create a symlink to the target directory.
950    #[cfg(unix)]
951    fn create_link(&self, id: &ArchiveId, dst: impl AsRef<Path>) -> io::Result<()> {
952        let dst = dst.as_ref();
953        let dst_parent = dst.parent().expect("Cache entry to have parent");
954        // Construct the relative link target.
955        let src = uv_fs::relative_to(self.archive(id), dst_parent)?;
956
957        uv_fs::replace_symlink(&src, dst)
958    }
959
960    /// Resolve an archive link, returning the fully-resolved path.
961    ///
962    /// Returns an error if the link target does not exist.
963    #[cfg(unix)]
964    pub fn resolve_link(&self, path: impl AsRef<Path>) -> io::Result<PathBuf> {
965        path.as_ref().canonicalize()
966    }
967}
968
969/// An archive (unzipped wheel) that exists in the local cache.
970#[derive(Debug, Clone)]
971#[allow(unused)]
972struct Link {
973    /// The unique ID of the entry in the archive bucket.
974    id: ArchiveId,
975    /// The version of the archive bucket.
976    version: u8,
977}
978
979#[allow(unused)]
980impl Link {
981    /// Create a new [`Archive`] with the given ID and hashes.
982    fn new(id: ArchiveId) -> Self {
983        Self {
984            id,
985            version: ARCHIVE_VERSION,
986        }
987    }
988}
989
990impl Display for Link {
991    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
992        write!(f, "archive-v{}/{}", self.version, self.id)
993    }
994}
995
996impl FromStr for Link {
997    type Err = io::Error;
998
999    fn from_str(s: &str) -> Result<Self, Self::Err> {
1000        let mut parts = s.splitn(2, '/');
1001        let version = parts
1002            .next()
1003            .filter(|s| !s.is_empty())
1004            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing version"))?;
1005        let id = parts
1006            .next()
1007            .filter(|s| !s.is_empty())
1008            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing ID"))?;
1009
1010        // Parse the archive version from `archive-v{version}/{id}`.
1011        let version = version
1012            .strip_prefix("archive-v")
1013            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing version prefix"))?;
1014        let version = u8::from_str(version).map_err(|err| {
1015            io::Error::new(
1016                io::ErrorKind::InvalidData,
1017                format!("failed to parse version: {err}"),
1018            )
1019        })?;
1020
1021        // Parse the ID from `archive-v{version}/{id}`.
1022        let id = ArchiveId::from_str(id).map_err(|err| {
1023            io::Error::new(
1024                io::ErrorKind::InvalidData,
1025                format!("failed to parse ID: {err}"),
1026            )
1027        })?;
1028
1029        Ok(Self { id, version })
1030    }
1031}
1032
1033pub trait CleanReporter: Send + Sync {
1034    /// Called after one file or directory is removed.
1035    fn on_clean(&self);
1036
1037    /// Called after all files and directories are removed.
1038    fn on_complete(&self);
1039}
1040
1041/// The different kinds of data in the cache are stored in different bucket, which in our case
1042/// are subdirectories of the cache root.
1043#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
1044pub enum CacheBucket {
1045    /// Wheels (excluding built wheels), alongside their metadata and cache policy.
1046    ///
1047    /// There are three kinds from cache entries: Wheel metadata and policy as `MsgPack` files, the
1048    /// wheels themselves, and the unzipped wheel archives. If a wheel file is over an in-memory
1049    /// size threshold, we first download the zip file into the cache, then unzip it into a
1050    /// directory with the same name (exclusive of the `.whl` extension).
1051    ///
1052    /// Cache structure:
1053    ///  * `wheel-metadata-v0/pypi/foo/{foo-1.0.0-py3-none-any.msgpack, foo-1.0.0-py3-none-any.whl}`
1054    ///  * `wheel-metadata-v0/<digest(index-url)>/foo/{foo-1.0.0-py3-none-any.msgpack, foo-1.0.0-py3-none-any.whl}`
1055    ///  * `wheel-metadata-v0/url/<digest(url)>/foo/{foo-1.0.0-py3-none-any.msgpack, foo-1.0.0-py3-none-any.whl}`
1056    ///
1057    /// See `uv_client::RegistryClient::wheel_metadata` for information on how wheel metadata
1058    /// is fetched.
1059    ///
1060    /// # Example
1061    ///
1062    /// Consider the following `requirements.in`:
1063    /// ```text
1064    /// # pypi wheel
1065    /// pandas
1066    /// # url wheel
1067    /// flask @ https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl
1068    /// ```
1069    ///
1070    /// When we run `pip compile`, it will only fetch and cache the metadata (and cache policy), it
1071    /// doesn't need the actual wheels yet:
1072    /// ```text
1073    /// wheel-v0
1074    /// ├── pypi
1075    /// │   ...
1076    /// │   ├── pandas
1077    /// │   │   └── pandas-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.msgpack
1078    /// │   ...
1079    /// └── url
1080    ///     └── 4b8be67c801a7ecb
1081    ///         └── flask
1082    ///             └── flask-3.0.0-py3-none-any.msgpack
1083    /// ```
1084    ///
1085    /// We get the following `requirement.txt` from `pip compile`:
1086    ///
1087    /// ```text
1088    /// [...]
1089    /// flask @ https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl
1090    /// [...]
1091    /// pandas==2.1.3
1092    /// [...]
1093    /// ```
1094    ///
1095    /// If we run `pip sync` on `requirements.txt` on a different machine, it also fetches the
1096    /// wheels:
1097    ///
1098    /// TODO(konstin): This is still wrong, we need to store the cache policy too!
1099    /// ```text
1100    /// wheel-v0
1101    /// ├── pypi
1102    /// │   ...
1103    /// │   ├── pandas
1104    /// │   │   ├── pandas-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
1105    /// │   │   ├── pandas-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64
1106    /// │   ...
1107    /// └── url
1108    ///     └── 4b8be67c801a7ecb
1109    ///         └── flask
1110    ///             └── flask-3.0.0-py3-none-any.whl
1111    ///                 ├── flask
1112    ///                 │   └── ...
1113    ///                 └── flask-3.0.0.dist-info
1114    ///                     └── ...
1115    /// ```
1116    ///
1117    /// If we run first `pip compile` and then `pip sync` on the same machine, we get both:
1118    ///
1119    /// ```text
1120    /// wheels-v0
1121    /// ├── pypi
1122    /// │   ├── ...
1123    /// │   ├── pandas
1124    /// │   │   ├── pandas-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.msgpack
1125    /// │   │   ├── pandas-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
1126    /// │   │   └── pandas-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64
1127    /// │   │       ├── pandas
1128    /// │   │       │   ├── ...
1129    /// │   │       ├── pandas-2.1.3.dist-info
1130    /// │   │       │   ├── ...
1131    /// │   │       └── pandas.libs
1132    /// │   ├── ...
1133    /// └── url
1134    ///     └── 4b8be67c801a7ecb
1135    ///         └── flask
1136    ///             ├── flask-3.0.0-py3-none-any.msgpack
1137    ///             ├── flask-3.0.0-py3-none-any.msgpack
1138    ///             └── flask-3.0.0-py3-none-any
1139    ///                 ├── flask
1140    ///                 │   └── ...
1141    ///                 └── flask-3.0.0.dist-info
1142    ///                     └── ...
1143    Wheels,
1144    /// Source distributions, wheels built from source distributions, their extracted metadata, and the
1145    /// cache policy of the source distribution.
1146    ///
1147    /// The structure is similar of that of the `Wheel` bucket, except we have an additional layer
1148    /// for the source distribution filename and the metadata is at the source distribution-level,
1149    /// not at the wheel level.
1150    ///
1151    /// TODO(konstin): The cache policy should be on the source distribution level, the metadata we
1152    /// can put next to the wheels as in the `Wheels` bucket.
1153    ///
1154    /// The unzipped source distribution is stored in a directory matching the source distribution
1155    /// archive name.
1156    ///
1157    /// Source distributions are built into zipped wheel files (as PEP 517 specifies) and unzipped
1158    /// lazily before installing. So when resolving, we only build the wheel and store the archive
1159    /// file in the cache, when installing, we unpack it under the same name (exclusive of the
1160    /// `.whl` extension). You may find a mix of wheel archive zip files and unzipped wheel
1161    /// directories in the cache.
1162    ///
1163    /// Cache structure:
1164    ///  * `built-wheels-v0/pypi/foo/34a17436ed1e9669/{manifest.msgpack, metadata.msgpack, foo-1.0.0.zip, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1165    ///  * `built-wheels-v0/<digest(index-url)>/foo/foo-1.0.0.zip/{manifest.msgpack, metadata.msgpack, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1166    ///  * `built-wheels-v0/url/<digest(url)>/foo/foo-1.0.0.zip/{manifest.msgpack, metadata.msgpack, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1167    ///  * `built-wheels-v0/git/<digest(url)>/<git sha>/foo/foo-1.0.0.zip/{metadata.msgpack, foo-1.0.0-py3-none-any.whl, ...other wheels}`
1168    ///
1169    /// But the url filename does not need to be a valid source dist filename
1170    /// (<https://github.com/search?q=path%3A**%2Frequirements.txt+master.zip&type=code>),
1171    /// so it could also be the following and we have to take any string as filename:
1172    ///  * `built-wheels-v0/url/<sha256(url)>/master.zip/metadata.msgpack`
1173    ///
1174    /// # Example
1175    ///
1176    /// The following requirements:
1177    /// ```text
1178    /// # git source dist
1179    /// pydantic-extra-types @ git+https://github.com/pydantic/pydantic-extra-types.git
1180    /// # pypi source dist
1181    /// django_allauth==0.51.0
1182    /// # url source dist
1183    /// werkzeug @ https://files.pythonhosted.org/packages/0d/cc/ff1904eb5eb4b455e442834dabf9427331ac0fa02853bf83db817a7dd53d/werkzeug-3.0.1.tar.gz
1184    /// ```
1185    ///
1186    /// ...may be cached as:
1187    /// ```text
1188    /// built-wheels-v4/
1189    /// ├── git
1190    /// │   └── 2122faf3e081fb7a
1191    /// │       └── 7a2d650a4a7b4d04
1192    /// │           ├── metadata.msgpack
1193    /// │           └── pydantic_extra_types-2.9.0-py3-none-any.whl
1194    /// ├── pypi
1195    /// │   └── django-allauth
1196    /// │       └── 0.51.0
1197    /// │           ├── 0gH-_fwv8tdJ7JwwjJsUc
1198    /// │           │   ├── django-allauth-0.51.0.tar.gz
1199    /// │           │   │   └── [UNZIPPED CONTENTS]
1200    /// │           │   ├── django_allauth-0.51.0-py3-none-any.whl
1201    /// │           │   └── metadata.msgpack
1202    /// │           └── revision.http
1203    /// └── url
1204    ///     └── 6781bd6440ae72c2
1205    ///         ├── APYY01rbIfpAo_ij9sCY6
1206    ///         │   ├── metadata.msgpack
1207    ///         │   ├── werkzeug-3.0.1-py3-none-any.whl
1208    ///         │   └── werkzeug-3.0.1.tar.gz
1209    ///         │       └── [UNZIPPED CONTENTS]
1210    ///         └── revision.http
1211    /// ```
1212    ///
1213    /// Structurally, the `manifest.msgpack` is empty, and only contains the caching information
1214    /// needed to invalidate the cache. The `metadata.msgpack` contains the metadata of the source
1215    /// distribution.
1216    SourceDistributions,
1217    /// Flat index responses, a format very similar to the simple metadata API.
1218    ///
1219    /// Cache structure:
1220    ///  * `flat-index-v0/index/<digest(flat_index_url)>.msgpack`
1221    ///
1222    /// The response is stored as `Vec<File>`.
1223    FlatIndex,
1224    /// Git repositories.
1225    Git,
1226    /// Information about an interpreter at a path.
1227    ///
1228    /// To avoid caching pyenv shims, bash scripts which may redirect to a new python version
1229    /// without the shim itself changing, we only cache when the path equals `sys.executable`, i.e.
1230    /// the path we're running is the python executable itself and not a shim.
1231    ///
1232    /// Cache structure: `interpreter-v0/<digest(path)>.msgpack`
1233    ///
1234    /// # Example
1235    ///
1236    /// The contents of each of the `MsgPack` files has a timestamp field in unix time, the [PEP 508]
1237    /// markers and some information from the `sys`/`sysconfig` modules.
1238    ///
1239    /// ```json
1240    /// {
1241    ///   "timestamp": 1698047994491,
1242    ///   "data": {
1243    ///     "markers": {
1244    ///       "implementation_name": "cpython",
1245    ///       "implementation_version": "3.12.0",
1246    ///       "os_name": "posix",
1247    ///       "platform_machine": "x86_64",
1248    ///       "platform_python_implementation": "CPython",
1249    ///       "platform_release": "6.5.0-13-generic",
1250    ///       "platform_system": "Linux",
1251    ///       "platform_version": "#13-Ubuntu SMP PREEMPT_DYNAMIC Fri Nov  3 12:16:05 UTC 2023",
1252    ///       "python_full_version": "3.12.0",
1253    ///       "python_version": "3.12",
1254    ///       "sys_platform": "linux"
1255    ///     },
1256    ///     "base_exec_prefix": "/home/ferris/.pyenv/versions/3.12.0",
1257    ///     "base_prefix": "/home/ferris/.pyenv/versions/3.12.0",
1258    ///     "sys_executable": "/home/ferris/projects/uv/.venv/bin/python"
1259    ///   }
1260    /// }
1261    /// ```
1262    ///
1263    /// [PEP 508]: https://peps.python.org/pep-0508/#environment-markers
1264    Interpreter,
1265    /// Index responses through the simple metadata API.
1266    ///
1267    /// Cache structure:
1268    ///  * `simple-v0/pypi/<package_name>.rkyv`
1269    ///  * `simple-v0/<digest(index_url)>/<package_name>.rkyv`
1270    ///
1271    /// The response is parsed into `uv_client::SimpleDetailMetadata` before storage.
1272    Simple,
1273    /// A cache of unzipped wheels, stored as directories. This is used internally within the cache.
1274    /// When other buckets need to store directories, they should persist them to
1275    /// [`CacheBucket::Archive`], and then symlink them into the appropriate bucket. This ensures
1276    /// that cache entries can be atomically replaced and removed, as storing directories in the
1277    /// other buckets directly would make atomic operations impossible.
1278    Archive,
1279    /// Content-addressed files that are hardlinked into cached archives.
1280    Files,
1281    /// Ephemeral virtual environments used to execute PEP 517 builds and other operations.
1282    Builds,
1283    /// Reusable virtual environments for Python tools and projects.
1284    Environments,
1285    /// Cached Python downloads
1286    Python,
1287    /// Downloaded tool binaries (e.g., Ruff).
1288    Binaries,
1289    /// Cached vulnerability data from [OSV](https://osv.dev/).
1290    ///
1291    /// Cache structure:
1292    ///  * `osv-v1/vulnerability/<vuln_id>.msgpack` — cached full vulnerability records
1293    Osv,
1294}
1295
1296impl CacheBucket {
1297    fn to_str(self) -> &'static str {
1298        match self {
1299            // Note that when bumping this, you'll also need to bump it
1300            // in `crates/uv/tests/build/cache_prune.rs`.
1301            Self::SourceDistributions => "sdists-v10",
1302            // Note that when bumping this, you'll also need to bump it
1303            // in `crates/uv/tests/lock/lock.rs`.
1304            Self::FlatIndex => "flat-index-v7",
1305            Self::Git => "git-v1",
1306            Self::Interpreter => "interpreter-v4",
1307            // Note that when bumping this, you'll also need to bump it
1308            // in `crates/uv/tests/build/cache_clean.rs`.
1309            Self::Simple => "simple-v27",
1310            // Note that when bumping this, you'll also need to bump it
1311            // in `crates/uv/tests/build/cache_prune.rs`.
1312            Self::Wheels => "wheels-v7",
1313            // Note that when bumping this, you'll also need to bump
1314            // `ARCHIVE_VERSION` in `crates/uv-cache/src/lib.rs`.
1315            Self::Archive => "archive-v0",
1316            Self::Files => "files-v0",
1317            Self::Builds => "builds-v0",
1318            Self::Environments => "environments-v2",
1319            Self::Python => "python-v1",
1320            Self::Binaries => "binaries-v0",
1321            Self::Osv => "osv-v1",
1322        }
1323    }
1324
1325    /// Remove a package from the cache bucket.
1326    ///
1327    /// Returns the number of entries removed from the cache.
1328    fn remove(self, cache: &Cache, name: &PackageName) -> Result<Removal, io::Error> {
1329        /// Returns `true` if the [`Path`] represents a built wheel for the given package.
1330        fn is_match(path: &Path, name: &PackageName) -> bool {
1331            // The metadata can be in the revision itself or one shard down, if build settings
1332            // were used.
1333            let walker = walkdir::WalkDir::new(path)
1334                .min_depth(1)
1335                .max_depth(2)
1336                .into_iter();
1337            // Unpacked source contents aren't uv cache entries.
1338            for entry in walker.filter_entry(|entry| entry.file_name() != "src") {
1339                let Ok(entry) = entry else {
1340                    continue;
1341                };
1342                if entry.file_name() != "metadata.msgpack" {
1343                    continue;
1344                }
1345                let Ok(metadata) = fs_err::read(entry.path()) else {
1346                    continue;
1347                };
1348                let Ok(metadata) = rmp_serde::from_slice::<ResolutionMetadata>(&metadata) else {
1349                    continue;
1350                };
1351                if metadata.name == *name {
1352                    return true;
1353                }
1354            }
1355            false
1356        }
1357
1358        let mut summary = cache.removal();
1359        match self {
1360            Self::Wheels => {
1361                // For `pypi` wheels, we expect a directory per package (indexed by name).
1362                let root = cache.bucket(self).join(WheelCacheKind::Pypi);
1363                summary += cache.remove_path(root.join(name.to_string()))?;
1364
1365                // For alternate indices, we expect a directory for every index (under an `index`
1366                // subdirectory), followed by a directory per package (indexed by name).
1367                let root = cache.bucket(self).join(WheelCacheKind::Index);
1368                for directory in directories(root)? {
1369                    summary += cache.remove_path(directory.join(name.to_string()))?;
1370                }
1371
1372                // For direct URLs, we expect a directory for every URL, followed by a
1373                // directory per package (indexed by name).
1374                let root = cache.bucket(self).join(WheelCacheKind::Url);
1375                for directory in directories(root)? {
1376                    summary += cache.remove_path(directory.join(name.to_string()))?;
1377                }
1378            }
1379            Self::SourceDistributions => {
1380                // For `pypi` wheels, we expect a directory per package (indexed by name).
1381                let root = cache.bucket(self).join(WheelCacheKind::Pypi);
1382                summary += cache.remove_path(root.join(name.to_string()))?;
1383
1384                // For alternate indices, we expect a directory for every index (under an `index`
1385                // subdirectory), followed by a directory per package (indexed by name).
1386                let root = cache.bucket(self).join(WheelCacheKind::Index);
1387                for directory in directories(root)? {
1388                    summary += cache.remove_path(directory.join(name.to_string()))?;
1389                }
1390
1391                // For direct URLs, we expect a directory for every URL, followed by a
1392                // directory per version. To determine whether the URL is relevant, we need to
1393                // search for a wheel matching the package name.
1394                let root = cache.bucket(self).join(WheelCacheKind::Url);
1395                for url in directories(root)? {
1396                    if directories(&url)?.any(|version| is_match(&version, name)) {
1397                        summary += cache.remove_path(url)?;
1398                    }
1399                }
1400
1401                // For local dependencies, we expect a directory for every path, followed by a
1402                // directory per version. To determine whether the path is relevant, we need to
1403                // search for a wheel matching the package name.
1404                let root = cache.bucket(self).join(WheelCacheKind::Path);
1405                for path in directories(root)? {
1406                    if directories(&path)?.any(|version| is_match(&version, name)) {
1407                        summary += cache.remove_path(path)?;
1408                    }
1409                }
1410
1411                // For Git dependencies, we expect a directory for every repository, followed by a
1412                // directory for every SHA. To determine whether the SHA is relevant, we need to
1413                // search for a wheel matching the package name.
1414                let root = cache.bucket(self).join(WheelCacheKind::Git);
1415                for repository in directories(root)? {
1416                    for sha in directories(repository)? {
1417                        if is_match(&sha, name) {
1418                            summary += cache.remove_path(sha)?;
1419                        }
1420                    }
1421                }
1422            }
1423            Self::Simple => {
1424                // For `pypi` wheels, we expect a rkyv file per package, indexed by name.
1425                let root = cache.bucket(self).join(WheelCacheKind::Pypi);
1426                summary += cache.remove_path(root.join(format!("{name}.rkyv")))?;
1427
1428                // For alternate indices, we expect a directory for every index (under an `index`
1429                // subdirectory), followed by a directory per package (indexed by name).
1430                let root = cache.bucket(self).join(WheelCacheKind::Index);
1431                for directory in directories(root)? {
1432                    summary += cache.remove_path(directory.join(format!("{name}.rkyv")))?;
1433                }
1434            }
1435            Self::FlatIndex => {
1436                // We can't know if the flat index includes a package, so we just remove the entire
1437                // cache entry.
1438                let root = cache.bucket(self);
1439                summary += cache.remove_path(root)?;
1440            }
1441            Self::Git
1442            | Self::Interpreter
1443            | Self::Archive
1444            | Self::Files
1445            | Self::Builds
1446            | Self::Environments
1447            | Self::Python
1448            | Self::Binaries
1449            | Self::Osv => {
1450                // Nothing to do.
1451            }
1452        }
1453        Ok(summary)
1454    }
1455
1456    /// Return an iterator over all cache buckets.
1457    fn iter() -> impl Iterator<Item = Self> {
1458        [
1459            Self::Wheels,
1460            Self::SourceDistributions,
1461            Self::FlatIndex,
1462            Self::Git,
1463            Self::Interpreter,
1464            Self::Simple,
1465            Self::Archive,
1466            Self::Files,
1467            Self::Builds,
1468            Self::Environments,
1469            Self::Python,
1470            Self::Binaries,
1471            Self::Osv,
1472        ]
1473        .iter()
1474        .copied()
1475    }
1476}
1477
1478impl Display for CacheBucket {
1479    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1480        f.write_str(self.to_str())
1481    }
1482}
1483
1484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1485pub enum Freshness {
1486    /// The cache entry is fresh according to the [`Refresh`] policy.
1487    Fresh,
1488    /// The cache entry is stale according to the [`Refresh`] policy.
1489    Stale,
1490    /// The cache entry does not exist.
1491    Missing,
1492}
1493
1494impl Freshness {
1495    pub const fn is_fresh(self) -> bool {
1496        matches!(self, Self::Fresh)
1497    }
1498}
1499
1500/// A refresh policy for cache entries.
1501#[derive(Debug, Clone)]
1502pub enum Refresh {
1503    /// Don't refresh any entries.
1504    None(Timestamp),
1505    /// Refresh entries linked to the given packages, if created before the given timestamp.
1506    Packages(Vec<PackageName>, Vec<Box<Path>>, Timestamp),
1507    /// Refresh all entries created before the given timestamp.
1508    All(Timestamp),
1509}
1510
1511impl Refresh {
1512    /// Determine the refresh strategy to use based on the command-line arguments.
1513    pub fn from_args(refresh: Option<bool>, refresh_package: Vec<PackageName>) -> Self {
1514        let timestamp = Timestamp::now();
1515        match refresh {
1516            Some(true) => Self::All(timestamp),
1517            Some(false) => Self::None(timestamp),
1518            None => {
1519                if refresh_package.is_empty() {
1520                    Self::None(timestamp)
1521                } else {
1522                    Self::Packages(refresh_package, vec![], timestamp)
1523                }
1524            }
1525        }
1526    }
1527
1528    /// Combine two [`Refresh`] policies, taking the "max" of the two policies.
1529    #[must_use]
1530    pub fn combine(self, other: Self) -> Self {
1531        match (self, other) {
1532            // If the policy is `None`, return the existing refresh policy.
1533            // Take the `max` of the two timestamps.
1534            (Self::None(t1), Self::None(t2)) => Self::None(t1.max(t2)),
1535            (Self::None(t1), Self::All(t2)) => Self::All(t1.max(t2)),
1536            (Self::None(t1), Self::Packages(packages, paths, t2)) => {
1537                Self::Packages(packages, paths, t1.max(t2))
1538            }
1539
1540            // If the policy is `All`, refresh all packages.
1541            (Self::All(t1), Self::None(t2) | Self::All(t2) | Self::Packages(.., t2)) => {
1542                Self::All(t1.max(t2))
1543            }
1544
1545            // If the policy is `Packages`, take the "max" of the two policies.
1546            (Self::Packages(packages, paths, t1), Self::None(t2)) => {
1547                Self::Packages(packages, paths, t1.max(t2))
1548            }
1549            (Self::Packages(.., t1), Self::All(t2)) => Self::All(t1.max(t2)),
1550            (Self::Packages(packages1, paths1, t1), Self::Packages(packages2, paths2, t2)) => {
1551                Self::Packages(
1552                    packages1.into_iter().chain(packages2).collect(),
1553                    paths1.into_iter().chain(paths2).collect(),
1554                    t1.max(t2),
1555                )
1556            }
1557        }
1558    }
1559}
1560
1561#[cfg(test)]
1562mod tests {
1563    use std::str::FromStr;
1564
1565    use crate::ArchiveId;
1566
1567    use super::{Cache, Link};
1568
1569    #[test]
1570    fn test_link_round_trip() {
1571        let id = ArchiveId::new();
1572        let link = Link::new(id);
1573        let s = link.to_string();
1574        let parsed = Link::from_str(&s).unwrap();
1575        assert_eq!(link.id, parsed.id);
1576        assert_eq!(link.version, parsed.version);
1577    }
1578
1579    #[test]
1580    fn test_replace_archive_link() {
1581        let cache = Cache::temp().unwrap();
1582        let link = cache.root().join("link");
1583        for id in [ArchiveId::new(), ArchiveId::new()] {
1584            let archive = cache.archive(&id);
1585            fs_err::create_dir_all(&archive).unwrap();
1586            cache.create_link(&id, &link).unwrap();
1587            assert_eq!(
1588                cache.resolve_link(&link).unwrap(),
1589                archive.canonicalize().unwrap()
1590            );
1591        }
1592    }
1593
1594    #[test]
1595    fn test_link_deserialize() {
1596        assert!(Link::from_str("archive-v0/foo").is_ok());
1597        assert!(Link::from_str("archive/foo").is_err());
1598        assert!(Link::from_str("v1/foo").is_err());
1599        assert!(Link::from_str("archive-v0/").is_err());
1600    }
1601
1602    #[test]
1603    #[cfg(unix)]
1604    fn prune_does_not_follow_environment_symlinks() {
1605        use super::{Cache, CacheBucket};
1606
1607        let cache_root = tempfile::tempdir().unwrap();
1608        let victim_root = tempfile::tempdir().unwrap();
1609        let environments = cache_root.path().join(CacheBucket::Environments.to_str());
1610        let victim_dir = victim_root.path().join("victim-dir");
1611
1612        fs_err::create_dir_all(&environments).unwrap();
1613        fs_err::create_dir_all(&victim_dir).unwrap();
1614        fs_err::write(victim_dir.join("payload.txt"), "payload").unwrap();
1615        fs_err::os::unix::fs::symlink(&victim_dir, environments.join("escape")).unwrap();
1616
1617        let summary = Cache::from_path(cache_root.path()).prune(false).unwrap();
1618
1619        assert_eq!(summary.num_files, 1);
1620        assert_eq!(summary.num_dirs, 0);
1621        assert!(victim_dir.is_dir());
1622        assert!(victim_dir.join("payload.txt").is_file());
1623        assert!(fs_err::symlink_metadata(environments.join("escape")).is_err());
1624    }
1625
1626    #[test]
1627    #[cfg(unix)]
1628    fn prune_ci_does_not_follow_wheel_symlinks() {
1629        use super::{Cache, CacheBucket};
1630
1631        let cache_root = tempfile::tempdir().unwrap();
1632        let victim_root = tempfile::tempdir().unwrap();
1633        let wheels = cache_root.path().join(CacheBucket::Wheels.to_str());
1634        let source_distributions = cache_root
1635            .path()
1636            .join(CacheBucket::SourceDistributions.to_str());
1637        let victim_dir = victim_root.path().join("victim-dir");
1638        let symlink = wheels.join("escape");
1639
1640        fs_err::create_dir_all(&wheels).unwrap();
1641        fs_err::create_dir_all(&source_distributions).unwrap();
1642        fs_err::create_dir_all(&victim_dir).unwrap();
1643        fs_err::write(victim_dir.join("payload.txt"), "payload").unwrap();
1644        fs_err::os::unix::fs::symlink(&victim_dir, &symlink).unwrap();
1645
1646        let summary = Cache::from_path(cache_root.path()).prune(true).unwrap();
1647
1648        assert_eq!(summary.num_files, 1);
1649        assert_eq!(summary.num_dirs, 0);
1650        assert!(victim_dir.is_dir());
1651        assert!(victim_dir.join("payload.txt").is_file());
1652        assert!(fs_err::symlink_metadata(symlink).is_err());
1653    }
1654
1655    #[test]
1656    #[cfg(unix)]
1657    fn prune_does_not_follow_archive_symlinks() {
1658        use super::{Cache, CacheBucket};
1659
1660        let cache_root = tempfile::tempdir().unwrap();
1661        let victim_root = tempfile::tempdir().unwrap();
1662        let archives = cache_root.path().join(CacheBucket::Archive.to_str());
1663        let victim_dir = victim_root.path().join("victim-dir");
1664        let symlink = archives.join("escape");
1665
1666        fs_err::create_dir_all(&archives).unwrap();
1667        fs_err::create_dir_all(&victim_dir).unwrap();
1668        fs_err::write(victim_dir.join("payload.txt"), "payload").unwrap();
1669        fs_err::os::unix::fs::symlink(&victim_dir, &symlink).unwrap();
1670
1671        let summary = Cache::from_path(cache_root.path()).prune(false).unwrap();
1672
1673        assert_eq!(summary.num_files, 1);
1674        assert_eq!(summary.num_dirs, 0);
1675        assert!(victim_dir.is_dir());
1676        assert!(victim_dir.join("payload.txt").is_file());
1677        assert!(fs_err::symlink_metadata(symlink).is_err());
1678    }
1679}