diff --git a/Cargo.lock b/Cargo.lock index 5d7f29678..893691b78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3844,9 +3844,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5678,16 +5678,19 @@ dependencies = [ "itertools 0.14.0", "jiff", "percent-encoding", + "rcgen", "reqwest", "rkyv", "rmp-serde", "rustc-hash", + "rustls", "serde", "serde_json", "sys-info", "tempfile", "thiserror 2.0.17", "tokio", + "tokio-rustls", "tokio-util", "tracing", "url", diff --git a/Cargo.toml b/Cargo.toml index 8ff770e3d..c523ecec0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -211,15 +211,17 @@ byteorder = { version = "1.5.0" } filetime = { version = "0.2.25" } http-body-util = { version = "0.1.2" } hyper = { version = "1.4.1", features = ["server", "http1"] } -hyper-util = { version = "0.1.8", features = ["tokio"] } +hyper-util = { version = "0.1.8", features = ["tokio", "server", "http1"] } ignore = { version = "0.4.23" } insta = { version = "1.40.0", features = ["json", "filters", "redactions"] } predicates = { version = "3.1.2" } rcgen = { version = "0.14.5", features = ["crypto", "pem", "ring"], default-features = false } +rustls = { version = "0.23.29", default-features = false } similar = { version = "2.6.0" } temp-env = { version = "0.3.6" } test-case = { version = "3.3.1" } test-log = { version = "0.2.16", features = ["trace"], default-features = false } +tokio-rustls = { version = "0.26.2", default-features = false } whoami = { version = "1.6.0" } [workspace.metadata.cargo-shear] diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index 3348bc11f..ffeb09151 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -72,6 +72,9 @@ http-body-util = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } insta = { workspace = true } +rcgen = { workspace = true } +rustls = { workspace = true } tokio = { workspace = true } +tokio-rustls = { workspace = true } wiremock = { workspace = true } tempfile = { workspace = true } diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 97192dcd8..c66c4a038 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -363,7 +363,9 @@ impl<'a> BaseClientBuilder<'a> { let _ = write!(user_agent_string, " {output}"); } - // Check for the presence of an `SSL_CERT_FILE`. + // Checks for the presence of `SSL_CERT_FILE`. + // Certificate loading support is delegated to `rustls-native-certs`. + // See https://github.com/rustls/rustls-native-certs/blob/813790a297ad4399efe70a8e5264ca1b420acbec/src/lib.rs#L118-L125 let ssl_cert_file_exists = env::var_os(EnvVars::SSL_CERT_FILE).is_some_and(|path| { let path_exists = Path::new(&path).exists(); if !path_exists { @@ -375,11 +377,61 @@ impl<'a> BaseClientBuilder<'a> { path_exists }); + // Checks for the presence of `SSL_CERT_DIR`. + // Certificate loading support is delegated to `rustls-native-certs`. + // See https://github.com/rustls/rustls-native-certs/blob/813790a297ad4399efe70a8e5264ca1b420acbec/src/lib.rs#L118-L125 + let ssl_cert_dir_exists = env::var_os(EnvVars::SSL_CERT_DIR) + .filter(|v| !v.is_empty()) + .is_some_and(|dirs| { + // Parse `SSL_CERT_DIR`, with support for multiple entries using + // a platform-specific delimiter (`:` on Unix, `;` on Windows) + let (existing, missing): (Vec<_>, Vec<_>) = + env::split_paths(&dirs).partition(|p| p.exists()); + + if existing.is_empty() { + let end_note = if missing.len() == 1 { + "The directory does not exist." + } else { + "The entries do not exist." + }; + warn_user_once!( + "Ignoring invalid `SSL_CERT_DIR`. {end_note}: {}.", + missing + .iter() + .map(Simplified::simplified_display) + .join(", ") + .cyan() + ); + return false; + } + + // Warn on any missing entries + if !missing.is_empty() { + let end_note = if missing.len() == 1 { + "The following directory does not exist:" + } else { + "The following entries do not exist:" + }; + warn_user_once!( + "Invalid entries in `SSL_CERT_DIR`. {end_note}: {}.", + missing + .iter() + .map(Simplified::simplified_display) + .join(", ") + .cyan() + ); + } + + // Proceed while ignoring missing entries + true + }); + // Create a secure client that validates certificates. let raw_client = self.create_client( &user_agent_string, timeout, ssl_cert_file_exists, + ssl_cert_dir_exists, Security::Secure, self.redirect_policy, ); @@ -389,6 +441,7 @@ impl<'a> BaseClientBuilder<'a> { &user_agent_string, timeout, ssl_cert_file_exists, + ssl_cert_dir_exists, Security::Insecure, self.redirect_policy, ); @@ -401,6 +454,7 @@ impl<'a> BaseClientBuilder<'a> { user_agent: &str, timeout: Duration, ssl_cert_file_exists: bool, + ssl_cert_dir_exists: bool, security: Security, redirect_policy: RedirectPolicy, ) -> Client { @@ -419,7 +473,7 @@ impl<'a> BaseClientBuilder<'a> { Security::Insecure => client_builder.danger_accept_invalid_certs(true), }; - let client_builder = if self.native_tls || ssl_cert_file_exists { + let client_builder = if self.native_tls || ssl_cert_file_exists || ssl_cert_dir_exists { client_builder.tls_built_in_native_certs(true) } else { client_builder.tls_built_in_webpki_certs(true) diff --git a/crates/uv-client/tests/it/http_util.rs b/crates/uv-client/tests/it/http_util.rs new file mode 100644 index 000000000..b879699b3 --- /dev/null +++ b/crates/uv-client/tests/it/http_util.rs @@ -0,0 +1,382 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures::future; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::header::USER_AGENT; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, + Issuer, KeyPair, KeyUsagePurpose, SanType, date_time_ymd, +}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::server::WebPkiClientVerifier; +use rustls::{RootCertStore, ServerConfig}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; + +use uv_fs::Simplified; + +/// An issued certificate, together with the subject keypair. +#[derive(Debug)] +pub(crate) struct SelfSigned { + /// An issued certificate. + pub public: Certificate, + /// The certificate's subject signing key. + pub private: KeyPair, +} + +/// Defines the base location for temporary generated certs. +/// +/// See [`TestContext::test_bucket_dir`] for implementation rationale. +pub(crate) fn test_cert_dir() -> PathBuf { + std::env::temp_dir() + .simple_canonicalize() + .expect("failed to canonicalize temp dir") + .join("uv") + .join("tests") + .join("certs") +} + +/// Generates a self-signed server certificate for `uv-test-server`, `localhost` and `127.0.0.1`. +/// This certificate is standalone and not issued by a self-signed Root CA. +/// +/// Use sparingly as generation of certs is a slow operation. +pub(crate) fn generate_self_signed_certs() -> Result { + let mut params = CertificateParams::default(); + params.is_ca = IsCa::NoCa; + params.not_before = date_time_ymd(1975, 1, 1); + params.not_after = date_time_ymd(4096, 1, 1); + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params.key_usages.push(KeyUsagePurpose::KeyEncipherment); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + params + .distinguished_name + .push(DnType::CommonName, "uv-test-server"); + params + .subject_alt_names + .push(SanType::DnsName("uv-test-server".try_into()?)); + params + .subject_alt_names + .push(SanType::DnsName("localhost".try_into()?)); + params + .subject_alt_names + .push(SanType::IpAddress("127.0.0.1".parse()?)); + let private = KeyPair::generate()?; + let public = params.self_signed(&private)?; + + Ok(SelfSigned { public, private }) +} + +/// Generates a self-signed root CA, server certificate, and client certificate. +/// There are no intermediate certs generated as part of this function. +/// The server certificate is for `uv-test-server`, `localhost` and `127.0.0.1` issued by this CA. +/// The client certificate is for `uv-test-client` issued by this CA. +/// +/// Use sparingly as generation of these certs is a very slow operation. +pub(crate) fn generate_self_signed_certs_with_ca() -> Result<(SelfSigned, SelfSigned, SelfSigned)> { + // Generate the CA + let mut ca_params = CertificateParams::default(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); // root cert + ca_params.not_before = date_time_ymd(1975, 1, 1); + ca_params.not_after = date_time_ymd(4096, 1, 1); + ca_params.key_usages.push(KeyUsagePurpose::DigitalSignature); + ca_params.key_usages.push(KeyUsagePurpose::KeyCertSign); + ca_params.key_usages.push(KeyUsagePurpose::CrlSign); + ca_params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + ca_params + .distinguished_name + .push(DnType::CommonName, "uv-test-ca"); + ca_params + .subject_alt_names + .push(SanType::DnsName("uv-test-ca".try_into()?)); + let ca_private_key = KeyPair::generate()?; + let ca_public_cert = ca_params.self_signed(&ca_private_key)?; + let ca_cert_issuer = Issuer::new(ca_params, &ca_private_key); + + // Generate server cert issued by this CA + let mut server_params = CertificateParams::default(); + server_params.is_ca = IsCa::NoCa; + server_params.not_before = date_time_ymd(1975, 1, 1); + server_params.not_after = date_time_ymd(4096, 1, 1); + server_params.use_authority_key_identifier_extension = true; + server_params + .key_usages + .push(KeyUsagePurpose::DigitalSignature); + server_params + .key_usages + .push(KeyUsagePurpose::KeyEncipherment); + server_params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + server_params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + server_params + .distinguished_name + .push(DnType::CommonName, "uv-test-server"); + server_params + .subject_alt_names + .push(SanType::DnsName("uv-test-server".try_into()?)); + server_params + .subject_alt_names + .push(SanType::DnsName("localhost".try_into()?)); + server_params + .subject_alt_names + .push(SanType::IpAddress("127.0.0.1".parse()?)); + let server_private_key = KeyPair::generate()?; + let server_public_cert = server_params.signed_by(&server_private_key, &ca_cert_issuer)?; + + // Generate client cert issued by this CA + let mut client_params = CertificateParams::default(); + client_params.is_ca = IsCa::NoCa; + client_params.not_before = date_time_ymd(1975, 1, 1); + client_params.not_after = date_time_ymd(4096, 1, 1); + client_params.use_authority_key_identifier_extension = true; + client_params + .key_usages + .push(KeyUsagePurpose::DigitalSignature); + client_params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ClientAuth); + client_params + .distinguished_name + .push(DnType::OrganizationName, "Astral Software Inc."); + client_params + .distinguished_name + .push(DnType::CommonName, "uv-test-client"); + client_params + .subject_alt_names + .push(SanType::DnsName("uv-test-client".try_into()?)); + let client_private_key = KeyPair::generate()?; + let client_public_cert = client_params.signed_by(&client_private_key, &ca_cert_issuer)?; + + let ca_self_signed = SelfSigned { + public: ca_public_cert, + private: ca_private_key, + }; + let server_self_signed = SelfSigned { + public: server_public_cert, + private: server_private_key, + }; + let client_self_signed = SelfSigned { + public: client_public_cert, + private: client_private_key, + }; + + Ok((ca_self_signed, server_self_signed, client_self_signed)) +} + +// Plain is fine for now; Arc/Box could be used later if we need to support move. +type ServerSvcFn = + fn( + Request, + ) -> future::Ready>, hyper::Error>>; + +#[derive(Default)] +pub(crate) struct TestServerBuilder<'a> { + // Custom server response function + svc_fn: Option, + // CA certificate + ca_cert: Option<&'a SelfSigned>, + // Server certificate + server_cert: Option<&'a SelfSigned>, + // Enable mTLS Verification + mutual_tls: bool, +} + +impl<'a> TestServerBuilder<'a> { + pub(crate) fn new() -> Self { + Self { + svc_fn: None, + server_cert: None, + ca_cert: None, + mutual_tls: false, + } + } + + #[expect(unused)] + /// Provide a custom server response function. + pub(crate) fn with_svc_fn(mut self, svc_fn: ServerSvcFn) -> Self { + self.svc_fn = Some(svc_fn); + self + } + + /// Provide the server certificate. This will enable TLS (HTTPS). + pub(crate) fn with_server_cert(mut self, server_cert: &'a SelfSigned) -> Self { + self.server_cert = Some(server_cert); + self + } + + /// CA certificate used to build the `RootCertStore` for client verification. + /// Requires `with_server_cert`. + pub(crate) fn with_ca_cert(mut self, ca_cert: &'a SelfSigned) -> Self { + self.ca_cert = Some(ca_cert); + self + } + + /// Enforce mutual TLS (client cert auth). + /// Requires `with_server_cert` and `with_ca_cert`. + pub(crate) fn with_mutual_tls(mut self, mutual: bool) -> Self { + self.mutual_tls = mutual; + self + } + + /// Starts the HTTP(S) server with optional mTLS enforcement. + pub(crate) async fn start(self) -> Result<(JoinHandle>, SocketAddr)> { + // Validate builder input combinations + if self.ca_cert.is_some() && self.server_cert.is_none() { + anyhow::bail!("server certificate is required when CA certificate is provided"); + } + if self.mutual_tls && (self.ca_cert.is_none() || self.server_cert.is_none()) { + anyhow::bail!("ca certificate is required for mTLS"); + } + + // Set up the TCP listener on a random available port + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + // Setup TLS Config (if any) + let tls_acceptor = if let Some(server_cert) = self.server_cert { + // Prepare Server Cert and KeyPair + let server_key = PrivateKeyDer::try_from(server_cert.private.serialize_der()).unwrap(); + let server_cert = vec![CertificateDer::from(server_cert.public.der().to_vec())]; + + // Setup CA Verifier + let client_verifier = if let Some(ca_cert) = self.ca_cert { + let mut root_store = RootCertStore::empty(); + root_store + .add(CertificateDer::from(ca_cert.public.der().to_vec())) + .expect("failed to add CA cert"); + if self.mutual_tls { + // Setup mTLS CA config + WebPkiClientVerifier::builder(root_store.into()) + .build() + .expect("failed to setup client verifier") + } else { + // Only load the CA roots + WebPkiClientVerifier::builder(root_store.into()) + .allow_unauthenticated() + .build() + .expect("failed to setup client verifier") + } + } else { + WebPkiClientVerifier::no_client_auth() + }; + + let mut tls_config = ServerConfig::builder() + .with_client_cert_verifier(client_verifier) + .with_single_cert(server_cert, server_key)?; + tls_config.alpn_protocols = vec![b"http/1.1".to_vec(), b"http/1.0".to_vec()]; + + Some(TlsAcceptor::from(Arc::new(tls_config))) + } else { + None + }; + + // Setup Response Handler + let svc_fn = if let Some(custom_svc_fn) = self.svc_fn { + custom_svc_fn + } else { + |req: Request| { + // Get User Agent Header and send it back in the response + let user_agent = req + .headers() + .get(USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(ToString::to_string) + .unwrap_or_default(); // Empty Default + let response_content = Full::new(Bytes::from(user_agent)) + .map_err(|_| unreachable!()) + .boxed(); + // If we ever want a true echo server, we can use instead + // let response_content = req.into_body().boxed(); + // although uv-client doesn't expose post currently. + future::ok::<_, hyper::Error>(Response::new(response_content)) + } + }; + + // Spawn the server loop in a background task + let server_task = tokio::spawn(async move { + let svc = service_fn(move |req: Request| svc_fn(req)); + + let (tcp_stream, _remote_addr) = listener + .accept() + .await + .context("Failed to accept TCP connection")?; + + // Start Server (not wrapped in loop {} since we want a single response server) + // If we want server to accept multiple connections, we can wrap it in loop {} + // but we'll need to ensure to handle termination signals in the tests otherwise + // it may never stop. + if let Some(tls_acceptor) = tls_acceptor { + let tls_stream = tls_acceptor + .accept(tcp_stream) + .await + .context("Failed to accept TLS connection")?; + let socket = TokioIo::new(tls_stream); + tokio::task::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(socket, svc) + .await + .expect("HTTPS Server Started"); + }); + } else { + let socket = TokioIo::new(tcp_stream); + tokio::task::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(socket, svc) + .await + .expect("HTTP Server Started"); + }); + } + + Ok(()) + }); + + Ok((server_task, addr)) + } +} + +/// Single Request HTTP server that echoes the User Agent Header. +pub(crate) async fn start_http_user_agent_server() -> Result<(JoinHandle>, SocketAddr)> { + TestServerBuilder::new().start().await +} + +/// Single Request HTTPS server that echoes the User Agent Header. +pub(crate) async fn start_https_user_agent_server( + server_cert: &SelfSigned, +) -> Result<(JoinHandle>, SocketAddr)> { + TestServerBuilder::new() + .with_server_cert(server_cert) + .start() + .await +} + +/// Single Request HTTPS mTLS server that echoes the User Agent Header. +pub(crate) async fn start_https_mtls_user_agent_server( + ca_cert: &SelfSigned, + server_cert: &SelfSigned, +) -> Result<(JoinHandle>, SocketAddr)> { + TestServerBuilder::new() + .with_ca_cert(ca_cert) + .with_server_cert(server_cert) + .with_mutual_tls(true) + .start() + .await +} diff --git a/crates/uv-client/tests/it/main.rs b/crates/uv-client/tests/it/main.rs index c6f2b96e8..13674f930 100644 --- a/crates/uv-client/tests/it/main.rs +++ b/crates/uv-client/tests/it/main.rs @@ -1,2 +1,4 @@ +mod http_util; mod remote_metadata; +mod ssl_certs; mod user_agent_version; diff --git a/crates/uv-client/tests/it/remote_metadata.rs b/crates/uv-client/tests/it/remote_metadata.rs index 14ba42e03..b6461027c 100644 --- a/crates/uv-client/tests/it/remote_metadata.rs +++ b/crates/uv-client/tests/it/remote_metadata.rs @@ -21,11 +21,11 @@ async fn remote_metadata_with_and_without_cache() -> Result<()> { let filename = WheelFilename::from_str(url.rsplit_once('/').unwrap().1)?; let dist = BuiltDist::DirectUrl(DirectUrlBuiltDist { filename, - location: Box::new(DisplaySafeUrl::parse(url).unwrap()), - url: VerbatimUrl::from_str(url).unwrap(), + location: Box::new(DisplaySafeUrl::parse(url)?), + url: VerbatimUrl::from_str(url)?, }); let capabilities = IndexCapabilities::default(); - let metadata = client.wheel_metadata(&dist, &capabilities).await.unwrap(); + let metadata = client.wheel_metadata(&dist, &capabilities).await?; assert_eq!(metadata.version.to_string(), "4.66.1"); } diff --git a/crates/uv-client/tests/it/ssl_certs.rs b/crates/uv-client/tests/it/ssl_certs.rs new file mode 100644 index 000000000..b634b425b --- /dev/null +++ b/crates/uv-client/tests/it/ssl_certs.rs @@ -0,0 +1,333 @@ +use std::str::FromStr; + +use anyhow::Result; +use rustls::AlertDescription; +use url::Url; + +use uv_cache::Cache; +use uv_client::BaseClientBuilder; +use uv_client::RegistryClientBuilder; +use uv_redacted::DisplaySafeUrl; +use uv_static::EnvVars; + +use crate::http_util::{ + generate_self_signed_certs, generate_self_signed_certs_with_ca, + start_https_mtls_user_agent_server, start_https_user_agent_server, test_cert_dir, +}; + +// SAFETY: This test is meant to run with single thread configuration +#[tokio::test] +#[allow(unsafe_code)] +async fn ssl_env_vars() -> Result<()> { + // Ensure our environment is not polluted with anything that may affect `rustls-native-certs` + unsafe { + std::env::remove_var(EnvVars::UV_NATIVE_TLS); + std::env::remove_var(EnvVars::SSL_CERT_FILE); + std::env::remove_var(EnvVars::SSL_CERT_DIR); + std::env::remove_var(EnvVars::SSL_CLIENT_CERT); + } + + // Create temporary cert dirs + let cert_dir = test_cert_dir(); + fs_err::create_dir_all(&cert_dir).expect("Failed to create test cert bucket"); + let cert_dir = + tempfile::TempDir::new_in(cert_dir).expect("Failed to create test cert directory"); + let does_not_exist_cert_dir = cert_dir.path().join("does_not_exist"); + + // Generate self-signed standalone cert + let standalone_server_cert = generate_self_signed_certs()?; + let standalone_public_pem_path = cert_dir.path().join("standalone_public.pem"); + let standalone_private_pem_path = cert_dir.path().join("standalone_private.pem"); + + // Generate self-signed CA, server, and client certs + let (ca_cert, server_cert, client_cert) = generate_self_signed_certs_with_ca()?; + let ca_public_pem_path = cert_dir.path().join("ca_public.pem"); + let ca_private_pem_path = cert_dir.path().join("ca_private.pem"); + let server_public_pem_path = cert_dir.path().join("server_public.pem"); + let server_private_pem_path = cert_dir.path().join("server_private.pem"); + let client_combined_pem_path = cert_dir.path().join("client_combined.pem"); + + // Persist the certs in PKCS8 format as the env vars expect a path on disk + fs_err::write( + standalone_public_pem_path.as_path(), + standalone_server_cert.public.pem(), + )?; + fs_err::write( + standalone_private_pem_path.as_path(), + standalone_server_cert.private.serialize_pem(), + )?; + fs_err::write(ca_public_pem_path.as_path(), ca_cert.public.pem())?; + fs_err::write( + ca_private_pem_path.as_path(), + ca_cert.private.serialize_pem(), + )?; + fs_err::write(server_public_pem_path.as_path(), server_cert.public.pem())?; + fs_err::write( + server_private_pem_path.as_path(), + server_cert.private.serialize_pem(), + )?; + fs_err::write( + client_combined_pem_path.as_path(), + // SSL_CLIENT_CERT expects a "combined" cert with the public and private key. + format!( + "{}\n{}", + client_cert.public.pem(), + client_cert.private.serialize_pem() + ), + )?; + + // ** Set SSL_CERT_FILE to non-existent location + // ** Then verify our request fails to establish a connection + + unsafe { + std::env::set_var(EnvVars::SSL_CERT_FILE, does_not_exist_cert_dir.as_os_str()); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + } + + // Validate the client error + let Some(reqwest_middleware::Error::Middleware(middleware_error)) = res.err() else { + panic!("expected middleware error"); + }; + let reqwest_error = middleware_error + .chain() + .find_map(|err| { + err.downcast_ref::().map(|err| { + if let reqwest_middleware::Error::Reqwest(inner) = err { + inner + } else { + panic!("expected reqwest error") + } + }) + }) + .expect("expected reqwest error"); + assert!(reqwest_error.is_connect()); + + // Validate the server error + let server_res = server_task.await?; + let expected_err = if let Err(anyhow_err) = server_res + && let Some(io_err) = anyhow_err.downcast_ref::() + && let Some(wrapped_err) = io_err.get_ref() + && let Some(tls_err) = wrapped_err.downcast_ref::() + && matches!( + tls_err, + rustls::Error::AlertReceived(AlertDescription::UnknownCA) + ) { + true + } else { + false + }; + assert!(expected_err); + + // ** Set SSL_CERT_FILE to our public certificate + // ** Then verify our request successfully establishes a connection + + unsafe { + std::env::set_var( + EnvVars::SSL_CERT_FILE, + standalone_public_pem_path.as_os_str(), + ); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + assert!(res.is_ok()); + let _ = server_task.await?; // wait for server shutdown + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + } + + // ** Set SSL_CERT_DIR to our cert dir as well as some other dir that does not exist + // ** Then verify our request still successfully establishes a connection + + unsafe { + std::env::set_var( + EnvVars::SSL_CERT_DIR, + std::env::join_paths(vec![ + cert_dir.path().as_os_str(), + does_not_exist_cert_dir.as_os_str(), + ])?, + ); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + assert!(res.is_ok()); + let _ = server_task.await?; // wait for server shutdown + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_DIR); + } + + // ** Set SSL_CERT_DIR to only the dir that does not exist + // ** Then verify our request fails to establish a connection + + unsafe { + std::env::set_var(EnvVars::SSL_CERT_DIR, does_not_exist_cert_dir.as_os_str()); + } + let (server_task, addr) = start_https_user_agent_server(&standalone_server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_DIR); + } + + // Validate the client error + let Some(reqwest_middleware::Error::Middleware(middleware_error)) = res.err() else { + panic!("expected middleware error"); + }; + let reqwest_error = middleware_error + .chain() + .find_map(|err| { + err.downcast_ref::().map(|err| { + if let reqwest_middleware::Error::Reqwest(inner) = err { + inner + } else { + panic!("expected reqwest error") + } + }) + }) + .expect("expected reqwest error"); + assert!(reqwest_error.is_connect()); + + // Validate the server error + let server_res = server_task.await?; + let expected_err = if let Err(anyhow_err) = server_res + && let Some(io_err) = anyhow_err.downcast_ref::() + && let Some(wrapped_err) = io_err.get_ref() + && let Some(tls_err) = wrapped_err.downcast_ref::() + && matches!( + tls_err, + rustls::Error::AlertReceived(AlertDescription::UnknownCA) + ) { + true + } else { + false + }; + assert!(expected_err); + + // *** mTLS Tests + + // ** Set SSL_CERT_FILE to our CA and SSL_CLIENT_CERT to our client cert + // ** Then verify our request still successfully establishes a connection + + // We need to set SSL_CERT_FILE or SSL_CERT_DIR to our CA as we need to tell + // our HTTP client that we trust certificates issued by our self-signed CA. + // This inherently also tests that our server cert is also validated as part + // of the certificate path validation algorithm. + unsafe { + std::env::set_var(EnvVars::SSL_CERT_FILE, ca_public_pem_path.as_os_str()); + std::env::set_var( + EnvVars::SSL_CLIENT_CERT, + client_combined_pem_path.as_os_str(), + ); + } + let (server_task, addr) = start_https_mtls_user_agent_server(&ca_cert, &server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + assert!(res.is_ok()); + let _ = server_task.await?; // wait for server shutdown + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + std::env::remove_var(EnvVars::SSL_CLIENT_CERT); + } + + // ** Set SSL_CERT_FILE to our CA and unset SSL_CLIENT_CERT + // ** Then verify our request fails to establish a connection + + unsafe { + std::env::set_var(EnvVars::SSL_CERT_FILE, ca_public_pem_path.as_os_str()); + } + let (server_task, addr) = start_https_mtls_user_agent_server(&ca_cert, &server_cert).await?; + let url = DisplaySafeUrl::from_str(&format!("https://{addr}"))?; + let cache = Cache::temp()?.init()?; + let client = RegistryClientBuilder::new(BaseClientBuilder::default(), cache).build(); + let res = client + .cached_client() + .uncached() + .for_host(&url) + .get(Url::from(url)) + .send() + .await; + unsafe { + std::env::remove_var(EnvVars::SSL_CERT_FILE); + } + + // Validate the client error + let Some(reqwest_middleware::Error::Middleware(middleware_error)) = res.err() else { + panic!("expected middleware error"); + }; + let reqwest_error = middleware_error + .chain() + .find_map(|err| { + err.downcast_ref::().map(|err| { + if let reqwest_middleware::Error::Reqwest(inner) = err { + inner + } else { + panic!("expected reqwest error") + } + }) + }) + .expect("expected reqwest error"); + assert!(reqwest_error.is_connect()); + + // Validate the server error + let server_res = server_task.await?; + let expected_err = if let Err(anyhow_err) = server_res + && let Some(io_err) = anyhow_err.downcast_ref::() + && let Some(wrapped_err) = io_err.get_ref() + && let Some(tls_err) = wrapped_err.downcast_ref::() + && matches!(tls_err, rustls::Error::NoCertificatesPresented) + { + true + } else { + false + }; + assert!(expected_err); + + // Fin. + Ok(()) +} diff --git a/crates/uv-client/tests/it/user_agent_version.rs b/crates/uv-client/tests/it/user_agent_version.rs index 5c4a1e80c..90aecef38 100644 --- a/crates/uv-client/tests/it/user_agent_version.rs +++ b/crates/uv-client/tests/it/user_agent_version.rs @@ -1,16 +1,9 @@ -use anyhow::Result; -use futures::future; -use http_body_util::Full; -use hyper::body::Bytes; -use hyper::header::USER_AGENT; -use hyper::server::conn::http1; -use hyper::service::service_fn; -use hyper::{Request, Response}; -use hyper_util::rt::TokioIo; -use insta::{assert_json_snapshot, assert_snapshot, with_settings}; use std::str::FromStr; -use tokio::net::TcpListener; + +use anyhow::Result; +use insta::{assert_json_snapshot, assert_snapshot, with_settings}; use url::Url; + use uv_cache::Cache; use uv_client::RegistryClientBuilder; use uv_client::{BaseClientBuilder, LineHaul}; @@ -19,36 +12,12 @@ use uv_platform_tags::{Arch, Os, Platform}; use uv_redacted::DisplaySafeUrl; use uv_version::version; +use crate::http_util::start_http_user_agent_server; + #[tokio::test] async fn test_user_agent_has_version() -> Result<()> { - // Set up the TCP listener on a random available port - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - // Spawn the server loop in a background task - let server_task = tokio::spawn(async move { - let svc = service_fn(move |req: Request| { - // Get User Agent Header and send it back in the response - let user_agent = req - .headers() - .get(USER_AGENT) - .and_then(|v| v.to_str().ok()) - .map(ToString::to_string) - .unwrap_or_default(); // Empty Default - future::ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from(user_agent)))) - }); - // Start Server (not wrapped in loop {} since we want a single response server) - // If you want server to accept multiple connections, wrap it in loop {} - let (socket, _) = listener.accept().await.unwrap(); - let socket = TokioIo::new(socket); - tokio::task::spawn(async move { - http1::Builder::new() - .serve_connection(socket, svc) - .with_upgrades() - .await - .expect("Server Started"); - }); - }); + // Initialize dummy http server + let (server_task, addr) = start_http_user_agent_server().await?; // Initialize uv-client let cache = Cache::temp()?.init()?; @@ -94,41 +63,15 @@ async fn test_user_agent_has_version() -> Result<()> { }); // Wait for the server task to complete, to be a good citizen. - server_task.await?; + let _ = server_task.await?; Ok(()) } #[tokio::test] async fn test_user_agent_has_linehaul() -> Result<()> { - // Set up the TCP listener on a random available port - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - // Spawn the server loop in a background task - let server_task = tokio::spawn(async move { - let svc = service_fn(move |req: Request| { - // Get User Agent Header and send it back in the response - let user_agent = req - .headers() - .get(USER_AGENT) - .and_then(|v| v.to_str().ok()) - .map(ToString::to_string) - .unwrap_or_default(); // Empty Default - future::ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from(user_agent)))) - }); - // Start Server (not wrapped in loop {} since we want a single response server) - // If you want server to accept multiple connections, wrap it in loop {} - let (socket, _) = listener.accept().await.unwrap(); - let socket = TokioIo::new(socket); - tokio::task::spawn(async move { - http1::Builder::new() - .serve_connection(socket, svc) - .with_upgrades() - .await - .expect("Server Started"); - }); - }); + // Initialize dummy http server + let (server_task, addr) = start_http_user_agent_server().await?; // Add some representative markers for an Ubuntu CI runner let markers = MarkerEnvironment::try_from(MarkerEnvironmentBuilder { @@ -143,8 +86,7 @@ async fn test_user_agent_has_linehaul() -> Result<()> { python_full_version: "3.12.2", python_version: "3.12", sys_platform: "linux", - }) - .unwrap(); + })?; // Initialize uv-client let cache = Cache::temp()?.init()?; @@ -189,7 +131,7 @@ async fn test_user_agent_has_linehaul() -> Result<()> { let body = res.text().await?; // Wait for the server task to complete, to be a good citizen. - server_task.await?; + let _ = server_task.await?; // Unpack User-Agent with linehaul let (uv_version, uv_linehaul) = body diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 53e1d98f1..0a4c824c3 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -586,9 +586,19 @@ impl EnvVars { pub const XDG_BIN_HOME: &'static str = "XDG_BIN_HOME"; /// Custom certificate bundle file path for SSL connections. + /// + /// Takes precedence over `UV_NATIVE_TLS` when set. #[attr_added_in("0.1.14")] pub const SSL_CERT_FILE: &'static str = "SSL_CERT_FILE"; + /// Custom path for certificate bundles for SSL connections. + /// Multiple entries are supported separated using a platform-specific + /// delimiter (`:` on Unix, `;` on Windows). + /// + /// Takes precedence over `UV_NATIVE_TLS` when set. + #[attr_added_in("next release")] + pub const SSL_CERT_DIR: &'static str = "SSL_CERT_DIR"; + /// If set, uv will use this file for mTLS authentication. /// This should be a single file containing both the certificate and the private key in PEM format. #[attr_added_in("0.2.11")] diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 9bae80e46..ad2c4cf60 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -1004,11 +1004,22 @@ the fact that Windows' real main thread is only 1MB. That thread has size The standard `SHELL` posix env var. +### `SSL_CERT_DIR` +added in `next release` + +Custom path for certificate bundles for SSL connections. +Multiple entries are supported separated using a platform-specific +delimiter (`:` on Unix, `;` on Windows). + +Takes precedence over `UV_NATIVE_TLS` when set. + ### `SSL_CERT_FILE` added in `0.1.14` Custom certificate bundle file path for SSL connections. +Takes precedence over `UV_NATIVE_TLS` when set. + ### `SSL_CLIENT_CERT` added in `0.2.11`