From 66c4ccf58711605fef4fa241eb663fbd15eca7d9 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 8 Apr 2026 12:34:39 +0200 Subject: [PATCH] Remove trailing path separators in path normalization --- crates/uv-fs/src/path.rs | 58 +++++++++++++++++++++++++--- crates/uv-workspace/src/workspace.rs | 18 +++------ 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/crates/uv-fs/src/path.rs b/crates/uv-fs/src/path.rs index 7160fed47c337..9605d82fb3e78 100644 --- a/crates/uv-fs/src/path.rs +++ b/crates/uv-fs/src/path.rs @@ -206,17 +206,36 @@ pub fn normalize_absolute_path(path: &Path) -> Result { Ok(ret) } -/// Normalize a [`Path`], removing things like `.` and `..`. +/// Normalize a [`Path`], removing `.`, `..`, and trailing slashes. pub fn normalize_path(path: &Path) -> Cow<'_, Path> { - // Fast path: if the path is already normalized, return it as-is. - if path.components().all(|component| match component { + // A path with `.` or `..` is not normalized. + if !path.components().all(|component| match component { Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true, Component::ParentDir | Component::CurDir => false, }) { - Cow::Borrowed(path) - } else { - Cow::Owned(normalized(path)) + return Cow::Owned(normalized(path)); } + + // A path with a trailing path separator is not normalized. + if path + .as_os_str() + .as_encoded_bytes() + .last() + .is_some_and(|trailing| { + if cfg!(windows) { + *trailing == b'\\' || *trailing == b'/' + } else if cfg!(unix) { + *trailing == b'/' + } else { + unimplemented!("Only Windows and Unix are supported") + } + }) + { + return Cow::Owned(normalized(path)); + } + + // Fast path: if the path is already normalized, return it as-is. + Cow::Borrowed(path) } /// Normalize a [`PathBuf`], removing things like `.` and `..`. @@ -564,4 +583,31 @@ mod tests { assert_eq!(normalize_path(Path::new(input)), Path::new(expected)); } } + + #[test] + fn test_normalize_trailing_path_separator() { + let cases = [ + ( + "/home/ferris/projects/python/", + "/home/ferris/projects/python", + ), + ("python/", "python"), + ("/", "/"), + ]; + for (input, expected) in cases { + assert_eq!(normalize_path(Path::new(input)), Path::new(expected)); + } + } + + #[test] + #[cfg(windows)] + fn test_normalize_trailing_path_separator_windows() { + let cases = [( + r"C:\Users\Ferris\projects\python\", + r"C:\Users\Ferris\projects\python", + )]; + for (input, expected) in cases { + assert_eq!(normalize_path(Path::new(input)), Path::new(expected)); + } + } } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 70757f4ac5d25..ff22c5623ee69 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -193,10 +193,7 @@ impl Workspace { let path = std::path::absolute(path) .map_err(WorkspaceError::Normalize)? .clone(); - // Remove `.` and `..` let path = uv_fs::normalize_path(&path); - // Trim trailing slashes. - let path = path.components().collect::(); let project_path = path .ancestors() @@ -1400,10 +1397,7 @@ impl ProjectWorkspace { let project_path = std::path::absolute(install_path) .map_err(WorkspaceError::Normalize)? .clone(); - // Remove `.` and `..` let project_path = uv_fs::normalize_path(&project_path); - // Trim trailing slashes. - let project_path = project_path.components().collect::(); // Check if workspaces are explicitly disabled for the project. if project_pyproject_toml @@ -1414,7 +1408,7 @@ impl ProjectWorkspace { == Some(false) { debug!("Project `{}` is marked as unmanaged", project.name); - return Err(WorkspaceError::NonWorkspace(project_path)); + return Err(WorkspaceError::NonWorkspace(project_path.to_path_buf())); } // Check if the current project is also an explicit workspace root. @@ -1425,7 +1419,7 @@ impl ProjectWorkspace { .and_then(|uv| uv.workspace.as_ref()) .map(|workspace| { ( - project_path.clone(), + project_path.to_path_buf(), workspace.clone(), project_pyproject_toml.clone(), ) @@ -1438,7 +1432,7 @@ impl ProjectWorkspace { } let current_project = WorkspaceMember { - root: project_path.clone(), + root: project_path.to_path_buf(), project: project.clone(), pyproject_toml: project_pyproject_toml.clone(), }; @@ -1461,10 +1455,10 @@ impl ProjectWorkspace { )?; return Ok(Self { - project_root: project_path.clone(), + project_root: project_path.to_path_buf(), project_name: project.name.clone(), workspace: Workspace { - install_path: project_path.clone(), + install_path: project_path.to_path_buf(), packages: current_project_as_members, required_members, // There may be package sources, but we don't need to duplicate them into the @@ -1492,7 +1486,7 @@ impl ProjectWorkspace { .await?; Ok(Self { - project_root: project_path, + project_root: project_path.to_path_buf(), project_name: project.name.clone(), workspace, })