From 7f7fac812c2e72c655655983629c8f73cd0bfe22 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 22 Sep 2025 19:59:52 -0400 Subject: [PATCH] Add S3 request signing (#15925) ## Summary This PR enables users to mark a URL as an S3 endpoint, at which point uv will sign requests to that URL by detecting credentials from the standard AWS environment variables, configuration files, etc. Signing is handled by the [reqsign](https://docs.rs/reqsign/latest/reqsign/) crate, which we can also use in the future to sign requests for other providers. --- Cargo.lock | 231 ++++++++++++++++++ Cargo.toml | 1 + crates/uv-auth/Cargo.toml | 1 + crates/uv-auth/src/cache.rs | 78 +++--- crates/uv-auth/src/credentials.rs | 140 ++++++++++- crates/uv-auth/src/lib.rs | 7 +- crates/uv-auth/src/middleware.rs | 95 ++++--- crates/uv-auth/src/providers.rs | 47 ++++ crates/uv-auth/src/realm.rs | 98 ++++++++ crates/uv-distribution-types/src/index_url.rs | 3 +- .../uv-distribution/src/metadata/lowering.rs | 3 - crates/uv-preview/src/lib.rs | 4 + crates/uv-static/src/env_vars.rs | 29 +++ crates/uv/src/commands/project/lock.rs | 3 +- crates/uv/src/commands/project/sync.rs | 3 +- crates/uv/tests/it/show_settings.rs | 4 +- docs/reference/environment.md | 38 +++ 17 files changed, 689 insertions(+), 96 deletions(-) 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.