diff --git a/crates/uv-cache/src/removal.rs b/crates/uv-cache/src/removal.rs index 23a055f7b8a..995e7d25c8f 100644 --- a/crates/uv-cache/src/removal.rs +++ b/crates/uv-cache/src/removal.rs @@ -149,8 +149,43 @@ impl Removal { } for entry in walkdir::WalkDir::new(&path).contents_first(true) { - // If we hit a directory that lacks read permissions, try to make it readable. + // Capture file metadata up front so overlong files share the directory error handling. + let mut metadata = None; + let entry = match entry { + Ok(entry) if !entry.file_type().is_dir() => match entry.metadata() { + Ok(entry_metadata) => { + metadata = Some(entry_metadata); + Ok(entry) + } + #[cfg(target_os = "macos")] + Err(error) + if error.io_error().is_some_and(|error| { + error.kind() == io::ErrorKind::InvalidFilename + }) => + { + Err(error) + } + Err(_) => Ok(entry), + }, + entry => entry, + }; + if let Err(ref err) = entry { + // On Unix, `ENAMETOOLONG` is the only OS error mapped to `InvalidFilename`. + // NOTE: In the future, we may want to extend this to Linux and other targets, + // although it's less likely there given that Linux's `MAX_PATH` is 4096 instead + // of macOS's 1024. + #[cfg(target_os = "macos")] + if err + .io_error() + .is_some_and(|error| error.kind() == io::ErrorKind::InvalidFilename) + && let Some(parent) = err.path().and_then(Path::parent) + && parent != path.as_ref() + { + return self.rm_rf_overlong_subtree(&path, parent, reporter, skip_locked_file); + } + + // If we hit a directory that lacks read permissions, try to make it readable. if err .io_error() .is_some_and(|err| err.kind() == io::ErrorKind::PermissionDenied) @@ -189,30 +224,29 @@ impl Removal { false } } { - self.num_files += 1; remove_dir(entry.path())?; + self.num_files += 1; } else if entry.file_type().is_dir() { // Remove the directory with the exclusive lock last. if skip_locked_file && entry.path() == path.as_ref() { continue; } - self.num_dirs += 1; - // The contents should have been removed by now, but sometimes a race condition is // hit where other files have been added by the OS. Fall back to `remove_dir_all`, // which will remove the directory robustly across platforms. remove_dir_all(entry.path())?; + self.num_dirs += 1; } else { - self.num_files += 1; - // Remove the file. - if let Ok(metadata) = entry.metadata() { - self.add_file(entry.path(), &metadata); + if let Some(metadata) = &metadata { + self.add_file(entry.path(), metadata); } else if self.physical_bytes.is_some() { self.physical_bytes_incomplete = true; } + remove_file(entry.path())?; + self.num_files += 1; } reporter.map(CleanReporter::on_clean); @@ -222,6 +256,29 @@ impl Removal { Ok(()) } + + /// Remove an overlong subtree with descriptor-relative operations, then restart the walker. + #[cfg(target_os = "macos")] + fn rm_rf_overlong_subtree( + &mut self, + root: &Path, + directory: &Path, + reporter: Option<&dyn CleanReporter>, + skip_locked_file: bool, + ) -> io::Result<()> { + // `remove_dir_all` uses `openat` and `unlinkat`, so it can remove descendants whose + // complete paths exceed `PATH_MAX`. It does not report its contents, so only count the + // directory passed to it. + remove_dir_all(directory)?; + self.num_dirs += 1; + if self.physical_bytes.is_some() { + self.physical_bytes_incomplete = true; + } + reporter.map(CleanReporter::on_clean); + + // Restart because `walkdir` may otherwise yield entries from the removed directory. + self.rm_rf(root, reporter, skip_locked_file) + } } impl std::ops::AddAssign for Removal { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index fe21fc9063d..186779a226b 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -156,6 +156,9 @@ tokio-stream = { workspace = true } tokio-util = { workspace = true } wiremock = { workspace = true } +[target.'cfg(target_os = "macos")'.dev-dependencies] +nix = { workspace = true, features = ["fs"] } + [target.'cfg(unix)'.dependencies] nix = { workspace = true } uv-unix = { workspace = true } diff --git a/crates/uv/tests/build/cache_long_paths.rs b/crates/uv/tests/build/cache_long_paths.rs new file mode 100644 index 00000000000..ff82b95dcdb --- /dev/null +++ b/crates/uv/tests/build/cache_long_paths.rs @@ -0,0 +1,131 @@ +use std::{ + io::{self, Write}, + os::fd::OwnedFd, + path::Path, +}; + +use anyhow::Result; +use assert_fs::prelude::*; +use nix::{ + errno::Errno, + fcntl::{OFlag, openat}, + sys::stat::{Mode, mkdirat, stat}, +}; + +use uv_test::uv_snapshot; + +/// `cache clean` should remove directory trees that exceed the macOS path length limit. +#[test] +fn clean_handles_overlong_paths() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + create_overlong_path(context.cache_dir.path(), OverlongPathKind::Directory)?; + + uv_snapshot!(context.filters(), context.clean(), @" + exit_code: 0 (success) + ----- stderr ----- + Clearing cache at: [CACHE_DIR]/ + Removed 1 file + "); + + assert!(!context.cache_dir.exists()); + + Ok(()) +} + +/// `cache clean` should remove files whose full paths exceed the macOS path length limit. +#[test] +fn clean_handles_overlong_file_paths() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]); + create_overlong_path(context.cache_dir.path(), OverlongPathKind::File)?; + + uv_snapshot!(context.filters(), context.clean(), @" + exit_code: 0 (success) + ----- stderr ----- + Clearing cache at: [CACHE_DIR]/ + Removed 1 file + "); + + assert!(!context.cache_dir.exists()); + + Ok(()) +} + +/// `cache prune` should remove stale directory trees that exceed the macOS path length limit. +#[test] +fn prune_handles_overlong_paths() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]) + .with_filter((r"Removed \d+ directories", "Removed [N] directories")); + let stale_bucket = context.cache_dir.child("simple-v4"); + create_overlong_path(stale_bucket.path(), OverlongPathKind::Directory)?; + + uv_snapshot!(context.filters(), context.prune(), @" + exit_code: 0 (success) + ----- stderr ----- + Pruning cache at: [CACHE_DIR]/ + Removed [N] directories + "); + + assert!(!stale_bucket.exists()); + + Ok(()) +} + +#[derive(Clone, Copy)] +enum OverlongPathKind { + Directory, + File, +} + +/// Create a file or directory whose full path exceeds the macOS path length limit. +fn create_overlong_path(path: &Path, kind: OverlongPathKind) -> io::Result<()> { + fs_err::create_dir_all(path)?; + + let component = "x".repeat(100); + let mut directory: OwnedFd = fs_err::File::open(path)?.into(); + let mut overlong_path = path.to_path_buf(); + for _ in 0..14 { + if matches!(kind, OverlongPathKind::File) + && matches!( + stat(&overlong_path.join(&component)), + Err(Errno::ENAMETOOLONG) + ) + { + break; + } + + mkdirat( + &directory, + component.as_str(), + Mode::from_bits_truncate(0o755), + )?; + directory = openat( + &directory, + component.as_str(), + OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC, + Mode::empty(), + )?; + overlong_path.push(&component); + } + + let payload_name = match kind { + OverlongPathKind::Directory => "payload.txt".to_string(), + OverlongPathKind::File => "y".repeat(255), + }; + let payload = openat( + &directory, + payload_name.as_str(), + OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_EXCL | OFlag::O_CLOEXEC, + Mode::from_bits_truncate(0o644), + )?; + let payload_path = overlong_path.join(payload_name); + fs_err::File::from_parts(payload.into(), &payload_path).write_all(b"payload")?; + + let overlong_path = match kind { + OverlongPathKind::Directory => overlong_path, + OverlongPathKind::File => payload_path, + }; + let error = stat(&overlong_path).expect_err("Expected the path to be too long"); + assert_eq!(error, Errno::ENAMETOOLONG); + + Ok(()) +} diff --git a/crates/uv/tests/build/main.rs b/crates/uv/tests/build/main.rs index 1555870f2d0..efdee6882e9 100644 --- a/crates/uv/tests/build/main.rs +++ b/crates/uv/tests/build/main.rs @@ -18,6 +18,9 @@ mod cache; #[cfg(all(feature = "test-python", feature = "test-pypi"))] mod cache_clean; +#[cfg(target_os = "macos")] +mod cache_long_paths; + #[cfg(all(feature = "test-python", feature = "test-pypi"))] mod cache_prune;