From 0f3e8d9f6e948eedb836dc0abc2a87dbf9627757 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 14 Jul 2026 17:01:27 -0700 Subject: [PATCH] Add `--cert` support to `uv pip` (#20367) ## Summary Add pip-compatible `--cert ` support to all `uv pip` commands. The option is scoped to the `uv pip` namespace, so other uv commands continue to reject it. An explicit certificate bundle replaces uv's default, system, and `SSL_CERT_FILE` / `SSL_CERT_DIR` trust sources for that invocation, matching pip's behavior without modifying the environment inherited by child processes. --- crates/uv-cli/src/compat.rs | 18 ----- crates/uv-cli/src/lib.rs | 6 ++ crates/uv-client/src/base_client.rs | 20 +++-- crates/uv-client/src/lib.rs | 1 + crates/uv-client/src/tls.rs | 51 +++++++++++-- crates/uv-client/tests/it/ssl_certs.rs | 78 +++++++++++++++++--- crates/uv/src/lib.rs | 29 +++++++- crates/uv/tests/it/help.rs | 23 ++++++ crates/uv/tests/pip/pip_sync.rs | 11 ++- crates/uv/tests/pip_compile/pip_compile.rs | 11 ++- docs/concepts/authentication/certificates.md | 6 ++ 11 files changed, 201 insertions(+), 53 deletions(-) diff --git a/crates/uv-cli/src/compat.rs b/crates/uv-cli/src/compat.rs index ffaa07772e8b0..69938f4b147ef 100644 --- a/crates/uv-cli/src/compat.rs +++ b/crates/uv-cli/src/compat.rs @@ -32,9 +32,6 @@ pub struct PipCompileCompatArgs { #[clap(long, hide = true)] max_rounds: Option, - #[clap(long, hide = true)] - cert: Option, - #[clap(long, hide = true)] client_cert: Option, @@ -110,12 +107,6 @@ impl CompatArgs for PipCompileCompatArgs { )); } - if self.cert.is_some() { - return Err(anyhow!( - "pip-compile's `--cert` is unsupported (set the `SSL_CERT_FILE` environment variable to use a custom CA certificate bundle)" - )); - } - if self.client_cert.is_some() { return Err(anyhow!( "pip-compile's `--client-cert` is unsupported (uv doesn't support dedicated client certificates)" @@ -198,9 +189,6 @@ pub struct PipSyncCompatArgs { #[clap(long, hide = true)] user: bool, - #[clap(long, hide = true)] - cert: Option, - #[clap(long, hide = true)] client_cert: Option, @@ -239,12 +227,6 @@ impl CompatArgs for PipSyncCompatArgs { )); } - if self.cert.is_some() { - return Err(anyhow!( - "pip-sync's `--cert` is unsupported (set the `SSL_CERT_FILE` environment variable to use a custom CA certificate bundle)" - )); - } - if self.client_cert.is_some() { return Err(anyhow!( "pip-sync's `--client-cert` is unsupported (uv doesn't support dedicated client certificates)" diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 18a671485243f..b13dfb7abda9e 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -964,6 +964,12 @@ pub struct SizeArgs { pub struct PipNamespace { #[command(subcommand)] pub command: PipCommand, + + /// Path to a PEM-encoded CA certificate bundle. + /// + /// If provided, this overrides the default certificate source. + #[arg(global = true, long, value_name = "FILE", value_hint = ValueHint::FilePath)] + pub cert: Option, } #[derive(Subcommand)] diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index ecbe42946ae61..e91c3180471ff 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -93,6 +93,7 @@ pub struct BaseClientBuilder<'a> { preview: Preview, allow_insecure_host: Vec, system_certs: bool, + custom_certificates: Option, retries: u32, pub connectivity: Connectivity, markers: Option<&'a MarkerEnvironment>, @@ -165,6 +166,7 @@ impl Default for BaseClientBuilder<'_> { preview: Preview::default(), allow_insecure_host: vec![], system_certs: false, + custom_certificates: None, connectivity: Connectivity::Online, retries: DEFAULT_RETRIES, markers: None, @@ -258,6 +260,13 @@ impl<'a> BaseClientBuilder<'a> { self } + /// Use custom certificate authorities for TLS verification. + #[must_use] + pub fn custom_certificates(mut self, certificates: Certificates) -> Self { + self.custom_certificates = Some(certificates); + self + } + #[must_use] pub(crate) fn markers(mut self, markers: &'a MarkerEnvironment) -> Self { self.markers = Some(markers); @@ -476,8 +485,10 @@ impl<'a> BaseClientBuilder<'a> { let _ = write!(user_agent_string, " {output}"); } - // Load custom CA certificates from `SSL_CERT_FILE` and `SSL_CERT_DIR`. - let custom_certs = Certificates::from_env().map(|certs| certs.to_reqwest_certs()); + let custom_certs = self + .custom_certificates + .as_ref() + .map(Certificates::to_reqwest_certs); let certificate_source = if custom_certs.is_some() { CertificateSource::Custom } else if self.system_certs { @@ -537,8 +548,7 @@ impl<'a> BaseClientBuilder<'a> { // Configure the certificate source. // - // `SSL_CERT_FILE` and `SSL_CERT_DIR` override the default certificate source when they - // contain valid certificates. + // Explicit certificates override the default certificate source. let client_builder = if let Some(custom_certs) = custom_certs { client_builder.tls_certs_only(custom_certs) } else if self.system_certs { @@ -709,7 +719,7 @@ pub(crate) enum CertificateSource { System, /// The bundled `WebPKI` certificate roots. WebPki, - /// Custom roots loaded from `SSL_CERT_FILE` or `SSL_CERT_DIR`. + /// Custom certificate roots. Custom, /// An externally constructed client whose certificate roots are unknown. Unknown, diff --git a/crates/uv-client/src/lib.rs b/crates/uv-client/src/lib.rs index dd43e7ebddced..2d04b5e99d378 100644 --- a/crates/uv-client/src/lib.rs +++ b/crates/uv-client/src/lib.rs @@ -14,6 +14,7 @@ pub use registry_client::{ pub(crate) use retry::UvRetryableStrategy; pub use retry::{RetriableError, RetryState, retryable_on_request_failure}; pub use rkyvutil::OwnedArchive; +pub use tls::{CertificateFileError, Certificates}; mod base_client; mod cached_client; diff --git a/crates/uv-client/src/tls.rs b/crates/uv-client/src/tls.rs index 9ade0f9e3c8e5..ea10aa0910aa2 100644 --- a/crates/uv-client/src/tls.rs +++ b/crates/uv-client/src/tls.rs @@ -17,13 +17,15 @@ use uv_warnings::warn_user_once; #[derive(Debug, Clone)] enum CertificateSource { + CertFileArg(PathBuf), SslCertFile(PathBuf), SslCertDir(PathBuf), } impl CertificateSource { - const fn env_var(&self) -> &'static str { + const fn description(&self) -> &'static str { match self { + Self::CertFileArg(_) => "--cert", Self::SslCertFile(_) => EnvVars::SSL_CERT_FILE, Self::SslCertDir(_) => EnvVars::SSL_CERT_DIR, } @@ -31,7 +33,7 @@ impl CertificateSource { fn path(&self) -> &Path { match self { - Self::SslCertFile(path) | Self::SslCertDir(path) => path, + Self::CertFileArg(path) | Self::SslCertFile(path) | Self::SslCertDir(path) => path, } } } @@ -133,7 +135,7 @@ impl Display for InvalidCertificateWarning { f, "certificate in `{}` (from `{}`) ", self.source.path().simplified_display(), - self.source.env_var() + self.source.description() )?; match &self.reason { InvalidCertificateReason::UnsupportedCriticalExtension => { @@ -192,7 +194,7 @@ impl Display for InvalidCertificateWarning { /// A collection of TLS certificates in DER form. #[derive(Debug, Clone, Default)] -pub(crate) struct Certificates(Vec>); +pub struct Certificates(Vec>); impl Certificates { /// Load the bundled Mozilla root certificates. @@ -210,12 +212,41 @@ impl Certificates { Self(webpki_root_certs::TLS_SERVER_ROOT_CERTS.to_vec()) } + /// Load a custom CA certificate bundle from an explicit path. + /// + /// Unlike [`Self::from_ssl_cert_file`], an invalid path or a bundle without any valid + /// certificates returns an error instead of being ignored with a warning. + pub fn from_file(file: &Path) -> Result { + let metadata = file + .metadata() + .map_err(|err| CertificateFileError::Io(file.to_path_buf(), err))?; + if !metadata.is_file() { + return Err(CertificateFileError::NotFile(file.to_path_buf())); + } + + let result = Self::from_paths(Some(file), None); + for err in &result.errors { + warn!( + "Failed to load certificate file ({}): {err}", + file.simplified_display() + ); + } + let certs = + Self::from(result).filter_invalid(&CertificateSource::CertFileArg(file.to_path_buf())); + if certs.0.is_empty() { + return Err(CertificateFileError::NoValidCertificates( + file.to_path_buf(), + )); + } + Ok(certs) + } + /// Load custom CA certificates from `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables. /// /// Returns `None` if neither variable is set, if the referenced files or directories are /// missing or inaccessible, or if no valid certificates are found (with a warning in each /// case). Delegates path loading to [`rustls_native_certs::load_certs_from_paths`]. - pub(crate) fn from_env() -> Option { + pub fn from_env() -> Option { let mut certs = Self::default(); let mut has_source = false; @@ -441,6 +472,16 @@ pub(crate) enum CertificateError { Reqwest(reqwest::Error), } +#[derive(thiserror::Error, Debug)] +pub enum CertificateFileError { + #[error("Failed to read certificate file `{}`", .0.simplified_display())] + Io(PathBuf, #[source] io::Error), + #[error("Certificate path is not a file: `{}`", .0.simplified_display())] + NotFile(PathBuf), + #[error("No valid certificates found in: `{}`", .0.simplified_display())] + NoValidCertificates(PathBuf), +} + /// Return the `Identity` from the provided file. pub(crate) fn read_identity( ssl_client_cert: &std::ffi::OsStr, diff --git a/crates/uv-client/tests/it/ssl_certs.rs b/crates/uv-client/tests/it/ssl_certs.rs index 55aac31ee216b..4e9a2fe813435 100644 --- a/crates/uv-client/tests/it/ssl_certs.rs +++ b/crates/uv-client/tests/it/ssl_certs.rs @@ -10,8 +10,7 @@ use tempfile::{NamedTempFile, TempDir}; use url::Url; use uv_cache::Cache; -use uv_client::BaseClientBuilder; -use uv_client::RegistryClientBuilder; +use uv_client::{BaseClientBuilder, Certificates, RegistryClientBuilder}; use uv_distribution_types::IndexUrl; use uv_errors::{ErrorOptions, Hint, write_error_chain_with_options}; use uv_redacted::DisplaySafeUrl; @@ -133,6 +132,7 @@ struct TestClient { overrides: Vec<(&'static str, String)>, system_certs: bool, custom_client: bool, + cert: Option, } /// Create a [`TestClient`] with no environment overrides. @@ -141,10 +141,17 @@ fn client() -> TestClient { overrides: Vec::new(), system_certs: false, custom_client: false, + cert: None, } } impl TestClient { + /// Set the explicit certificate file used to construct [`Certificates`]. + fn cert(mut self, path: &Path) -> Self { + self.cert = Some(path.to_path_buf()); + self + } + /// Enable or disable system certificate loading. fn system_certs(mut self, enabled: bool) -> Self { self.system_certs = enabled; @@ -221,13 +228,16 @@ impl TestClient { async_with_vars(vars, async { let (server_task, addr) = start_https_user_agent_server(&cert.server).await.unwrap(); let cache = Cache::temp().unwrap().init().await.unwrap(); - let client = RegistryClientBuilder::new( - BaseClientBuilder::default() - .retries(0) - .no_retry_delay(true) - .with_system_certs(system_certs), - cache, - ); + let base = BaseClientBuilder::default() + .retries(0) + .no_retry_delay(true) + .with_system_certs(system_certs); + let base = if let Some(certificates) = Certificates::from_env() { + base.custom_certificates(certificates) + } else { + base + }; + let client = RegistryClientBuilder::new(base, cache); let client = if custom_client { client.with_reqwest_client(reqwest::Client::new()) } else { @@ -370,7 +380,7 @@ impl TestClient { let system_certs = self.system_certs; async_with_vars(vars, async { let (server_task, addr) = start_https_user_agent_server(&cert.server).await.unwrap(); - let response = send_request(addr, system_certs).await; + let response = send_request(addr, system_certs, self.cert.as_deref()).await; check(response, server_task).await; }) .await; @@ -392,7 +402,7 @@ impl TestClient { let (server_task, addr) = start_https_mtls_user_agent_server(&cert.ca, &cert.server) .await .unwrap(); - let response = send_request(addr, system_certs).await; + let response = send_request(addr, system_certs, self.cert.as_deref()).await; check(response, server_task).await; }) .await; @@ -403,20 +413,40 @@ impl TestClient { async fn send_request( addr: SocketAddr, system_certs: bool, + cert: Option<&Path>, ) -> Result { let url = DisplaySafeUrl::from_str(&format!("https://{addr}")).unwrap(); - send_request_to(&url, system_certs).await + send_request_to_with_cert(&url, system_certs, cert).await } /// Send a GET request to an arbitrary URL using a fresh registry client. +#[cfg(feature = "test-pypi")] async fn send_request_to( url: &DisplaySafeUrl, system_certs: bool, +) -> Result { + send_request_to_with_cert(url, system_certs, None).await +} + +async fn send_request_to_with_cert( + url: &DisplaySafeUrl, + system_certs: bool, + cert: Option<&Path>, ) -> Result { let cache = Cache::temp().unwrap().init().await.unwrap(); + let custom_certificates = if let Some(cert) = cert { + Some(Certificates::from_file(cert).expect("failed to load certificate file")) + } else { + Certificates::from_env() + }; let base = BaseClientBuilder::default() .no_retry_delay(true) .with_system_certs(system_certs); + let base = if let Some(certificates) = custom_certificates { + base.custom_certificates(certificates) + } else { + base + }; let client = RegistryClientBuilder::new(base, cache) .build() .expect("failed to build registry client"); @@ -531,6 +561,30 @@ async fn test_ssl_cert_file_valid() -> Result<()> { Ok(()) } +/// An explicit certificate file pointing to the server's CA cert is trusted. +#[tokio::test] +async fn test_cli_cert_valid() -> Result<()> { + let cert = TestCertificate::new()?; + client() + .cert(&cert.trust_path) + .expect_https_connect_succeeds(&cert) + .await; + Ok(()) +} + +/// An explicit certificate file overrides the environment certificate sources. +#[tokio::test] +async fn test_cli_cert_overrides_environment() -> Result<()> { + let cert = TestCertificate::new()?; + let wrong_cert = TestCertificate::new()?; + client() + .ssl_cert_file(&wrong_cert.trust_path) + .cert(&cert.trust_path) + .expect_https_connect_succeeds(&cert) + .await; + Ok(()) +} + /// If `SSL_CERT_FILE` contains only an invalid trust anchor, the invalid /// certificate is ignored and the client falls back to webpki roots. #[tokio::test] diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c938562d8720d..bad52b17663b4 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -32,7 +32,7 @@ use uv_cli::{ PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace, compat::CompatArgs, }; -use uv_client::BaseClientBuilder; +use uv_client::{BaseClientBuilder, Certificates}; use uv_configuration::min_stack_size; use uv_flags::EnvironmentFlags; use uv_fs::{CWD, Simplified, normalize_path}; @@ -623,6 +623,18 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul WorkspaceCache::default() }; + // Configure custom CA certificates from `--cert` or from the environment (`SSL_CERT_FILE` and + // `SSL_CERT_DIR`). + // Like pip, an explicit certificate bundle overrides the environment certificate sources. + let custom_certificates = if let Commands::Pip(PipNamespace { + cert: Some(cert), .. + }) = &*cli.command + { + Some(Certificates::from_file(cert)?) + } else { + Certificates::from_env() + }; + // Configure the global network settings. let client_builder = BaseClientBuilder::new( globals.network_settings.connectivity, @@ -636,6 +648,11 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul .http_proxy(globals.network_settings.http_proxy.clone()) .https_proxy(globals.network_settings.https_proxy.clone()) .no_proxy(globals.network_settings.no_proxy.clone()); + let client_builder = if let Some(certificates) = custom_certificates { + client_builder.custom_certificates(certificates) + } else { + client_builder + }; match *cli.command { Commands::Auth(AuthNamespace { @@ -717,6 +734,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul ), Commands::Pip(PipNamespace { command: PipCommand::Compile(args), + .. }) => { args.compat_args.validate()?; @@ -836,6 +854,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Sync(args), + .. }) => { args.compat_args.validate()?; @@ -925,6 +944,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Install(args), + .. }) => { args.compat_args.validate()?; @@ -1087,6 +1107,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Uninstall(args), + .. }) => { args.compat_args.validate()?; @@ -1124,6 +1145,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Freeze(args), + .. }) => { // Resolve the settings from the command-line arguments and workspace configuration. let args = PipFreezeSettings::resolve(args, filesystem, environment); @@ -1148,6 +1170,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::List(args), + .. }) => { args.compat_args.validate()?; @@ -1183,6 +1206,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Show(args), + .. }) => { // Resolve the settings from the command-line arguments and workspace configuration. let args = PipShowSettings::resolve(args, filesystem, environment); @@ -1206,6 +1230,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Tree(args), + .. }) => { // Resolve the settings from the command-line arguments and workspace configuration. let args = PipTreeSettings::resolve(args, filesystem, environment); @@ -1239,6 +1264,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Check(args), + .. }) => { // Resolve the settings from the command-line arguments and workspace configuration. let args = PipCheckSettings::resolve(args, filesystem, environment); @@ -1259,6 +1285,7 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul } Commands::Pip(PipNamespace { command: PipCommand::Debug(_), + .. }) => Err(anyhow!( "pip's `debug` is unsupported (consider using `uvx pip debug` instead)" )), diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index ac9455eb50aa4..e01a5e557f6ac 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -2,6 +2,29 @@ use uv_static::EnvVars; use uv_test::uv_snapshot; +#[test] +fn cert_is_limited_to_pip() { + let context = uv_test::test_context_with_versions!(&[]); + + uv_snapshot!(context.filters(), context.command() + .arg("sync") + .arg("--cert") + .arg("ca-bundle.pem"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: unexpected argument '--cert' found + + tip: a similar argument exists: '--script' + + Usage: uv sync --script