From 91597317920f8c253c8ddb84846719db92f128a4 Mon Sep 17 00:00:00 2001 From: Hans Baker Date: Wed, 13 Mar 2024 13:02:18 -0700 Subject: [PATCH] Add support for retrieving credentials from `keyring` (#2254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary 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 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:///python/simple/ --extra-index-url https:///pypi-mirror/simple/ watchdog Finished dev [unoptimized + debuginfo] target(s) in 0.18s Running `target/debug/uv pip install --index-url 'https:///python/simple/' --extra-index-url 'https:///pypi-mirror/simple/' watchdog` error: HTTP status client error (401 Unauthorized) for url (https:///pypi-mirror/simple/watchdog/) ➜ cargo run -- pip install --keyring-provider subprocess --index-url https:///python/simple/ --extra-index-url https:///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:///python/simple/' --extra-index-url 'https:///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:///python/simple/ --extra-index-url https:///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 ... Downloaded 47 packages in 19.32s Installed 195 packages in 276ms + ... ``` --------- Co-authored-by: Thomas Gilgenast Co-authored-by: Zanie Blue --- Cargo.lock | 189 ++++++++++++++++++-- Cargo.toml | 4 +- crates/distribution-types/src/file.rs | 12 +- crates/uv-auth/Cargo.toml | 16 +- crates/uv-auth/src/keyring.rs | 109 ++++++++++++ crates/uv-auth/src/lib.rs | 218 ++++++++++-------------- crates/uv-auth/src/middleware.rs | 183 ++++++++++++++++++++ crates/uv-auth/src/store.rs | 175 +++++++++++++++++++ crates/uv-client/Cargo.toml | 2 - crates/uv-client/src/flat_index.rs | 6 +- crates/uv-client/src/middleware.rs | 76 --------- crates/uv-client/src/registry_client.rs | 24 +-- crates/uv/Cargo.toml | 1 + crates/uv/src/commands/pip_compile.rs | 3 + crates/uv/src/commands/pip_install.rs | 3 + crates/uv/src/commands/pip_sync.rs | 3 + crates/uv/src/commands/venv.rs | 5 + crates/uv/src/main.rs | 33 ++++ 18 files changed, 816 insertions(+), 246 deletions(-) create mode 100644 crates/uv-auth/src/keyring.rs create mode 100644 crates/uv-auth/src/middleware.rs create mode 100644 crates/uv-auth/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index a58cb2a6e..b02e64a60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 316ea17d3..b8e95d1e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/distribution-types/src/file.rs b/crates/distribution-types/src/file.rs index d43f101ee..1c09675ce 100644 --- a/crates/distribution-types/src/file.rs +++ b/crates/distribution-types/src/file.rs @@ -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) }, diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index c5bf668b9..14cc92ba1 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -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 } diff --git a/crates/uv-auth/src/keyring.rs b/crates/uv-auth/src/keyring.rs new file mode 100644 index 000000000..d3aebe532 --- /dev/null +++ b/crates/uv-auth/src/keyring.rs @@ -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 +#[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 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 +/// +pub fn get_keyring_subprocess_auth(url: &Url) -> Result, 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")); + } +} diff --git a/crates/uv-auth/src/lib.rs b/crates/uv-auth/src/lib.rs index 3db9fc864..f73fb66d2 100644 --- a/crates/uv-auth/src/lib.rs +++ b/crates/uv-auth/src/lib.rs @@ -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, 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. /// /// /// -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. +// +// +// 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, + port: Option, +} - // 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. - // - 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(()) } diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs new file mode 100644 index 000000000..7c51d2b6d --- /dev/null +++ b/crates/uv-auth/src/middleware.rs @@ -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: . +pub struct AuthMiddleware { + nrc: Option, + 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 { + 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: +fn basic_auth(username: U, password: Option

) -> 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> { + 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(()) + } +} diff --git a/crates/uv-auth/src/store.rs b/crates/uv-auth/src/store.rs new file mode 100644 index 000000000..2296fd92c --- /dev/null +++ b/crates/uv-auth/src/store.rs @@ -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>> = 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 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 +// +#[derive(Clone, Debug, PartialEq)] +pub struct UrlAuthData { + pub username: String, + pub password: Option, +} + +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 +// +#[derive(Clone, Debug, PartialEq)] +pub struct BasicAuthData { + pub username: String, + pub password: Option, +} + +pub struct AuthenticationStore; + +impl AuthenticationStore { + pub fn get(url: &Url) -> Option> { + let netloc = NetLoc::from(url); + let passwords = PASSWORDS.lock().unwrap(); + passwords.get(&netloc).cloned() + } + + pub fn set(url: &Url, auth: Option) { + 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(¬_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(¬_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(_)))); + } +} diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index 26c3b73d7..c4ed0ca07 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -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 } diff --git a/crates/uv-client/src/flat_index.rs b/crates/uv-client/src/flat_index.rs index ca624f7b8..99e326bf9 100644 --- a/crates/uv-client/src/flat_index.rs +++ b/crates/uv-client/src/flat_index.rs @@ -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 = files .into_iter() .filter_map(|file| { diff --git a/crates/uv-client/src/middleware.rs b/crates/uv-client/src/middleware.rs index df7e0b59d..e83d45547 100644 --- a/crates/uv-client/src/middleware.rs +++ b/crates/uv-client/src/middleware.rs @@ -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: . -pub(crate) struct NetrcMiddleware { - nrc: Netrc, -} - -impl NetrcMiddleware { - pub(crate) fn new() -> Result { - 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 { - // 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: -fn basic_auth(username: U, password: Option

) -> 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 -} diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index 42b12db81..1e3ef8446 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -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) } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 53f5e7e06..e16bb6d73 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -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" } diff --git a/crates/uv/src/commands/pip_compile.rs b/crates/uv/src/commands/pip_compile.rs index 93954ca91..ae63f21f8 100644 --- a/crates/uv/src/commands/pip_compile.rs +++ b/crates/uv/src/commands/pip_compile.rs @@ -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`. diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index f98442c0b..44e932fda 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -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`. diff --git a/crates/uv/src/commands/pip_sync.rs b/crates/uv/src/commands/pip_sync.rs index f073f6478..f4d610b10 100644 --- a/crates/uv/src/commands/pip_sync.rs +++ b/crates/uv/src/commands/pip_sync.rs @@ -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`. diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 38c69362e..f518deb06 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -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(); diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 2846c89a8..bcf83f191 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -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 { 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 { 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 { dependency_mode, upgrade, index_urls, + args.keyring_provider, &reinstall, args.link_mode, args.compile, @@ -1694,6 +1726,7 @@ async fn run() -> Result { &args.name, args.python.as_deref(), &index_locations, + args.keyring_provider, uv_virtualenv::Prompt::from_args(prompt), args.system_site_packages, if args.offline {