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