Skip to content

Commit 6012b9d

Browse files
committed
Preserve trailing slash on find-links URLs
1 parent 5233821 commit 6012b9d

File tree

4 files changed

+105
-56
lines changed

4 files changed

+105
-56
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -990,7 +990,7 @@ fn parse_find_links(input: &str) -> Result<Maybe<PipFindLinks>, String> {
990990
if input.is_empty() {
991991
Ok(Maybe::None)
992992
} else {
993-
IndexUrl::from_str(input)
993+
IndexUrl::parse_preserving_trailing_slash(input)
994994
.map(Index::from_find_links)
995995
.map(|index| Index {
996996
origin: Some(Origin::Cli),

crates/uv-client/src/flat_index.rs

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use std::borrow::Cow;
21
use std::path::{Path, PathBuf};
32

43
use futures::{FutureExt, StreamExt};
@@ -150,20 +149,10 @@ impl<'a> FlatIndexClient<'a> {
150149
Self::read_from_directory(&path, index)
151150
.map_err(|err| FlatIndexError::FindLinksDirectory(path.clone(), err))
152151
}
153-
IndexUrl::Pypi(url) | IndexUrl::Url(url) => {
154-
// If the URL was originally provided with a slash, we restore that slash
155-
// before making a request.
156-
let url_with_original_slash =
157-
if url.given().is_some_and(|given| given.ends_with('/')) {
158-
index.url_with_trailing_slash()
159-
} else {
160-
Cow::Borrowed(index.url())
161-
};
162-
163-
self.read_from_url(&url_with_original_slash, index)
164-
.await
165-
.map_err(|err| FlatIndexError::FindLinksUrl(url.to_url(), err))
166-
}
152+
IndexUrl::Pypi(url) | IndexUrl::Url(url) => self
153+
.read_from_url(url, index)
154+
.await
155+
.map_err(|err| FlatIndexError::FindLinksUrl(url.to_url(), err)),
167156
}
168157
}
169158

crates/uv-distribution-types/src/index_url.rs

Lines changed: 98 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ impl IndexUrl {
4141
///
4242
/// Normalizes non-file URLs by removing trailing slashes for consistency.
4343
pub fn parse(path: &str, root_dir: Option<&Path>) -> Result<Self, IndexUrlError> {
44+
Self::parse_with_trailing_slash_policy(path, root_dir, TrailingSlashPolicy::Remove)
45+
}
46+
47+
/// Parse an [`IndexUrl`] from a string, relative to an optional root directory.
48+
///
49+
/// If no root directory is provided, relative paths are resolved against the current working
50+
/// directory.
51+
///
52+
/// Preserves trailing slash if present in `path`.
53+
pub fn parse_preserving_trailing_slash(path: &str) -> Result<Self, IndexUrlError> {
54+
Self::parse_with_trailing_slash_policy(path, None, TrailingSlashPolicy::Preserve)
55+
}
56+
57+
/// Parse an [`IndexUrl`] from a string, relative to an optional root directory.
58+
///
59+
/// If no root directory is provided, relative paths are resolved against the current working
60+
/// directory.
61+
///
62+
/// Applies trailing slash policy to non-file URLs.
63+
fn parse_with_trailing_slash_policy(
64+
path: &str,
65+
root_dir: Option<&Path>,
66+
slash_policy: TrailingSlashPolicy,
67+
) -> Result<Self, IndexUrlError> {
4468
let url = match split_scheme(path) {
4569
Some((scheme, ..)) => {
4670
match Scheme::parse(scheme) {
@@ -67,7 +91,10 @@ impl IndexUrl {
6791
}
6892
}
6993
};
70-
Ok(Self::from(url.with_given(path)))
94+
Ok(Self::from_verbatim_url_with_trailing_slash_policy(
95+
url.with_given(path),
96+
slash_policy,
97+
))
7198
}
7299

73100
/// Return the root [`Url`] of the index, if applicable.
@@ -91,6 +118,34 @@ impl IndexUrl {
91118
url.path_segments_mut().ok()?.pop_if_empty().pop();
92119
Some(url)
93120
}
121+
122+
/// Construct an [`IndexUrl`] from a [`VerbatimUrl`], preserving a trailing
123+
/// slash if present.
124+
pub fn from_verbatim_url_preserving_trailing_slash(url: VerbatimUrl) -> Self {
125+
Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Preserve)
126+
}
127+
128+
/// Construct an [`IndexUrl`] from a [`VerbatimUrl`], applying a [`TrailingSlashPolicy`]
129+
/// to non-file URLs.
130+
fn from_verbatim_url_with_trailing_slash_policy(
131+
mut url: VerbatimUrl,
132+
slash_policy: TrailingSlashPolicy,
133+
) -> Self {
134+
if url.scheme() == "file" {
135+
return Self::Path(Arc::new(url));
136+
}
137+
138+
if slash_policy == TrailingSlashPolicy::Remove {
139+
if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() {
140+
path_segments.pop_if_empty();
141+
}
142+
}
143+
if *url.raw() == *PYPI_URL {
144+
Self::Pypi(Arc::new(url))
145+
} else {
146+
Self::Url(Arc::new(url))
147+
}
148+
}
94149
}
95150

96151
#[cfg(feature = "schemars")]
@@ -117,18 +172,6 @@ impl IndexUrl {
117172
}
118173
}
119174

120-
/// Return the raw URL for the index with a trailing slash.
121-
pub fn url_with_trailing_slash(&self) -> Cow<'_, DisplaySafeUrl> {
122-
let path = self.url().path();
123-
if path.ends_with('/') {
124-
Cow::Borrowed(self.url())
125-
} else {
126-
let mut url = self.url().clone();
127-
url.set_path(&format!("{path}/"));
128-
Cow::Owned(url)
129-
}
130-
}
131-
132175
/// Convert the index URL into a [`DisplaySafeUrl`].
133176
pub fn into_url(self) -> DisplaySafeUrl {
134177
match self {
@@ -196,6 +239,16 @@ impl Verbatim for IndexUrl {
196239
}
197240
}
198241

242+
/// Whether to preserve or remove a trailing slash from a non-file URL.
243+
#[derive(Default, Clone, Copy, Eq, PartialEq)]
244+
enum TrailingSlashPolicy {
245+
/// Preserve trailing slash if present.
246+
#[default]
247+
Preserve,
248+
/// Remove trailing slash if present.
249+
Remove,
250+
}
251+
199252
/// Checks if a path is disambiguated.
200253
///
201254
/// Disambiguated paths are absolute paths, paths with valid schemes,
@@ -270,21 +323,10 @@ impl<'de> serde::de::Deserialize<'de> for IndexUrl {
270323
}
271324

272325
impl From<VerbatimUrl> for IndexUrl {
273-
fn from(mut url: VerbatimUrl) -> Self {
274-
if url.scheme() == "file" {
275-
Self::Path(Arc::new(url))
276-
} else {
277-
// Remove trailing slashes for consistency. They'll be re-added if necessary when
278-
// querying the Simple API.
279-
if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() {
280-
path_segments.pop_if_empty();
281-
}
282-
if *url.raw() == *PYPI_URL {
283-
Self::Pypi(Arc::new(url))
284-
} else {
285-
Self::Url(Arc::new(url))
286-
}
287-
}
326+
fn from(url: VerbatimUrl) -> Self {
327+
// Remove trailing slashes for consistency. They'll be re-added if necessary when
328+
// querying the Simple API.
329+
Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Remove)
288330
}
289331
}
290332

@@ -727,21 +769,40 @@ mod tests {
727769
}
728770

729771
#[test]
730-
fn test_index_url_with_trailing_slash() {
772+
fn test_index_url_trailing_slash_policies() {
731773
let url_with_trailing_slash = DisplaySafeUrl::parse("https://example.com/path/").unwrap();
732-
733-
let index_url_with_given_slash =
734-
IndexUrl::parse("https://example.com/path/", None).unwrap();
774+
let url_without_trailing_slash = DisplaySafeUrl::parse("https://example.com/path").unwrap();
775+
let verbatim_url_with_trailing_slash =
776+
VerbatimUrl::from_url(url_with_trailing_slash.clone());
777+
let verbatim_url_without_trailing_slash =
778+
VerbatimUrl::from_url(url_without_trailing_slash.clone());
779+
780+
// Test `From<VerbatimUrl>` implementation.
781+
// Trailing slash should be removed if present.
735782
assert_eq!(
736-
&*index_url_with_given_slash.url_with_trailing_slash(),
737-
&url_with_trailing_slash
783+
IndexUrl::from(verbatim_url_with_trailing_slash.clone()).url(),
784+
&url_without_trailing_slash
785+
);
786+
assert_eq!(
787+
IndexUrl::from(verbatim_url_without_trailing_slash.clone()).url(),
788+
&url_without_trailing_slash
738789
);
739790

740-
let index_url_without_given_slash =
741-
IndexUrl::parse("https://example.com/path", None).unwrap();
791+
// Test `from_verbatim_url_preserving_trailing_slash`.
792+
// Trailing slash should be preserved if present.
742793
assert_eq!(
743-
&*index_url_without_given_slash.url_with_trailing_slash(),
794+
IndexUrl::from_verbatim_url_preserving_trailing_slash(
795+
verbatim_url_with_trailing_slash.clone()
796+
)
797+
.url(),
744798
&url_with_trailing_slash
745799
);
800+
assert_eq!(
801+
IndexUrl::from_verbatim_url_preserving_trailing_slash(
802+
verbatim_url_without_trailing_slash.clone()
803+
)
804+
.url(),
805+
&url_without_trailing_slash
806+
);
746807
}
747808
}

crates/uv-requirements/src/specification.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,8 @@ use tracing::instrument;
3737
use uv_cache_key::CanonicalUrl;
3838
use uv_client::BaseClientBuilder;
3939
use uv_configuration::{DependencyGroups, NoBinary, NoBuild};
40-
use uv_distribution_types::Requirement;
4140
use uv_distribution_types::{
42-
IndexUrl, NameRequirementSpecification, UnresolvedRequirement,
41+
IndexUrl, NameRequirementSpecification, Requirement, UnresolvedRequirement,
4342
UnresolvedRequirementSpecification,
4443
};
4544
use uv_fs::{CWD, Simplified};
@@ -152,7 +151,7 @@ impl RequirementsSpecification {
152151
find_links: requirements_txt
153152
.find_links
154153
.into_iter()
155-
.map(IndexUrl::from)
154+
.map(IndexUrl::from_verbatim_url_preserving_trailing_slash)
156155
.collect(),
157156
no_binary: requirements_txt.no_binary,
158157
no_build: requirements_txt.only_binary,

0 commit comments

Comments
 (0)