Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions crates/uv-cli/src/compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ pub struct PipCompileCompatArgs {
#[clap(long, hide = true)]
max_rounds: Option<usize>,

#[clap(long, hide = true)]
cert: Option<String>,

#[clap(long, hide = true)]
client_cert: Option<String>,

Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -198,9 +189,6 @@ pub struct PipSyncCompatArgs {
#[clap(long, hide = true)]
user: bool,

#[clap(long, hide = true)]
cert: Option<String>,

#[clap(long, hide = true)]
client_cert: Option<String>,

Expand Down Expand Up @@ -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)"
Expand Down
6 changes: 6 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
}

#[derive(Subcommand)]
Expand Down
20 changes: 15 additions & 5 deletions crates/uv-client/src/base_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub struct BaseClientBuilder<'a> {
preview: Preview,
allow_insecure_host: Vec<TrustedHost>,
system_certs: bool,
custom_certificates: Option<Certificates>,
retries: u32,
pub connectivity: Connectivity,
markers: Option<&'a MarkerEnvironment>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/uv-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 46 additions & 5 deletions crates/uv-client/src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,23 @@ 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,
}
}

fn path(&self) -> &Path {
match self {
Self::SslCertFile(path) | Self::SslCertDir(path) => path,
Self::CertFileArg(path) | Self::SslCertFile(path) | Self::SslCertDir(path) => path,
}
}
}
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<CertificateDer<'static>>);
pub struct Certificates(Vec<CertificateDer<'static>>);

impl Certificates {
/// Load the bundled Mozilla root certificates.
Expand All @@ -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<Self, CertificateFileError> {
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<Self> {
pub fn from_env() -> Option<Self> {
let mut certs = Self::default();
let mut has_source = false;

Expand Down Expand Up @@ -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,
Expand Down
78 changes: 66 additions & 12 deletions crates/uv-client/tests/it/ssl_certs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -133,6 +132,7 @@ struct TestClient {
overrides: Vec<(&'static str, String)>,
system_certs: bool,
custom_client: bool,
cert: Option<PathBuf>,
}

/// Create a [`TestClient`] with no environment overrides.
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -403,20 +413,40 @@ impl TestClient {
async fn send_request(
addr: SocketAddr,
system_certs: bool,
cert: Option<&Path>,
) -> Result<reqwest::Response, reqwest_middleware::Error> {
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<reqwest::Response, reqwest_middleware::Error> {
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<reqwest::Response, reqwest_middleware::Error> {
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");
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading