mirror of https://github.com/astral-sh/uv
Add native keyring integration for git credentials
This commit is contained in:
parent
56f9074d2a
commit
7f3adc36fb
|
|
@ -20,12 +20,12 @@ static UV_SERVICE_PREFIX: &str = "uv-credentials:";
|
|||
///
|
||||
/// See pip's implementation for reference
|
||||
/// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L102>
|
||||
#[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<Credentials> {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TrustedHost>,
|
||||
/// The number of retries to attempt on transient errors.
|
||||
retries: u32,
|
||||
/// Backend for providing credentials from a keyring.
|
||||
keyring_provider: Option<KeyringProvider>,
|
||||
}
|
||||
|
||||
#[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<KeyringProvider> {
|
||||
&self.keyring_provider
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper around [`ClientWithMiddleware`] that manages redirects.
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HashMap<RepositoryUrl, Arc<Credentials>>>);
|
|||
|
||||
impl GitStore {
|
||||
/// Insert [`Credentials`] for the given URL into the store.
|
||||
pub fn insert(&self, url: RepositoryUrl, credentials: Credentials) -> Option<Arc<Credentials>> {
|
||||
///
|
||||
/// 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<Arc<Credentials>> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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]: <https://developer.github.com/v3/repos/commits/#get-the-sha-1-of-a-commit-reference>
|
||||
fn github_fast_path(
|
||||
git: &mut GitRepository,
|
||||
url: &Url,
|
||||
url: &DisplaySafeUrl,
|
||||
reference: ReferenceOrOid<'_>,
|
||||
client: &ClientWithMiddleware,
|
||||
) -> Result<FastPathRev> {
|
||||
|
|
|
|||
|
|
@ -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<Arc<dyn Reporter>>,
|
||||
keyring_provider: Option<&KeyringProvider>,
|
||||
) -> Result<Fetch, GitResolverError> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Fetch> {
|
||||
pub async fn fetch(self, keyring_provider: Option<&KeyringProvider>) -> Result<Fetch> {
|
||||
// Compute the canonical URL for the repository.
|
||||
let canonical = RepositoryUrl::new(self.git.repository());
|
||||
|
||||
|
|
@ -79,24 +80,54 @@ 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<usize>)> {
|
||||
let (db, actual_rev, maybe_task) =
|
||||
tokio::task::spawn_blocking(move || -> Result<(GitDatabase, GitOid, Option<usize>)> {
|
||||
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 let (Some(rev), Some(db)) = (git.precise(), &maybe_db) {
|
||||
if db.contains(rev) {
|
||||
debug!("Using existing Git source `{}`", self.git.repository());
|
||||
debug!("Using existing Git source `{}`", git.repository());
|
||||
return Ok((maybe_db.unwrap(), rev, None));
|
||||
}
|
||||
}
|
||||
|
|
@ -105,12 +136,12 @@ impl GitSource {
|
|||
// 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 GitReference::BranchOrTagOrCommit(maybe_commit) = git.reference() {
|
||||
if let Ok(oid) = maybe_commit.parse::<GitOid>() {
|
||||
if db.contains(oid) {
|
||||
// This reference is an exact commit. Treat it like it's
|
||||
// locked.
|
||||
debug!("Using existing Git source `{}`", self.git.repository());
|
||||
debug!("Using existing Git source `{}`", git.repository());
|
||||
return Ok((maybe_db.unwrap(), oid, None));
|
||||
}
|
||||
}
|
||||
|
|
@ -120,25 +151,26 @@ impl GitSource {
|
|||
// ... 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());
|
||||
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())
|
||||
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(),
|
||||
git.reference(),
|
||||
git.precise(),
|
||||
&self.client,
|
||||
self.disable_ssl,
|
||||
self.offline,
|
||||
)?;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Requirement>,
|
||||
target: &AddTarget,
|
||||
editable: Option<bool>,
|
||||
|
|
@ -780,6 +782,7 @@ fn edits(
|
|||
extras: &[ExtraName],
|
||||
index: Option<&IndexName>,
|
||||
toml: &mut PyProjectTomlMut,
|
||||
keyring_provider: KeyringProviderType,
|
||||
) -> Result<Vec<DependencyEdit>> {
|
||||
let mut edits = Vec::<DependencyEdit>::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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue