diff --git a/Cargo.lock b/Cargo.lock index 834b7d9f7..59696503c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,15 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -644,6 +653,19 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.0", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -868,6 +890,32 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1116,6 +1164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1158,6 +1207,15 @@ dependencies = [ "syn", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -1903,6 +1961,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -2755,6 +2837,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3119,6 +3211,16 @@ dependencies = [ "version-ranges", ] +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.8" @@ -3379,6 +3481,104 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqsign" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be18806fe2251c9924d875549573c9bf0e43b51d7efcf32a19ec31bb32196987" +dependencies = [ + "reqsign-aws-v4", + "reqsign-command-execute-tokio", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-http-send-reqwest", +] + +[[package]] +name = "reqsign-aws-v4" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b0fb0ac0a8222efdb0746d83c5ec36c6bdb0f5310b4b92147e3de7c45ef6657" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "form_urlencoded", + "http", + "log", + "percent-encoding", + "quick-xml", + "reqsign-core", + "reqwest", + "rust-ini", + "serde", + "serde_json", + "serde_urlencoded", + "sha1", +] + +[[package]] +name = "reqsign-command-execute-tokio" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e145d2d409f3db11aa3094b8905d69b084d4060771c12e6522dde55924bfecd" +dependencies = [ + "async-trait", + "reqsign-core", + "tokio", +] + +[[package]] +name = "reqsign-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fd7359352a79b293168785b9cbb239e7017b4fa0c934351518feee394f92bc" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "form_urlencoded", + "hex", + "hmac", + "http", + "log", + "percent-encoding", + "sha1", + "sha2", + "thiserror 2.0.16", + "windows-sys 0.60.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857154204885ec746f5f221393aec7dc47cbde9216b18774eb0a0c6e966f1ee0" +dependencies = [ + "anyhow", + "async-trait", + "reqsign-core", + "tokio", +] + +[[package]] +name = "reqsign-http-send-reqwest" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f388d66e254d67e570eba0a4a3fe39427ff7f1df26c73a8b7330e8ca1e639f80" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http", + "http-body-util", + "reqsign-core", + "reqwest", +] + [[package]] name = "reqwest" version = "0.12.22" @@ -3591,6 +3791,16 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust-netrc" version = "0.1.2" @@ -3969,6 +4179,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4458,6 +4679,15 @@ dependencies = [ "tikv-jemalloc-sys", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.8.4" @@ -5177,6 +5407,7 @@ dependencies = [ "insta", "jiff", "percent-encoding", + "reqsign", "reqwest", "reqwest-middleware", "rust-netrc", diff --git a/Cargo.toml b/Cargo.toml index 972f57cf0..92c2143b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,6 +151,7 @@ ref-cast = { version = "1.0.24" } reflink-copy = { version = "0.1.19" } regex = { version = "1.10.6" } regex-automata = { version = "0.4.8", default-features = false, features = ["dfa-build", "dfa-search", "perf", "std", "syntax"] } +reqsign = { version = "0.17.0", features = ["aws", "default-context"], default-features = false } reqwest = { version = "0.12.22", default-features = false, features = ["json", "gzip", "deflate", "zstd", "stream", "system-proxy", "rustls-tls", "rustls-tls-native-roots", "socks", "multipart", "http2", "blocking"] } reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2", features = ["multipart"] } reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "7650ed76215a962a96d94a79be71c27bffde7ab2" } diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index 17d37057f..3be96fd2e 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -31,6 +31,7 @@ futures = { workspace = true } http = { workspace = true } jiff = { workspace = true } percent-encoding = { workspace = true } +reqsign = { workspace = true } reqwest = { workspace = true } reqwest-middleware = { workspace = true } rust-netrc = { workspace = true } diff --git a/crates/uv-auth/src/cache.rs b/crates/uv-auth/src/cache.rs index 5b3223e9e..cf08a5e05 100644 --- a/crates/uv-auth/src/cache.rs +++ b/crates/uv-auth/src/cache.rs @@ -12,7 +12,7 @@ use uv_once_map::OnceMap; use uv_redacted::DisplaySafeUrl; use crate::Realm; -use crate::credentials::{Credentials, Username}; +use crate::credentials::{Authentication, Username}; type FxOnceMap = OnceMap>; @@ -35,11 +35,11 @@ impl Display for FetchUrl { pub struct CredentialsCache { /// A cache per realm and username - realms: RwLock>>, + realms: RwLock>>, /// A cache tracking the result of realm or index URL fetches from external services - pub(crate) fetches: FxOnceMap<(FetchUrl, Username), Option>>, + pub(crate) fetches: FxOnceMap<(FetchUrl, Username), Option>>, /// A cache per URL, uses a trie for efficient prefix queries. - urls: RwLock, + urls: RwLock>>, } impl Default for CredentialsCache { @@ -59,7 +59,11 @@ impl CredentialsCache { } /// Return the credentials that should be used for a realm and username, if any. - pub(crate) fn get_realm(&self, realm: Realm, username: Username) -> Option> { + pub(crate) fn get_realm( + &self, + realm: Realm, + username: Username, + ) -> Option> { let realms = self.realms.read().unwrap(); let given_username = username.is_some(); let key = (realm, username); @@ -93,7 +97,7 @@ impl CredentialsCache { /// Note we do not cache per username, but if a username is passed we will confirm that the /// cached credentials have a username equal to the provided one — otherwise `None` is returned. /// If multiple usernames are used per URL, the realm cache should be queried instead. - pub(crate) fn get_url(&self, url: &Url, username: &Username) -> Option> { + pub(crate) fn get_url(&self, url: &Url, username: &Username) -> Option> { let urls = self.urls.read().unwrap(); let credentials = urls.get(url); if let Some(credentials) = credentials { @@ -112,7 +116,7 @@ impl CredentialsCache { } /// Update the cache with the given credentials. - pub(crate) fn insert(&self, url: &Url, credentials: Arc) { + pub(crate) fn insert(&self, url: &Url, credentials: Arc) { // Do not cache empty credentials if credentials.is_empty() { return; @@ -139,8 +143,8 @@ impl CredentialsCache { fn insert_realm( &self, key: (Realm, Username), - credentials: &Arc, - ) -> Option> { + credentials: &Arc, + ) -> Option> { // Do not cache empty credentials if credentials.is_empty() { return None; @@ -166,24 +170,33 @@ impl CredentialsCache { } #[derive(Debug)] -struct UrlTrie { - states: Vec, +struct UrlTrie { + states: Vec>, } -#[derive(Debug, Default)] -struct TrieState { +#[derive(Debug)] +struct TrieState { children: Vec<(String, usize)>, - value: Option>, + value: Option, } -impl UrlTrie { +impl Default for TrieState { + fn default() -> Self { + Self { + children: vec![], + value: None, + } + } +} + +impl UrlTrie { fn new() -> Self { let mut trie = Self { states: vec![] }; trie.alloc(); trie } - fn get(&self, url: &Url) -> Option<&Arc> { + fn get(&self, url: &Url) -> Option<&T> { let mut state = 0; let realm = Realm::from(url).to_string(); for component in [realm.as_str()] @@ -198,7 +211,7 @@ impl UrlTrie { self.states[state].value.as_ref() } - fn insert(&mut self, url: &Url, value: Arc) { + fn insert(&mut self, url: &Url, value: T) { let mut state = 0; let realm = Realm::from(url).to_string(); for component in [realm.as_str()] @@ -226,7 +239,7 @@ impl UrlTrie { } } -impl TrieState { +impl TrieState { fn get(&self, component: &str) -> Option { let i = self.index(component).ok()?; Some(self.children[i].1) @@ -260,28 +273,21 @@ impl From<(Realm, Username)> for RealmUsername { #[cfg(test)] mod tests { + use crate::Credentials; use crate::credentials::Password; use super::*; #[test] fn test_trie() { - let credentials1 = Arc::new(Credentials::basic( - Some("username1".to_string()), - Some("password1".to_string()), - )); - let credentials2 = Arc::new(Credentials::basic( - Some("username2".to_string()), - Some("password2".to_string()), - )); - let credentials3 = Arc::new(Credentials::basic( - Some("username3".to_string()), - Some("password3".to_string()), - )); - let credentials4 = Arc::new(Credentials::basic( - Some("username4".to_string()), - Some("password4".to_string()), - )); + let credentials1 = + Credentials::basic(Some("username1".to_string()), Some("password1".to_string())); + let credentials2 = + Credentials::basic(Some("username2".to_string()), Some("password2".to_string())); + let credentials3 = + Credentials::basic(Some("username3".to_string()), Some("password3".to_string())); + let credentials4 = + Credentials::basic(Some("username4".to_string()), Some("password4".to_string())); let mut trie = UrlTrie::new(); trie.insert( @@ -339,10 +345,10 @@ mod tests { fn test_url_with_credentials() { let username = Username::new(Some(String::from("username"))); let password = Password::new(String::from("password")); - let credentials = Arc::new(Credentials::Basic { + let credentials = Arc::new(Authentication::from(Credentials::Basic { username: username.clone(), password: Some(password), - }); + })); let cache = CredentialsCache::default(); // Insert with URL with credentials and get with redacted URL. let url = Url::parse("https://username:password@example.com/foobar").unwrap(); diff --git a/crates/uv-auth/src/credentials.rs b/crates/uv-auth/src/credentials.rs index 673b711d3..6a778d4de 100644 --- a/crates/uv-auth/src/credentials.rs +++ b/crates/uv-auth/src/credentials.rs @@ -1,21 +1,24 @@ +use std::borrow::Cow; +use std::fmt; +use std::io::Read; +use std::io::Write; +use std::str::FromStr; + use base64::prelude::BASE64_STANDARD; use base64::read::DecoderReader; use base64::write::EncoderWriter; -use serde::{Deserialize, Serialize}; -use std::borrow::Cow; -use std::fmt; -use uv_redacted::DisplaySafeUrl; - +use http::Uri; use netrc::Netrc; +use reqsign::aws::DefaultSigner; use reqwest::Request; use reqwest::header::HeaderValue; -use std::io::Read; -use std::io::Write; +use serde::{Deserialize, Serialize}; use url::Url; +use uv_redacted::DisplaySafeUrl; use uv_static::EnvVars; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Credentials { Basic { /// The username to use for authentication. @@ -345,6 +348,127 @@ impl Credentials { } } +#[derive(Clone, Debug)] +pub(crate) enum Authentication { + /// HTTP Basic or Bearer Authentication credentials. + Credentials(Credentials), + + /// AWS Signature Version 4 signing. + Signer(DefaultSigner), +} + +impl PartialEq for Authentication { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Credentials(a), Self::Credentials(b)) => a == b, + (Self::Signer(..), Self::Signer(..)) => true, + _ => false, + } + } +} + +impl Eq for Authentication {} + +impl From for Authentication { + fn from(credentials: Credentials) -> Self { + Self::Credentials(credentials) + } +} + +impl From for Authentication { + fn from(signer: DefaultSigner) -> Self { + Self::Signer(signer) + } +} + +impl Authentication { + /// Return the password used for authentication, if any. + pub(crate) fn password(&self) -> Option<&str> { + match self { + Self::Credentials(credentials) => credentials.password(), + Self::Signer(..) => None, + } + } + + /// Return the username used for authentication, if any. + pub(crate) fn username(&self) -> Option<&str> { + match self { + Self::Credentials(credentials) => credentials.username(), + Self::Signer(..) => None, + } + } + + /// Return the username used for authentication, if any. + pub(crate) fn as_username(&self) -> Cow<'_, Username> { + match self { + Self::Credentials(credentials) => credentials.as_username(), + Self::Signer(..) => Cow::Owned(Username::none()), + } + } + + /// Return the username used for authentication, if any. + pub(crate) fn to_username(&self) -> Username { + match self { + Self::Credentials(credentials) => credentials.to_username(), + Self::Signer(..) => Username::none(), + } + } + + /// Return `true` if the object contains a means of authenticating. + pub(crate) fn is_authenticated(&self) -> bool { + match self { + Self::Credentials(credentials) => credentials.is_authenticated(), + Self::Signer(..) => true, + } + } + + /// Return `true` if the object contains no credentials. + pub(crate) fn is_empty(&self) -> bool { + match self { + Self::Credentials(credentials) => credentials.is_empty(), + Self::Signer(..) => false, + } + } + + /// Apply the authentication to the given request. + /// + /// Any existing credentials will be overridden. + #[must_use] + pub(crate) async fn authenticate(&self, mut request: Request) -> Request { + match self { + Self::Credentials(credentials) => credentials.authenticate(request), + Self::Signer(signer) => { + // Build an `http::Request` from the `reqwest::Request`. + // SAFETY: If we have a valid `reqwest::Request`, we expect (e.g.) the URL to be valid. + let uri = Uri::from_str(request.url().as_str()).unwrap(); + let mut http_req = http::Request::builder() + .method(request.method().clone()) + .uri(uri) + .body(()) + .unwrap(); + *http_req.headers_mut() = request.headers().clone(); + + // Sign the parts. + let (mut parts, ()) = http_req.into_parts(); + signer + .sign(&mut parts, None) + .await + .expect("AWS signing should succeed"); + + // Copy over the signed headers. + request.headers_mut().extend(parts.headers); + + // Copy over the signed path and query, if any. + if let Some(path_and_query) = parts.uri.path_and_query() { + request.url_mut().set_path(path_and_query.path()); + request.url_mut().set_query(path_and_query.query()); + } + request + } + } + } +} + #[cfg(test)] mod tests { use insta::assert_debug_snapshot; diff --git a/crates/uv-auth/src/lib.rs b/crates/uv-auth/src/lib.rs index 6aaf664ec..8d66cde7c 100644 --- a/crates/uv-auth/src/lib.rs +++ b/crates/uv-auth/src/lib.rs @@ -4,6 +4,7 @@ use tracing::trace; use uv_redacted::DisplaySafeUrl; +use crate::credentials::Authentication; pub use access_token::AccessToken; use cache::CredentialsCache; pub use credentials::{Credentials, Username}; @@ -43,7 +44,7 @@ pub(crate) static CREDENTIALS_CACHE: LazyLock = pub fn store_credentials_from_url(url: &DisplaySafeUrl) -> bool { if let Some(credentials) = Credentials::from_url(url) { trace!("Caching credentials for {url}"); - CREDENTIALS_CACHE.insert(url, Arc::new(credentials)); + CREDENTIALS_CACHE.insert(url, Arc::new(Authentication::from(credentials))); true } else { false @@ -53,7 +54,7 @@ pub fn store_credentials_from_url(url: &DisplaySafeUrl) -> bool { /// Populate the global authentication store with credentials on a URL, if there are any. /// /// Returns `true` if the store was updated. -pub fn store_credentials(url: &DisplaySafeUrl, credentials: Arc) { +pub fn store_credentials(url: &DisplaySafeUrl, credentials: Credentials) { trace!("Caching credentials for {url}"); - CREDENTIALS_CACHE.insert(url, credentials); + CREDENTIALS_CACHE.insert(url, Arc::new(Authentication::from(credentials))); } diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index febe76d60..82b5b5b58 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -12,7 +12,8 @@ use uv_preview::{Preview, PreviewFeatures}; use uv_redacted::DisplaySafeUrl; use uv_warnings::owo_colors::OwoColorize; -use crate::providers::HuggingFaceProvider; +use crate::credentials::Authentication; +use crate::providers::{HuggingFaceProvider, S3EndpointProvider}; use crate::pyx::{DEFAULT_TOLERANCE_SECS, PyxTokenStore}; use crate::{ AccessToken, CREDENTIALS_CACHE, CredentialsCache, KeyringProvider, @@ -21,7 +22,6 @@ use crate::{ index::{AuthPolicy, Indexes}, realm::Realm, }; - use crate::{Index, TextCredentialStore, TomlCredentialError}; /// Strategy for loading netrc files. @@ -292,7 +292,7 @@ impl Middleware for AuthMiddleware { next: Next<'_>, ) -> reqwest_middleware::Result { // Check for credentials attached to the request already - let request_credentials = Credentials::from_request(&request); + let request_credentials = Credentials::from_request(&request).map(Authentication::from); // In the middleware, existing credentials are already moved from the URL // to the headers so for display purposes we restore some information @@ -301,7 +301,7 @@ impl Middleware for AuthMiddleware { let auth_policy = self.indexes.auth_policy_for(request.url()); trace!("Handling request for {url} with authentication policy {auth_policy}"); - let credentials: Option> = if matches!(auth_policy, AuthPolicy::Never) { + let credentials: Option> = if matches!(auth_policy, AuthPolicy::Never) { None } else { if let Some(request_credentials) = request_credentials { @@ -325,7 +325,7 @@ impl Middleware for AuthMiddleware { // making a failing request let credentials = self.cache().get_url(request.url(), &Username::none()); if let Some(credentials) = credentials.as_ref() { - request = credentials.authenticate(request); + request = credentials.authenticate(request).await; // If it's fully authenticated, finish the request if credentials.is_authenticated() { @@ -422,7 +422,7 @@ impl Middleware for AuthMiddleware { if let Some(credentials) = credentials.as_ref() { if credentials.is_authenticated() { trace!("Retrying request for {url} with credentials from cache {credentials:?}"); - retry_request = credentials.authenticate(retry_request); + retry_request = credentials.authenticate(retry_request).await; return self .complete_request(None, retry_request, extensions, next, auth_policy) .await; @@ -440,7 +440,7 @@ impl Middleware for AuthMiddleware { ) .await { - retry_request = credentials.authenticate(retry_request); + retry_request = credentials.authenticate(retry_request).await; trace!("Retrying request for {url} with {credentials:?}"); return self .complete_request( @@ -456,7 +456,7 @@ impl Middleware for AuthMiddleware { if let Some(credentials) = credentials.as_ref() { if !attempt_has_username { trace!("Retrying request for {url} with username from cache {credentials:?}"); - retry_request = credentials.authenticate(retry_request); + retry_request = credentials.authenticate(retry_request).await; return self .complete_request(None, retry_request, extensions, next, auth_policy) .await; @@ -492,7 +492,7 @@ impl AuthMiddleware { /// If credentials are present, insert them into the cache on success. async fn complete_request( &self, - credentials: Option>, + credentials: Option>, request: Request, extensions: &mut Extensions, next: Next<'_>, @@ -524,7 +524,7 @@ impl AuthMiddleware { /// Use known request credentials to complete the request. async fn complete_request_with_request_credentials( &self, - credentials: Credentials, + credentials: Authentication, mut request: Request, extensions: &mut Extensions, next: Next<'_>, @@ -559,7 +559,7 @@ impl AuthMiddleware { .get_realm(Realm::from(request.url()), credentials.to_username()) }; if let Some(credentials) = maybe_cached_credentials { - request = credentials.authenticate(request); + request = credentials.authenticate(request).await; // Do not insert already-cached credentials let credentials = None; return self @@ -571,7 +571,7 @@ impl AuthMiddleware { .cache() .get_url(request.url(), credentials.as_username().as_ref()) { - request = credentials.authenticate(request); + request = credentials.authenticate(request).await; // Do not insert already-cached credentials None } else if let Some(credentials) = self @@ -583,7 +583,7 @@ impl AuthMiddleware { ) .await { - request = credentials.authenticate(request); + request = credentials.authenticate(request).await; Some(credentials) } else if index.is_some() { // If this is a known index, we fall back to checking for the realm. @@ -591,7 +591,7 @@ impl AuthMiddleware { .cache() .get_realm(Realm::from(request.url()), credentials.to_username()) { - request = credentials.authenticate(request); + request = credentials.authenticate(request).await; Some(credentials) } else { Some(credentials) @@ -610,11 +610,11 @@ impl AuthMiddleware { /// Supports netrc file and keyring lookups. async fn fetch_credentials( &self, - credentials: Option<&Credentials>, + credentials: Option<&Authentication>, url: &DisplaySafeUrl, index: Option<&Index>, auth_policy: AuthPolicy, - ) -> Option> { + ) -> Option> { let username = Username::from( credentials.map(|credentials| credentials.username().unwrap_or_default().to_string()), ); @@ -646,13 +646,25 @@ impl AuthMiddleware { return credentials; } - // Support for known providers, like Hugging Face. - if let Some(credentials) = HuggingFaceProvider::credentials_for(url).map(Arc::new) { + // Support for known providers, like Hugging Face and S3. + if let Some(credentials) = HuggingFaceProvider::credentials_for(url) + .map(Authentication::from) + .map(Arc::new) + { debug!("Found Hugging Face credentials for {url}"); self.cache().fetches.done(key, Some(credentials.clone())); return Some(credentials); } + if let Some(credentials) = S3EndpointProvider::credentials_for(url, self.preview) + .map(Authentication::from) + .map(Arc::new) + { + debug!("Found S3 credentials for {url}"); + self.cache().fetches.done(key, Some(credentials.clone())); + return Some(credentials); + } + // If this is a known URL, authenticate it via the token store. if let Some(base_client) = self.base_client.as_ref() { if let Some(token_store) = self.pyx_token_store.as_ref() { @@ -682,7 +694,7 @@ impl AuthMiddleware { let credentials = token.map(|token| { trace!("Using credentials from token store for {url}"); - Arc::new(Credentials::from(token)) + Arc::new(Authentication::from(Credentials::from(token))) }); // Register the fetch for this key @@ -778,6 +790,7 @@ impl AuthMiddleware { } else { None } + .map(Authentication::from) .map(Arc::new); // Register the fetch for this key @@ -787,9 +800,9 @@ impl AuthMiddleware { } } -fn tracing_url(request: &Request, credentials: Option<&Credentials>) -> DisplaySafeUrl { +fn tracing_url(request: &Request, credentials: Option<&Authentication>) -> DisplaySafeUrl { let mut url = DisplaySafeUrl::from(request.url().clone()); - if let Some(creds) = credentials { + if let Some(Authentication::Credentials(creds)) = credentials { if let Some(username) = creds.username() { let _ = url.set_username(username); } @@ -930,10 +943,10 @@ mod tests { let cache = CredentialsCache::new(); cache.insert( &base_url, - Arc::new(Credentials::basic( + Arc::new(Authentication::from(Credentials::basic( Some(username.to_string()), Some(password.to_string()), - )), + ))), ); let client = test_client_builder() @@ -984,7 +997,10 @@ mod tests { let cache = CredentialsCache::new(); cache.insert( &base_url, - Arc::new(Credentials::basic(Some(username.to_string()), None)), + Arc::new(Authentication::from(Credentials::basic( + Some(username.to_string()), + None, + ))), ); let client = test_client_builder() @@ -1377,7 +1393,10 @@ mod tests { // URL. cache.insert( &base_url, - Arc::new(Credentials::basic(Some(username.to_string()), None)), + Arc::new(Authentication::from(Credentials::basic( + Some(username.to_string()), + None, + ))), ); let client = test_client_builder() .with(AuthMiddleware::new().with_cache(cache).with_keyring(Some( @@ -1426,17 +1445,17 @@ mod tests { // Seed the cache with our credentials cache.insert( &base_url_1, - Arc::new(Credentials::basic( + Arc::new(Authentication::from(Credentials::basic( Some(username_1.to_string()), Some(password_1.to_string()), - )), + ))), ); cache.insert( &base_url_2, - Arc::new(Credentials::basic( + Arc::new(Authentication::from(Credentials::basic( Some(username_2.to_string()), Some(password_2.to_string()), - )), + ))), ); let client = test_client_builder() @@ -1621,17 +1640,17 @@ mod tests { // Seed the cache with our credentials cache.insert( &base_url_1, - Arc::new(Credentials::basic( + Arc::new(Authentication::from(Credentials::basic( Some(username_1.to_string()), Some(password_1.to_string()), - )), + ))), ); cache.insert( &base_url_2, - Arc::new(Credentials::basic( + Arc::new(Authentication::from(Credentials::basic( Some(username_2.to_string()), Some(password_2.to_string()), - )), + ))), ); let client = test_client_builder() @@ -2334,20 +2353,20 @@ mod tests { DisplaySafeUrl::parse("https://pypi-proxy.fly.dev/basic-auth/simple").unwrap() ); - let creds = Credentials::Basic { + let creds = Authentication::from(Credentials::Basic { username: Username::new(Some(String::from("user"))), password: None, - }; + }); let req = create_request("https://pypi-proxy.fly.dev/basic-auth/simple"); assert_eq!( tracing_url(&req, Some(&creds)), DisplaySafeUrl::parse("https://user@pypi-proxy.fly.dev/basic-auth/simple").unwrap() ); - let creds = Credentials::Basic { + let creds = Authentication::from(Credentials::Basic { username: Username::new(Some(String::from("user"))), password: Some(Password::new(String::from("password"))), - }; + }); let req = create_request("https://pypi-proxy.fly.dev/basic-auth/simple"); assert_eq!( tracing_url(&req, Some(&creds)), @@ -2368,7 +2387,7 @@ mod tests { let mut store = TextCredentialStore::default(); let service = crate::Service::try_from(base_url.to_string()).unwrap(); let credentials = - crate::Credentials::basic(Some(username.to_string()), Some(password.to_string())); + Credentials::basic(Some(username.to_string()), Some(password.to_string())); store.insert(service.clone(), credentials); let client = test_client_builder() diff --git a/crates/uv-auth/src/providers.rs b/crates/uv-auth/src/providers.rs index 2c531d3da..268e0c17d 100644 --- a/crates/uv-auth/src/providers.rs +++ b/crates/uv-auth/src/providers.rs @@ -1,8 +1,13 @@ +use std::borrow::Cow; use std::sync::LazyLock; + +use reqsign::aws::DefaultSigner; use tracing::debug; use url::Url; +use uv_preview::{Preview, PreviewFeatures}; use uv_static::EnvVars; +use uv_warnings::warn_user_once; use crate::Credentials; use crate::realm::{Realm, RealmRef}; @@ -47,3 +52,45 @@ impl HuggingFaceProvider { None } } + +/// The [`Url`] for the S3 endpoint, if set. +static S3_ENDPOINT_REALM: LazyLock> = LazyLock::new(|| { + let s3_endpoint_url = std::env::var(EnvVars::UV_S3_ENDPOINT_URL).ok()?; + let url = Url::parse(&s3_endpoint_url).expect("Failed to parse S3 endpoint URL"); + Some(Realm::from(&url)) +}); + +/// A provider for authentication credentials for S3 endpoints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct S3EndpointProvider; + +impl S3EndpointProvider { + /// Returns the credentials for the S3 endpoint, if available. + pub(crate) fn credentials_for(url: &Url, preview: Preview) -> Option { + if let Some(s3_endpoint_realm) = S3_ENDPOINT_REALM.as_ref().map(RealmRef::from) { + if !preview.is_enabled(PreviewFeatures::S3_ENDPOINT) { + warn_user_once!( + "The `s3-endpoint` option is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::S3_ENDPOINT + ); + } + + // Treat any URL on the same domain or subdomain as available for S3 signing. + let realm = RealmRef::from(url); + if realm == s3_endpoint_realm || realm.is_subdomain_of(s3_endpoint_realm) { + // TODO(charlie): Can `reqsign` infer the region for us? Profiles, for example, + // often have a region set already. + let region = std::env::var(EnvVars::AWS_REGION) + .map(Cow::Owned) + .unwrap_or_else(|_| { + std::env::var(EnvVars::AWS_DEFAULT_REGION) + .map(Cow::Owned) + .unwrap_or_else(|_| Cow::Borrowed("us-east-1")) + }); + let signer = reqsign::aws::default_signer("s3", ®ion); + return Some(signer); + } + } + None + } +} diff --git a/crates/uv-auth/src/realm.rs b/crates/uv-auth/src/realm.rs index b2abd1266..6f4acaa12 100644 --- a/crates/uv-auth/src/realm.rs +++ b/crates/uv-auth/src/realm.rs @@ -81,6 +81,21 @@ pub(crate) struct RealmRef<'a> { port: Option, } +impl RealmRef<'_> { + /// Returns true if this realm is a subdomain of the other realm. + pub(crate) fn is_subdomain_of(&self, other: Self) -> bool { + other.scheme == self.scheme + && other.port == self.port + && other.host.is_some_and(|other_host| { + self.host.is_some_and(|self_host| { + self_host + .strip_suffix(other_host) + .is_some_and(|prefix| prefix.ends_with('.')) + }) + }) + } +} + impl<'a> From<&'a Url> for RealmRef<'a> { fn from(url: &'a Url) -> Self { Self { @@ -215,4 +230,87 @@ mod tests { Ok(()) } + + #[test] + fn test_is_subdomain_of() -> Result<(), ParseError> { + use crate::realm::RealmRef; + + // Subdomain relationship: sub.example.com is a subdomain of example.com + let subdomain_url = Url::parse("https://sub.example.com")?; + let domain_url = Url::parse("https://example.com")?; + let subdomain = RealmRef::from(&subdomain_url); + let domain = RealmRef::from(&domain_url); + assert!(subdomain.is_subdomain_of(domain)); + + // Deeper subdomain: foo.bar.example.com is a subdomain of example.com + let deep_subdomain_url = Url::parse("https://foo.bar.example.com")?; + let deep_subdomain = RealmRef::from(&deep_subdomain_url); + assert!(deep_subdomain.is_subdomain_of(domain)); + + // Deeper subdomain: foo.bar.example.com is also a subdomain of bar.example.com + let parent_subdomain_url = Url::parse("https://bar.example.com")?; + let parent_subdomain = RealmRef::from(&parent_subdomain_url); + assert!(deep_subdomain.is_subdomain_of(parent_subdomain)); + + // Not a subdomain: example.com is not a subdomain of sub.example.com + assert!(!domain.is_subdomain_of(subdomain)); + + // Same domain is not a subdomain of itself + assert!(!domain.is_subdomain_of(domain)); + + // Different TLD: example.org is not a subdomain of example.com + let different_tld_url = Url::parse("https://example.org")?; + let different_tld = RealmRef::from(&different_tld_url); + assert!(!different_tld.is_subdomain_of(domain)); + + // Partial match but not a subdomain: notexample.com is not a subdomain of example.com + let partial_match_url = Url::parse("https://notexample.com")?; + let partial_match = RealmRef::from(&partial_match_url); + assert!(!partial_match.is_subdomain_of(domain)); + + // Different scheme: http subdomain is not a subdomain of https domain + let http_subdomain_url = Url::parse("http://sub.example.com")?; + let https_domain_url = Url::parse("https://example.com")?; + let http_subdomain = RealmRef::from(&http_subdomain_url); + let https_domain = RealmRef::from(&https_domain_url); + assert!(!http_subdomain.is_subdomain_of(https_domain)); + + // Different port: same subdomain with different port is not a subdomain + let subdomain_port_8080_url = Url::parse("https://sub.example.com:8080")?; + let domain_port_9090_url = Url::parse("https://example.com:9090")?; + let subdomain_port_8080 = RealmRef::from(&subdomain_port_8080_url); + let domain_port_9090 = RealmRef::from(&domain_port_9090_url); + assert!(!subdomain_port_8080.is_subdomain_of(domain_port_9090)); + + // Same port: subdomain with same explicit port is a subdomain + let subdomain_with_port_url = Url::parse("https://sub.example.com:8080")?; + let domain_with_port_url = Url::parse("https://example.com:8080")?; + let subdomain_with_port = RealmRef::from(&subdomain_with_port_url); + let domain_with_port = RealmRef::from(&domain_with_port_url); + assert!(subdomain_with_port.is_subdomain_of(domain_with_port)); + + // Default port handling: subdomain with implicit port is a subdomain + let subdomain_default_url = Url::parse("https://sub.example.com")?; + let domain_explicit_443_url = Url::parse("https://example.com:443")?; + let subdomain_default = RealmRef::from(&subdomain_default_url); + let domain_explicit_443 = RealmRef::from(&domain_explicit_443_url); + assert!(subdomain_default.is_subdomain_of(domain_explicit_443)); + + // Edge case: empty host (shouldn't happen with valid URLs but testing defensive code) + let file_url = Url::parse("file:///path/to/file")?; + let https_url = Url::parse("https://example.com")?; + let file_realm = RealmRef::from(&file_url); + let https_realm = RealmRef::from(&https_url); + assert!(!file_realm.is_subdomain_of(https_realm)); + assert!(!https_realm.is_subdomain_of(file_realm)); + + // Subdomain with path (path should be ignored) + let subdomain_with_path_url = Url::parse("https://sub.example.com/path")?; + let domain_with_path_url = Url::parse("https://example.com/other")?; + let subdomain_with_path = RealmRef::from(&subdomain_with_path_url); + let domain_with_path = RealmRef::from(&domain_with_path_url); + assert!(subdomain_with_path.is_subdomain_of(domain_with_path)); + + Ok(()) + } } diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index d283f0641..81a3261e0 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -445,11 +445,10 @@ impl<'a> IndexLocations { .map(ToString::to_string) .unwrap_or_else(|| index.url.to_string()) ); - let credentials = Arc::new(credentials); - uv_auth::store_credentials(index.raw_url(), credentials.clone()); if let Some(root_url) = index.root_url() { uv_auth::store_credentials(&root_url, credentials.clone()); } + uv_auth::store_credentials(index.raw_url(), credentials); } } } diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index d86cafa49..627e2d4dd 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -1,7 +1,6 @@ use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; -use std::sync::Arc; use either::Either; use thiserror::Error; @@ -230,7 +229,6 @@ impl LoweredRequirement { )); }; if let Some(credentials) = index.credentials() { - let credentials = Arc::new(credentials); uv_auth::store_credentials(index.raw_url(), credentials); } let index = IndexMetadata { @@ -464,7 +462,6 @@ impl LoweredRequirement { )); }; if let Some(credentials) = index.credentials() { - let credentials = Arc::new(credentials); uv_auth::store_credentials(index.raw_url(), credentials); } let index = IndexMetadata { diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 4c122edde..2eb861f34 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -19,6 +19,7 @@ bitflags::bitflags! { const DETECT_MODULE_CONFLICTS = 1 << 7; const FORMAT = 1 << 8; const NATIVE_AUTH = 1 << 9; + const S3_ENDPOINT = 1 << 10; } } @@ -38,6 +39,7 @@ impl PreviewFeatures { Self::DETECT_MODULE_CONFLICTS => "detect-module-conflicts", Self::FORMAT => "format", Self::NATIVE_AUTH => "native-auth", + Self::S3_ENDPOINT => "s3-endpoint", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -85,6 +87,7 @@ impl FromStr for PreviewFeatures { "detect-module-conflicts" => Self::DETECT_MODULE_CONFLICTS, "format" => Self::FORMAT, "native-auth" => Self::NATIVE_AUTH, + "s3-endpoint" => Self::S3_ENDPOINT, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -260,6 +263,7 @@ mod tests { "detect-module-conflicts" ); assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format"); + assert_eq!(PreviewFeatures::S3_ENDPOINT.flag_as_str(), "s3-endpoint"); } #[test] diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 3216009bd..97d0b9f28 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -886,6 +886,11 @@ impl EnvVars { /// Disable Hugging Face authentication, even if `HF_TOKEN` is set. pub const UV_NO_HF_TOKEN: &'static str = "UV_NO_HF_TOKEN"; + /// The URL to treat as an S3-compatible storage endpoint. Requests to this endpoint + /// will be signed using AWS Signature Version 4 based on the `AWS_ACCESS_KEY_ID`, + /// `AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, and `AWS_CONFIG_FILE` environment variables. + pub const UV_S3_ENDPOINT_URL: &'static str = "UV_S3_ENDPOINT_URL"; + /// The URL of the pyx Simple API server. pub const PYX_API_URL: &'static str = "PYX_API_URL"; @@ -908,4 +913,28 @@ impl EnvVars { /// Specifies the directory where uv stores pyx credentials. pub const PYX_CREDENTIALS_DIR: &'static str = "PYX_CREDENTIALS_DIR"; + + /// The AWS region to use when signing S3 requests. + pub const AWS_REGION: &'static str = "AWS_REGION"; + + /// The default AWS region to use when signing S3 requests, if `AWS_REGION` is not set. + pub const AWS_DEFAULT_REGION: &'static str = "AWS_DEFAULT_REGION"; + + /// The AWS access key ID to use when signing S3 requests. + pub const AWS_ACCESS_KEY_ID: &'static str = "AWS_ACCESS_KEY_ID"; + + /// The AWS secret access key to use when signing S3 requests. + pub const AWS_SECRET_ACCESS_KEY: &'static str = "AWS_SECRET_ACCESS_KEY"; + + /// The AWS session token to use when signing S3 requests. + pub const AWS_SESSION_TOKEN: &'static str = "AWS_SESSION_TOKEN"; + + /// The AWS profile to use when signing S3 requests. + pub const AWS_PROFILE: &'static str = "AWS_PROFILE"; + + /// The AWS config file to use when signing S3 requests. + pub const AWS_CONFIG_FILE: &'static str = "AWS_CONFIG_FILE"; + + /// The AWS shared credentials file to use when signing S3 requests. + pub const AWS_SHARED_CREDENTIALS_FILE: &'static str = "AWS_SHARED_CREDENTIALS_FILE"; } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 3dd6dfef8..83de92aef 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -621,11 +621,10 @@ async fn do_lock( for index in target.indexes() { if let Some(credentials) = index.credentials() { - let credentials = Arc::new(credentials); - uv_auth::store_credentials(index.raw_url(), credentials.clone()); if let Some(root_url) = index.root_url() { uv_auth::store_credentials(&root_url, credentials.clone()); } + uv_auth::store_credentials(index.raw_url(), credentials); } } diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index f6190e1cd..27b3c70e6 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -900,11 +900,10 @@ fn store_credentials_from_target(target: InstallTarget<'_>) { // Iterate over any indexes in the target. for index in target.indexes() { if let Some(credentials) = index.credentials() { - let credentials = Arc::new(credentials); - uv_auth::store_credentials(index.raw_url(), credentials.clone()); if let Some(root_url) = index.root_url() { uv_auth::store_credentials(&root_url, credentials.clone()); } + uv_auth::store_credentials(index.raw_url(), credentials); } } diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index ed6a9ee4e..b37952fa9 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7686,7 +7686,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT, ), }, python_preference: Managed, @@ -7910,7 +7910,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT, ), }, python_preference: Managed, diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 82f9b797e..7fd46a4a0 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -493,6 +493,12 @@ uv will require that all dependencies have a hash specified in the requirements Equivalent to the `--resolution` command-line argument. For example, if set to `lowest-direct`, uv will install the lowest compatible versions of all direct dependencies. +### `UV_S3_ENDPOINT_URL` + +The URL to treat as an S3-compatible storage endpoint. Requests to this endpoint +will be signed using AWS Signature Version 4 based on the `AWS_ACCESS_KEY_ID`, +`AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, and `AWS_CONFIG_FILE` environment variables. + ### `UV_STACK_SIZE` Use to set the stack size used by uv. @@ -568,6 +574,38 @@ Defaults to `24`. Path to user-level configuration directory on Windows systems. +### `AWS_ACCESS_KEY_ID` + +The AWS access key ID to use when signing S3 requests. + +### `AWS_CONFIG_FILE` + +The AWS config file to use when signing S3 requests. + +### `AWS_DEFAULT_REGION` + +The default AWS region to use when signing S3 requests, if `AWS_REGION` is not set. + +### `AWS_PROFILE` + +The AWS profile to use when signing S3 requests. + +### `AWS_REGION` + +The AWS region to use when signing S3 requests. + +### `AWS_SECRET_ACCESS_KEY` + +The AWS secret access key to use when signing S3 requests. + +### `AWS_SESSION_TOKEN` + +The AWS session token to use when signing S3 requests. + +### `AWS_SHARED_CREDENTIALS_FILE` + +The AWS shared credentials file to use when signing S3 requests. + ### `BASH_VERSION` Used to detect Bash shell usage.