From 52338214e3d399542508cdb4762c050303d6ec41 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Mon, 30 Jun 2025 13:15:50 +0200 Subject: [PATCH 1/2] Add trailing slash if missing to find-links URL --- crates/uv-client/src/flat_index.rs | 19 +++++++++--- crates/uv-distribution-types/src/index_url.rs | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/uv-client/src/flat_index.rs b/crates/uv-client/src/flat_index.rs index 91668c5c4..61f65c370 100644 --- a/crates/uv-client/src/flat_index.rs +++ b/crates/uv-client/src/flat_index.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::path::{Path, PathBuf}; use futures::{FutureExt, StreamExt}; @@ -149,10 +150,20 @@ impl<'a> FlatIndexClient<'a> { Self::read_from_directory(&path, index) .map_err(|err| FlatIndexError::FindLinksDirectory(path.clone(), err)) } - IndexUrl::Pypi(url) | IndexUrl::Url(url) => self - .read_from_url(url, index) - .await - .map_err(|err| FlatIndexError::FindLinksUrl(url.to_url(), err)), + IndexUrl::Pypi(url) | IndexUrl::Url(url) => { + // If the URL was originally provided with a slash, we restore that slash + // before making a request. + let url_with_original_slash = + if url.given().is_some_and(|given| given.ends_with('/')) { + index.url_with_trailing_slash() + } else { + Cow::Borrowed(index.url()) + }; + + self.read_from_url(&url_with_original_slash, index) + .await + .map_err(|err| FlatIndexError::FindLinksUrl(url.to_url(), err)) + } } } diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index eb91e852e..24dfc5515 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -117,6 +117,18 @@ impl IndexUrl { } } + /// Return the raw URL for the index with a trailing slash. + pub fn url_with_trailing_slash(&self) -> Cow<'_, DisplaySafeUrl> { + let path = self.url().path(); + if path.ends_with('/') { + Cow::Borrowed(self.url()) + } else { + let mut url = self.url().clone(); + url.set_path(&format!("{path}/")); + Cow::Owned(url) + } + } + /// Convert the index URL into a [`DisplaySafeUrl`]. pub fn into_url(self) -> DisplaySafeUrl { match self { @@ -713,4 +725,23 @@ mod tests { "git+https://github.com/example/repo.git" )); } + + #[test] + fn test_index_url_with_trailing_slash() { + let url_with_trailing_slash = DisplaySafeUrl::parse("https://example.com/path/").unwrap(); + + let index_url_with_given_slash = + IndexUrl::parse("https://example.com/path/", None).unwrap(); + assert_eq!( + &*index_url_with_given_slash.url_with_trailing_slash(), + &url_with_trailing_slash + ); + + let index_url_without_given_slash = + IndexUrl::parse("https://example.com/path", None).unwrap(); + assert_eq!( + &*index_url_without_given_slash.url_with_trailing_slash(), + &url_with_trailing_slash + ); + } } From 6012b9de8fcc29060c286670723ec479c82aef99 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Fri, 4 Jul 2025 10:06:17 +0200 Subject: [PATCH 2/2] Preserve trailing slash on find-links URLs --- crates/uv-cli/src/lib.rs | 2 +- crates/uv-client/src/flat_index.rs | 19 +-- crates/uv-distribution-types/src/index_url.rs | 133 +++++++++++++----- crates/uv-requirements/src/specification.rs | 5 +- 4 files changed, 104 insertions(+), 55 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bf605198f..f7f740f89 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -990,7 +990,7 @@ fn parse_find_links(input: &str) -> Result, String> { if input.is_empty() { Ok(Maybe::None) } else { - IndexUrl::from_str(input) + IndexUrl::parse_preserving_trailing_slash(input) .map(Index::from_find_links) .map(|index| Index { origin: Some(Origin::Cli), diff --git a/crates/uv-client/src/flat_index.rs b/crates/uv-client/src/flat_index.rs index 61f65c370..91668c5c4 100644 --- a/crates/uv-client/src/flat_index.rs +++ b/crates/uv-client/src/flat_index.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::path::{Path, PathBuf}; use futures::{FutureExt, StreamExt}; @@ -150,20 +149,10 @@ impl<'a> FlatIndexClient<'a> { Self::read_from_directory(&path, index) .map_err(|err| FlatIndexError::FindLinksDirectory(path.clone(), err)) } - IndexUrl::Pypi(url) | IndexUrl::Url(url) => { - // If the URL was originally provided with a slash, we restore that slash - // before making a request. - let url_with_original_slash = - if url.given().is_some_and(|given| given.ends_with('/')) { - index.url_with_trailing_slash() - } else { - Cow::Borrowed(index.url()) - }; - - self.read_from_url(&url_with_original_slash, index) - .await - .map_err(|err| FlatIndexError::FindLinksUrl(url.to_url(), err)) - } + IndexUrl::Pypi(url) | IndexUrl::Url(url) => self + .read_from_url(url, index) + .await + .map_err(|err| FlatIndexError::FindLinksUrl(url.to_url(), err)), } } diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index 24dfc5515..913144625 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -41,6 +41,30 @@ impl IndexUrl { /// /// Normalizes non-file URLs by removing trailing slashes for consistency. pub fn parse(path: &str, root_dir: Option<&Path>) -> Result { + Self::parse_with_trailing_slash_policy(path, root_dir, TrailingSlashPolicy::Remove) + } + + /// Parse an [`IndexUrl`] from a string, relative to an optional root directory. + /// + /// If no root directory is provided, relative paths are resolved against the current working + /// directory. + /// + /// Preserves trailing slash if present in `path`. + pub fn parse_preserving_trailing_slash(path: &str) -> Result { + Self::parse_with_trailing_slash_policy(path, None, TrailingSlashPolicy::Preserve) + } + + /// Parse an [`IndexUrl`] from a string, relative to an optional root directory. + /// + /// If no root directory is provided, relative paths are resolved against the current working + /// directory. + /// + /// Applies trailing slash policy to non-file URLs. + fn parse_with_trailing_slash_policy( + path: &str, + root_dir: Option<&Path>, + slash_policy: TrailingSlashPolicy, + ) -> Result { let url = match split_scheme(path) { Some((scheme, ..)) => { match Scheme::parse(scheme) { @@ -67,7 +91,10 @@ impl IndexUrl { } } }; - Ok(Self::from(url.with_given(path))) + Ok(Self::from_verbatim_url_with_trailing_slash_policy( + url.with_given(path), + slash_policy, + )) } /// Return the root [`Url`] of the index, if applicable. @@ -91,6 +118,34 @@ impl IndexUrl { url.path_segments_mut().ok()?.pop_if_empty().pop(); Some(url) } + + /// Construct an [`IndexUrl`] from a [`VerbatimUrl`], preserving a trailing + /// slash if present. + pub fn from_verbatim_url_preserving_trailing_slash(url: VerbatimUrl) -> Self { + Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Preserve) + } + + /// Construct an [`IndexUrl`] from a [`VerbatimUrl`], applying a [`TrailingSlashPolicy`] + /// to non-file URLs. + fn from_verbatim_url_with_trailing_slash_policy( + mut url: VerbatimUrl, + slash_policy: TrailingSlashPolicy, + ) -> Self { + if url.scheme() == "file" { + return Self::Path(Arc::new(url)); + } + + if slash_policy == TrailingSlashPolicy::Remove { + if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() { + path_segments.pop_if_empty(); + } + } + if *url.raw() == *PYPI_URL { + Self::Pypi(Arc::new(url)) + } else { + Self::Url(Arc::new(url)) + } + } } #[cfg(feature = "schemars")] @@ -117,18 +172,6 @@ impl IndexUrl { } } - /// Return the raw URL for the index with a trailing slash. - pub fn url_with_trailing_slash(&self) -> Cow<'_, DisplaySafeUrl> { - let path = self.url().path(); - if path.ends_with('/') { - Cow::Borrowed(self.url()) - } else { - let mut url = self.url().clone(); - url.set_path(&format!("{path}/")); - Cow::Owned(url) - } - } - /// Convert the index URL into a [`DisplaySafeUrl`]. pub fn into_url(self) -> DisplaySafeUrl { match self { @@ -196,6 +239,16 @@ impl Verbatim for IndexUrl { } } +/// Whether to preserve or remove a trailing slash from a non-file URL. +#[derive(Default, Clone, Copy, Eq, PartialEq)] +enum TrailingSlashPolicy { + /// Preserve trailing slash if present. + #[default] + Preserve, + /// Remove trailing slash if present. + Remove, +} + /// Checks if a path is disambiguated. /// /// Disambiguated paths are absolute paths, paths with valid schemes, @@ -270,21 +323,10 @@ impl<'de> serde::de::Deserialize<'de> for IndexUrl { } impl From for IndexUrl { - fn from(mut url: VerbatimUrl) -> Self { - if url.scheme() == "file" { - Self::Path(Arc::new(url)) - } else { - // Remove trailing slashes for consistency. They'll be re-added if necessary when - // querying the Simple API. - if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() { - path_segments.pop_if_empty(); - } - if *url.raw() == *PYPI_URL { - Self::Pypi(Arc::new(url)) - } else { - Self::Url(Arc::new(url)) - } - } + fn from(url: VerbatimUrl) -> Self { + // Remove trailing slashes for consistency. They'll be re-added if necessary when + // querying the Simple API. + Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Remove) } } @@ -727,21 +769,40 @@ mod tests { } #[test] - fn test_index_url_with_trailing_slash() { + fn test_index_url_trailing_slash_policies() { let url_with_trailing_slash = DisplaySafeUrl::parse("https://example.com/path/").unwrap(); + let url_without_trailing_slash = DisplaySafeUrl::parse("https://example.com/path").unwrap(); + let verbatim_url_with_trailing_slash = + VerbatimUrl::from_url(url_with_trailing_slash.clone()); + let verbatim_url_without_trailing_slash = + VerbatimUrl::from_url(url_without_trailing_slash.clone()); - let index_url_with_given_slash = - IndexUrl::parse("https://example.com/path/", None).unwrap(); + // Test `From` implementation. + // Trailing slash should be removed if present. assert_eq!( - &*index_url_with_given_slash.url_with_trailing_slash(), - &url_with_trailing_slash + IndexUrl::from(verbatim_url_with_trailing_slash.clone()).url(), + &url_without_trailing_slash + ); + assert_eq!( + IndexUrl::from(verbatim_url_without_trailing_slash.clone()).url(), + &url_without_trailing_slash ); - let index_url_without_given_slash = - IndexUrl::parse("https://example.com/path", None).unwrap(); + // Test `from_verbatim_url_preserving_trailing_slash`. + // Trailing slash should be preserved if present. assert_eq!( - &*index_url_without_given_slash.url_with_trailing_slash(), + IndexUrl::from_verbatim_url_preserving_trailing_slash( + verbatim_url_with_trailing_slash.clone() + ) + .url(), &url_with_trailing_slash ); + assert_eq!( + IndexUrl::from_verbatim_url_preserving_trailing_slash( + verbatim_url_without_trailing_slash.clone() + ) + .url(), + &url_without_trailing_slash + ); } } diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 4c5741392..e637c6012 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -37,9 +37,8 @@ use tracing::instrument; use uv_cache_key::CanonicalUrl; use uv_client::BaseClientBuilder; use uv_configuration::{DependencyGroups, NoBinary, NoBuild}; -use uv_distribution_types::Requirement; use uv_distribution_types::{ - IndexUrl, NameRequirementSpecification, UnresolvedRequirement, + IndexUrl, NameRequirementSpecification, Requirement, UnresolvedRequirement, UnresolvedRequirementSpecification, }; use uv_fs::{CWD, Simplified}; @@ -152,7 +151,7 @@ impl RequirementsSpecification { find_links: requirements_txt .find_links .into_iter() - .map(IndexUrl::from) + .map(IndexUrl::from_verbatim_url_preserving_trailing_slash) .collect(), no_binary: requirements_txt.no_binary, no_build: requirements_txt.only_binary,