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
|
/// See pip's implementation for reference
|
||||||
/// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L102>
|
/// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L102>
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct KeyringProvider {
|
pub struct KeyringProvider {
|
||||||
backend: KeyringProviderBackend,
|
backend: KeyringProviderBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) enum KeyringProviderBackend {
|
pub(crate) enum KeyringProviderBackend {
|
||||||
/// Use system keyring integration to fetch credentials.
|
/// Use system keyring integration to fetch credentials.
|
||||||
Native,
|
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
|
/// Store credentials for the given [`Url`] to the keyring if the
|
||||||
/// keyring provider backend is `Native`.
|
/// keyring provider backend is `Native`.
|
||||||
#[instrument(skip_all, fields(url = % url.to_string(), username))]
|
#[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
|
/// Returns [`None`] if no password was found for the username or if any errors
|
||||||
/// are encountered in the keyring backend.
|
/// 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)))
|
credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -398,8 +398,9 @@ impl AuthMiddleware {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_ok_and(|response| response.error_for_status_ref().is_ok())
|
.is_ok_and(|response| response.error_for_status_ref().is_ok())
|
||||||
{
|
{
|
||||||
if let (Some(index_url), Some(keyring)) = (index_url, &self.keyring) {
|
if let Some(keyring) = &self.keyring {
|
||||||
keyring.store_if_native(index_url, &credentials).await;
|
let url = index_url.unwrap_or(&url);
|
||||||
|
keyring.store_if_native(url, &credentials).await;
|
||||||
}
|
}
|
||||||
trace!("Updating cached credentials for {url} to {credentials:?}");
|
trace!("Updating cached credentials for {url} to {credentials:?}");
|
||||||
self.cache().insert(&url, 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,
|
// 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
|
// if a keyring implementation returns different credentials for different URLs in the
|
||||||
// same realm we will use the wrong credentials.
|
// same realm we will use the wrong credentials.
|
||||||
} else if let Some(credentials) = match self.keyring {
|
} else { match self.keyring {
|
||||||
Some(ref keyring) => {
|
Some(ref keyring) => {
|
||||||
// The subprocess keyring provider is _slow_ so we do not perform fetches for all
|
// 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
|
// URLs; instead, we fetch if there's a username or if the user has requested to
|
||||||
|
|
@ -603,12 +604,7 @@ impl AuthMiddleware {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
} {
|
} }
|
||||||
debug!("Found credentials in keyring for {url}");
|
|
||||||
Some(credentials)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
.map(Arc::new);
|
.map(Arc::new);
|
||||||
|
|
||||||
// Register the fetch for this key
|
// Register the fetch for this key
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ use std::fmt::{Debug, Formatter};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use url::Url;
|
|
||||||
use uv_redacted::DisplaySafeUrl;
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
|
||||||
use crate::cache_key::{CacheKey, CacheKeyHasher};
|
use crate::cache_key::{CacheKey, CacheKeyHasher};
|
||||||
|
|
@ -186,7 +185,7 @@ impl Hash for RepositoryUrl {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deref for RepositoryUrl {
|
impl Deref for RepositoryUrl {
|
||||||
type Target = Url;
|
type Target = DisplaySafeUrl;
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
fn deref(&self) -> &Self::Target {
|
||||||
&self.0
|
&self.0
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ use url::ParseError;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use uv_auth::Credentials;
|
use uv_auth::Credentials;
|
||||||
|
use uv_auth::KeyringProvider;
|
||||||
use uv_auth::{AuthMiddleware, Indexes};
|
use uv_auth::{AuthMiddleware, Indexes};
|
||||||
use uv_configuration::{KeyringProviderType, TrustedHost};
|
use uv_configuration::{KeyringProviderType, TrustedHost};
|
||||||
use uv_fs::Simplified;
|
use uv_fs::Simplified;
|
||||||
|
|
@ -325,6 +326,7 @@ impl<'a> BaseClientBuilder<'a> {
|
||||||
dangerous_client,
|
dangerous_client,
|
||||||
raw_dangerous_client,
|
raw_dangerous_client,
|
||||||
timeout,
|
timeout,
|
||||||
|
keyring_provider: self.keyring.to_provider(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,6 +353,7 @@ impl<'a> BaseClientBuilder<'a> {
|
||||||
raw_client: existing.raw_client.clone(),
|
raw_client: existing.raw_client.clone(),
|
||||||
raw_dangerous_client: existing.raw_dangerous_client.clone(),
|
raw_dangerous_client: existing.raw_dangerous_client.clone(),
|
||||||
timeout: existing.timeout,
|
timeout: existing.timeout,
|
||||||
|
keyring_provider: self.keyring.to_provider(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,6 +527,8 @@ pub struct BaseClient {
|
||||||
allow_insecure_host: Vec<TrustedHost>,
|
allow_insecure_host: Vec<TrustedHost>,
|
||||||
/// The number of retries to attempt on transient errors.
|
/// The number of retries to attempt on transient errors.
|
||||||
retries: u32,
|
retries: u32,
|
||||||
|
/// Backend for providing credentials from a keyring.
|
||||||
|
keyring_provider: Option<KeyringProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|
@ -571,6 +576,11 @@ impl BaseClient {
|
||||||
pub fn retry_policy(&self) -> ExponentialBackoff {
|
pub fn retry_policy(&self) -> ExponentialBackoff {
|
||||||
ExponentialBackoff::builder().build_with_max_retries(self.retries)
|
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.
|
/// Wrapper around [`ClientWithMiddleware`] that manages redirects.
|
||||||
|
|
|
||||||
|
|
@ -1559,6 +1559,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
||||||
self.reporter
|
self.reporter
|
||||||
.clone()
|
.clone()
|
||||||
.map(|reporter| reporter.into_git_reporter()),
|
.map(|reporter| reporter.into_git_reporter()),
|
||||||
|
client
|
||||||
|
.unmanaged
|
||||||
|
.cached_client()
|
||||||
|
.uncached()
|
||||||
|
.keyring_provider()
|
||||||
|
.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -1763,6 +1769,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
||||||
self.reporter
|
self.reporter
|
||||||
.clone()
|
.clone()
|
||||||
.map(|reporter| reporter.into_git_reporter()),
|
.map(|reporter| reporter.into_git_reporter()),
|
||||||
|
client
|
||||||
|
.unmanaged
|
||||||
|
.cached_client()
|
||||||
|
.uncached()
|
||||||
|
.keyring_provider()
|
||||||
|
.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -2010,6 +2022,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
||||||
self.reporter
|
self.reporter
|
||||||
.clone()
|
.clone()
|
||||||
.map(|reporter| reporter.into_git_reporter()),
|
.map(|reporter| reporter.into_git_reporter()),
|
||||||
|
client
|
||||||
|
.unmanaged
|
||||||
|
.cached_client()
|
||||||
|
.uncached()
|
||||||
|
.keyring_provider()
|
||||||
|
.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
use uv_auth::Credentials;
|
use uv_auth::{Credentials, KeyringProvider};
|
||||||
use uv_cache_key::RepositoryUrl;
|
use uv_cache_key::RepositoryUrl;
|
||||||
use uv_redacted::DisplaySafeUrl;
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
|
||||||
|
|
@ -16,7 +16,20 @@ pub struct GitStore(RwLock<HashMap<RepositoryUrl, Arc<Credentials>>>);
|
||||||
|
|
||||||
impl GitStore {
|
impl GitStore {
|
||||||
/// Insert [`Credentials`] for the given URL into the store.
|
/// 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))
|
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.
|
/// Populate the global authentication store with credentials on a Git URL, if there are any.
|
||||||
///
|
///
|
||||||
/// Returns `true` if the store was updated.
|
/// 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) {
|
if let Some(credentials) = Credentials::from_url(url) {
|
||||||
trace!("Caching credentials for {url}");
|
trace!("Caching credentials for {url}");
|
||||||
GIT_STORE.insert(RepositoryUrl::new(url), credentials);
|
GIT_STORE
|
||||||
|
.insert(RepositoryUrl::new(url), credentials, keyring_provider)
|
||||||
|
.await;
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@ impl GitCheckout {
|
||||||
/// The `remote_url` argument is the git remote URL where we want to fetch from.
|
/// The `remote_url` argument is the git remote URL where we want to fetch from.
|
||||||
fn fetch(
|
fn fetch(
|
||||||
repo: &mut GitRepository,
|
repo: &mut GitRepository,
|
||||||
remote_url: &Url,
|
remote_url: &DisplaySafeUrl,
|
||||||
reference: ReferenceOrOid<'_>,
|
reference: ReferenceOrOid<'_>,
|
||||||
client: &ClientWithMiddleware,
|
client: &ClientWithMiddleware,
|
||||||
disable_ssl: bool,
|
disable_ssl: bool,
|
||||||
|
|
@ -601,7 +601,7 @@ fn fetch(
|
||||||
/// Attempts to use `git` CLI installed on the system to fetch a repository.
|
/// Attempts to use `git` CLI installed on the system to fetch a repository.
|
||||||
fn fetch_with_cli(
|
fn fetch_with_cli(
|
||||||
repo: &mut GitRepository,
|
repo: &mut GitRepository,
|
||||||
url: &Url,
|
url: &DisplaySafeUrl,
|
||||||
refspecs: &[String],
|
refspecs: &[String],
|
||||||
tags: bool,
|
tags: bool,
|
||||||
disable_ssl: 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>
|
/// [^1]: <https://developer.github.com/v3/repos/commits/#get-the-sha-1-of-a-commit-reference>
|
||||||
fn github_fast_path(
|
fn github_fast_path(
|
||||||
git: &mut GitRepository,
|
git: &mut GitRepository,
|
||||||
url: &Url,
|
url: &DisplaySafeUrl,
|
||||||
reference: ReferenceOrOid<'_>,
|
reference: ReferenceOrOid<'_>,
|
||||||
client: &ClientWithMiddleware,
|
client: &ClientWithMiddleware,
|
||||||
) -> Result<FastPathRev> {
|
) -> Result<FastPathRev> {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use fs_err::tokio as fs;
|
||||||
use reqwest_middleware::ClientWithMiddleware;
|
use reqwest_middleware::ClientWithMiddleware;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
use uv_auth::KeyringProvider;
|
||||||
use uv_cache_key::{RepositoryUrl, cache_digest};
|
use uv_cache_key::{RepositoryUrl, cache_digest};
|
||||||
use uv_fs::LockedFile;
|
use uv_fs::LockedFile;
|
||||||
use uv_git_types::{GitHubRepository, GitOid, GitReference, GitUrl};
|
use uv_git_types::{GitHubRepository, GitOid, GitReference, GitUrl};
|
||||||
|
|
@ -148,6 +149,7 @@ impl GitResolver {
|
||||||
offline: bool,
|
offline: bool,
|
||||||
cache: PathBuf,
|
cache: PathBuf,
|
||||||
reporter: Option<Arc<dyn Reporter>>,
|
reporter: Option<Arc<dyn Reporter>>,
|
||||||
|
keyring_provider: Option<&KeyringProvider>,
|
||||||
) -> Result<Fetch, GitResolverError> {
|
) -> Result<Fetch, GitResolverError> {
|
||||||
debug!("Fetching source distribution from Git: {url}");
|
debug!("Fetching source distribution from Git: {url}");
|
||||||
|
|
||||||
|
|
@ -187,8 +189,9 @@ impl GitResolver {
|
||||||
source
|
source
|
||||||
};
|
};
|
||||||
|
|
||||||
let fetch = tokio::task::spawn_blocking(move || source.fetch())
|
let fetch = source
|
||||||
.await?
|
.fetch(keyring_provider)
|
||||||
|
.await
|
||||||
.map_err(GitResolverError::Git)?;
|
.map_err(GitResolverError::Git)?;
|
||||||
|
|
||||||
// Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches
|
// 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 reqwest_middleware::ClientWithMiddleware;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
|
use uv_auth::KeyringProvider;
|
||||||
use uv_cache_key::{RepositoryUrl, cache_digest};
|
use uv_cache_key::{RepositoryUrl, cache_digest};
|
||||||
use uv_git_types::{GitOid, GitReference, GitUrl};
|
use uv_git_types::{GitOid, GitReference, GitUrl};
|
||||||
use uv_redacted::DisplaySafeUrl;
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
|
@ -71,7 +72,7 @@ impl GitSource {
|
||||||
|
|
||||||
/// Fetch the underlying Git repository at the given revision.
|
/// Fetch the underlying Git repository at the given revision.
|
||||||
#[instrument(skip(self), fields(repository = %self.git.repository(), rev = ?self.git.precise()))]
|
#[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.
|
// Compute the canonical URL for the repository.
|
||||||
let canonical = RepositoryUrl::new(self.git.repository());
|
let canonical = RepositoryUrl::new(self.git.repository());
|
||||||
|
|
||||||
|
|
@ -79,24 +80,54 @@ impl GitSource {
|
||||||
let ident = cache_digest(&canonical);
|
let ident = cache_digest(&canonical);
|
||||||
let db_path = self.cache.join("db").join(&ident);
|
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.
|
// 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()))
|
Cow::Owned(credentials.apply(self.git.repository().clone()))
|
||||||
} else {
|
} else {
|
||||||
Cow::Borrowed(self.git.repository())
|
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
|
// 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.
|
// 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 git_remote = GitRemote::new(&remote);
|
||||||
let maybe_db = git_remote.db_at(&db_path).ok();
|
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
|
// If we have a locked revision, and we have a pre-existing database which has that
|
||||||
// revision, then no update needs to happen.
|
// 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) {
|
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));
|
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
|
// 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.
|
// hash. If so, treat it like it's locked.
|
||||||
if let Some(db) = &maybe_db {
|
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 let Ok(oid) = maybe_commit.parse::<GitOid>() {
|
||||||
if db.contains(oid) {
|
if db.contains(oid) {
|
||||||
// This reference is an exact commit. Treat it like it's
|
// This reference is an exact commit. Treat it like it's
|
||||||
// locked.
|
// locked.
|
||||||
debug!("Using existing Git source `{}`", self.git.repository());
|
debug!("Using existing Git source `{}`", git.repository());
|
||||||
return Ok((maybe_db.unwrap(), oid, None));
|
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
|
// ... 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
|
// for being offline here, for example in the situation that we have a locked revision
|
||||||
// but the database doesn't have it.
|
// 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.
|
// Report the checkout operation to the reporter.
|
||||||
let task = self.reporter.as_ref().map(|reporter| {
|
let task = reporter.as_ref().map(|reporter| {
|
||||||
reporter.on_checkout_start(git_remote.url(), self.git.reference().as_rev())
|
reporter.on_checkout_start(git_remote.url(), git.reference().as_rev())
|
||||||
});
|
});
|
||||||
|
|
||||||
let (db, actual_rev) = git_remote.checkout(
|
let (db, actual_rev) = git_remote.checkout(
|
||||||
&db_path,
|
&db_path,
|
||||||
maybe_db,
|
maybe_db,
|
||||||
self.git.reference(),
|
git.reference(),
|
||||||
self.git.precise(),
|
git.precise(),
|
||||||
&self.client,
|
&self.client,
|
||||||
self.disable_ssl,
|
self.disable_ssl,
|
||||||
self.offline,
|
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
|
// Don’t use the full hash, in order to contribute less to reaching the
|
||||||
// path length limit on Windows.
|
// path length limit on Windows.
|
||||||
|
|
@ -158,7 +190,7 @@ impl GitSource {
|
||||||
// Report the checkout operation to the reporter.
|
// Report the checkout operation to the reporter.
|
||||||
if let Some(task) = maybe_task {
|
if let Some(task) = maybe_task {
|
||||||
if let Some(reporter) = self.reporter.as_ref() {
|
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_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
|
||||||
use uv_configuration::{
|
use uv_configuration::{
|
||||||
Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun,
|
Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun,
|
||||||
EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions, Preview,
|
EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions,
|
||||||
PreviewFeatures, SourceStrategy,
|
KeyringProviderType, Preview, PreviewFeatures, SourceStrategy,
|
||||||
};
|
};
|
||||||
use uv_dispatch::BuildDispatch;
|
use uv_dispatch::BuildDispatch;
|
||||||
use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies};
|
use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies};
|
||||||
|
|
@ -650,7 +650,9 @@ pub(crate) async fn add(
|
||||||
&extras_of_dependency,
|
&extras_of_dependency,
|
||||||
index,
|
index,
|
||||||
&mut toml,
|
&mut toml,
|
||||||
)?;
|
settings.resolver.keyring_provider,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Validate any indexes that were provided on the command-line to ensure
|
// Validate any indexes that were provided on the command-line to ensure
|
||||||
// they point to existing non-empty directories when using path URLs.
|
// 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>,
|
requirements: Vec<Requirement>,
|
||||||
target: &AddTarget,
|
target: &AddTarget,
|
||||||
editable: Option<bool>,
|
editable: Option<bool>,
|
||||||
|
|
@ -780,6 +782,7 @@ fn edits(
|
||||||
extras: &[ExtraName],
|
extras: &[ExtraName],
|
||||||
index: Option<&IndexName>,
|
index: Option<&IndexName>,
|
||||||
toml: &mut PyProjectTomlMut,
|
toml: &mut PyProjectTomlMut,
|
||||||
|
keyring_provider: KeyringProviderType,
|
||||||
) -> Result<Vec<DependencyEdit>> {
|
) -> Result<Vec<DependencyEdit>> {
|
||||||
let mut edits = Vec::<DependencyEdit>::with_capacity(requirements.len());
|
let mut edits = Vec::<DependencyEdit>::with_capacity(requirements.len());
|
||||||
for mut requirement in requirements {
|
for mut requirement in requirements {
|
||||||
|
|
@ -853,13 +856,33 @@ fn edits(
|
||||||
extra,
|
extra,
|
||||||
group,
|
group,
|
||||||
}) => {
|
}) => {
|
||||||
let credentials = uv_auth::Credentials::from_url(&git);
|
if let Some(credentials) = uv_auth::Credentials::from_url(&git) {
|
||||||
if let Some(credentials) = credentials {
|
let username = git.username().to_string();
|
||||||
debug!("Caching credentials for: {git}");
|
// Redact the credentials from the URL.
|
||||||
GIT_STORE.insert(RepositoryUrl::new(&git), credentials);
|
|
||||||
|
|
||||||
// Redact the credentials.
|
|
||||||
git.remove_credentials();
|
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 {
|
Some(Source::Git {
|
||||||
git,
|
git,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use itertools::Itertools;
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
use uv_auth::KeyringProvider;
|
||||||
use uv_cache::Cache;
|
use uv_cache::Cache;
|
||||||
use uv_cli::SyncFormat;
|
use uv_cli::SyncFormat;
|
||||||
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
|
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
|
||||||
|
|
@ -719,7 +720,7 @@ pub(super) async fn do_sync(
|
||||||
index_locations.cache_index_credentials();
|
index_locations.cache_index_credentials();
|
||||||
|
|
||||||
// Populate credentials from the target.
|
// 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.
|
// Initialize the registry client.
|
||||||
let client = RegistryClientBuilder::try_from(client_builder)?
|
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`,
|
/// These credentials can come from any of `tool.uv.sources`, `tool.uv.dev-dependencies`,
|
||||||
/// `project.dependencies`, and `project.optional-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.
|
// Iterate over any indexes in the target.
|
||||||
for index in target.indexes() {
|
for index in target.indexes() {
|
||||||
if let Some(credentials) = index.credentials() {
|
if let Some(credentials) = index.credentials() {
|
||||||
|
|
@ -891,7 +895,7 @@ fn store_credentials_from_target(target: InstallTarget<'_>) {
|
||||||
for source in target.sources() {
|
for source in target.sources() {
|
||||||
match source {
|
match source {
|
||||||
Source::Git { git, .. } => {
|
Source::Git { git, .. } => {
|
||||||
uv_git::store_credentials_from_url(git);
|
uv_git::store_credentials_from_url(git, keyring_provider).await;
|
||||||
}
|
}
|
||||||
Source::Url { url, .. } => {
|
Source::Url { url, .. } => {
|
||||||
uv_auth::store_credentials_from_url(url);
|
uv_auth::store_credentials_from_url(url);
|
||||||
|
|
@ -907,7 +911,7 @@ fn store_credentials_from_target(target: InstallTarget<'_>) {
|
||||||
};
|
};
|
||||||
match &url.parsed_url {
|
match &url.parsed_url {
|
||||||
ParsedUrl::Git(ParsedGitUrl { 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, .. }) => {
|
ParsedUrl::Archive(ParsedArchiveUrl { url, .. }) => {
|
||||||
uv_auth::store_credentials_from_url(url);
|
uv_auth::store_credentials_from_url(url);
|
||||||
|
|
|
||||||
|
|
@ -12624,6 +12624,140 @@ fn add_package_persist_system_keyring_credentials() -> Result<()> {
|
||||||
Ok(())
|
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
|
/// 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,
|
/// credentials. In the absence of a netrc entry for the new location,
|
||||||
/// it should fail.
|
/// it should fail.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue