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