Add a plain text backend for credential storage (#15588)

Adds a default plain text storage mechanism to `uv auth`.

While we'd prefer to use the system store, the "native" keyring support
is experimental still and I don't want to ship an unusable interface.
@geofft also suggested that the story for secure credential storage is
much weaker on Linux than macOS and Windows and felt this approach would
be needed regardless.

We'll switch over to using the native keyring by default in the future.
On Linux, we can now fallback to a plaintext store the secret store is
not configured, which is a nice property.

Right now, we store credentials in a TOML file in the uv state
directory. I expect to also read from the uv config directory in the
future, but we don't need it immediately.
This commit is contained in:
Zanie Blue 2025-08-30 11:55:16 -05:00
parent ddf2f5ed8c
commit ac5dc9be1f
20 changed files with 1076 additions and 187 deletions

5
Cargo.lock generated
View File

@ -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",
]

View File

@ -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 }

View File

@ -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<String>);
impl Username {
@ -69,7 +71,8 @@ impl From<Option<String>> 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 {

View File

@ -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

View File

@ -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<Option<TextCredentialStore>>),
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<KeyringProvider>,
cache: Option<CredentialsCache>,
/// 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<TextCredentialStore>) -> 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<KeyringProvider>) -> 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())
}

View File

@ -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<Self, Self::Err> {
// 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<String> for Service {
type Error = ServiceParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_str(&value)
}
}
impl From<Service> for String {
fn from(service: Service) -> Self {
service.to_string()
}
}
impl TryFrom<DisplaySafeUrl> for Service {
type Error = ServiceParseError;
fn try_from(value: DisplaySafeUrl) -> Result<Self, Self::Error> {
Self::check_scheme(&value)?;
Ok(Self(value))
}
}

527
crates/uv-auth/src/store.rs Normal file
View File

@ -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 <token>` 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<Password>,
/// The token to use. Only allowed with [`AuthScheme::Bearer`].
pub token: Option<String>,
}
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<Self, TomlCredentialError> {
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<TomlCredential>,
}
/// A credential store with a plain text storage backend.
#[derive(Debug, Default)]
pub struct TextCredentialStore {
credentials: FxHashMap<Service, Credentials>,
}
impl TextCredentialStore {
/// Return the default credential file path.
pub fn default_file() -> Result<PathBuf, TomlCredentialError> {
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<P: AsRef<Path>>(path: P) -> Result<Self, TomlCredentialError> {
let content = fs::read_to_string(path)?;
let credentials: TomlCredentials = toml::from_str(&content)?;
let credentials: FxHashMap<Service, Credentials> = 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<P: AsRef<Path>>(self, path: P) -> Result<(), TomlCredentialError> {
let credentials = self
.credentials
.into_iter()
.map(|(service, cred)| TomlCredential::from_credentials(service, cred))
.collect::<Result<Vec<_>, _>>()?;
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<Credentials> {
self.credentials.insert(service, credentials)
}
/// Remove credentials for a given service.
pub fn remove(&mut self, service: &Service) -> Option<Credentials> {
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"));
}
}

View File

@ -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 }

View File

@ -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,

View File

@ -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 }

View File

@ -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<Self, Self::Err> {
// 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)
}
}

View File

@ -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",
}
}
}

View File

@ -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<ExitStatus> {
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));
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(),

View File

@ -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<ExitStatus> {
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}"))?;
}
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(),

View File

@ -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<Self, TomlCredentialError> {
// 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),
}
}
}

View File

@ -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<ExitStatus> {
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
let credentials = match &backend {
AuthBackend::Keyring(provider) => provider
.fetch(url, Some(&username))
.await
.with_context(|| format!("Failed to fetch credentials for {display_url}"))?;
.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!(

View File

@ -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,

View File

@ -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:
<SERVICE>
Usage: uv auth token --cache-dir [CACHE_DIR] <SERVICE>
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 -----
"
);
}

View File

@ -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

View File

@ -3,7 +3,6 @@
pub(crate) mod common;
#[cfg(feature = "keyring-tests")]
mod auth;
mod branching_urls;