mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
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:
parent
d4d78b0cc3
commit
9159731792
18 changed files with 816 additions and 246 deletions
189
Cargo.lock
generated
189
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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 }
|
||||
|
|
109
crates/uv-auth/src/keyring.rs
Normal file
109
crates/uv-auth/src/keyring.rs
Normal 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"));
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
||||
|
|
183
crates/uv-auth/src/middleware.rs
Normal file
183
crates/uv-auth/src/middleware.rs
Normal 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
175
crates/uv-auth/src/store.rs
Normal 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(¬_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(_))));
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue