Add support for retrieving credentials from keyring (#2254)

<!--
Thank you for contributing to uv! To help us out with reviewing, please
consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Adds basic keyring auth support for `uv` commands. Adds clone of `pip`'s
`--keyring-provider subprocess` argument (using CLI `keyring` tool).

See issue: https://github.com/astral-sh/uv/issues/1520

## Test Plan

<!-- How was it tested? -->

Hard to write full-suite unit tests due to reliance on
`process::Command` for `keyring` cli

Manually tested end-to-end in a project with GCP artifact registry using
keyring password:
```bash
➜  uv pip uninstall watchdog
Uninstalled 1 package in 46ms
 - watchdog==4.0.0

➜  cargo run -- pip install --index-url https://<redacted>/python/simple/ --extra-index-url https://<redacted>/pypi-mirror/simple/ watchdog
    Finished dev [unoptimized + debuginfo] target(s) in 0.18s
     Running `target/debug/uv pip install --index-url 'https://<redacted>/python/simple/' --extra-index-url 'https://<redacted>/pypi-mirror/simple/' watchdog`
error: HTTP status client error (401 Unauthorized) for url (https://<redacted>/pypi-mirror/simple/watchdog/)

➜  cargo run -- pip install --keyring-provider subprocess --index-url https://<redacted>/python/simple/ --extra-index-url https://<redacted>/pypi-mirror/simple/ watchdog
    Finished dev [unoptimized + debuginfo] target(s) in 0.17s
     Running `target/debug/uv pip install --keyring-provider subprocess --index-url 'https://<redacted>/python/simple/' --extra-index-url 'https://<redacted>/pypi-mirror/simple/' watchdog`
Resolved 1 package in 2.34s
Installed 1 package in 27ms
 + watchdog==4.0.0
```

`requirements.txt`
```
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
#    .bin/generate-requirements
#
--index-url https://<redacted>/python/simple/
--extra-index-url https://<redacted>/pypi-mirror/simple/

...
```

```bash
➜  cargo run -- pip install --keyring-provider subprocess -r requirements.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.19s
     Running `target/debug/uv pip install --keyring-provider subprocess -r requirements.txt`
Resolved 205 packages in 23.52s
   Built <redacted>
   ...
Downloaded 47 packages in 19.32s
Installed 195 packages in 276ms
 + <redacted>
  ...
```

---------

Co-authored-by: Thomas Gilgenast <thomas@vant.ai>
Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Hans Baker 2024-03-13 13:02:18 -07:00 committed by GitHub
parent d4d78b0cc3
commit 9159731792
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 816 additions and 246 deletions

189
Cargo.lock generated
View file

@ -145,6 +145,16 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "assert_cmd"
version = "2.0.14"
@ -857,6 +867,24 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d7439c3735f405729d52c3fbbe4de140eaf938a1fe47d227c27f8254d4302a5"
[[package]]
name = "deadpool"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490"
dependencies = [
"async-trait",
"deadpool-runtime",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63dfa964fe2a66f3fde91fc70b267fe193d822c7e603e2a675a49a7f46ad3f49"
[[package]]
name = "derivative"
version = "2.2.0"
@ -1342,7 +1370,26 @@ dependencies = [
"futures-core",
"futures-sink",
"futures-util",
"http",
"http 0.2.12",
"indexmap 2.2.5",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h2"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http 1.1.0",
"indexmap 2.2.5",
"slab",
"tokio",
@ -1431,6 +1478,17 @@ dependencies = [
"itoa",
]
[[package]]
name = "http"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "http-body"
version = "0.4.6"
@ -1438,7 +1496,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http",
"http 0.2.12",
"pin-project-lite",
]
[[package]]
name = "http-body"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
dependencies = [
"bytes",
"http 1.1.0",
]
[[package]]
name = "http-body-util"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
dependencies = [
"bytes",
"futures-core",
"http 1.1.0",
"http-body 1.0.0",
"pin-project-lite",
]
@ -1470,9 +1551,9 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"h2 0.3.24",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa",
@ -1484,6 +1565,27 @@ dependencies = [
"want",
]
[[package]]
name = "hyper"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"h2 0.4.2",
"http 1.1.0",
"http-body 1.0.0",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
@ -1491,13 +1593,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http",
"hyper",
"http 0.2.12",
"hyper 0.14.28",
"rustls",
"tokio",
"tokio-rustls",
]
[[package]]
name = "hyper-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
dependencies = [
"bytes",
"futures-util",
"http 1.1.0",
"http-body 1.0.0",
"hyper 1.2.0",
"pin-project-lite",
"socket2",
"tokio",
]
[[package]]
name = "iana-time-zone"
version = "0.1.60"
@ -2787,10 +2905,10 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"h2 0.3.24",
"http 0.2.12",
"http-body 0.4.6",
"hyper 0.14.28",
"hyper-rustls",
"ipnet",
"js-sys",
@ -2829,7 +2947,7 @@ checksum = "88a3e86aa6053e59030e7ce2d2a3b258dd08fc2d337d52f73f6cb480f5858690"
dependencies = [
"anyhow",
"async-trait",
"http",
"http 0.2.12",
"reqwest",
"serde",
"task-local-extensions",
@ -2847,8 +2965,8 @@ dependencies = [
"chrono",
"futures",
"getrandom",
"http",
"hyper",
"http 0.2.12",
"hyper 0.14.28",
"parking_lot 0.11.2",
"reqwest",
"reqwest-middleware",
@ -4134,6 +4252,7 @@ dependencies = [
"tracing-tree",
"unicode-width",
"url",
"uv-auth",
"uv-build",
"uv-cache",
"uv-client",
@ -4155,8 +4274,20 @@ dependencies = [
name = "uv-auth"
version = "0.0.1"
dependencies = [
"async-trait",
"base64 0.21.7",
"clap",
"lazy_static",
"reqwest",
"reqwest-middleware",
"rust-netrc",
"task-local-extensions",
"tempfile",
"thiserror",
"tokio",
"tracing",
"url",
"wiremock",
]
[[package]]
@ -4217,7 +4348,6 @@ dependencies = [
"async-trait",
"async_http_range_reader",
"async_zip",
"base64 0.21.7",
"cache-key",
"chrono",
"distribution-filename",
@ -4225,8 +4355,8 @@ dependencies = [
"fs-err",
"futures",
"html-escape",
"http",
"hyper",
"http 0.2.12",
"hyper 0.14.28",
"insta",
"install-wheel-rs",
"pep440_rs",
@ -4238,7 +4368,6 @@ dependencies = [
"reqwest-retry",
"rkyv",
"rmp-serde",
"rust-netrc",
"rustc-hash",
"rustls",
"rustls-native-certs",
@ -5060,6 +5189,30 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "wiremock"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec874e1eef0df2dcac546057fe5e29186f09c378181cd7b635b4b7bcc98e9d81"
dependencies = [
"assert-json-diff",
"async-trait",
"base64 0.21.7",
"deadpool",
"futures",
"http 1.1.0",
"http-body-util",
"hyper 1.2.0",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wyz"
version = "0.5.1"

View file

@ -60,6 +60,7 @@ indicatif = { version = "0.17.7" }
indoc = { version = "2.0.4" }
itertools = { version = "0.12.1" }
junction = { version = "1.0.0" }
lazy_static = { version = "1.4.0" }
mailparse = { version = "0.14.0" }
miette = { version = "6.0.0" }
nanoid = { version = "0.4.0" }
@ -95,7 +96,7 @@ tempfile = { version = "3.9.0" }
textwrap = { version = "0.16.1" }
thiserror = { version = "1.0.56" }
tl = { version = "0.7.7" }
tokio = { version = "1.35.1", features = ["rt-multi-thread"] }
tokio = { version = "1.35.1", features = ["rt-multi-thread", "macros"] }
tokio-stream = { version = "0.1.14" }
tokio-tar = { version = "0.3.1" }
tokio-util = { version = "0.7.10", features = ["compat"] }
@ -109,6 +110,7 @@ unicode-width = { version = "0.1.11" }
unscanny = { version = "0.1.0" }
url = { version = "2.5.0" }
urlencoding = { version = "2.1.3" }
wiremock = { version = "0.6.0" }
walkdir = { version = "2.5.0" }
which = { version = "6.0.0" }
winapi = { version = "0.3.9" }

View file

@ -7,7 +7,7 @@ use thiserror::Error;
use pep440_rs::{VersionSpecifiers, VersionSpecifiersParseError};
use pypi_types::{DistInfoMetadata, Hashes, Yanked};
use url::Url;
use uv_auth::safe_copy_url_auth_to_str;
use uv_auth::AuthenticationStore;
/// Error converting [`pypi_types::File`] to [`distribution_type::File`].
#[derive(Debug, Error)]
@ -53,12 +53,10 @@ impl File {
size: file.size,
upload_time_utc_ms: file.upload_time.map(|dt| dt.timestamp_millis()),
url: if file.url.contains("://") {
let url = safe_copy_url_auth_to_str(base, &file.url)
.map_err(|err| FileConversionError::Url(file.url.clone(), err))?
.map(|url| url.to_string())
.unwrap_or(file.url);
FileLocation::AbsoluteUrl(url)
let url = Url::parse(&file.url)
.map_err(|err| FileConversionError::Url(file.url.clone(), err))?;
let url = AuthenticationStore::with_url_encoded_auth(url);
FileLocation::AbsoluteUrl(url.to_string())
} else {
FileLocation::RelativeUrl(base.to_string(), file.url)
},

View file

@ -4,5 +4,19 @@ version = "0.0.1"
edition = "2021"
[dependencies]
url = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive", "env"], optional = true }
lazy_static = { workspace = true }
reqwest = { workspace = true }
reqwest-middleware = { workspace = true }
rust-netrc = { workspace = true }
task-local-extensions = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true }
wiremock = { workspace = true }

View file

@ -0,0 +1,109 @@
use std::process::Command;
use thiserror::Error;
use tracing::debug;
use url::Url;
use crate::store::{BasicAuthData, Credential};
/// Keyring provider to use for authentication
///
/// See <https://pip.pypa.io/en/stable/topics/authentication/#keyring-support>
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
pub enum KeyringProvider {
/// Will not use keyring for authentication
#[default]
Disabled,
/// Will use keyring CLI command for authentication
Subprocess,
// /// Not yet implemented
// Auto,
// /// Not implemented yet. Maybe use <https://docs.rs/keyring/latest/keyring/> for this?
// Import,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("Url is not valid Keyring target: {0}")]
NotKeyringTarget(String),
#[error(transparent)]
CliFailure(#[from] std::io::Error),
#[error(transparent)]
ParseFailed(#[from] std::string::FromUtf8Error),
}
/// Get credentials from keyring for given url
///
/// See `pip`'s KeyringCLIProvider
/// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L102>
pub fn get_keyring_subprocess_auth(url: &Url) -> Result<Option<Credential>, Error> {
let host = url.host_str();
if host.is_none() {
return Err(Error::NotKeyringTarget(
"Should only use keyring for urls with host".to_string(),
));
}
if url.password().is_some() {
return Err(Error::NotKeyringTarget(
"Url already contains password - keyring not required".to_string(),
));
}
let username = match url.username() {
u if !u.is_empty() => u,
// this is the username keyring.get_credentials returns as username for GCP registry
_ => "oauth2accesstoken",
};
debug!(
"Running `keyring get` for `{}` with username `{}`",
url.to_string(),
username
);
let output = match Command::new("keyring")
.arg("get")
.arg(url.to_string())
.arg(username)
.output()
{
Ok(output) if output.status.success() => Ok(Some(
String::from_utf8(output.stdout)
.map_err(Error::ParseFailed)?
.trim_end()
.to_owned(),
)),
Ok(_) => Ok(None),
Err(e) => Err(Error::CliFailure(e)),
};
output.map(|password| {
password.map(|password| {
Credential::Basic(BasicAuthData {
username: username.to_string(),
password: Some(password),
})
})
})
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn hostless_url_should_err() {
let url = Url::parse("file:/etc/bin/").unwrap();
let res = get_keyring_subprocess_auth(&url);
assert!(res.is_err());
assert!(matches!(res.unwrap_err(),
Error::NotKeyringTarget(s) if s == "Should only use keyring for urls with host"));
}
#[test]
fn passworded_url_should_err() {
let url = Url::parse("https://u:p@example.com").unwrap();
let res = get_keyring_subprocess_auth(&url);
assert!(res.is_err());
assert!(matches!(res.unwrap_err(),
Error::NotKeyringTarget(s) if s == "Url already contains password - keyring not required"));
}
}

View file

@ -1,170 +1,132 @@
/// HTTP authentication utilities.
use tracing::warn;
mod keyring;
mod middleware;
mod store;
pub use keyring::KeyringProvider;
pub use middleware::AuthMiddleware;
pub use store::AuthenticationStore;
use url::Url;
/// Optimized version of [`safe_copy_url_auth`] which avoids parsing a string
/// into a URL unless the given URL has authentication to copy. Useful for patterns
/// where the returned URL would immediately be cast into a string.
///
/// Returns [`Err`] if there is authentication to copy and `new_url` is not a valid URL.
/// Returns [`None`] if there is no authentication to copy.
pub fn safe_copy_url_auth_to_str(
trusted_url: &Url,
new_url: &str,
) -> Result<Option<Url>, url::ParseError> {
if trusted_url.username().is_empty() && trusted_url.password().is_none() {
return Ok(None);
}
let new_url = Url::parse(new_url)?;
Ok(Some(safe_copy_url_auth(trusted_url, new_url)))
}
/// Copy authentication from one URL to another URL if applicable.
///
/// See [`should_retain_auth`] for details on when authentication is retained.
#[must_use]
pub fn safe_copy_url_auth(trusted_url: &Url, mut new_url: Url) -> Url {
if should_retain_auth(trusted_url, &new_url) {
new_url
.set_username(trusted_url.username())
.unwrap_or_else(|()| warn!("Failed to transfer username to response URL: {new_url}"));
new_url
.set_password(trusted_url.password())
.unwrap_or_else(|()| warn!("Failed to transfer password to response URL: {new_url}"));
}
new_url
}
/// Determine if authentication information should be retained on a new URL.
/// Implements the specification defined in RFC 7235 and 7230.
/// Used to determine if authentication information should be retained on a new URL.
/// Based on the specification defined in RFC 7235 and 7230.
///
/// <https://datatracker.ietf.org/doc/html/rfc7235#section-2.2>
/// <https://datatracker.ietf.org/doc/html/rfc7230#section-5.5>
fn should_retain_auth(trusted_url: &Url, new_url: &Url) -> bool {
// The "scheme" and "authority" components must match to retain authentication
// The "authority", is composed of the host and port.
//
// The "scheme" and "authority" components must match to retain authentication
// The "authority", is composed of the host and port.
//
// The scheme must always be an exact match.
// Note some clients such as Python's `requests` library allow an upgrade
// from `http` to `https` but this is not spec-compliant.
// <https://github.com/pypa/pip/blob/75f54cae9271179b8cc80435f92336c97e349f9d/src/pip/_vendor/requests/sessions.py#L133-L136>
//
// The host must always be an exact match.
//
// The port is only allowed to differ if it it matches the "default port" for the scheme.
// However, `url` (and therefore `reqwest`) sets the `port` to `None` if it matches the default port
// so we do not need any special handling here.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct NetLoc {
scheme: String,
host: Option<String>,
port: Option<u16>,
}
// Check the scheme.
// The scheme must always be an exact match.
// Note some clients such as Python's `requests` library allow an upgrade
// from `http` to `https` but this is not spec-compliant.
// <https://github.com/pypa/pip/blob/75f54cae9271179b8cc80435f92336c97e349f9d/src/pip/_vendor/requests/sessions.py#L133-L136>
if trusted_url.scheme() != new_url.scheme() {
return false;
impl From<&Url> for NetLoc {
fn from(url: &Url) -> Self {
Self {
scheme: url.scheme().to_string(),
host: url.host_str().map(str::to_string),
port: url.port(),
}
}
// The host must always be an exact match.
if trusted_url.host() != new_url.host() {
return false;
}
// Check the port.
// The port is only allowed to differ if it it matches the "default port" for the scheme.
// However, `reqwest` sets the `port` to `None` if it matches the default port so we do
// not need any special handling here.
if trusted_url.port() != new_url.port() {
return false;
}
true
}
#[cfg(test)]
mod tests {
use url::{ParseError, Url};
use crate::should_retain_auth;
use crate::NetLoc;
#[test]
fn test_should_retain_auth() -> Result<(), ParseError> {
// Exact match (https)
assert!(should_retain_auth(
&Url::parse("https://example.com")?,
&Url::parse("https://example.com")?,
));
assert_eq!(
NetLoc::from(&Url::parse("https://example.com")?),
NetLoc::from(&Url::parse("https://example.com")?)
);
// Exact match (with port)
assert!(should_retain_auth(
&Url::parse("https://example.com:1234")?,
&Url::parse("https://example.com:1234")?,
));
assert_eq!(
NetLoc::from(&Url::parse("https://example.com:1234")?),
NetLoc::from(&Url::parse("https://example.com:1234")?)
);
// Exact match (http)
assert!(should_retain_auth(
&Url::parse("http://example.com")?,
&Url::parse("http://example.com")?,
));
assert_eq!(
NetLoc::from(&Url::parse("http://example.com")?),
NetLoc::from(&Url::parse("http://example.com")?)
);
// Okay, path differs
assert!(should_retain_auth(
&Url::parse("http://example.com/foo")?,
&Url::parse("http://example.com/bar")?,
));
assert_eq!(
NetLoc::from(&Url::parse("http://example.com/foo")?),
NetLoc::from(&Url::parse("http://example.com/bar")?)
);
// Okay, default port differs (https)
assert!(should_retain_auth(
&Url::parse("https://example.com:443")?,
&Url::parse("https://example.com")?,
));
assert!(should_retain_auth(
&Url::parse("https://example.com")?,
&Url::parse("https://example.com:443")?,
));
assert_eq!(
NetLoc::from(&Url::parse("https://example.com:443")?),
NetLoc::from(&Url::parse("https://example.com")?)
);
// Okay, default port differs (http)
assert!(should_retain_auth(
&Url::parse("http://example.com:80")?,
&Url::parse("http://example.com")?,
));
assert!(should_retain_auth(
&Url::parse("http://example.com")?,
&Url::parse("http://example.com:80")?,
));
assert_eq!(
NetLoc::from(&Url::parse("http://example.com:80")?),
NetLoc::from(&Url::parse("http://example.com")?)
);
// Mismatched scheme
assert!(!should_retain_auth(
&Url::parse("https://example.com")?,
&Url::parse("http://example.com")?,
));
assert_ne!(
NetLoc::from(&Url::parse("https://example.com")?),
NetLoc::from(&Url::parse("http://example.com")?)
);
// Mismatched scheme, we explicitly do not allow upgrade to https
assert!(!should_retain_auth(
&Url::parse("http://example.com")?,
&Url::parse("https://example.com")?,
));
assert_ne!(
NetLoc::from(&Url::parse("http://example.com")?),
NetLoc::from(&Url::parse("https://example.com")?)
);
// Mismatched host
assert!(!should_retain_auth(
&Url::parse("https://foo.com")?,
&Url::parse("https://bar.com")?,
));
assert_ne!(
NetLoc::from(&Url::parse("https://foo.com")?),
NetLoc::from(&Url::parse("https://bar.com")?)
);
// Mismatched port
assert!(!should_retain_auth(
&Url::parse("https://example.com:1234")?,
&Url::parse("https://example.com:5678")?,
));
assert_ne!(
NetLoc::from(&Url::parse("https://example.com:1234")?),
NetLoc::from(&Url::parse("https://example.com:5678")?)
);
// Mismatched port, with one as default for scheme
assert!(!should_retain_auth(
&Url::parse("https://example.com:443")?,
&Url::parse("https://example.com:5678")?,
));
assert!(!should_retain_auth(
&Url::parse("https://example.com:1234")?,
&Url::parse("https://example.com:443")?,
));
assert_ne!(
NetLoc::from(&Url::parse("https://example.com:443")?),
NetLoc::from(&Url::parse("https://example.com:5678")?)
);
assert_ne!(
NetLoc::from(&Url::parse("https://example.com:1234")?),
NetLoc::from(&Url::parse("https://example.com:443")?)
);
// Mismatched port, with default for a different scheme
assert!(!should_retain_auth(
&Url::parse("https://example.com")?,
&Url::parse("https://example.com:80")?,
));
assert!(!should_retain_auth(
&Url::parse("https://example.com:80")?,
&Url::parse("https://example.com")?,
));
assert_ne!(
NetLoc::from(&Url::parse("https://example.com:80")?),
NetLoc::from(&Url::parse("https://example.com")?)
);
Ok(())
}

View file

@ -0,0 +1,183 @@
use netrc::Netrc;
use reqwest::{header::HeaderValue, Request, Response};
use reqwest_middleware::{Middleware, Next};
use std::path::Path;
use task_local_extensions::Extensions;
use tracing::{debug, warn};
use crate::{
keyring::{get_keyring_subprocess_auth, KeyringProvider},
store::{AuthenticationStore, Credential},
};
/// A middleware that adds basic authentication to requests based on the netrc file and the keyring.
///
/// Netrc support Based on: <https://github.com/gribouille/netrc>.
pub struct AuthMiddleware {
nrc: Option<Netrc>,
keyring_provider: KeyringProvider,
}
impl AuthMiddleware {
pub fn new(keyring_provider: KeyringProvider) -> Self {
Self {
nrc: Netrc::new().ok(),
keyring_provider,
}
}
pub fn from_netrc_file(file: &Path, keyring_provider: KeyringProvider) -> Self {
Self {
nrc: Netrc::from_file(file).ok(),
keyring_provider,
}
}
}
#[async_trait::async_trait]
impl Middleware for AuthMiddleware {
async fn handle(
&self,
mut req: Request,
_extensions: &mut Extensions,
next: Next<'_>,
) -> reqwest_middleware::Result<Response> {
let url = req.url().clone();
// If the request already has an authorization header, we don't need to do anything.
// This gives in-URL credentials precedence over the netrc file.
if req.headers().contains_key(reqwest::header::AUTHORIZATION) {
if !url.username().is_empty() {
AuthenticationStore::save_from_url(&url);
}
return next.run(req, _extensions).await;
}
// Try auth strategies in order of precedence:
if let Some(stored_auth) = AuthenticationStore::get(&url) {
// If we've already seen this URL, we can use the stored credentials
if let Some(auth) = stored_auth {
match auth {
Credential::Basic(_) => {
req.headers_mut().insert(
reqwest::header::AUTHORIZATION,
basic_auth(auth.username(), auth.password()),
);
}
// Url must already have auth if before middleware runs - see `AuthenticationStore::with_url_encoded_auth`
Credential::UrlEncoded(_) => (),
}
}
} else if let Some(auth) = self.nrc.as_ref().and_then(|nrc| {
// If we find a matching entry in the netrc file, we can use it
url.host_str()
.and_then(|host| nrc.hosts.get(host).or_else(|| nrc.hosts.get("default")))
}) {
let auth = Credential::from(auth.to_owned());
req.headers_mut().insert(
reqwest::header::AUTHORIZATION,
basic_auth(auth.username(), auth.password()),
);
AuthenticationStore::set(&url, Some(auth));
} else if matches!(self.keyring_provider, KeyringProvider::Subprocess) {
// If we have keyring support enabled, we check there as well
match get_keyring_subprocess_auth(&url) {
Ok(Some(auth)) => {
req.headers_mut().insert(
reqwest::header::AUTHORIZATION,
basic_auth(auth.username(), auth.password()),
);
AuthenticationStore::set(&url, Some(auth));
}
Ok(None) => {
debug!("No keyring credentials found for {url}");
}
Err(e) => {
warn!("Failed to get keyring credentials for {url}: {e}");
}
}
}
// If we still don't have any credentials, we save the URL so we don't have to check netrc or keyring again
if !req.headers().contains_key(reqwest::header::AUTHORIZATION) {
AuthenticationStore::set(&url, None);
}
next.run(req, _extensions).await
}
}
/// Create a `HeaderValue` for basic authentication.
///
/// Source: <https://github.com/seanmonstar/reqwest/blob/2c11ef000b151c2eebeed2c18a7b81042220c6b0/src/util.rs#L3>
fn basic_auth<U, P>(username: U, password: Option<P>) -> HeaderValue
where
U: std::fmt::Display,
P: std::fmt::Display,
{
use base64::prelude::BASE64_STANDARD;
use base64::write::EncoderWriter;
use std::io::Write;
let mut buf = b"Basic ".to_vec();
{
let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD);
let _ = write!(encoder, "{}:", username);
if let Some(password) = password {
let _ = write!(encoder, "{}", password);
}
}
let mut header = HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue");
header.set_sensitive(true);
header
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::Client;
use reqwest_middleware::ClientBuilder;
use std::io::Write;
use tempfile::NamedTempFile;
use wiremock::matchers::{basic_auth, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const NETRC: &str = r#"default login myuser password mypassword"#;
#[tokio::test]
async fn test_init() -> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hello"))
.and(basic_auth("myuser", "mypassword"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let status = ClientBuilder::new(Client::builder().build()?)
.build()
.get(format!("{}/hello", &server.uri()))
.send()
.await?
.status();
assert_eq!(status, 404);
let mut netrc_file = NamedTempFile::new()?;
writeln!(netrc_file, "{}", NETRC)?;
let status = ClientBuilder::new(Client::builder().build()?)
.with(AuthMiddleware::from_netrc_file(
netrc_file.path(),
KeyringProvider::Disabled,
))
.build()
.get(format!("{}/hello", &server.uri()))
.send()
.await?
.status();
assert_eq!(status, 200);
Ok(())
}
}

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

@ -0,0 +1,175 @@
use lazy_static::lazy_static;
use std::{collections::HashMap, sync::Mutex};
use netrc::Authenticator;
use tracing::warn;
use url::Url;
use crate::NetLoc;
lazy_static! {
// Store credentials for NetLoc
static ref PASSWORDS: Mutex<HashMap<NetLoc, Option<Credential>>> = Mutex::new(HashMap::new());
}
#[derive(Clone, Debug, PartialEq)]
pub enum Credential {
Basic(BasicAuthData),
UrlEncoded(UrlAuthData),
}
impl Credential {
pub fn username(&self) -> &str {
match self {
Credential::Basic(auth) => &auth.username,
Credential::UrlEncoded(auth) => &auth.username,
}
}
pub fn password(&self) -> Option<&str> {
match self {
Credential::Basic(auth) => auth.password.as_deref(),
Credential::UrlEncoded(auth) => auth.password.as_deref(),
}
}
}
impl From<Authenticator> for Credential {
fn from(auth: Authenticator) -> Self {
Credential::Basic(BasicAuthData {
username: auth.login,
password: Some(auth.password),
})
}
}
// Used for URL encoded auth in User info
// <https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1>
#[derive(Clone, Debug, PartialEq)]
pub struct UrlAuthData {
pub username: String,
pub password: Option<String>,
}
impl UrlAuthData {
pub fn apply_to_url(&self, mut url: Url) -> Url {
url.set_username(&self.username)
.unwrap_or_else(|()| warn!("Failed to set username"));
url.set_password(self.password.as_deref())
.unwrap_or_else(|()| warn!("Failed to set password"));
url
}
}
// HttpBasicAuth - Used for netrc and keyring auth
// <https://datatracker.ietf.org/doc/html/rfc7617>
#[derive(Clone, Debug, PartialEq)]
pub struct BasicAuthData {
pub username: String,
pub password: Option<String>,
}
pub struct AuthenticationStore;
impl AuthenticationStore {
pub fn get(url: &Url) -> Option<Option<Credential>> {
let netloc = NetLoc::from(url);
let passwords = PASSWORDS.lock().unwrap();
passwords.get(&netloc).cloned()
}
pub fn set(url: &Url, auth: Option<Credential>) {
let netloc = NetLoc::from(url);
let mut passwords = PASSWORDS.lock().unwrap();
passwords.insert(netloc, auth);
}
/// Copy authentication from one URL to another URL if applicable.
pub fn with_url_encoded_auth(url: Url) -> Url {
let netloc = NetLoc::from(&url);
let passwords = PASSWORDS.lock().unwrap();
if let Some(Some(Credential::UrlEncoded(url_auth))) = passwords.get(&netloc) {
url_auth.apply_to_url(url)
} else {
url
}
}
pub fn save_from_url(url: &Url) {
let netloc = NetLoc::from(url);
let mut passwords = PASSWORDS.lock().unwrap();
if url.username().is_empty() {
// No credentials to save
return;
}
let auth = UrlAuthData {
username: url.username().to_string(),
password: url.password().map(str::to_string),
};
passwords.insert(netloc, Some(Credential::UrlEncoded(auth)));
}
}
#[cfg(test)]
mod test {
use super::*;
// NOTE: Because tests run in parallel, it is imperative to use different URLs for each
#[test]
fn set_get_work() {
let url = Url::parse("https://test1example1.com/simple/").unwrap();
let not_set_res = AuthenticationStore::get(&url);
assert!(not_set_res.is_none());
let found_first_url = Url::parse("https://test1example2.com/simple/first/").unwrap();
let not_found_first_url = Url::parse("https://test1example3.com/simple/first/").unwrap();
AuthenticationStore::set(
&found_first_url,
Some(Credential::Basic(BasicAuthData {
username: "u".to_string(),
password: Some("p".to_string()),
})),
);
AuthenticationStore::set(&not_found_first_url, None);
let found_second_url = Url::parse("https://test1example2.com/simple/second/").unwrap();
let not_found_second_url = Url::parse("https://test1example3.com/simple/second/").unwrap();
let found_res = AuthenticationStore::get(&found_second_url);
assert!(found_res.is_some());
let found_res = found_res.unwrap();
assert!(matches!(found_res, Some(Credential::Basic(_))));
let not_found_res = AuthenticationStore::get(&not_found_second_url);
assert!(not_found_res.is_some());
let not_found_res = not_found_res.unwrap();
assert!(not_found_res.is_none());
}
#[test]
fn with_url_encoded_auth_works() {
let url = Url::parse("https://test2example.com/simple/").unwrap();
let auth = Credential::UrlEncoded(UrlAuthData {
username: "u".to_string(),
password: Some("p".to_string()),
});
AuthenticationStore::set(&url, Some(auth.clone()));
let url = AuthenticationStore::with_url_encoded_auth(url);
assert_eq!(url.username(), "u");
assert_eq!(url.password(), Some("p"));
}
#[test]
fn save_from_url_works() {
let url = Url::parse("https://u:p@test3example.com/simple/").unwrap();
AuthenticationStore::save_from_url(&url);
let found_res = AuthenticationStore::get(&url);
assert!(found_res.is_some());
let found_res = found_res.unwrap();
assert!(matches!(found_res, Some(Credential::UrlEncoded(_))));
}
}

View file

@ -23,7 +23,6 @@ anyhow = { workspace = true }
async-trait = { workspace = true }
async_http_range_reader = { workspace = true }
async_zip = { workspace = true, features = ["tokio"] }
base64 = { workspace = true }
chrono = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] }
futures = { workspace = true }
@ -34,7 +33,6 @@ reqwest-middleware = { workspace = true }
reqwest-retry = { workspace = true }
rkyv = { workspace = true, features = ["strict", "validation"] }
rmp-serde = { workspace = true }
rust-netrc = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -17,7 +17,7 @@ use pep440_rs::Version;
use pep508_rs::VerbatimUrl;
use platform_tags::Tags;
use pypi_types::Hashes;
use uv_auth::safe_copy_url_auth;
use uv_auth::AuthenticationStore;
use uv_cache::{Cache, CacheBucket};
use uv_normalize::PackageName;
@ -157,13 +157,13 @@ impl<'a> FlatIndexClient<'a> {
async {
// Use the response URL, rather than the request URL, as the base for relative URLs.
// This ensures that we handle redirects and other URL transformations correctly.
let url = safe_copy_url_auth(url, response.url().clone());
let url = AuthenticationStore::with_url_encoded_auth(response.url().clone());
let text = response.text().await.map_err(ErrorKind::from)?;
let SimpleHtml { base, files } = SimpleHtml::parse(&text, &url)
.map_err(|err| Error::from_html_err(err, url.clone()))?;
let base = safe_copy_url_auth(&url, base.into_url());
let base = AuthenticationStore::with_url_encoded_auth(base.into_url());
let files: Vec<File> = files
.into_iter()
.filter_map(|file| {

View file

@ -1,7 +1,5 @@
use std::fmt::Debug;
use http::HeaderValue;
use netrc::{Netrc, Result};
use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use task_local_extensions::Extensions;
@ -47,77 +45,3 @@ impl Middleware for OfflineMiddleware {
))
}
}
/// A middleware with support for netrc files.
///
/// Based on: <https://github.com/gribouille/netrc>.
pub(crate) struct NetrcMiddleware {
nrc: Netrc,
}
impl NetrcMiddleware {
pub(crate) fn new() -> Result<Self> {
Netrc::new().map(|nrc| NetrcMiddleware { nrc })
}
}
#[async_trait::async_trait]
impl Middleware for NetrcMiddleware {
async fn handle(
&self,
mut req: Request,
_extensions: &mut Extensions,
next: Next<'_>,
) -> reqwest_middleware::Result<Response> {
// If the request already has an authorization header, we don't need to do anything.
// This gives in-URL credentials precedence over the netrc file.
if req.headers().contains_key(reqwest::header::AUTHORIZATION) {
return next.run(req, _extensions).await;
}
if let Some(auth) = req.url().host_str().and_then(|host| {
self.nrc
.hosts
.get(host)
.or_else(|| self.nrc.hosts.get("default"))
}) {
req.headers_mut().insert(
reqwest::header::AUTHORIZATION,
basic_auth(
&auth.login,
if auth.password.is_empty() {
None
} else {
Some(&auth.password)
},
),
);
}
next.run(req, _extensions).await
}
}
/// Create a `HeaderValue` for basic authentication.
///
/// Source: <https://github.com/seanmonstar/reqwest/blob/2c11ef000b151c2eebeed2c18a7b81042220c6b0/src/util.rs#L3>
fn basic_auth<U, P>(username: U, password: Option<P>) -> HeaderValue
where
U: std::fmt::Display,
P: std::fmt::Display,
{
use base64::prelude::BASE64_STANDARD;
use base64::write::EncoderWriter;
use std::io::Write;
let mut buf = b"Basic ".to_vec();
{
let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD);
let _ = write!(encoder, "{}:", username);
if let Some(password) = password {
let _ = write!(encoder, "{}", password);
}
}
let mut header = HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue");
header.set_sensitive(true);
header
}

View file

@ -21,7 +21,7 @@ use distribution_types::{BuiltDist, File, FileLocation, IndexUrl, IndexUrls, Nam
use install_wheel_rs::metadata::{find_archive_dist_info, is_metadata_entry};
use pep440_rs::Version;
use pypi_types::{Metadata23, SimpleJson};
use uv_auth::safe_copy_url_auth;
use uv_auth::{AuthMiddleware, AuthenticationStore, KeyringProvider};
use uv_cache::{Cache, CacheBucket, WheelCache};
use uv_fs::Simplified;
use uv_normalize::PackageName;
@ -30,7 +30,7 @@ use uv_warnings::warn_user_once;
use crate::cached_client::CacheControl;
use crate::html::SimpleHtml;
use crate::middleware::{NetrcMiddleware, OfflineMiddleware};
use crate::middleware::OfflineMiddleware;
use crate::remote_metadata::wheel_metadata_from_remote_zip;
use crate::rkyvutil::OwnedArchive;
use crate::tls::Roots;
@ -40,6 +40,7 @@ use crate::{tls, CachedClient, CachedClientError, Error, ErrorKind};
#[derive(Debug, Clone)]
pub struct RegistryClientBuilder {
index_urls: IndexUrls,
keyring_provider: KeyringProvider,
native_tls: bool,
retries: u32,
connectivity: Connectivity,
@ -51,6 +52,7 @@ impl RegistryClientBuilder {
pub fn new(cache: Cache) -> Self {
Self {
index_urls: IndexUrls::default(),
keyring_provider: KeyringProvider::default(),
native_tls: false,
cache,
connectivity: Connectivity::Online,
@ -67,6 +69,12 @@ impl RegistryClientBuilder {
self
}
#[must_use]
pub fn keyring_provider(mut self, keyring_provider: KeyringProvider) -> Self {
self.keyring_provider = keyring_provider;
self
}
#[must_use]
pub fn connectivity(mut self, connectivity: Connectivity) -> Self {
self.connectivity = connectivity;
@ -159,12 +167,8 @@ impl RegistryClientBuilder {
let retry_strategy = RetryTransientMiddleware::new_with_policy(retry_policy);
let client = client.with(retry_strategy);
// Initialize the netrc middleware.
let client = if let Ok(netrc) = NetrcMiddleware::new() {
client.with(netrc)
} else {
client
};
// Initialize the authentication middleware to set headers.
let client = client.with(AuthMiddleware::new(self.keyring_provider));
client.build()
}
@ -313,7 +317,7 @@ impl RegistryClient {
async {
// Use the response URL, rather than the request URL, as the base for relative URLs.
// This ensures that we handle redirects and other URL transformations correctly.
let url = safe_copy_url_auth(&url, response.url().clone());
let url = AuthenticationStore::with_url_encoded_auth(response.url().clone());
let content_type = response
.headers()
@ -342,7 +346,7 @@ impl RegistryClient {
let text = response.text().await.map_err(ErrorKind::from)?;
let SimpleHtml { base, files } = SimpleHtml::parse(&text, &url)
.map_err(|err| Error::from_html_err(err, url.clone()))?;
let base = safe_copy_url_auth(&url, base.into_url());
let base = AuthenticationStore::with_url_encoded_auth(base.into_url());
SimpleMetadata::from_files(files, package_name, &base)
}

View file

@ -22,6 +22,7 @@ pep508_rs = { path = "../pep508-rs" }
platform-tags = { path = "../platform-tags" }
pypi-types = { path = "../pypi-types" }
requirements-txt = { path = "../requirements-txt", features = ["reqwest"] }
uv-auth = { path = "../uv-auth", features = ["clap"] }
uv-build = { path = "../uv-build" }
uv-cache = { path = "../uv-cache", features = ["clap"] }
uv-client = { path = "../uv-client" }

View file

@ -18,6 +18,7 @@ use tracing::debug;
use distribution_types::{IndexLocations, LocalEditable, Verbatim};
use platform_tags::Tags;
use requirements_txt::EditableRequirement;
use uv_auth::KeyringProvider;
use uv_cache::Cache;
use uv_client::{Connectivity, FlatIndex, FlatIndexClient, RegistryClientBuilder};
use uv_dispatch::BuildDispatch;
@ -58,6 +59,7 @@ pub(crate) async fn pip_compile(
include_index_url: bool,
include_find_links: bool,
index_locations: IndexLocations,
keyring_provider: KeyringProvider,
setup_py: SetupPyStrategy,
config_settings: ConfigSettings,
connectivity: Connectivity,
@ -190,6 +192,7 @@ pub(crate) async fn pip_compile(
.native_tls(native_tls)
.connectivity(connectivity)
.index_urls(index_locations.index_urls())
.keyring_provider(keyring_provider)
.build();
// Resolve the flat indexes from `--find-links`.

View file

@ -20,6 +20,7 @@ use pep508_rs::{MarkerEnvironment, Requirement};
use platform_tags::Tags;
use pypi_types::Yanked;
use requirements_txt::EditableRequirement;
use uv_auth::KeyringProvider;
use uv_cache::Cache;
use uv_client::{Connectivity, FlatIndex, FlatIndexClient, RegistryClient, RegistryClientBuilder};
use uv_dispatch::BuildDispatch;
@ -54,6 +55,7 @@ pub(crate) async fn pip_install(
dependency_mode: DependencyMode,
upgrade: Upgrade,
index_locations: IndexLocations,
keyring_provider: KeyringProvider,
reinstall: &Reinstall,
link_mode: LinkMode,
compile: bool,
@ -184,6 +186,7 @@ pub(crate) async fn pip_install(
.native_tls(native_tls)
.connectivity(connectivity)
.index_urls(index_locations.index_urls())
.keyring_provider(keyring_provider)
.build();
// Resolve the flat indexes from `--find-links`.

View file

@ -10,6 +10,7 @@ use install_wheel_rs::linker::LinkMode;
use platform_tags::Tags;
use pypi_types::Yanked;
use requirements_txt::EditableRequirement;
use uv_auth::KeyringProvider;
use uv_cache::{ArchiveTarget, ArchiveTimestamp, Cache};
use uv_client::{Connectivity, FlatIndex, FlatIndexClient, RegistryClient, RegistryClientBuilder};
use uv_dispatch::BuildDispatch;
@ -34,6 +35,7 @@ pub(crate) async fn pip_sync(
link_mode: LinkMode,
compile: bool,
index_locations: IndexLocations,
keyring_provider: KeyringProvider,
setup_py: SetupPyStrategy,
connectivity: Connectivity,
config_settings: &ConfigSettings,
@ -118,6 +120,7 @@ pub(crate) async fn pip_sync(
.native_tls(native_tls)
.connectivity(connectivity)
.index_urls(index_locations.index_urls())
.keyring_provider(keyring_provider)
.build();
// Resolve the flat indexes from `--find-links`.

View file

@ -13,6 +13,7 @@ use thiserror::Error;
use distribution_types::{DistributionMetadata, IndexLocations, Name};
use pep508_rs::Requirement;
use uv_auth::KeyringProvider;
use uv_cache::Cache;
use uv_client::{Connectivity, FlatIndex, FlatIndexClient, RegistryClientBuilder};
use uv_dispatch::BuildDispatch;
@ -32,6 +33,7 @@ pub(crate) async fn venv(
path: &Path,
python_request: Option<&str>,
index_locations: &IndexLocations,
keyring_provider: KeyringProvider,
prompt: uv_virtualenv::Prompt,
system_site_packages: bool,
connectivity: Connectivity,
@ -44,6 +46,7 @@ pub(crate) async fn venv(
path,
python_request,
index_locations,
keyring_provider,
prompt,
system_site_packages,
connectivity,
@ -87,6 +90,7 @@ async fn venv_impl(
path: &Path,
python_request: Option<&str>,
index_locations: &IndexLocations,
keyring_provider: KeyringProvider,
prompt: uv_virtualenv::Prompt,
system_site_packages: bool,
connectivity: Connectivity,
@ -136,6 +140,7 @@ async fn venv_impl(
// Instantiate a client.
let client = RegistryClientBuilder::new(cache.clone())
.index_urls(index_locations.index_urls())
.keyring_provider(keyring_provider)
.connectivity(connectivity)
.build();

View file

@ -14,6 +14,7 @@ use tracing::instrument;
use distribution_types::{FlatIndexLocation, IndexLocations, IndexUrl};
use requirements::ExtrasSpecification;
use uv_auth::KeyringProvider;
use uv_cache::{Cache, CacheArgs, Refresh};
use uv_client::Connectivity;
use uv_installer::{NoBinary, Reinstall};
@ -358,6 +359,13 @@ struct PipCompileArgs {
#[clap(long, conflicts_with = "index_url", conflicts_with = "extra_index_url")]
no_index: bool,
/// Attempt to use `keyring` for authentication for index urls
///
/// Due to not having Python imports, only `--keyring-provider subprocess` argument is currently
/// implemented `uv` will try to use `keyring` via CLI when this flag is used.
#[clap(long, default_value_t, value_enum, env = "UV_KEYRING_PROVIDER")]
keyring_provider: KeyringProvider,
/// Locations to search for candidate distributions, beyond those found in the indexes.
///
/// If a path, the target must be a directory that contains package as wheel files (`.whl`) or
@ -525,6 +533,13 @@ struct PipSyncArgs {
#[clap(long, conflicts_with = "index_url", conflicts_with = "extra_index_url")]
no_index: bool,
/// Attempt to use `keyring` for authentication for index urls
///
/// Function's similar to `pip`'s `--keyring-provider subprocess` argument,
/// `uv` will try to use `keyring` via CLI when this flag is used.
#[clap(long, default_value_t, value_enum, env = "UV_KEYRING_PROVIDER")]
keyring_provider: KeyringProvider,
/// The Python interpreter into which packages should be installed.
///
/// By default, `uv` installs into the virtual environment in the current working directory or
@ -776,6 +791,13 @@ struct PipInstallArgs {
#[clap(long, conflicts_with = "index_url", conflicts_with = "extra_index_url")]
no_index: bool,
/// Attempt to use `keyring` for authentication for index urls
///
/// Due to not having Python imports, only `--keyring-provider subprocess` argument is currently
/// implemented `uv` will try to use `keyring` via CLI when this flag is used.
#[clap(long, default_value_t, value_enum, env = "UV_KEYRING_PROVIDER")]
keyring_provider: KeyringProvider,
/// The Python interpreter into which packages should be installed.
///
/// By default, `uv` installs into the virtual environment in the current working directory or
@ -1218,6 +1240,13 @@ struct VenvArgs {
#[clap(long, conflicts_with = "index_url", conflicts_with = "extra_index_url")]
no_index: bool,
/// Attempt to use `keyring` for authentication for index urls
///
/// Due to not having Python imports, only `--keyring-provider subprocess` argument is currently
/// implemented `uv` will try to use `keyring` via CLI when this flag is used.
#[clap(long, default_value_t, value_enum, env = "UV_KEYRING_PROVIDER")]
keyring_provider: uv_auth::KeyringProvider,
/// Run offline, i.e., without accessing the network.
#[arg(global = true, long)]
offline: bool,
@ -1424,6 +1453,7 @@ async fn run() -> Result<ExitStatus> {
args.emit_index_url,
args.emit_find_links,
index_urls,
args.keyring_provider,
setup_py,
config_settings,
if args.offline {
@ -1479,6 +1509,7 @@ async fn run() -> Result<ExitStatus> {
args.link_mode,
args.compile,
index_urls,
args.keyring_provider,
setup_py,
if args.offline {
Connectivity::Offline
@ -1571,6 +1602,7 @@ async fn run() -> Result<ExitStatus> {
dependency_mode,
upgrade,
index_urls,
args.keyring_provider,
&reinstall,
args.link_mode,
args.compile,
@ -1694,6 +1726,7 @@ async fn run() -> Result<ExitStatus> {
&args.name,
args.python.as_deref(),
&index_locations,
args.keyring_provider,
uv_virtualenv::Prompt::from_args(prompt),
args.system_site_packages,
if args.offline {