diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 66dfb7c2d..f1e76a4f5 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -744,7 +744,10 @@ impl AuthMiddleware { // Text credential store support. } else if let Some(credentials) = self.text_store.get().await.and_then(|text_store| { debug!("Checking text store for credentials for {url}"); - text_store.get_credentials(url, credentials.as_ref().and_then(|credentials| credentials.username())).cloned() + text_store.get_credentials( + url, + credentials.as_ref().and_then(|credentials| credentials.username()), + ).ok().unwrap_or_default().cloned() }) { debug!("Found credentials in plaintext store for {url}"); Some(credentials) diff --git a/crates/uv-auth/src/store.rs b/crates/uv-auth/src/store.rs index 19a54192f..6a18941d2 100644 --- a/crates/uv-auth/src/store.rs +++ b/crates/uv-auth/src/store.rs @@ -122,6 +122,12 @@ pub enum BearerAuthError { UnexpectedPassword, } +#[derive(Debug, Error, PartialEq)] +pub enum LookupError { + #[error("Multiple credentials found for URL '{0}', specify which username to use")] + AmbiguousUsername(DisplaySafeUrl), +} + /// A single credential entry in a TOML credentials file. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")] @@ -334,7 +340,7 @@ impl TextCredentialStore { &self, url: &DisplaySafeUrl, username: Option<&str>, - ) -> Option<&Credentials> { + ) -> Result, LookupError> { let request_realm = Realm::from(url); // Perform an exact lookup first @@ -345,7 +351,7 @@ impl TextCredentialStore { url_service.clone(), Username::from(username.map(str::to_string)), )) { - return Some(credential); + return Ok(Some(credential)); } } @@ -376,15 +382,17 @@ impl TextCredentialStore { let specificity = service.url().path().len(); if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) { best = Some((specificity, service, credential)); + } else if best.is_some_and(|(best_specificity, _, _)| specificity == best_specificity) { + return Err(LookupError::AmbiguousUsername(url.clone())); } } // Return the most specific match if let Some((_, _, credential)) = best { - return Some(credential); + return Ok(Some(credential)); } - None + Ok(None) } /// Store credentials for a given service. @@ -455,10 +463,10 @@ mod tests { let service = Service::from_str("https://example.com").unwrap(); store.insert(service.clone(), credentials.clone()); let url = DisplaySafeUrl::parse("https://example.com/").unwrap(); - assert!(store.get_credentials(&url, None).is_some()); + assert!(store.get_credentials(&url, None).unwrap().is_some()); let url = DisplaySafeUrl::parse("https://example.com/path").unwrap(); - let retrieved = store.get_credentials(&url, None).unwrap(); + let retrieved = store.get_credentials(&url, None).unwrap().unwrap(); assert_eq!(retrieved.username(), Some("user")); assert_eq!(retrieved.password(), Some("pass")); @@ -468,7 +476,7 @@ mod tests { .is_some() ); let url = DisplaySafeUrl::parse("https://example.com/").unwrap(); - assert!(store.get_credentials(&url, None).is_none()); + assert!(store.get_credentials(&url, None).unwrap().is_none()); } #[tokio::test] @@ -494,12 +502,12 @@ password = "pass2" let store = TextCredentialStore::from_file(temp_file.path()).unwrap(); let url = DisplaySafeUrl::parse("https://example.com/").unwrap(); - assert!(store.get_credentials(&url, None).is_some()); + assert!(store.get_credentials(&url, None).unwrap().is_some()); let url = DisplaySafeUrl::parse("https://test.org/").unwrap(); - assert!(store.get_credentials(&url, None).is_some()); + assert!(store.get_credentials(&url, None).unwrap().is_some()); let url = DisplaySafeUrl::parse("https://example.com").unwrap(); - let cred = store.get_credentials(&url, None).unwrap(); + let cred = store.get_credentials(&url, None).unwrap().unwrap(); assert_eq!(cred.username(), Some("testuser")); assert_eq!(cred.password(), Some("testpass")); @@ -535,7 +543,7 @@ password = "pass2" for url_str in matching_urls { let url = DisplaySafeUrl::parse(url_str).unwrap(); - let cred = store.get_credentials(&url, None); + let cred = store.get_credentials(&url, None).unwrap(); assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}"); } @@ -548,7 +556,7 @@ password = "pass2" for url_str in non_matching_urls { let url = DisplaySafeUrl::parse(url_str).unwrap(); - let cred = store.get_credentials(&url, None); + let cred = store.get_credentials(&url, None).unwrap(); assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}"); } } @@ -572,7 +580,7 @@ password = "pass2" for url_str in matching_urls { let url = DisplaySafeUrl::parse(url_str).unwrap(); - let cred = store.get_credentials(&url, None); + let cred = store.get_credentials(&url, None).unwrap(); assert!( cred.is_some(), "Failed to match URL in same realm: {url_str}" @@ -588,7 +596,7 @@ password = "pass2" for url_str in non_matching_urls { let url = DisplaySafeUrl::parse(url_str).unwrap(); - let cred = store.get_credentials(&url, None); + let cred = store.get_credentials(&url, None).unwrap(); assert!( cred.is_none(), "Should not match URL in different realm: {url_str}" @@ -612,12 +620,12 @@ password = "pass2" // Should match the most specific prefix let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap(); - let cred = store.get_credentials(&url, None).unwrap(); + let cred = store.get_credentials(&url, None).unwrap().unwrap(); assert_eq!(cred.username(), Some("specific")); // Should match the general prefix for non-specific paths let url = DisplaySafeUrl::parse("https://example.com/api/v2").unwrap(); - let cred = store.get_credentials(&url, None).unwrap(); + let cred = store.get_credentials(&url, None).unwrap().unwrap(); assert_eq!(cred.username(), Some("general")); } @@ -630,17 +638,17 @@ password = "pass2" store.insert(service.clone(), user1_creds.clone()); // Should return credentials when username matches - let result = store.get_credentials(&url, Some("user1")); + let result = store.get_credentials(&url, Some("user1")).unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().username(), Some("user1")); assert_eq!(result.unwrap().password(), Some("pass1")); // Should not return credentials when username doesn't match - let result = store.get_credentials(&url, Some("user2")); + let result = store.get_credentials(&url, Some("user2")).unwrap(); assert!(result.is_none()); // Should return credentials when no username is specified - let result = store.get_credentials(&url, None); + let result = store.get_credentials(&url, None).unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().username(), Some("user1")); } @@ -668,12 +676,12 @@ password = "pass2" let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap(); // Should match specific credentials when username matches - let result = store.get_credentials(&url, Some("specific_user")); + let result = store.get_credentials(&url, Some("specific_user")).unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().username(), Some("specific_user")); // Should match the general credentials when requesting general_user (falls back to less specific prefix) - let result = store.get_credentials(&url, Some("general_user")); + let result = store.get_credentials(&url, Some("general_user")).unwrap(); assert!( result.is_some(), "Should match general_user from less specific prefix" @@ -681,8 +689,37 @@ password = "pass2" assert_eq!(result.unwrap().username(), Some("general_user")); // Should match most specific when no username specified - let result = store.get_credentials(&url, None); + let result = store.get_credentials(&url, None).unwrap(); assert!(result.is_some()); assert_eq!(result.unwrap().username(), Some("specific_user")); } + + #[test] + fn test_ambiguous_username_error() { + let mut store = TextCredentialStore::default(); + + // Add two credentials for the same service with different usernames + let service = Service::from_str("https://example.com/api").unwrap(); + let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string())); + let user2_creds = Credentials::basic(Some("user2".to_string()), Some("pass2".to_string())); + + store.insert(service.clone(), user1_creds); + store.insert(service.clone(), user2_creds); + + let url = DisplaySafeUrl::parse("https://example.com/api/v1").unwrap(); + + // When no username is specified, should return an error because there are multiple matches with same specificity + let result = store.get_credentials(&url, None); + assert!(result.is_err()); + assert_eq!(result, Err(LookupError::AmbiguousUsername(url.clone()))); + + // When a specific username is provided, should return the correct credentials + let result = store.get_credentials(&url, Some("user1")).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().username(), Some("user1")); + + let result = store.get_credentials(&url, Some("user2")).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().username(), Some("user2")); + } } diff --git a/crates/uv/src/commands/auth/helper.rs b/crates/uv/src/commands/auth/helper.rs index 320e0216e..1accddf01 100644 --- a/crates/uv/src/commands/auth/helper.rs +++ b/crates/uv/src/commands/auth/helper.rs @@ -105,7 +105,7 @@ async fn credentials_for_url( let backend = AuthBackend::from_settings(preview).await?; let credentials = match &backend { AuthBackend::System(provider) => provider.fetch(url, username).await, - AuthBackend::TextStore(store, _lock) => store.get_credentials(url, username).cloned(), + AuthBackend::TextStore(store, _lock) => store.get_credentials(url, username)?.cloned(), }; Ok(credentials) } diff --git a/crates/uv/src/commands/auth/token.rs b/crates/uv/src/commands/auth/token.rs index 9bf206c19..ba9d8f587 100644 --- a/crates/uv/src/commands/auth/token.rs +++ b/crates/uv/src/commands/auth/token.rs @@ -74,7 +74,7 @@ pub(crate) async fn token( .await .ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?, AuthBackend::TextStore(store, _lock) => store - .get_credentials(url, Some(&username)) + .get_credentials(url, Some(&username))? .cloned() .ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?, };