diff --git a/crates/uv-cache/src/removal.rs b/crates/uv-cache/src/removal.rs index 8fbf041fb..5fdd4c581 100644 --- a/crates/uv-cache/src/removal.rs +++ b/crates/uv-cache/src/removal.rs @@ -62,7 +62,9 @@ impl Removal { reporter: Option<&dyn CleanReporter>, skip_locked_file: bool, ) -> io::Result<()> { - let metadata = match fs_err::symlink_metadata(path) { + let path = uv_fs::verbatim_path(path); + + let metadata = match fs_err::symlink_metadata(&path) { Ok(metadata) => metadata, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(err), @@ -79,18 +81,18 @@ impl Removal { use std::os::windows::fs::FileTypeExt; if metadata.file_type().is_symlink_dir() { - remove_dir(path)?; + remove_dir(&path)?; } else { - remove_file(path)?; + remove_file(&path)?; } } #[cfg(not(windows))] { - remove_file(path)?; + remove_file(&path)?; } } else { - remove_file(path)?; + remove_file(&path)?; } reporter.map(CleanReporter::on_clean); @@ -98,7 +100,7 @@ impl Removal { return Ok(()); } - for entry in walkdir::WalkDir::new(path).contents_first(true) { + for entry in walkdir::WalkDir::new(&path).contents_first(true) { // If we hit a directory that lacks read permissions, try to make it readable. if let Err(ref err) = entry { if err @@ -109,7 +111,7 @@ impl Removal { if set_readable(dir).unwrap_or(false) { // Retry the operation; if we _just_ `self.rm_rf(dir)` and continue, // `walkdir` may give us duplicate entries for the directory. - return self.rm_rf(path, reporter, skip_locked_file); + return self.rm_rf(&path, reporter, skip_locked_file); } } } @@ -122,7 +124,7 @@ impl Removal { && entry.file_name() == ".lock" && entry .path() - .strip_prefix(path) + .strip_prefix(&path) .is_ok_and(|suffix| suffix == Path::new(".lock")) { continue; @@ -143,7 +145,7 @@ impl Removal { remove_dir(entry.path())?; } else if entry.file_type().is_dir() { // Remove the directory with the exclusive lock last. - if skip_locked_file && entry.path() == path { + if skip_locked_file && entry.path() == path.as_ref() { continue; } diff --git a/crates/uv-fs/src/path.rs b/crates/uv-fs/src/path.rs index 9605d82fb..58f1c8821 100644 --- a/crates/uv-fs/src/path.rs +++ b/crates/uv-fs/src/path.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; -use std::path::{Component, Path, PathBuf}; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf, Prefix}; use std::sync::LazyLock; use either::Either; @@ -351,6 +352,107 @@ pub fn try_relative_to_if( } } +/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass +/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space). +/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This +/// function will attempt to perform path normalization similar to Win32 default normalization +/// without triggering the existing Win32 limitations. +/// +/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported. +/// * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share` +/// * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:` +/// +/// Other representations do not yield a `verbatim` path. The following cases are returned as-is: +/// * Non-Windows systems. +/// * Device paths such as those starting with `\\.\`. +/// * Paths already prefixed with `\\?\` or `\\?\UNC\`. +/// +/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even +/// though it allows operations on paths that are normally unavailable, it can also be used to +/// create entries that can potentially lead to further issues with operations that expect +/// normalization such as symbolic links, junctions or reparse points. +/// +/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation +/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars +/// +/// See: +/// * +/// * +pub fn verbatim_path(path: &Path) -> Cow<'_, Path> { + if !cfg!(windows) { + return Cow::Borrowed(path); + } + + // Attempt to resolve a fully qualified path just like Win32 path normalization would. + // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function + // as it results in Win32 default path normalization. + let resolved_path = if path.is_relative() { + Cow::Owned(CWD.join(path)) + } else { + Cow::Borrowed(path) + }; + + // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk + if let Some(Component::Prefix(prefix)) = resolved_path.components().next() { + match prefix.kind() { + Prefix::UNC(..) | Prefix::Disk(_) => {}, + // return as-is as there's no verbatim equivalent for `\\.\device` + Prefix::DeviceNS(_) + // return as-is as its already verbatim + | Prefix::Verbatim(_) + | Prefix::VerbatimDisk(_) + | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path) + } + } + + // Resolve relative directory components while avoiding default Win32 path normalization + let normalized_path = normalized(&resolved_path); + + let mut components = normalized_path.components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return Cow::Borrowed(path); + }; + + match prefix.kind() { + // `DriveLetter:` -> `\\?\DriveLetter:` + Prefix::Disk(_) => { + let mut result = OsString::from(r"\\?\"); + result.push(normalized_path.as_os_str()); // e.g. "C:" + Cow::Owned(PathBuf::from(result)) + } + // `\\server\share` -> `\\?\UNC\server\share` + Prefix::UNC(server, share) => { + let mut result = OsString::from(r"\\?\UNC\"); + result.push(server); + result.push(r"\"); + result.push(share); + for component in components { + match component { + Component::RootDir => {} // being cautious + Component::Prefix(_) => { + debug_assert!(false, "prefix already consumed"); + } + Component::CurDir | Component::ParentDir => { + debug_assert!(false, "path already normalized"); + } + Component::Normal(_) => { + result.push(r"\"); + result.push(component.as_os_str()); + } + } + } + Cow::Owned(PathBuf::from(result)) + } + Prefix::DeviceNS(_) + | Prefix::Verbatim(_) + | Prefix::VerbatimDisk(_) + | Prefix::VerbatimUNC(..) => { + debug_assert!(false, "skipped via fast path"); + Cow::Borrowed(path) + } + } +} + /// A path that can be serialized and deserialized in a portable way by converting Windows-style /// backslashes to forward slashes, and using a `.` for an empty path. /// @@ -610,4 +712,60 @@ mod tests { assert_eq!(normalize_path(Path::new(input)), Path::new(expected)); } } + + #[cfg(windows)] + #[test] + fn test_verbatim_path() { + let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display()); + let relative_root = format!( + r"\\?\{}\path\to\logging.", + CWD.components() + .next() + .expect("expected a drive letter prefix") + .simplified_display() + ); + let cases = [ + // Non-Verbatim disk + (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."), + (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."), + (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."), + (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."), + (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths + (r".\path\to\.\logging.", relative_path.as_str()), + (r"path\to\..\to\logging.", relative_path.as_str()), + (r"./path/to/logging.", relative_path.as_str()), + (r"\path\to\logging.", relative_root.as_str()), + // Non-Verbatim UNC + ( + r"\\127.0.0.1\c$\path\to\logging.", + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", + ), + ( + r"\\127.0.0.1\c$\path\to\.\logging.", + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", + ), + ( + r"\\127.0.0.1\c$\path\to\..\to\logging.", + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", + ), + ( + r"//127.0.0.1/c$/path/to/../to/./logging.", + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", + ), + // Verbatim Disk + (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."), + // Verbatim UNC + ( + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", + r"\\?\UNC\127.0.0.1\c$\path\to\logging.", + ), + // Device Namespace + (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"), + (r"\\.\NUL", r"\\.\NUL"), + ]; + + for (input, expected) in cases { + assert_eq!(verbatim_path(Path::new(input)), Path::new(expected)); + } + } } diff --git a/crates/uv/tests/it/cache_clean.rs b/crates/uv/tests/it/cache_clean.rs index 03f812bd0..e380a7ef2 100644 --- a/crates/uv/tests/it/cache_clean.rs +++ b/crates/uv/tests/it/cache_clean.rs @@ -275,3 +275,48 @@ async fn cache_timeout() { error: Timeout ([TIME]) when waiting for lock on `[CACHE_DIR]/` at `[CACHE_DIR]/.lock`, is another uv process running? You can set `UV_LOCK_TIMEOUT` to increase the timeout. "); } + +/// `cache clean` should handle file paths normally restricted by Win32 path normalization. +#[cfg(windows)] +#[test] +fn clean_handles_verbatim_paths() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Clean slate + fs_err::remove_dir_all(&context.cache_dir)?; + + // Cached sdist path resembling the uwsgi==2.0.31 build failure. + let uwsgi_shard = context + .cache_dir + .child("sdists-v9") + .child("pypi") + .child("uwsgi") + .child("2.0.31") + .child("QxDIp0qpjbsWjWURKmegK") + .child("src") + .child("core"); + + // Attempt to create a file with a trailing dot (we need to make it verbatim to do so) + uwsgi_shard.create_dir_all()?; + let invalid_path = uwsgi_shard.child("logging.").to_path_buf(); + let invalid_file = uv_fs::verbatim_path(invalid_path.as_path()); + fs_err::write(&invalid_file, b"")?; + + // Confirm Win32 normalized path causes an os error when attempting to remove + let remove_err = fs_err::remove_file(&invalid_path).expect_err("expected to fail"); + assert_eq!(remove_err.kind(), std::io::ErrorKind::NotFound); + + // Tests cache clean leverages verbatim conversion + uv_snapshot!(context.filters(), context.clean().arg("--verbose"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + DEBUG uv [VERSION] ([COMMIT] DATE) + Clearing cache at: [CACHE_DIR]/ + Removed 2 files + "); + + Ok(()) +}