From 3b18aa6a957e9feb5b8fe20b5562edb6d508a274 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 4 Jun 2026 21:47:18 -0400 Subject: [PATCH 1/2] Avoid following external symlinks during cache clean --- crates/uv-cache/src/lib.rs | 10 ++++++++- crates/uv/tests/it/cache_clean.rs | 37 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index f9aa9d7412cee..d9337a3a84789 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -562,9 +562,17 @@ impl Cache { summary += bucket.remove(self, name)?; } + if references.is_empty() { + return Ok(summary); + } + + // Only remove targets in the archive bucket. Cache entries may contain unexpected links + // to paths outside the cache. + let archive_root = fs_err::canonicalize(&self.root)?.join(CacheBucket::Archive.to_str()); + // Remove any archives that are no longer referenced. for (target, references) in references { - if references.iter().all(|path| !path.exists()) { + if target.starts_with(&archive_root) && references.iter().all(|path| !path.exists()) { debug!("Removing dangling cache entry: {}", target.display()); summary += rm_rf(target)?; } diff --git a/crates/uv/tests/it/cache_clean.rs b/crates/uv/tests/it/cache_clean.rs index f2887a427a840..8cf67d4182465 100644 --- a/crates/uv/tests/it/cache_clean.rs +++ b/crates/uv/tests/it/cache_clean.rs @@ -263,6 +263,43 @@ fn clean_package_index() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[test] +fn clean_package_does_not_follow_symlinks() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let victim_dir = context.temp_dir.child("victim"); + let archive_entry = context.cache_dir.child("archive-v0").child("archive"); + let package_entry = context + .cache_dir + .child("wheels-v6") + .child("pypi") + .child("demo"); + + victim_dir.create_dir_all()?; + victim_dir.child("payload.txt").write_str("payload")?; + archive_entry.create_dir_all()?; + archive_entry.child("payload.txt").write_str("payload")?; + package_entry.create_dir_all()?; + fs_err::os::unix::fs::symlink(&victim_dir, package_entry.join("escape"))?; + fs_err::os::unix::fs::symlink(&archive_entry, package_entry.join("archive"))?; + + uv_snapshot!(context.filters(), context.clean().arg("demo"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Removed 3 files ([SIZE]) + "); + + assert!(victim_dir.is_dir()); + assert!(victim_dir.child("payload.txt").is_file()); + assert!(fs_err::symlink_metadata(package_entry).is_err()); + assert!(fs_err::symlink_metadata(archive_entry).is_err()); + + Ok(()) +} + #[tokio::test] async fn cache_timeout() { let context = uv_test::test_context!("3.12"); From 6c930b3e9344a619d5170aaf37b161ea43bd1616 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 4 Jun 2026 21:51:59 -0400 Subject: [PATCH 2/2] Clarify cache clean symlink test --- crates/uv/tests/it/cache_clean.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/uv/tests/it/cache_clean.rs b/crates/uv/tests/it/cache_clean.rs index 8cf67d4182465..9cf452fe04b58 100644 --- a/crates/uv/tests/it/cache_clean.rs +++ b/crates/uv/tests/it/cache_clean.rs @@ -280,6 +280,8 @@ fn clean_package_does_not_follow_symlinks() -> Result<()> { archive_entry.create_dir_all()?; archive_entry.child("payload.txt").write_str("payload")?; package_entry.create_dir_all()?; + + // Preserve external targets while still removing unreferenced entries in the archive bucket. fs_err::os::unix::fs::symlink(&victim_dir, package_entry.join("escape"))?; fs_err::os::unix::fs::symlink(&archive_entry, package_entry.join("archive"))?;