diff --git a/Cargo.lock b/Cargo.lock index a794bfbc5..dbbfa8b41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5077,6 +5077,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "fs-err", "futures", "http", "insta", @@ -5091,8 +5092,10 @@ dependencies = [ "test-log", "thiserror 2.0.16", "tokio", + "toml", "tracing", "url", + "uv-dirs", "uv-keyring", "uv-once-map", "uv-redacted", @@ -5307,6 +5310,7 @@ dependencies = [ "insta", "serde", "url", + "uv-auth", "uv-cache", "uv-configuration", "uv-distribution-types", @@ -5411,7 +5415,6 @@ dependencies = [ "uv-pep508", "uv-platform-tags", "uv-preview", - "uv-redacted", "uv-static", "uv-warnings", ] diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index d3f288ab9..327e6c487 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -10,6 +10,7 @@ doctest = false workspace = true [dependencies] +uv-dirs = { workspace = true } uv-keyring = { workspace = true, features = ["apple-native", "secret-service", "windows-native"] } uv-once-map = { workspace = true } uv-redacted = { workspace = true } @@ -20,6 +21,7 @@ uv-warnings = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +fs-err = { workspace = true } futures = { workspace = true } http = { workspace = true } percent-encoding = { workspace = true } @@ -31,6 +33,7 @@ schemars = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } tokio = { workspace = true } +toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } diff --git a/crates/uv-auth/src/credentials.rs b/crates/uv-auth/src/credentials.rs index e3e22b271..9cda2c87c 100644 --- a/crates/uv-auth/src/credentials.rs +++ b/crates/uv-auth/src/credentials.rs @@ -1,6 +1,7 @@ use base64::prelude::BASE64_STANDARD; use base64::read::DecoderReader; use base64::write::EncoderWriter; +use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::fmt; use uv_redacted::DisplaySafeUrl; @@ -28,7 +29,8 @@ pub enum Credentials { }, } -#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash, Default)] +#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)] +#[serde(transparent)] pub struct Username(Option); impl Username { @@ -69,7 +71,8 @@ impl From> for Username { } } -#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)] +#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)] +#[serde(transparent)] pub struct Password(String); impl Password { @@ -80,6 +83,10 @@ impl Password { pub fn as_str(&self) -> &str { self.0.as_str() } + + pub fn into_string(self) -> String { + self.0 + } } impl fmt::Debug for Password { diff --git a/crates/uv-auth/src/lib.rs b/crates/uv-auth/src/lib.rs index 8e8a0e057..26ea60b2d 100644 --- a/crates/uv-auth/src/lib.rs +++ b/crates/uv-auth/src/lib.rs @@ -8,6 +8,8 @@ pub use index::{AuthPolicy, Index, Indexes}; pub use keyring::KeyringProvider; pub use middleware::AuthMiddleware; use realm::Realm; +pub use service::{Service, ServiceParseError}; +pub use store::{AuthScheme, TextCredentialStore, TomlCredentialError}; use uv_redacted::DisplaySafeUrl; mod cache; @@ -17,6 +19,8 @@ mod keyring; mod middleware; mod providers; mod realm; +mod service; +pub mod store; // TODO(zanieb): Consider passing a cache explicitly throughout diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 40812f3ea..5e3350e96 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -7,6 +7,8 @@ use reqwest::{Request, Response}; use reqwest_middleware::{Error, Middleware, Next}; use tracing::{debug, trace, warn}; +use uv_redacted::DisplaySafeUrl; + use crate::providers::HuggingFaceProvider; use crate::{ CREDENTIALS_CACHE, CredentialsCache, KeyringProvider, @@ -15,7 +17,7 @@ use crate::{ index::{AuthPolicy, Indexes}, realm::Realm, }; -use uv_redacted::DisplaySafeUrl; +use crate::{TextCredentialStore, TomlCredentialError}; /// Strategy for loading netrc files. enum NetrcMode { @@ -51,12 +53,64 @@ impl NetrcMode { } } +/// Strategy for loading text-based credential files. +enum TextStoreMode { + Automatic(LazyLock>), + Enabled(TextCredentialStore), + Disabled, +} + +impl Default for TextStoreMode { + fn default() -> Self { + // TODO(zanieb): Reconsider this pattern. We're just mirroring the [`NetrcMode`] + // implementation for now. + Self::Automatic(LazyLock::new(|| { + let state_dir = uv_dirs::user_state_dir()?; + let credentials_path = state_dir.join("credentials").join("credentials.toml"); + + match TextCredentialStore::from_file(&credentials_path) { + Ok(store) => { + debug!("Loaded credential file {}", credentials_path.display()); + Some(store) + } + Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + debug!( + "No credentials file found at {}", + credentials_path.display() + ); + None + } + Err(err) => { + warn!( + "Failed to load credentials from {}: {}", + credentials_path.display(), + err + ); + None + } + } + })) + } +} + +impl TextStoreMode { + /// Get the parsed credential store if enabled. + fn get(&self) -> Option<&TextCredentialStore> { + match self { + Self::Automatic(lock) => lock.as_ref(), + Self::Enabled(store) => Some(store), + Self::Disabled => None, + } + } +} + /// A middleware that adds basic authentication to requests. /// /// Uses a cache to propagate credentials from previously seen requests and -/// fetches credentials from a netrc file and the keyring. +/// fetches credentials from a netrc file, TOML file, and the keyring. pub struct AuthMiddleware { netrc: NetrcMode, + text_store: TextStoreMode, keyring: Option, cache: Option, /// Auth policies for specific URLs. @@ -70,6 +124,7 @@ impl AuthMiddleware { pub fn new() -> Self { Self { netrc: NetrcMode::default(), + text_store: TextStoreMode::default(), keyring: None, cache: None, indexes: Indexes::new(), @@ -90,6 +145,19 @@ impl AuthMiddleware { self } + /// Configure the text credential store to use. + /// + /// `None` disables authentication via text store. + #[must_use] + pub fn with_text_store(mut self, store: Option) -> Self { + self.text_store = if let Some(store) = store { + TextStoreMode::Enabled(store) + } else { + TextStoreMode::Disabled + }; + self + } + /// Configure the [`KeyringProvider`] to use. #[must_use] pub fn with_keyring(mut self, keyring: Option) -> Self { @@ -525,6 +593,14 @@ impl AuthMiddleware { debug!("Found credentials in netrc file for {url}"); Some(credentials) + // Text credential store support. + } else if let Some(credentials) = self.text_store.get().and_then(|text_store| { + debug!("Checking text store for credentials for {url}"); + text_store.get_credentials(&url::Url::from(url.clone())).cloned() + }) { + debug!("Found credentials in text store for {url}"); + Some(credentials) + // N.B. The keyring provider performs lookups for the exact URL then falls back to the host. // But, in the absence of an index URL, we cache the result per realm. So in that case, // if a keyring implementation returns different credentials for different URLs in the @@ -2141,6 +2217,61 @@ mod tests { ); } + #[test(tokio::test)] + async fn test_text_store_basic_auth() -> Result<(), Error> { + let username = "user"; + let password = "password"; + + let server = start_test_server(username, password).await; + let base_url = Url::parse(&server.uri())?; + + // Create a text credential store with matching credentials + let mut store = TextCredentialStore::default(); + let service = crate::Service::try_from(base_url.to_string()).unwrap(); + let credentials = + crate::Credentials::basic(Some(username.to_string()), Some(password.to_string())); + store.insert(service.clone(), credentials); + + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_text_store(Some(store)), + ) + .build(); + + assert_eq!( + client.get(server.uri()).send().await?.status(), + 200, + "Credentials should be pulled from the text store" + ); + + Ok(()) + } + + #[test(tokio::test)] + async fn test_text_store_disabled() -> Result<(), Error> { + let username = "user"; + let password = "password"; + let server = start_test_server(username, password).await; + + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_text_store(None), // Explicitly disable text store + ) + .build(); + + assert_eq!( + client.get(server.uri()).send().await?.status(), + 401, + "Credentials should not be found when text store is disabled" + ); + + Ok(()) + } + fn create_request(url: &str) -> Request { Request::new(Method::GET, Url::parse(url).unwrap()) } diff --git a/crates/uv-auth/src/service.rs b/crates/uv-auth/src/service.rs new file mode 100644 index 000000000..0a634de36 --- /dev/null +++ b/crates/uv-auth/src/service.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use thiserror::Error; +use uv_redacted::DisplaySafeUrl; + +#[derive(Error, Debug)] +pub enum ServiceParseError { + #[error("failed to parse URL: {0}")] + InvalidUrl(#[from] url::ParseError), + #[error("only HTTPS is supported")] + UnsupportedScheme, +} + +/// A service URL that wraps [`DisplaySafeUrl`] for CLI usage. +/// +/// This type provides automatic URL parsing and validation when used as a CLI argument, +/// eliminating the need for manual parsing in command functions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct Service(DisplaySafeUrl); + +impl Service { + /// Get the underlying [`DisplaySafeUrl`]. + pub fn url(&self) -> &DisplaySafeUrl { + &self.0 + } + + /// Convert into the underlying [`DisplaySafeUrl`]. + pub fn into_url(self) -> DisplaySafeUrl { + self.0 + } + + /// Validate that the URL scheme is supported. + fn check_scheme(url: &DisplaySafeUrl) -> Result<(), ServiceParseError> { + match url.scheme() { + "https" => Ok(()), + #[cfg(test)] + "http" => Ok(()), + _ => Err(ServiceParseError::UnsupportedScheme), + } + } +} + +impl FromStr for Service { + type Err = ServiceParseError; + + fn from_str(s: &str) -> Result { + // First try parsing as-is + let url = match DisplaySafeUrl::parse(s) { + Ok(url) => url, + Err(url::ParseError::RelativeUrlWithoutBase) => { + // If it's a relative URL, try prepending https:// + let with_https = format!("https://{s}"); + DisplaySafeUrl::parse(&with_https)? + } + Err(err) => return Err(err.into()), + }; + + Self::check_scheme(&url)?; + + Ok(Self(url)) + } +} + +impl std::fmt::Display for Service { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl TryFrom for Service { + type Error = ServiceParseError; + + fn try_from(value: String) -> Result { + Self::from_str(&value) + } +} + +impl From for String { + fn from(service: Service) -> Self { + service.to_string() + } +} + +impl TryFrom for Service { + type Error = ServiceParseError; + + fn try_from(value: DisplaySafeUrl) -> Result { + Self::check_scheme(&value)?; + Ok(Self(value)) + } +} diff --git a/crates/uv-auth/src/store.rs b/crates/uv-auth/src/store.rs new file mode 100644 index 000000000..d06ff0d13 --- /dev/null +++ b/crates/uv-auth/src/store.rs @@ -0,0 +1,527 @@ +use std::ops::Deref; +use std::path::{Path, PathBuf}; + +use fs_err as fs; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::debug; +use url::Url; +use uv_redacted::DisplaySafeUrl; + +use crate::Credentials; +use crate::credentials::{Password, Username}; +use crate::realm::Realm; +use crate::service::Service; + +/// Authentication scheme to use. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AuthScheme { + /// HTTP Basic Authentication + /// + /// Uses a username and password. + Basic, + /// Bearer token authentication. + /// + /// Uses a token provided as `Bearer ` in the `Authorization` header. + Bearer, +} + +impl Default for AuthScheme { + fn default() -> Self { + Self::Basic + } +} + +/// Errors that can occur when working with TOML credential storage. +#[derive(Debug, Error)] +pub enum TomlCredentialError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("Failed to parse TOML credential file: {0}")] + ParseError(#[from] toml::de::Error), + #[error("Failed to serialize credentials to TOML")] + SerializeError(#[from] toml::ser::Error), + #[error(transparent)] + BasicAuthError(#[from] BasicAuthError), + #[error(transparent)] + BearerAuthError(#[from] BearerAuthError), + #[error("Failed to determine credentials directory")] + CredentialsDirError, + #[error("Token is not valid unicode")] + TokenNotUnicode(#[from] std::string::FromUtf8Error), +} + +#[derive(Debug, Error)] +pub enum BasicAuthError { + #[error("`username` is required with `scheme = basic`")] + MissingUsername, + #[error("`token` cannot be provided with `scheme = basic`")] + UnexpectedToken, +} + +#[derive(Debug, Error)] +pub enum BearerAuthError { + #[error("`token` is required with `scheme = bearer`")] + MissingToken, + #[error("`username` cannot be provided with `scheme = bearer`")] + UnexpectedUsername, + #[error("`password` cannot be provided with `scheme = bearer`")] + UnexpectedPassword, +} + +/// A single credential entry in a TOML credentials file. +// TODO(zanieb): It's a little clunky that we need don't nest the scheme-specific fields under a +// that scheme, but I want the username / password case to be easily accessible without +// understanding authentication schemes. We should consider a better structure here, e.g., by +// adding an internal type that we cast to after validation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TomlCredential { + /// The service URL for this credential. + pub service: Service, + /// The username to use. Only allowed with [`AuthScheme::Basic`]. + pub username: Username, + /// The authentication scheme. + #[serde(default)] + pub scheme: AuthScheme, + /// The password to use. Only allowed with [`AuthScheme::Basic`]. + pub password: Option, + /// The token to use. Only allowed with [`AuthScheme::Bearer`]. + pub token: Option, +} + +impl TomlCredential { + /// Validate that the credential configuration is correct for the scheme. + fn validate(&self) -> Result<(), TomlCredentialError> { + match self.scheme { + AuthScheme::Basic => { + if self.username.as_deref().is_none() { + return Err(TomlCredentialError::BasicAuthError( + BasicAuthError::MissingUsername, + )); + } + if self.token.is_some() { + return Err(TomlCredentialError::BasicAuthError( + BasicAuthError::UnexpectedToken, + )); + } + } + AuthScheme::Bearer => { + if self.username.is_some() { + return Err(TomlCredentialError::BearerAuthError( + BearerAuthError::UnexpectedUsername, + )); + } + if self.password.is_some() { + return Err(TomlCredentialError::BearerAuthError( + BearerAuthError::UnexpectedPassword, + )); + } + if self.token.is_none() { + return Err(TomlCredentialError::BearerAuthError( + BearerAuthError::MissingToken, + )); + } + } + } + + Ok(()) + } + + /// Convert to [`Credentials`]. + /// + /// This method can panic if [`TomlCredential::validate`] has not been called. + pub fn into_credentials(self) -> Credentials { + match self.scheme { + AuthScheme::Basic => Credentials::Basic { + username: self.username, + password: self.password, + }, + AuthScheme::Bearer => Credentials::Bearer { + token: self.token.unwrap().into_bytes(), + }, + } + } + + /// Construct a [`TomlCredential`] for a service from [`Credentials`]. + pub fn from_credentials( + service: Service, + credentials: Credentials, + ) -> Result { + match credentials { + Credentials::Basic { username, password } => Ok(Self { + service, + username, + scheme: AuthScheme::Basic, + password, + token: None, + }), + Credentials::Bearer { token } => Ok(Self { + service, + username: Username::new(None), + scheme: AuthScheme::Bearer, + password: None, + token: Some(String::from_utf8(token)?), + }), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TomlCredentials { + /// Array of credential entries. + #[serde(rename = "credential")] + pub credentials: Vec, +} + +/// A credential store with a plain text storage backend. +#[derive(Debug, Default)] +pub struct TextCredentialStore { + credentials: FxHashMap, +} + +impl TextCredentialStore { + /// Return the default credential file path. + pub fn default_file() -> Result { + let state_dir = + uv_dirs::user_state_dir().ok_or(TomlCredentialError::CredentialsDirError)?; + let credentials_dir = state_dir.join("credentials"); + Ok(credentials_dir.join("credentials.toml")) + } + + /// Read credentials from a file. + pub fn from_file>(path: P) -> Result { + let content = fs::read_to_string(path)?; + let credentials: TomlCredentials = toml::from_str(&content)?; + + let credentials: FxHashMap = credentials + .credentials + .into_iter() + .filter_map(|credential| { + // TODO(zanieb): Determine a better strategy for invalid credential entries + if let Err(err) = credential.validate() { + debug!( + "Skipping invalid credential for {}: {}", + credential.service, err + ); + return None; + } + + Some((credential.service.clone(), credential.into_credentials())) + }) + .collect(); + + Ok(Self { credentials }) + } + + /// Persist credentials to a file. + pub fn write>(self, path: P) -> Result<(), TomlCredentialError> { + let credentials = self + .credentials + .into_iter() + .map(|(service, cred)| TomlCredential::from_credentials(service, cred)) + .collect::, _>>()?; + + let toml_creds = TomlCredentials { credentials }; + let content = toml::to_string_pretty(&toml_creds)?; + fs::create_dir_all( + path.as_ref() + .parent() + .ok_or(TomlCredentialError::CredentialsDirError)?, + )?; + + // TODO(zanieb): We should use an atomic write here + fs::write(path, content)?; + Ok(()) + } + + /// Get credentials for a given URL. + /// Uses realm-based prefix matching following RFC 7235 and 7230 specifications. + /// Credentials are matched by finding the most specific prefix that matches the request URL. + pub fn get_credentials(&self, url: &Url) -> Option<&Credentials> { + let request_realm = Realm::from(url); + + // Perform an exact lookup first + // TODO(zanieb): Consider adding `DisplaySafeUrlRef` so we can avoid this clone + // TODO(zanieb): We could also return early here if we can't normalize to a `Service` + if let Ok(url_service) = Service::try_from(DisplaySafeUrl::from(url.clone())) { + if let Some(credential) = self.credentials.get(&url_service) { + return Some(credential); + } + } + + // If that fails, iterate through to find a prefix match + let mut best: Option<(usize, &Service, &Credentials)> = None; + + for (service, credential) in &self.credentials { + let service_realm = Realm::from(service.url().deref()); + + // Only consider services in the same realm + if service_realm != request_realm { + continue; + } + + // Service path must be a prefix of request path + if !url.path().starts_with(service.url().path()) { + continue; + } + + // Update our best matching credential based on prefix length + let specificity = service.url().path().len(); + if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) { + best = Some((specificity, service, credential)); + } + } + + // Return the most specific match + if let Some((_, _, credential)) = best { + return Some(credential); + } + + None + } + + /// Store credentials for a given service. + pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option { + self.credentials.insert(service, credentials) + } + + /// Remove credentials for a given service. + pub fn remove(&mut self, service: &Service) -> Option { + self.credentials.remove(service) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::str::FromStr; + use tempfile::NamedTempFile; + + #[test] + fn test_toml_credential_conversion() { + let toml_cred = TomlCredential { + service: Service::from_str("https://example.com").unwrap(), + username: Username::new(Some("user".to_string())), + scheme: AuthScheme::Basic, + password: Some(Password::new("pass".to_string())), + token: None, + }; + + let credentials = toml_cred.into_credentials(); + assert_eq!(credentials.username(), Some("user")); + assert_eq!(credentials.password(), Some("pass")); + + let back_to_toml = TomlCredential::from_credentials( + Service::from_str("https://example.com").unwrap(), + credentials, + ) + .unwrap(); + assert_eq!(back_to_toml.service.to_string(), "https://example.com/"); + assert_eq!(back_to_toml.username.as_deref(), Some("user")); + assert_eq!(back_to_toml.password.as_ref().unwrap().as_str(), "pass"); + assert_eq!(back_to_toml.scheme, AuthScheme::Basic); + } + + #[test] + fn test_toml_serialization() { + let credentials = TomlCredentials { + credentials: vec![ + TomlCredential { + service: Service::from_str("https://example.com").unwrap(), + username: Username::new(Some("user1".to_string())), + scheme: AuthScheme::Basic, + password: Some(Password::new("pass1".to_string())), + token: None, + }, + TomlCredential { + service: Service::from_str("https://test.org").unwrap(), + username: Username::new(Some("user2".to_string())), + scheme: AuthScheme::Basic, + password: Some(Password::new("pass2".to_string())), + token: None, + }, + ], + }; + + let toml_str = toml::to_string_pretty(&credentials).unwrap(); + let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap(); + + assert_eq!(parsed.credentials.len(), 2); + assert_eq!( + parsed.credentials[0].service.to_string(), + "https://example.com/" + ); + assert_eq!( + parsed.credentials[1].service.to_string(), + "https://test.org/" + ); + } + + #[test] + fn test_credential_store_operations() { + let mut store = TextCredentialStore::default(); + let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string())); + + let service = Service::from_str("https://example.com").unwrap(); + store.insert(service.clone(), credentials.clone()); + let url = Url::parse("https://example.com/").unwrap(); + assert!(store.get_credentials(&url).is_some()); + + let url = Url::parse("https://example.com/path").unwrap(); + let retrieved = store.get_credentials(&url).unwrap(); + assert_eq!(retrieved.username(), Some("user")); + assert_eq!(retrieved.password(), Some("pass")); + + assert!(store.remove(&service).is_some()); + let url = Url::parse("https://example.com/").unwrap(); + assert!(store.get_credentials(&url).is_none()); + } + + #[test] + fn test_file_operations() { + let mut temp_file = NamedTempFile::new().unwrap(); + writeln!( + temp_file, + r#" +[[credential]] +service = "https://example.com" +username = "testuser" +scheme = "basic" +password = "testpass" + +[[credential]] +service = "https://test.org" +username = "user2" +password = "pass2" +"# + ) + .unwrap(); + + let store = TextCredentialStore::from_file(temp_file.path()).unwrap(); + + let url = Url::parse("https://example.com/").unwrap(); + assert!(store.get_credentials(&url).is_some()); + let url = Url::parse("https://test.org/").unwrap(); + assert!(store.get_credentials(&url).is_some()); + + let url = Url::parse("https://example.com").unwrap(); + let cred = store.get_credentials(&url).unwrap(); + assert_eq!(cred.username(), Some("testuser")); + assert_eq!(cred.password(), Some("testpass")); + + // Test saving + let temp_output = NamedTempFile::new().unwrap(); + store.write(temp_output.path()).unwrap(); + + let content = fs::read_to_string(temp_output.path()).unwrap(); + assert!(content.contains("example.com")); + assert!(content.contains("testuser")); + } + + #[test] + fn test_prefix_matching() { + let mut store = TextCredentialStore::default(); + let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string())); + + // Store credentials for a specific path prefix + let service = Service::from_str("https://example.com/api").unwrap(); + store.insert(service.clone(), credentials.clone()); + + // Should match URLs that are prefixes of the stored service + let matching_urls = [ + "https://example.com/api", + "https://example.com/api/v1", + "https://example.com/api/v1/users", + ]; + + for url_str in matching_urls { + let url = Url::parse(url_str).unwrap(); + let cred = store.get_credentials(&url); + assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}"); + } + + // Should NOT match URLs that are not prefixes + let non_matching_urls = [ + "https://example.com/different", + "https://example.com/ap", // Not a complete path segment match + "https://example.com", // Shorter than the stored prefix + ]; + + for url_str in non_matching_urls { + let url = Url::parse(url_str).unwrap(); + let cred = store.get_credentials(&url); + assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}"); + } + } + + #[test] + fn test_realm_based_matching() { + let mut store = TextCredentialStore::default(); + let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string())); + + // Store by full URL (realm) + let service = Service::from_str("https://example.com").unwrap(); + store.insert(service.clone(), credentials.clone()); + + // Should match URLs in the same realm + let matching_urls = [ + "https://example.com", + "https://example.com/path", + "https://example.com/different/path", + "https://example.com:443/path", // Default HTTPS port + ]; + + for url_str in matching_urls { + let url = Url::parse(url_str).unwrap(); + let cred = store.get_credentials(&url); + assert!( + cred.is_some(), + "Failed to match URL in same realm: {url_str}" + ); + } + + // Should NOT match URLs in different realms + let non_matching_urls = [ + "http://example.com", // Different scheme + "https://different.com", // Different host + "https://example.com:8080", // Different port + ]; + + for url_str in non_matching_urls { + let url = Url::parse(url_str).unwrap(); + let cred = store.get_credentials(&url); + assert!( + cred.is_none(), + "Should not match URL in different realm: {url_str}" + ); + } + } + + #[test] + fn test_most_specific_prefix_matching() { + let mut store = TextCredentialStore::default(); + let general_cred = + Credentials::basic(Some("general".to_string()), Some("pass1".to_string())); + let specific_cred = + Credentials::basic(Some("specific".to_string()), Some("pass2".to_string())); + + // Store credentials with different prefix lengths + let general_service = Service::from_str("https://example.com/api").unwrap(); + let specific_service = Service::from_str("https://example.com/api/v1").unwrap(); + store.insert(general_service.clone(), general_cred); + store.insert(specific_service.clone(), specific_cred); + + // Should match the most specific prefix + let url = Url::parse("https://example.com/api/v1/users").unwrap(); + let cred = store.get_credentials(&url).unwrap(); + assert_eq!(cred.username(), Some("specific")); + + // Should match the general prefix for non-specific paths + let url = Url::parse("https://example.com/api/v2").unwrap(); + let cred = store.get_credentials(&url).unwrap(); + assert_eq!(cred.username(), Some("general")); + } +} diff --git a/crates/uv-cli/Cargo.toml b/crates/uv-cli/Cargo.toml index b9bc64797..efba10bd4 100644 --- a/crates/uv-cli/Cargo.toml +++ b/crates/uv-cli/Cargo.toml @@ -17,6 +17,7 @@ doctest = false workspace = true [dependencies] +uv-auth = { workspace = true } uv-cache = { workspace = true, features = ["clap"] } uv-configuration = { workspace = true, features = ["clap"] } uv-distribution-types = { workspace = true } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 7a5e9ac5b..7a44dcd0d 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -8,10 +8,11 @@ use clap::builder::Styles; use clap::builder::styling::{AnsiColor, Effects, Style}; use clap::{Args, Parser, Subcommand}; +use uv_auth::Service; use uv_cache::CacheArgs; use uv_configuration::{ ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, ProjectBuildBackend, - Service, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, + TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, }; use uv_distribution_types::{ ConfigSettingEntry, ConfigSettingPackageEntry, Index, IndexUrl, Origin, PipExtraIndex, diff --git a/crates/uv-configuration/Cargo.toml b/crates/uv-configuration/Cargo.toml index 5c8e69bf8..7560dc926 100644 --- a/crates/uv-configuration/Cargo.toml +++ b/crates/uv-configuration/Cargo.toml @@ -26,7 +26,6 @@ uv-pep440 = { workspace = true } uv-pep508 = { workspace = true, features = ["schemars"] } uv-platform-tags = { workspace = true } uv-preview = { workspace = true } -uv-redacted = { workspace = true } uv-static = { workspace = true } uv-warnings = { workspace = true } clap = { workspace = true, features = ["derive"], optional = true } diff --git a/crates/uv-configuration/src/authentication.rs b/crates/uv-configuration/src/authentication.rs index ca3317696..037266b4f 100644 --- a/crates/uv-configuration/src/authentication.rs +++ b/crates/uv-configuration/src/authentication.rs @@ -1,7 +1,5 @@ -use std::str::FromStr; use uv_auth::{self, KeyringProvider}; use uv_preview::{Preview, PreviewFeatures}; -use uv_redacted::DisplaySafeUrl; use uv_warnings::warn_user_once; /// Keyring provider type to use for credential lookup. @@ -51,60 +49,3 @@ impl std::fmt::Display for KeyringProviderType { } } } - -#[derive(thiserror::Error, Debug)] -pub enum ServiceParseError { - #[error("failed to parse URL: {0}")] - InvalidUrl(#[from] url::ParseError), - #[error("only HTTPS is supported")] - UnsupportedScheme, -} - -/// A service URL that wraps [`DisplaySafeUrl`] for CLI usage. -/// -/// This type provides automatic URL parsing and validation when used as a CLI argument, -/// eliminating the need for manual parsing in command functions. -#[derive(Debug, Clone)] -pub struct Service(DisplaySafeUrl); - -impl Service { - /// Get the underlying [`DisplaySafeUrl`]. - pub fn url(&self) -> &DisplaySafeUrl { - &self.0 - } - - /// Convert into the underlying [`DisplaySafeUrl`]. - pub fn into_url(self) -> DisplaySafeUrl { - self.0 - } -} - -impl FromStr for Service { - type Err = ServiceParseError; - - fn from_str(s: &str) -> Result { - // First try parsing as-is - let url = match DisplaySafeUrl::parse(s) { - Ok(url) => url, - Err(url::ParseError::RelativeUrlWithoutBase) => { - // If it's a relative URL, try prepending https:// - let with_https = format!("https://{s}"); - DisplaySafeUrl::parse(&with_https)? - } - Err(err) => return Err(err.into()), - }; - - // Only allow HTTPS URLs - if url.scheme() != "https" { - return Err(ServiceParseError::UnsupportedScheme); - } - - Ok(Self(url)) - } -} - -impl std::fmt::Display for Service { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} diff --git a/crates/uv-state/src/lib.rs b/crates/uv-state/src/lib.rs index c207c1da9..46b10a817 100644 --- a/crates/uv-state/src/lib.rs +++ b/crates/uv-state/src/lib.rs @@ -105,6 +105,8 @@ pub enum StateBucket { ManagedPython, /// Installed tools. Tools, + /// Stored authentication credentials. + Credentials, } impl StateBucket { @@ -112,6 +114,7 @@ impl StateBucket { match self { Self::ManagedPython => "python", Self::Tools => "tools", + Self::Credentials => "credentials", } } } diff --git a/crates/uv/src/commands/auth/login.rs b/crates/uv/src/commands/auth/login.rs index bbde2758a..6fa57316d 100644 --- a/crates/uv/src/commands/auth/login.rs +++ b/crates/uv/src/commands/auth/login.rs @@ -4,10 +4,12 @@ use anyhow::{Result, bail}; use console::Term; use owo_colors::OwoColorize; -use uv_auth::Credentials; -use uv_configuration::{KeyringProviderType, Service}; +use uv_auth::Service; +use uv_auth::{Credentials, TextCredentialStore}; +use uv_configuration::KeyringProviderType; use uv_preview::Preview; +use crate::commands::auth::AuthBackend; use crate::{commands::ExitStatus, printer::Printer}; /// Login to a service. @@ -21,23 +23,7 @@ pub(crate) async fn login( preview: Preview, ) -> Result { let url = service.url(); - - // Be helpful about incompatible `keyring-provider` settings - let Some(keyring_provider) = &keyring_provider else { - bail!( - "Logging in requires setting `keyring-provider = {}` for credentials to be retrieved in subsequent commands", - KeyringProviderType::Native - ); - }; - let provider = match keyring_provider { - KeyringProviderType::Native => keyring_provider.to_provider(&preview).unwrap(), - KeyringProviderType::Disabled | KeyringProviderType::Subprocess => { - bail!( - "Cannot login with `keyring-provider = {keyring_provider}`, use `keyring-provider = {}` instead", - KeyringProviderType::Native - ); - } - }; + let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?; // Extract credentials from URL if present let url_credentials = Credentials::from_url(url); @@ -116,7 +102,15 @@ pub(crate) async fn login( // TODO(zanieb): Add support for other authentication schemes here, e.g., `Credentials::Bearer` let credentials = Credentials::basic(Some(username), Some(password)); - provider.store(url, &credentials).await?; + match backend { + AuthBackend::Keyring(provider) => { + provider.store(url, &credentials).await?; + } + AuthBackend::TextStore(mut text_store) => { + text_store.insert(service.clone(), credentials); + text_store.write(TextCredentialStore::default_file()?)?; + } + } writeln!( printer.stderr(), diff --git a/crates/uv/src/commands/auth/logout.rs b/crates/uv/src/commands/auth/logout.rs index 705f36508..81affc051 100644 --- a/crates/uv/src/commands/auth/logout.rs +++ b/crates/uv/src/commands/auth/logout.rs @@ -3,10 +3,12 @@ use std::fmt::Write; use anyhow::{Context, Result, bail}; use owo_colors::OwoColorize; -use uv_auth::Credentials; -use uv_configuration::{KeyringProviderType, Service}; +use uv_auth::Service; +use uv_auth::{Credentials, TextCredentialStore}; +use uv_configuration::KeyringProviderType; use uv_preview::Preview; +use crate::commands::auth::AuthBackend; use crate::{commands::ExitStatus, printer::Printer}; /// Logout from a service. @@ -20,6 +22,7 @@ pub(crate) async fn logout( preview: Preview, ) -> Result { let url = service.url(); + let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?; // Extract credentials from URL if present let url_credentials = Credentials::from_url(url); @@ -42,25 +45,23 @@ pub(crate) async fn logout( format!("{username}@{}", url.without_credentials()) }; - // Unlike login, we'll default to the native provider if none is requested since it's the only - // valid option and it doesn't matter if the credentials are available in subsequent commands. - let keyring_provider = keyring_provider.unwrap_or(KeyringProviderType::Native); - - // Be helpful about incompatible `keyring-provider` settings - let provider = match keyring_provider { - KeyringProviderType::Native => keyring_provider.to_provider(&preview).unwrap(), - KeyringProviderType::Disabled | KeyringProviderType::Subprocess => { - bail!( - "Cannot logout with `keyring-provider = {keyring_provider}`, use `keyring-provider = {}` instead", - KeyringProviderType::Native - ); + // TODO(zanieb): Consider exhaustively logging out from all backends + match backend { + AuthBackend::Keyring(provider) => { + provider + .remove(url, &username) + .await + .with_context(|| format!("Unable to remove credentials for {display_url}"))?; } - }; - - provider - .remove(url, &username) - .await - .with_context(|| format!("Unable to remove credentials for {display_url}"))?; + AuthBackend::TextStore(mut text_store) => { + if text_store.remove(&service).is_none() { + bail!("No matching entry found for {display_url}"); + } + text_store + .write(TextCredentialStore::default_file()?) + .with_context(|| "Failed to persist changes to credentials after removal")?; + } + } writeln!( printer.stderr(), diff --git a/crates/uv/src/commands/auth/mod.rs b/crates/uv/src/commands/auth/mod.rs index 8904696da..118c25366 100644 --- a/crates/uv/src/commands/auth/mod.rs +++ b/crates/uv/src/commands/auth/mod.rs @@ -1,3 +1,37 @@ +use uv_auth::{KeyringProvider, TextCredentialStore, TomlCredentialError}; +use uv_configuration::KeyringProviderType; +use uv_preview::Preview; + pub(crate) mod login; pub(crate) mod logout; pub(crate) mod token; + +/// The storage backend to use in `uv auth` commands. +enum AuthBackend { + Keyring(KeyringProvider), + TextStore(TextCredentialStore), +} + +impl AuthBackend { + fn from_settings( + keyring: Option<&KeyringProviderType>, + preview: Preview, + ) -> Result { + // For keyring providers, we only support persistence via the native keyring right now + if let Some(keyring) = match keyring { + Some(provider @ KeyringProviderType::Native) => provider.to_provider(&preview), + _ => None, + } { + return Ok(Self::Keyring(keyring)); + } + + // Otherwise, we'll use the plain text credential store + match TextCredentialStore::from_file(TextCredentialStore::default_file()?) { + Ok(store) => Ok(Self::TextStore(store)), + Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + Ok(Self::TextStore(TextCredentialStore::default())) + } + Err(err) => Err(err), + } + } +} diff --git a/crates/uv/src/commands/auth/token.rs b/crates/uv/src/commands/auth/token.rs index a05464667..31aa73d15 100644 --- a/crates/uv/src/commands/auth/token.rs +++ b/crates/uv/src/commands/auth/token.rs @@ -1,11 +1,13 @@ use std::fmt::Write; -use anyhow::{Context, Result, bail}; +use anyhow::{Result, bail}; use uv_auth::Credentials; -use uv_configuration::{KeyringProviderType, Service}; +use uv_auth::Service; +use uv_configuration::KeyringProviderType; use uv_preview::Preview; +use crate::commands::auth::AuthBackend; use crate::{commands::ExitStatus, printer::Printer}; /// Show the token that will be used for a service. @@ -17,13 +19,7 @@ pub(crate) async fn token( preview: Preview, ) -> Result { let url = service.url(); - // Determine the keyring provider to use - let Some(keyring_provider) = &keyring_provider else { - bail!("Retrieving credentials requires setting a `keyring-provider`"); - }; - let Some(provider) = keyring_provider.to_provider(&preview) else { - bail!("Cannot retrieve credentials with `keyring-provider = {keyring_provider}`"); - }; + let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?; // Extract credentials from URL if present let url_credentials = Credentials::from_url(url); @@ -46,10 +42,16 @@ pub(crate) async fn token( format!("{username}@{}", url.without_credentials()) }; - let credentials = provider - .fetch(url, Some(&username)) - .await - .with_context(|| format!("Failed to fetch credentials for {display_url}"))?; + let credentials = match &backend { + AuthBackend::Keyring(provider) => provider + .fetch(url, Some(&username)) + .await + .ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?, + AuthBackend::TextStore(text_store) => text_store + .get_credentials(url) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?, + }; let Some(password) = credentials.password() else { bail!( diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index cf939f4e3..cb14c0bcf 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::process; use std::str::FromStr; +use uv_auth::Service; use uv_cache::{CacheArgs, Refresh}; use uv_cli::comma::CommaSeparatedRequirements; use uv_cli::{ @@ -25,8 +26,7 @@ use uv_configuration::{ BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode, ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, KeyringProviderType, NoBinary, NoBuild, ProjectBuildBackend, Reinstall, RequiredVersion, - Service, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, - VersionControlSystem, + SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, VersionControlSystem, }; use uv_distribution_types::{ ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, IndexUrl, @@ -3506,6 +3506,8 @@ impl AuthLogoutSettings { keyring_provider, .. } = top_level; + let keyring_provider = args.keyring_provider.combine(keyring_provider); + Self { service: args.service, username: args.username, diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index ab684f3fc..00d03f428 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1,11 +1,15 @@ +#[cfg(feature = "keyring-tests")] use anyhow::Result; use assert_cmd::assert::OutputAssertExt; +#[cfg(feature = "keyring-tests")] use assert_fs::{fixture::PathChild, prelude::FileWriteStr}; use uv_static::EnvVars; -use crate::common::{TestContext, uv_snapshot, venv_bin_path}; +use crate::common::venv_bin_path; +use crate::common::{TestContext, uv_snapshot}; #[test] +#[cfg(feature = "keyring-tests")] fn add_package_native_keyring() -> Result<()> { let context = TestContext::new("3.12").with_real_home(); @@ -15,6 +19,8 @@ fn add_package_native_keyring() -> Result<()> { .arg("https://pypi-proxy.fly.dev/basic-auth/simple") .arg("--username") .arg("public") + .arg("--keyring-provider") + .arg("native") .status()?; // Configure `pyproject.toml` with native keyring provider. @@ -117,6 +123,7 @@ fn add_package_native_keyring() -> Result<()> { } #[test] +#[cfg(feature = "keyring-tests")] fn token_native_keyring() -> Result<()> { let context = TestContext::new_with_versions(&[]).with_real_home(); @@ -126,34 +133,10 @@ fn token_native_keyring() -> Result<()> { .arg("https://pypi-proxy.fly.dev/basic-auth/simple") .arg("--username") .arg("public") + .arg("--keyring-provider") + .arg("native") .status()?; - // Without a service name - uv_snapshot!(context.auth_token(), @r" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: the following required arguments were not provided: - - - Usage: uv auth token --cache-dir [CACHE_DIR] - - For more information, try '--help'. - "); - - // Without a keyring provider... - uv_snapshot!(context.auth_token() - .arg("https://pypi-proxy.fly.dev/basic-auth/simple"), @r" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: Retrieving credentials requires setting a `keyring-provider` - "); - // Without persisted credentials uv_snapshot!(context.auth_token() .arg("https://pypi-proxy.fly.dev/basic-auth/simple") @@ -364,8 +347,6 @@ fn token_subprocess_keyring() { ----- stdout ----- ----- stderr ----- - Keyring request for public@https://public@pypi-proxy.fly.dev/basic-auth/simple - Keyring request for public@pypi-proxy.fly.dev error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple " ); @@ -378,14 +359,12 @@ fn token_subprocess_keyring() { .arg("subprocess") .env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#) .env(EnvVars::PATH, venv_bin_path(&context.venv)), @r" - success: true - exit_code: 0 + success: false + exit_code: 2 ----- stdout ----- - heron ----- stderr ----- - Keyring request for public@https://public@pypi-proxy.fly.dev/basic-auth/simple - Keyring request for public@pypi-proxy.fly.dev + error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple " ); @@ -409,6 +388,7 @@ fn token_subprocess_keyring() { } #[test] +#[cfg(feature = "keyring-tests")] fn login_native_keyring() -> Result<()> { let context = TestContext::new_with_versions(&[]).with_real_home(); @@ -418,6 +398,8 @@ fn login_native_keyring() -> Result<()> { .arg("https://pypi-proxy.fly.dev/basic-auth/simple") .arg("--username") .arg("public") + .arg("--keyring-provider") + .arg("native") .status()?; // Without a service name @@ -465,19 +447,6 @@ fn login_native_keyring() -> Result<()> { error: No password provided; did you mean to provide `--password` or `--token`? "); - // Without a keyring provider - uv_snapshot!(context.auth_login() - .arg("https://pypi-proxy.fly.dev/basic-auth/simple") - .arg("--token") - .arg("foo"), @r" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: Logging in requires setting `keyring-provider = native` for credentials to be retrieved in subsequent commands - "); - // Successful uv_snapshot!(context.auth_login() .arg("https://pypi-proxy.fly.dev/basic-auth/simple") @@ -501,6 +470,7 @@ fn login_native_keyring() -> Result<()> { } #[test] +#[cfg(feature = "keyring-tests")] fn login_token_native_keyring() -> Result<()> { let context = TestContext::new_with_versions(&[]).with_real_home(); @@ -510,6 +480,8 @@ fn login_token_native_keyring() -> Result<()> { .arg("https://pypi-proxy.fly.dev/basic-auth/simple") .arg("--username") .arg("__token__") + .arg("--keyring-provider") + .arg("native") .status()?; // Successful with token @@ -533,6 +505,7 @@ fn login_token_native_keyring() -> Result<()> { } #[test] +#[cfg(feature = "keyring-tests")] fn logout_native_keyring() -> Result<()> { let context = TestContext::new_with_versions(&[]).with_real_home(); @@ -542,6 +515,8 @@ fn logout_native_keyring() -> Result<()> { .arg("https://pypi-proxy.fly.dev/basic-auth/simple") .arg("--username") .arg("public") + .arg("--keyring-provider") + .arg("native") .status()?; // Without a service name @@ -559,9 +534,11 @@ fn logout_native_keyring() -> Result<()> { For more information, try '--help'. "); - // Logout without a keyring provider + // Logout before logging in uv_snapshot!(context.auth_logout() - .arg("https://pypi-proxy.fly.dev/basic-auth/simple"), @r" + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--keyring-provider") + .arg("native"), @r" success: true exit_code: 0 ----- stdout ----- @@ -571,21 +548,6 @@ fn logout_native_keyring() -> Result<()> { Removed credentials for https://pypi-proxy.fly.dev/basic-auth/simple "); - // Logout before logging in (without a username) - uv_snapshot!(context.auth_logout() - .arg("https://pypi-proxy.fly.dev/basic-auth/simple") - .arg("--keyring-provider") - .arg("native"), @r" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - error: Unable to remove credentials for https://pypi-proxy.fly.dev/basic-auth/simple - Caused by: No matching entry found in secure storage - "); - // Logout before logging in (with a username) uv_snapshot!(context.auth_logout() .arg("https://pypi-proxy.fly.dev/basic-auth/simple") @@ -693,6 +655,7 @@ fn logout_native_keyring() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. error: Cannot specify a username both via the URL and CLI; found `--username foo` and `public` "); @@ -716,6 +679,7 @@ fn logout_native_keyring() -> Result<()> { } #[test] +#[cfg(feature = "keyring-tests")] fn logout_token_native_keyring() -> Result<()> { let context = TestContext::new_with_versions(&[]).with_real_home(); @@ -723,6 +687,8 @@ fn logout_token_native_keyring() -> Result<()> { context .auth_logout() .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--keyring-provider") + .arg("native") .status()?; // Login with a token @@ -760,6 +726,7 @@ fn logout_token_native_keyring() -> Result<()> { } #[test] +#[cfg(feature = "keyring-tests")] fn login_native_keyring_url() { let context = TestContext::new_with_versions(&[]).with_real_home(); @@ -934,3 +901,178 @@ fn login_native_keyring_url() { error: When using `--token`, a username cannot not be provided; found: test "); } + +#[test] +fn login_text_store() { + let context = TestContext::new_with_versions(&[]); + + // Successful login without keyring provider (uses text store) + uv_snapshot!(context.auth_login() + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + " + ); + + // Token-based login + uv_snapshot!(context.auth_login() + .arg("https://example.com/simple") + .arg("--token") + .arg("test-token"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for https://example.com/simple + " + ); +} + +#[test] +fn token_text_store() { + let context = TestContext::new_with_versions(&[]); + + // Login first + context + .auth_login() + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron") + .assert() + .success(); + + // Retrieve the token + uv_snapshot!(context.auth_token() + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--username") + .arg("public"), @r" + success: true + exit_code: 0 + ----- stdout ----- + heron + + ----- stderr ----- + " + ); + + // Login with token + context + .auth_login() + .arg("https://example.com/simple") + .arg("--token") + .arg("test-token") + .assert() + .success(); + + // Retrieve token without username + uv_snapshot!(context.auth_token() + .arg("https://example.com/simple"), @r" + success: true + exit_code: 0 + ----- stdout ----- + test-token + + ----- stderr ----- + " + ); +} + +#[test] +fn logout_text_store() { + let context = TestContext::new_with_versions(&[]); + + // Login first + context + .auth_login() + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron") + .assert() + .success(); + + // Logout + uv_snapshot!(context.auth_logout() + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--username") + .arg("public"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + " + ); + + // Login with token then logout + context + .auth_login() + .arg("https://example.com/simple") + .arg("--token") + .arg("test-token") + .assert() + .success(); + + uv_snapshot!(context.auth_logout() + .arg("https://example.com/simple"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Removed credentials for https://example.com/simple + " + ); +} + +#[test] +fn auth_disabled_provider_uses_text_store() { + let context = TestContext::new_with_versions(&[]); + + // Login with disabled provider should use text store + uv_snapshot!(context.auth_login() + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron") + .arg("--keyring-provider") + .arg("disabled"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + " + ); + + // Token retrieval should work with disabled provider + uv_snapshot!(context.auth_token() + .arg("https://pypi-proxy.fly.dev/basic-auth/simple") + .arg("--username") + .arg("public") + .arg("--keyring-provider") + .arg("disabled"), @r" + success: true + exit_code: 0 + ----- stdout ----- + heron + + ----- stderr ----- + " + ); +} diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 5b518cd8f..6e68de600 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -854,6 +854,10 @@ impl TestContext { .env(EnvVars::HOME, self.home_dir.as_os_str()) .env(EnvVars::APPDATA, self.home_dir.as_os_str()) .env(EnvVars::USERPROFILE, self.home_dir.as_os_str()) + .env( + EnvVars::XDG_DATA_HOME, + self.home_dir.join("data").as_os_str(), + ) .env(EnvVars::UV_PYTHON_INSTALL_DIR, "") // Installations are not allowed by default; see `Self::with_managed_python_dirs` .env(EnvVars::UV_PYTHON_DOWNLOADS, "never") @@ -868,7 +872,6 @@ impl TestContext { .env_remove(EnvVars::UV_CACHE_DIR) .env_remove(EnvVars::UV_TOOL_BIN_DIR) .env_remove(EnvVars::XDG_CONFIG_HOME) - .env_remove(EnvVars::XDG_DATA_HOME) // I believe the intent of all tests is that they are run outside the // context of an existing git repository. And when they aren't, state // from the parent git repository can bleed into the behavior of `uv diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 0e47d254a..332179f06 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -3,7 +3,6 @@ pub(crate) mod common; -#[cfg(feature = "keyring-tests")] mod auth; mod branching_urls;