mirror of https://github.com/astral-sh/uv
Make TextCredentialStore.get_credentials fail on ambiguous matches
This commit is contained in:
parent
0c5391a7c7
commit
c8fa990ddd
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Option<&Credentials>, 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"))?,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue