diff --git a/crates/uv-auth/src/keyring.rs b/crates/uv-auth/src/keyring.rs index 7b21eda46..5ec800790 100644 --- a/crates/uv-auth/src/keyring.rs +++ b/crates/uv-auth/src/keyring.rs @@ -20,12 +20,12 @@ static UV_SERVICE_PREFIX: &str = "uv-credentials:"; /// /// See pip's implementation for reference /// -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct KeyringProvider { backend: KeyringProviderBackend, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub(crate) enum KeyringProviderBackend { /// Use system keyring integration to fetch credentials. Native, @@ -50,6 +50,11 @@ impl KeyringProvider { } } + /// Whether the backend is [`KeyringProviderBackend::Native`]. + pub fn is_native(&self) -> bool { + matches!(self.backend, KeyringProviderBackend::Native) + } + /// Store credentials for the given [`Url`] to the keyring if the /// keyring provider backend is `Native`. #[instrument(skip_all, fields(url = % url.to_string(), username))] @@ -109,7 +114,22 @@ impl KeyringProvider { } } - /// Fetch credentials for the given [`Url`] from the keyring. + /// Fetch credentials for the given [`DisplaySafeUrl`] from the keyring if the backend is + /// [`KeyringProviderBackend::Native`]. + #[instrument(skip_all, fields(url = % url.to_string(), username))] + pub async fn fetch_if_native( + &self, + url: &DisplaySafeUrl, + username: Option<&str>, + ) -> Option { + if self.is_native() { + self.fetch(url, username).await + } else { + None + } + } + + /// Fetch credentials for the given [`DisplaySafeUrl`] from the keyring. /// /// Returns [`None`] if no password was found for the username or if any errors /// are encountered in the keyring backend. @@ -160,6 +180,10 @@ impl KeyringProvider { }; } + if credentials.is_some() { + debug!("Found credentials in keyring for {url}"); + } + credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password))) } diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 0fd43c122..aba01e67e 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -398,8 +398,9 @@ impl AuthMiddleware { .as_ref() .is_ok_and(|response| response.error_for_status_ref().is_ok()) { - if let (Some(index_url), Some(keyring)) = (index_url, &self.keyring) { - keyring.store_if_native(index_url, &credentials).await; + if let Some(keyring) = &self.keyring { + let url = index_url.unwrap_or(&url); + keyring.store_if_native(url, &credentials).await; } trace!("Updating cached credentials for {url} to {credentials:?}"); self.cache().insert(&url, credentials); @@ -575,7 +576,7 @@ impl AuthMiddleware { // 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 // same realm we will use the wrong credentials. - } else if let Some(credentials) = match self.keyring { + } else { match self.keyring { Some(ref keyring) => { // The subprocess keyring provider is _slow_ so we do not perform fetches for all // URLs; instead, we fetch if there's a username or if the user has requested to @@ -603,12 +604,7 @@ impl AuthMiddleware { } } None => None, - } { - debug!("Found credentials in keyring for {url}"); - Some(credentials) - } else { - None - } + } } .map(Arc::new); // Register the fetch for this key diff --git a/crates/uv-cache-key/src/canonical_url.rs b/crates/uv-cache-key/src/canonical_url.rs index 19f5a3d7c..86402ca5c 100644 --- a/crates/uv-cache-key/src/canonical_url.rs +++ b/crates/uv-cache-key/src/canonical_url.rs @@ -3,7 +3,6 @@ use std::fmt::{Debug, Formatter}; use std::hash::{Hash, Hasher}; use std::ops::Deref; -use url::Url; use uv_redacted::DisplaySafeUrl; use crate::cache_key::{CacheKey, CacheKeyHasher}; @@ -186,7 +185,7 @@ impl Hash for RepositoryUrl { } impl Deref for RepositoryUrl { - type Target = Url; + type Target = DisplaySafeUrl; fn deref(&self) -> &Self::Target { &self.0 diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 4df6bd475..4ac17b8e5 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -28,6 +28,7 @@ use url::ParseError; use url::Url; use uv_auth::Credentials; +use uv_auth::KeyringProvider; use uv_auth::{AuthMiddleware, Indexes}; use uv_configuration::{KeyringProviderType, TrustedHost}; use uv_fs::Simplified; @@ -325,6 +326,7 @@ impl<'a> BaseClientBuilder<'a> { dangerous_client, raw_dangerous_client, timeout, + keyring_provider: self.keyring.to_provider(), } } @@ -351,6 +353,7 @@ impl<'a> BaseClientBuilder<'a> { raw_client: existing.raw_client.clone(), raw_dangerous_client: existing.raw_dangerous_client.clone(), timeout: existing.timeout, + keyring_provider: self.keyring.to_provider(), } } @@ -524,6 +527,8 @@ pub struct BaseClient { allow_insecure_host: Vec, /// The number of retries to attempt on transient errors. retries: u32, + /// Backend for providing credentials from a keyring. + keyring_provider: Option, } #[derive(Debug, Clone, Copy)] @@ -571,6 +576,11 @@ impl BaseClient { pub fn retry_policy(&self) -> ExponentialBackoff { ExponentialBackoff::builder().build_with_max_retries(self.retries) } + + /// The [`KeyringProvider`] if one exists. + pub fn keyring_provider(&self) -> &Option { + &self.keyring_provider + } } /// Wrapper around [`ClientWithMiddleware`] that manages redirects. diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index d1c5cca00..fea31c02d 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -1559,6 +1559,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.reporter .clone() .map(|reporter| reporter.into_git_reporter()), + client + .unmanaged + .cached_client() + .uncached() + .keyring_provider() + .as_ref(), ) .await?; @@ -1763,6 +1769,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.reporter .clone() .map(|reporter| reporter.into_git_reporter()), + client + .unmanaged + .cached_client() + .uncached() + .keyring_provider() + .as_ref(), ) .await?; @@ -2010,6 +2022,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.reporter .clone() .map(|reporter| reporter.into_git_reporter()), + client + .unmanaged + .cached_client() + .uncached() + .keyring_provider() + .as_ref(), ) .await?; diff --git a/crates/uv-git/src/credentials.rs b/crates/uv-git/src/credentials.rs index 051560980..1b2188e4a 100644 --- a/crates/uv-git/src/credentials.rs +++ b/crates/uv-git/src/credentials.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::{Arc, LazyLock, RwLock}; use tracing::trace; -use uv_auth::Credentials; +use uv_auth::{Credentials, KeyringProvider}; use uv_cache_key::RepositoryUrl; use uv_redacted::DisplaySafeUrl; @@ -16,7 +16,20 @@ pub struct GitStore(RwLock>>); impl GitStore { /// Insert [`Credentials`] for the given URL into the store. - pub fn insert(&self, url: RepositoryUrl, credentials: Credentials) -> Option> { + /// + /// If a native keyring provider is available, the credentials will also be + /// persisted to the system keyring for future use. + /// + /// Returns the previously stored credentials for this URL, if any. + pub async fn insert( + &self, + url: RepositoryUrl, + credentials: Credentials, + keyring_provider: Option<&KeyringProvider>, + ) -> Option> { + if let Some(keyring_provider) = keyring_provider { + keyring_provider.store_if_native(&url, &credentials).await; + } self.0.write().unwrap().insert(url, Arc::new(credentials)) } @@ -29,10 +42,15 @@ impl GitStore { /// Populate the global authentication store with credentials on a Git URL, if there are any. /// /// Returns `true` if the store was updated. -pub fn store_credentials_from_url(url: &DisplaySafeUrl) -> bool { +pub async fn store_credentials_from_url( + url: &DisplaySafeUrl, + keyring_provider: Option<&KeyringProvider>, +) -> bool { if let Some(credentials) = Credentials::from_url(url) { trace!("Caching credentials for {url}"); - GIT_STORE.insert(RepositoryUrl::new(url), credentials); + GIT_STORE + .insert(RepositoryUrl::new(url), credentials, keyring_provider) + .await; true } else { false diff --git a/crates/uv-git/src/git.rs b/crates/uv-git/src/git.rs index 4b37a5286..6ca5e9f8e 100644 --- a/crates/uv-git/src/git.rs +++ b/crates/uv-git/src/git.rs @@ -459,7 +459,7 @@ impl GitCheckout { /// The `remote_url` argument is the git remote URL where we want to fetch from. fn fetch( repo: &mut GitRepository, - remote_url: &Url, + remote_url: &DisplaySafeUrl, reference: ReferenceOrOid<'_>, client: &ClientWithMiddleware, disable_ssl: bool, @@ -601,7 +601,7 @@ fn fetch( /// Attempts to use `git` CLI installed on the system to fetch a repository. fn fetch_with_cli( repo: &mut GitRepository, - url: &Url, + url: &DisplaySafeUrl, refspecs: &[String], tags: bool, disable_ssl: bool, @@ -733,7 +733,7 @@ enum FastPathRev { /// [^1]: fn github_fast_path( git: &mut GitRepository, - url: &Url, + url: &DisplaySafeUrl, reference: ReferenceOrOid<'_>, client: &ClientWithMiddleware, ) -> Result { diff --git a/crates/uv-git/src/resolver.rs b/crates/uv-git/src/resolver.rs index e6e6e5ccd..808386569 100644 --- a/crates/uv-git/src/resolver.rs +++ b/crates/uv-git/src/resolver.rs @@ -9,6 +9,7 @@ use fs_err::tokio as fs; use reqwest_middleware::ClientWithMiddleware; use tracing::debug; +use uv_auth::KeyringProvider; use uv_cache_key::{RepositoryUrl, cache_digest}; use uv_fs::LockedFile; use uv_git_types::{GitHubRepository, GitOid, GitReference, GitUrl}; @@ -148,6 +149,7 @@ impl GitResolver { offline: bool, cache: PathBuf, reporter: Option>, + keyring_provider: Option<&KeyringProvider>, ) -> Result { debug!("Fetching source distribution from Git: {url}"); @@ -187,8 +189,9 @@ impl GitResolver { source }; - let fetch = tokio::task::spawn_blocking(move || source.fetch()) - .await? + let fetch = source + .fetch(keyring_provider) + .await .map_err(GitResolverError::Git)?; // Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches diff --git a/crates/uv-git/src/source.rs b/crates/uv-git/src/source.rs index cb6d0a24f..ce667c2a1 100644 --- a/crates/uv-git/src/source.rs +++ b/crates/uv-git/src/source.rs @@ -10,6 +10,7 @@ use anyhow::Result; use reqwest_middleware::ClientWithMiddleware; use tracing::{debug, instrument}; +use uv_auth::KeyringProvider; use uv_cache_key::{RepositoryUrl, cache_digest}; use uv_git_types::{GitOid, GitReference, GitUrl}; use uv_redacted::DisplaySafeUrl; @@ -71,7 +72,7 @@ impl GitSource { /// Fetch the underlying Git repository at the given revision. #[instrument(skip(self), fields(repository = %self.git.repository(), rev = ?self.git.precise()))] - pub fn fetch(self) -> Result { + pub async fn fetch(self, keyring_provider: Option<&KeyringProvider>) -> Result { // Compute the canonical URL for the repository. let canonical = RepositoryUrl::new(self.git.repository()); @@ -79,66 +80,97 @@ impl GitSource { let ident = cache_digest(&canonical); let db_path = self.cache.join("db").join(&ident); + let git_store_credentials = GIT_STORE.get(&canonical); + let credentials = match (&git_store_credentials, keyring_provider) { + (Some(creds), _) if creds.password().is_some() => git_store_credentials, + (_, Some(keyring)) => { + let repo_username = self.git.repository().username(); + let username = if !repo_username.is_empty() { + Some(repo_username) + } else { + git_store_credentials + .as_ref() + .and_then(|creds| creds.username()) + }; + let mut git = self.git.repository().clone(); + + git.remove_credentials(); + + keyring + .fetch_if_native(&git, username) + .await + .map(Arc::new) + .or(git_store_credentials) + } + _ => git_store_credentials, + }; + // Authenticate the URL, if necessary. - let remote = if let Some(credentials) = GIT_STORE.get(&canonical) { + let remote = if let Some(credentials) = credentials { Cow::Owned(credentials.apply(self.git.repository().clone())) } else { Cow::Borrowed(self.git.repository()) }; + let git = self.git.clone(); + let reporter = self.reporter.clone(); + let remote_clone = remote.as_ref().clone(); // Fetch the commit, if we don't already have it. Wrapping this section in a closure makes // it easier to short-circuit this in the cases where we do have the commit. - let (db, actual_rev, maybe_task) = || -> Result<(GitDatabase, GitOid, Option)> { - let git_remote = GitRemote::new(&remote); - let maybe_db = git_remote.db_at(&db_path).ok(); + let (db, actual_rev, maybe_task) = + tokio::task::spawn_blocking(move || -> Result<(GitDatabase, GitOid, Option)> { + let remote = remote_clone; + let git_remote = GitRemote::new(&remote); + let maybe_db = git_remote.db_at(&db_path).ok(); - // If we have a locked revision, and we have a pre-existing database which has that - // revision, then no update needs to happen. - if let (Some(rev), Some(db)) = (self.git.precise(), &maybe_db) { - if db.contains(rev) { - debug!("Using existing Git source `{}`", self.git.repository()); - return Ok((maybe_db.unwrap(), rev, None)); + // If we have a locked revision, and we have a pre-existing database which has that + // revision, then no update needs to happen. + if let (Some(rev), Some(db)) = (git.precise(), &maybe_db) { + if db.contains(rev) { + debug!("Using existing Git source `{}`", git.repository()); + return Ok((maybe_db.unwrap(), rev, None)); + } } - } - // If the revision isn't locked, but it looks like it might be an exact commit hash, - // and we do have a pre-existing database, then check whether it is, in fact, a commit - // hash. If so, treat it like it's locked. - if let Some(db) = &maybe_db { - if let GitReference::BranchOrTagOrCommit(maybe_commit) = self.git.reference() { - if let Ok(oid) = maybe_commit.parse::() { - if db.contains(oid) { - // This reference is an exact commit. Treat it like it's - // locked. - debug!("Using existing Git source `{}`", self.git.repository()); - return Ok((maybe_db.unwrap(), oid, None)); + // If the revision isn't locked, but it looks like it might be an exact commit hash, + // and we do have a pre-existing database, then check whether it is, in fact, a commit + // hash. If so, treat it like it's locked. + if let Some(db) = &maybe_db { + if let GitReference::BranchOrTagOrCommit(maybe_commit) = git.reference() { + if let Ok(oid) = maybe_commit.parse::() { + if db.contains(oid) { + // This reference is an exact commit. Treat it like it's + // locked. + debug!("Using existing Git source `{}`", git.repository()); + return Ok((maybe_db.unwrap(), oid, None)); + } } } } - } - // ... otherwise, we use this state to update the Git database. Note that we still check - // for being offline here, for example in the situation that we have a locked revision - // but the database doesn't have it. - debug!("Updating Git source `{}`", self.git.repository()); + // ... otherwise, we use this state to update the Git database. Note that we still check + // for being offline here, for example in the situation that we have a locked revision + // but the database doesn't have it. + debug!("Updating Git source `{}`", git.repository()); - // Report the checkout operation to the reporter. - let task = self.reporter.as_ref().map(|reporter| { - reporter.on_checkout_start(git_remote.url(), self.git.reference().as_rev()) - }); + // Report the checkout operation to the reporter. + let task = reporter.as_ref().map(|reporter| { + reporter.on_checkout_start(git_remote.url(), git.reference().as_rev()) + }); - let (db, actual_rev) = git_remote.checkout( - &db_path, - maybe_db, - self.git.reference(), - self.git.precise(), - &self.client, - self.disable_ssl, - self.offline, - )?; + let (db, actual_rev) = git_remote.checkout( + &db_path, + maybe_db, + git.reference(), + git.precise(), + &self.client, + self.disable_ssl, + self.offline, + )?; - Ok((db, actual_rev, task)) - }()?; + Ok((db, actual_rev, task)) + }) + .await??; // Don’t use the full hash, in order to contribute less to reaching the // path length limit on Windows. @@ -158,7 +190,7 @@ impl GitSource { // Report the checkout operation to the reporter. if let Some(task) = maybe_task { if let Some(reporter) = self.reporter.as_ref() { - reporter.on_checkout_complete(remote.as_ref(), actual_rev.as_str(), task); + reporter.on_checkout_complete(&remote, actual_rev.as_str(), task); } } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c8257bb18..fc13612ab 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -18,8 +18,8 @@ use uv_cache_key::RepositoryUrl; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun, - EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions, Preview, - PreviewFeatures, SourceStrategy, + EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions, + KeyringProviderType, Preview, PreviewFeatures, SourceStrategy, }; use uv_dispatch::BuildDispatch; use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies}; @@ -650,7 +650,9 @@ pub(crate) async fn add( &extras_of_dependency, index, &mut toml, - )?; + settings.resolver.keyring_provider, + ) + .await?; // Validate any indexes that were provided on the command-line to ensure // they point to existing non-empty directories when using path URLs. @@ -768,7 +770,7 @@ pub(crate) async fn add( } } -fn edits( +async fn edits( requirements: Vec, target: &AddTarget, editable: Option, @@ -780,6 +782,7 @@ fn edits( extras: &[ExtraName], index: Option<&IndexName>, toml: &mut PyProjectTomlMut, + keyring_provider: KeyringProviderType, ) -> Result> { let mut edits = Vec::::with_capacity(requirements.len()); for mut requirement in requirements { @@ -853,13 +856,33 @@ fn edits( extra, group, }) => { - let credentials = uv_auth::Credentials::from_url(&git); - if let Some(credentials) = credentials { - debug!("Caching credentials for: {git}"); - GIT_STORE.insert(RepositoryUrl::new(&git), credentials); - - // Redact the credentials. + if let Some(credentials) = uv_auth::Credentials::from_url(&git) { + let username = git.username().to_string(); + // Redact the credentials from the URL. git.remove_credentials(); + + let credentials = if credentials.password().is_some() { + Some(credentials) + } else { + if let Some(keyring_provider) = keyring_provider.to_provider() { + keyring_provider + .fetch_if_native(&git, Some(&username)) + .await + } else { + Some(credentials) + } + }; + + debug!("Caching credentials for: {git}"); + if let Some(credentials) = credentials { + GIT_STORE + .insert( + RepositoryUrl::new(&git), + credentials, + keyring_provider.to_provider().as_ref(), + ) + .await; + } } Some(Source::Git { git, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 3eb07d79b..1a760a5a4 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -8,6 +8,7 @@ use itertools::Itertools; use owo_colors::OwoColorize; use serde::Serialize; use tracing::warn; +use uv_auth::KeyringProvider; use uv_cache::Cache; use uv_cli::SyncFormat; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; @@ -719,7 +720,7 @@ pub(super) async fn do_sync( index_locations.cache_index_credentials(); // Populate credentials from the target. - store_credentials_from_target(target); + store_credentials_from_target(target, keyring_provider.to_provider().as_ref()).await; // Initialize the registry client. let client = RegistryClientBuilder::try_from(client_builder)? @@ -875,7 +876,10 @@ fn apply_editable_mode(resolution: Resolution, editable: EditableMode) -> Resolu /// /// These credentials can come from any of `tool.uv.sources`, `tool.uv.dev-dependencies`, /// `project.dependencies`, and `project.optional-dependencies`. -fn store_credentials_from_target(target: InstallTarget<'_>) { +async fn store_credentials_from_target( + target: InstallTarget<'_>, + keyring_provider: Option<&KeyringProvider>, +) { // Iterate over any indexes in the target. for index in target.indexes() { if let Some(credentials) = index.credentials() { @@ -891,7 +895,7 @@ fn store_credentials_from_target(target: InstallTarget<'_>) { for source in target.sources() { match source { Source::Git { git, .. } => { - uv_git::store_credentials_from_url(git); + uv_git::store_credentials_from_url(git, keyring_provider).await; } Source::Url { url, .. } => { uv_auth::store_credentials_from_url(url); @@ -907,7 +911,7 @@ fn store_credentials_from_target(target: InstallTarget<'_>) { }; match &url.parsed_url { ParsedUrl::Git(ParsedGitUrl { url, .. }) => { - uv_git::store_credentials_from_url(url.repository()); + uv_git::store_credentials_from_url(url.repository(), keyring_provider).await; } ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => { uv_auth::store_credentials_from_url(url); diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index be103de52..371049410 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -12624,6 +12624,140 @@ fn add_package_persist_system_keyring_credentials() -> Result<()> { Ok(()) } +/// Add a package from a private git repo using credentials stored in the system keyring. +/// +/// TODO(john): This test currently relies on the system keyring not yet having the +/// credentials for the git repo the test adds from. We need to update the test to clear those +/// credentials, possibly both defensively at the beginning and at the end to clean up. +#[cfg(feature = "keyring-tests")] +#[cfg(feature = "git")] +#[test] +fn add_git_private_persist_system_keyring_credentials() -> Result<()> { + let context = TestContext::new("3.12"); + let token = decode_token(READ_ONLY_GITHUB_TOKEN); + + // Configure `pyproject.toml` with native keyring provider. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + + [tool.uv] + keyring-provider = "native" + "# + })?; + + // Try to add a private Git package with only username. + uv_snapshot!(context.filters(), context.add().arg("uv-private-pypackage @ git+https://git@github.com/astral-test/uv-private-pypackage"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `uv-private-pypackage @ git+https://github.com/astral-test/uv-private-pypackage` + ├─▶ Git operation failed + ├─▶ failed to clone into: [CACHE_DIR]/git-v0/db/8401f5508e3e612d + ╰─▶ process didn't exit successfully: `/usr/bin/git fetch --force --update-head-ok 'https://github.com/astral-test/uv-private-pypackage' '+HEAD:refs/remotes/origin/HEAD'` (exit status: 128) + --- stderr + fatal: could not read Username for 'https://github.com': terminal prompts disabled + + help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. + " + ); + + // Try again with credentials. This should persist the credentials to the system keyring. + uv_snapshot!(context.filters(), context.add().arg(format!("uv-private-pypackage @ git+https://git:{token}@github.com/astral-test/uv-private-pypackage")), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071) + "); + + // Remove the package and clean cache. + uv_snapshot!(context.filters(), context.remove().arg("uv-private-pypackage"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Uninstalled 1 package in [TIME] + - uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071) + "); + + let filters: Vec<_> = context + .filters() + .into_iter() + .chain([ + // The cache entry does not have a stable key, so we filter it out. + ( + r"\[CACHE_DIR\](\\|\/)(.+)(\\|\/).*", + "[CACHE_DIR]/$2/[ENTRY]", + ), + // The file count varies by operating system, so we filter it out. + ("Removed \\d+ files?", "Removed [N] files"), + ]) + .collect(); + + uv_snapshot!(&filters, context.clean(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Clearing cache at: [CACHE_DIR]/ + Removed [N] files ([SIZE]) + "); + + // Try to add the original package again with username only. This should use + // credentials stored in the system keyring. + uv_snapshot!(context.filters(), context.add().arg("uv-private-pypackage @ git+https://git@github.com/astral-test/uv-private-pypackage"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071) + "); + + // Verify that `pyproject.toml` doesn't contain credentials + let pyproject_toml = context.read("pyproject.toml"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [ + "uv-private-pypackage", + ] + + [tool.uv] + keyring-provider = "native" + + [tool.uv.sources] + uv-private-pypackage = { git = "https://github.com/astral-test/uv-private-pypackage" } + "### + ); + }); + + Ok(()) +} + /// If uv receives a 302 redirect to a cross-origin server, it should not forward /// credentials. In the absence of a netrc entry for the new location, /// it should fail.