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}