Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions crates/uv-cache/src/removal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
131 changes: 131 additions & 0 deletions crates/uv/tests/build/cache_long_paths.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
3 changes: 3 additions & 0 deletions crates/uv/tests/build/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
woodruffw marked this conversation as resolved.

#[cfg(all(feature = "test-python", feature = "test-pypi"))]
mod cache_prune;

Expand Down