From 630805d351d3f317f07b65a28aa592068cd31898 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 14 Jul 2026 15:25:53 -0500 Subject: [PATCH] Prefer the most specific index authentication policy --- crates/uv-auth/src/index.rs | 50 +++++++++++- crates/uv-auth/src/middleware.rs | 136 ++++++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/crates/uv-auth/src/index.rs b/crates/uv-auth/src/index.rs index e3c79ff84208e..36472a0b4277d 100644 --- a/crates/uv-auth/src/index.rs +++ b/crates/uv-auth/src/index.rs @@ -124,7 +124,16 @@ impl Indexes { } fn find_prefix_index(&self, url: &Url) -> Option<&Index> { - self.0.iter().find(|&index| index.is_prefix_for(url)) + self.0 + .iter() + .filter(|&index| index.is_prefix_for(url)) + .max_by_key(|index| { + ( + index.root_url.path().trim_end_matches('/').len(), + matches!(index.auth_policy, AuthPolicy::Never), + matches!(index.auth_policy, AuthPolicy::Always), + ) + }) } } @@ -167,4 +176,43 @@ mod tests { ); } } + + #[test] + fn test_index_path_prefix_prefers_most_specific_root() { + let indexes = Indexes::from_indexes([ + index("https://example.com", AuthPolicy::Always), + index("https://example.com/public", AuthPolicy::Never), + ]); + let url = Url::parse("https://example.com/public/simple/anyio").unwrap(); + + assert_eq!(indexes.auth_policy_for(&url), AuthPolicy::Never); + assert_eq!( + indexes.index_for(&url).map(|index| index.root_url.path()), + Some("/public") + ); + } + + #[test] + fn test_index_path_prefix_prefers_never_for_equal_roots() { + let indexes = Indexes::from_indexes([ + index("https://example.com/public", AuthPolicy::Auto), + index("https://example.com/public", AuthPolicy::Always), + index("https://example.com/public", AuthPolicy::Never), + ]); + let url = Url::parse("https://example.com/public/simple/anyio").unwrap(); + + assert_eq!(indexes.auth_policy_for(&url), AuthPolicy::Never); + } + + #[test] + fn test_index_path_prefix_prefers_never_for_trailing_slash_roots() { + let indexes = Indexes::from_indexes([ + index("https://example.com/public/", AuthPolicy::Auto), + index("https://example.com/public/", AuthPolicy::Always), + index("https://example.com/public", AuthPolicy::Never), + ]); + let url = Url::parse("https://example.com/public/simple/anyio").unwrap(); + + assert_eq!(indexes.auth_policy_for(&url), AuthPolicy::Never); + } } diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 279d38150f872..d6b151d25f4d1 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -381,6 +381,12 @@ impl Middleware for AuthMiddleware { let auth_policy = self.indexes.auth_policy_for(request.url()); trace!("Handling request for {url} with authentication policy {auth_policy}"); + if matches!(auth_policy, AuthPolicy::Never) && request_credentials.is_some() { + return Err(Error::Middleware(format_err!( + "Credentials were provided for {url}, but authentication is disabled for this index" + ))); + } + let credentials: Option> = if matches!(auth_policy, AuthPolicy::Never) { None } else { @@ -2454,19 +2460,15 @@ mod tests { ) .build(); - let mut url = base_url.clone(); + let mut url = base_url.join("foo")?; url.set_username(username).unwrap(); url.set_password(Some(password)).unwrap(); - assert_eq!( - client - .get(format!("{}/foo", server.uri())) - .send() - .await? - .status(), - 401, - "Requests should not be completed if credentials are required" - ); + assert!(matches!( + client.get(url).send().await, + Err(reqwest_middleware::Error::Middleware(_)) + )); + assert!(server.received_requests().await.unwrap().is_empty()); Ok(()) } @@ -2508,6 +2510,120 @@ mod tests { Ok(()) } + /// A nested index with `authenticate = never` must not receive credentials cached for a + /// broader index root. + #[test(tokio::test)] + async fn test_auth_policy_never_with_overlapping_index_roots() -> Result<(), Error> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/public/.*")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let broad_url = DisplaySafeUrl::parse(&server.uri())?; + let nested_url = DisplaySafeUrl::parse(&format!("{}/public", server.uri()))?; + let indexes = Indexes::from_indexes([ + Index { + url: broad_url.clone(), + root_url: broad_url.clone(), + auth_policy: AuthPolicy::Auto, + }, + Index { + url: nested_url.clone(), + root_url: nested_url, + auth_policy: AuthPolicy::Never, + }, + ]); + let cache = CredentialsCache::new(); + cache.store_credentials( + &broad_url, + Credentials::Basic { + username: Username::new(Some("user".to_string())), + password: Some(Password::new("password".to_string())), + }, + ); + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(cache) + .with_indexes(indexes), + ) + .build(); + + assert_eq!( + client + .get(format!("{}/public/simple/anyio", server.uri())) + .send() + .await? + .status(), + 200 + ); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert!(!requests[0].headers.contains_key("authorization")); + + Ok(()) + } + + /// Equivalent index roots with and without a trailing slash must not let a broader policy + /// send cached credentials when the other root disables authentication. + #[test(tokio::test)] + async fn test_auth_policy_never_with_trailing_slash_index_roots() -> Result<(), Error> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/public/.*")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let authenticated_url = DisplaySafeUrl::parse(&format!("{}/public/", server.uri()))?; + let unauthenticated_url = DisplaySafeUrl::parse(&format!("{}/public", server.uri()))?; + let indexes = Indexes::from_indexes([ + Index { + url: authenticated_url.clone(), + root_url: authenticated_url.clone(), + auth_policy: AuthPolicy::Auto, + }, + Index { + url: unauthenticated_url.clone(), + root_url: unauthenticated_url, + auth_policy: AuthPolicy::Never, + }, + ]); + let cache = CredentialsCache::new(); + cache.store_credentials( + &authenticated_url, + Credentials::Basic { + username: Username::new(Some("user".to_string())), + password: Some(Password::new("password".to_string())), + }, + ); + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(cache) + .with_indexes(indexes), + ) + .build(); + + assert_eq!( + client + .get(format!("{}/public/simple/anyio", server.uri())) + .send() + .await? + .status(), + 200 + ); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert!(!requests[0].headers.contains_key("authorization")); + + Ok(()) + } + #[test] fn test_tracing_url() { // No credentials