Improve certificate loading error messages (#18924)
See https://github.com/astral-sh/uv/issues/18890 Adds special-case validation for `SSL_CERT_FILE` and `SSL_CERT_DIR` where we actually check if webpki will accept the given certificates and, if not, emit a better error message about why. This means we perform eager validation of certificates, parsing them more than once since reqwest will parse them again on client build. Unfortunately, there's not a straight-forward way to provide our pre-parsed certificates to reqwest without doing a lot more work. Nor is there a clear way to retrieve the parsed certificates on error. We use https://github.com/rusticata/x509-parser for parsing which seems reputable. We may want to _drop_ all invalid certificates instead, but that can be a future decision and this machinery can be reused for warnings. Ideally webpki would just have better error messages, but that's a separate project.
This commit is contained in:
Generated
+2
@@ -6215,6 +6215,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"temp-env",
|
||||
@@ -6248,6 +6249,7 @@ dependencies = [
|
||||
"uv-warnings",
|
||||
"webpki-root-certs",
|
||||
"wiremock",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -334,6 +334,8 @@ rcgen = { version = "0.14.5", features = [
|
||||
], default-features = false }
|
||||
rustls = { version = "0.23.36", default-features = false }
|
||||
similar = { version = "2.6.0" }
|
||||
webpki = { package = "rustls-webpki", version = "0.103.10" }
|
||||
x509-parser = { version = "0.18.0" }
|
||||
temp-env = { version = "0.3.6", features = ["async_closure"] }
|
||||
test-case = { version = "3.3.1" }
|
||||
test-log = { version = "0.2.16", features = [
|
||||
|
||||
@@ -63,6 +63,8 @@ rustc-hash = { workspace = true }
|
||||
rustls-native-certs = { workspace = true }
|
||||
rustls-pki-types = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
webpki = { workspace = true }
|
||||
x509-parser = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uv-platform = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -47,7 +47,7 @@ use uv_warnings::warn_user_once;
|
||||
|
||||
use crate::linehaul::LineHaul;
|
||||
use crate::middleware::OfflineMiddleware;
|
||||
use crate::tls::{Certificates, read_identity};
|
||||
use crate::tls::{Certificates, TlsConfigurationError, read_identity};
|
||||
use crate::{Connectivity, WrappedReqwestError};
|
||||
|
||||
pub const DEFAULT_RETRIES: u32 = 3;
|
||||
@@ -71,6 +71,44 @@ pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// timeout on the entire upload.
|
||||
pub const DEFAULT_READ_TIMEOUT_UPLOAD: Duration = Duration::from_mins(15);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClientBuildError(ClientBuildErrorKind);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum ClientBuildErrorKind {
|
||||
#[error(transparent)]
|
||||
Reqwest(reqwest::Error),
|
||||
#[error(transparent)]
|
||||
TlsConfiguration(TlsConfigurationError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientBuildError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("failed to build HTTP client")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientBuildError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match &self.0 {
|
||||
ClientBuildErrorKind::Reqwest(error) => Some(error),
|
||||
ClientBuildErrorKind::TlsConfiguration(error) => Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for ClientBuildError {
|
||||
fn from(error: reqwest::Error) -> Self {
|
||||
Self(ClientBuildErrorKind::Reqwest(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TlsConfigurationError> for ClientBuildError {
|
||||
fn from(error: TlsConfigurationError) -> Self {
|
||||
Self(ClientBuildErrorKind::TlsConfiguration(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Selectively skip parts or the entire auth middleware.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum AuthIntegration {
|
||||
@@ -380,7 +418,7 @@ impl<'a> BaseClientBuilder<'a> {
|
||||
retry_policy(self.retries, self.no_retry_delay)
|
||||
}
|
||||
|
||||
pub fn build(&self) -> reqwest::Result<BaseClient> {
|
||||
pub fn build(&self) -> Result<BaseClient, ClientBuildError> {
|
||||
if let Some(name) = self.client_name {
|
||||
debug!(
|
||||
"Using request connect timeout of {}s and read timeout of {}s for {} client",
|
||||
@@ -464,7 +502,7 @@ impl<'a> BaseClientBuilder<'a> {
|
||||
&self,
|
||||
read_timeout: Duration,
|
||||
connect_timeout: Duration,
|
||||
) -> reqwest::Result<(Client, Client)> {
|
||||
) -> Result<(Client, Client), ClientBuildError> {
|
||||
// Create user agent.
|
||||
let mut user_agent_string = format!("uv/{}", version());
|
||||
|
||||
@@ -475,7 +513,7 @@ impl<'a> BaseClientBuilder<'a> {
|
||||
}
|
||||
|
||||
// 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 = Certificates::from_env()?.map(|certs| certs.to_reqwest_certs());
|
||||
|
||||
// Create a secure client that validates certificates.
|
||||
let raw_client = self.create_client(
|
||||
@@ -508,7 +546,7 @@ impl<'a> BaseClientBuilder<'a> {
|
||||
custom_certs: Option<Vec<Certificate>>,
|
||||
security: Security,
|
||||
redirect_policy: RedirectPolicy,
|
||||
) -> reqwest::Result<Client> {
|
||||
) -> Result<Client, ClientBuildError> {
|
||||
// Configure the builder.
|
||||
let client_builder = ClientBuilder::new()
|
||||
.http1_title_case_headers()
|
||||
@@ -574,7 +612,7 @@ impl<'a> BaseClientBuilder<'a> {
|
||||
client_builder = client_builder.proxy(proxy);
|
||||
}
|
||||
|
||||
client_builder.build()
|
||||
client_builder.build().map_err(Into::into)
|
||||
}
|
||||
|
||||
fn apply_middleware(&self, client: Client) -> ClientWithMiddleware {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub use base_client::{
|
||||
AuthIntegration, BaseClient, BaseClientBuilder, DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_REDIRECTS,
|
||||
DEFAULT_READ_TIMEOUT, DEFAULT_READ_TIMEOUT_UPLOAD, DEFAULT_RETRIES, ExtraMiddleware,
|
||||
RedirectClientWithMiddleware, RedirectPolicy, RequestBuilder, RetriableError,
|
||||
AuthIntegration, BaseClient, BaseClientBuilder, ClientBuildError, DEFAULT_CONNECT_TIMEOUT,
|
||||
DEFAULT_MAX_REDIRECTS, DEFAULT_READ_TIMEOUT, DEFAULT_READ_TIMEOUT_UPLOAD, DEFAULT_RETRIES,
|
||||
ExtraMiddleware, RedirectClientWithMiddleware, RedirectPolicy, RequestBuilder, RetriableError,
|
||||
RetryParsingError, RetryState, UvRetryableStrategy, fetch_with_url_fallback,
|
||||
retryable_on_request_failure,
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ use uv_redacted::DisplaySafeUrl;
|
||||
use uv_small_str::SmallString;
|
||||
use uv_torch::TorchStrategy;
|
||||
|
||||
use crate::base_client::{BaseClientBuilder, ExtraMiddleware, RedirectPolicy};
|
||||
use crate::base_client::{BaseClientBuilder, ClientBuildError, ExtraMiddleware, RedirectPolicy};
|
||||
use crate::cached_client::CacheControl;
|
||||
use crate::flat_index::FlatIndexEntry;
|
||||
use crate::html::SimpleDetailHTML;
|
||||
@@ -163,7 +163,7 @@ impl<'a> RegistryClientBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(mut self) -> reqwest::Result<RegistryClient> {
|
||||
pub fn build(mut self) -> Result<RegistryClient, ClientBuildError> {
|
||||
self.cache_index_credentials();
|
||||
let index_urls = self.index_locations.index_urls();
|
||||
|
||||
|
||||
+194
-23
@@ -1,4 +1,5 @@
|
||||
use std::env;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -7,11 +8,148 @@ use reqwest::{Certificate, Identity};
|
||||
use rustls_native_certs::{CertificateResult, load_certs_from_paths};
|
||||
use rustls_pki_types::CertificateDer;
|
||||
use tracing::debug;
|
||||
use webpki::{Error as WebPkiError, anchor_from_trusted_cert};
|
||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
||||
|
||||
use uv_fs::Simplified;
|
||||
use uv_static::EnvVars;
|
||||
use uv_warnings::warn_user_once;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum CertificateSource {
|
||||
SslCertFile(PathBuf),
|
||||
SslCertDir(PathBuf),
|
||||
}
|
||||
|
||||
impl CertificateSource {
|
||||
const fn env_var(&self) -> &'static str {
|
||||
match self {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DiagnosticCertificate(CertificateDer<'static>);
|
||||
|
||||
impl DiagnosticCertificate {
|
||||
fn parse(&self) -> Option<X509Certificate<'_>> {
|
||||
let (_, certificate) = X509Certificate::from_der(self.0.as_ref()).ok()?;
|
||||
Some(certificate)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum TlsConfigurationError {
|
||||
UnsupportedCriticalExtension {
|
||||
source: CertificateSource,
|
||||
certificate: DiagnosticCertificate,
|
||||
},
|
||||
InvalidTrustAnchor {
|
||||
source: CertificateSource,
|
||||
certificate: DiagnosticCertificate,
|
||||
error: WebPkiError,
|
||||
},
|
||||
}
|
||||
|
||||
impl TlsConfigurationError {
|
||||
fn from_webpki_error(
|
||||
source: CertificateSource,
|
||||
error: WebPkiError,
|
||||
cert: &CertificateDer<'_>,
|
||||
) -> Self {
|
||||
let certificate = DiagnosticCertificate(cert.clone().into_owned());
|
||||
match error {
|
||||
WebPkiError::UnsupportedCriticalExtension => Self::UnsupportedCriticalExtension {
|
||||
source,
|
||||
certificate,
|
||||
},
|
||||
error => Self::InvalidTrustAnchor {
|
||||
source,
|
||||
certificate,
|
||||
error,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TlsConfigurationError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedCriticalExtension {
|
||||
source,
|
||||
certificate,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"certificate in `{}` (from `{}`) uses an unsupported critical extension",
|
||||
source.path().simplified_display(),
|
||||
source.env_var()
|
||||
)?;
|
||||
if let Some(certificate) = certificate.parse() {
|
||||
let subject = certificate.subject();
|
||||
if subject.iter_attributes().next().is_some() {
|
||||
write!(f, " on certificate `{subject}`")?;
|
||||
}
|
||||
let critical_extensions = certificate
|
||||
.iter_extensions()
|
||||
.filter(|extension| extension.critical)
|
||||
.map(|extension| extension.oid.to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
if let [critical_extension] = critical_extensions.as_slice() {
|
||||
write!(f, "; critical extension: `{critical_extension}`")?;
|
||||
} else if !critical_extensions.is_empty() {
|
||||
write!(
|
||||
f,
|
||||
"; critical extensions: {}",
|
||||
critical_extensions
|
||||
.iter()
|
||||
.map(|oid| format!("`{oid}`"))
|
||||
.join(", ")
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::InvalidTrustAnchor {
|
||||
source,
|
||||
certificate,
|
||||
..
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"certificate in `{}` (from `{}`) could not be used as a trust anchor",
|
||||
source.path().simplified_display(),
|
||||
source.env_var()
|
||||
)?;
|
||||
if let Some(certificate) = certificate.parse() {
|
||||
let subject = certificate.subject();
|
||||
if subject.iter_attributes().next().is_some() {
|
||||
write!(f, " on certificate `{subject}`")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TlsConfigurationError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::UnsupportedCriticalExtension { .. } => None,
|
||||
Self::InvalidTrustAnchor { error, .. } => Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection of TLS certificates in DER form.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct Certificates(Vec<CertificateDer<'static>>);
|
||||
@@ -37,34 +175,40 @@ impl Certificates {
|
||||
/// 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(crate) fn from_env() -> Result<Option<Self>, TlsConfigurationError> {
|
||||
let mut certs = Self::default();
|
||||
let mut has_source = false;
|
||||
|
||||
if let Some(ssl_cert_file) = env::var_os(EnvVars::SSL_CERT_FILE)
|
||||
&& let Some(file_certs) = Self::from_ssl_cert_file(&ssl_cert_file)
|
||||
&& let Some(file_certs) = Self::from_ssl_cert_file(&ssl_cert_file)?
|
||||
{
|
||||
has_source = true;
|
||||
certs.merge(file_certs);
|
||||
}
|
||||
|
||||
if let Some(ssl_cert_dir) = env::var_os(EnvVars::SSL_CERT_DIR)
|
||||
&& let Some(dir_certs) = Self::from_ssl_cert_dir(&ssl_cert_dir)
|
||||
&& let Some(dir_certs) = Self::from_ssl_cert_dir(&ssl_cert_dir)?
|
||||
{
|
||||
has_source = true;
|
||||
certs.merge(dir_certs);
|
||||
}
|
||||
|
||||
if has_source { Some(certs) } else { None }
|
||||
if has_source {
|
||||
Ok(Some(certs))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load certificates from the value of `SSL_CERT_FILE`.
|
||||
///
|
||||
/// Returns `None` if the value is empty, the path does not refer to an accessible file,
|
||||
/// or the file contains no valid certificates.
|
||||
fn from_ssl_cert_file(ssl_cert_file: &std::ffi::OsStr) -> Option<Self> {
|
||||
fn from_ssl_cert_file(
|
||||
ssl_cert_file: &std::ffi::OsStr,
|
||||
) -> Result<Option<Self>, TlsConfigurationError> {
|
||||
if ssl_cert_file.is_empty() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file = PathBuf::from(ssl_cert_file);
|
||||
@@ -83,30 +227,31 @@ impl Certificates {
|
||||
"Ignoring `SSL_CERT_FILE`. No certificates found in: {}.",
|
||||
file.simplified_display().cyan()
|
||||
);
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
Some(certs)
|
||||
certs.validate_trust_anchors(CertificateSource::SslCertFile(file))?;
|
||||
Ok(Some(certs))
|
||||
}
|
||||
Ok(_) => {
|
||||
warn_user_once!(
|
||||
"Ignoring invalid `SSL_CERT_FILE`. Path is not a file: {}.",
|
||||
file.simplified_display().cyan()
|
||||
);
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
warn_user_once!(
|
||||
"Ignoring invalid `SSL_CERT_FILE`. Path does not exist: {}.",
|
||||
file.simplified_display().cyan()
|
||||
);
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
warn_user_once!(
|
||||
"Ignoring invalid `SSL_CERT_FILE`. Path is not accessible: {} ({err}).",
|
||||
file.simplified_display().cyan()
|
||||
);
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,9 +263,11 @@ impl Certificates {
|
||||
///
|
||||
/// Returns `None` if the value is empty, no listed directories exist, or no valid
|
||||
/// certificates are found.
|
||||
fn from_ssl_cert_dir(ssl_cert_dir: &std::ffi::OsStr) -> Option<Self> {
|
||||
fn from_ssl_cert_dir(
|
||||
ssl_cert_dir: &std::ffi::OsStr,
|
||||
) -> Result<Option<Self>, TlsConfigurationError> {
|
||||
if ssl_cert_dir.is_empty() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (existing, missing): (Vec<_>, Vec<_>) =
|
||||
@@ -140,7 +287,7 @@ impl Certificates {
|
||||
.join(", ")
|
||||
.cyan()
|
||||
);
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
@@ -168,7 +315,11 @@ impl Certificates {
|
||||
dir.simplified_display().cyan()
|
||||
);
|
||||
}
|
||||
certs.merge(Self::from(result));
|
||||
let dir_certs = Self::from(result);
|
||||
if !dir_certs.0.is_empty() {
|
||||
dir_certs.validate_trust_anchors(CertificateSource::SslCertDir(dir.clone()))?;
|
||||
certs.merge(dir_certs);
|
||||
}
|
||||
}
|
||||
|
||||
if certs.0.is_empty() {
|
||||
@@ -180,10 +331,10 @@ impl Certificates {
|
||||
.join(", ")
|
||||
.cyan()
|
||||
);
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Some(certs)
|
||||
Ok(Some(certs))
|
||||
}
|
||||
|
||||
/// Load certificates from explicit file and directory paths.
|
||||
@@ -191,6 +342,26 @@ impl Certificates {
|
||||
load_certs_from_paths(file, dir)
|
||||
}
|
||||
|
||||
fn validate_trust_anchors(
|
||||
&self,
|
||||
source: CertificateSource,
|
||||
) -> Result<(), TlsConfigurationError> {
|
||||
for cert in &self.0 {
|
||||
if let Err(error) = anchor_from_trusted_cert(cert) {
|
||||
debug!(
|
||||
"Failed to validate certificate from `{}` ({}): {error}",
|
||||
source.env_var(),
|
||||
source.path().simplified_display()
|
||||
);
|
||||
|
||||
return Err(TlsConfigurationError::from_webpki_error(
|
||||
source, error, cert,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove duplicate certificates, sorting by DER bytes.
|
||||
fn dedup(&mut self) {
|
||||
self.0
|
||||
@@ -272,13 +443,13 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing_file = dir.path().join("missing.pem");
|
||||
|
||||
let certs = Certificates::from_ssl_cert_file(missing_file.as_os_str());
|
||||
let certs = Certificates::from_ssl_cert_file(missing_file.as_os_str()).unwrap();
|
||||
assert!(certs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_ssl_cert_file_empty_value_returns_none() {
|
||||
let certs = Certificates::from_ssl_cert_file(OsString::new().as_os_str());
|
||||
let certs = Certificates::from_ssl_cert_file(OsString::new().as_os_str()).unwrap();
|
||||
assert!(certs.is_none());
|
||||
}
|
||||
|
||||
@@ -288,13 +459,13 @@ mod tests {
|
||||
let cert_path = dir.path().join("empty.pem");
|
||||
fs_err::write(&cert_path, "not a certificate").unwrap();
|
||||
|
||||
let certs = Certificates::from_ssl_cert_file(cert_path.as_os_str());
|
||||
let certs = Certificates::from_ssl_cert_file(cert_path.as_os_str()).unwrap();
|
||||
assert!(certs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_ssl_cert_dir_empty_value_returns_none() {
|
||||
let certs = Certificates::from_ssl_cert_dir(OsString::new().as_os_str());
|
||||
let certs = Certificates::from_ssl_cert_dir(OsString::new().as_os_str()).unwrap();
|
||||
assert!(certs.is_none());
|
||||
}
|
||||
|
||||
@@ -304,7 +475,7 @@ mod tests {
|
||||
let missing_dir = dir.path().join("missing-dir");
|
||||
let cert_dirs = std::env::join_paths([&missing_dir]).unwrap();
|
||||
|
||||
let certs = Certificates::from_ssl_cert_dir(cert_dirs.as_os_str());
|
||||
let certs = Certificates::from_ssl_cert_dir(cert_dirs.as_os_str()).unwrap();
|
||||
assert!(certs.is_none());
|
||||
}
|
||||
|
||||
@@ -313,7 +484,7 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cert_dirs = std::env::join_paths([dir.path()]).unwrap();
|
||||
|
||||
let certs = Certificates::from_ssl_cert_dir(cert_dirs.as_os_str());
|
||||
let certs = Certificates::from_ssl_cert_dir(cert_dirs.as_os_str()).unwrap();
|
||||
assert!(certs.is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::error::Error;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Result;
|
||||
use insta::{assert_snapshot, with_settings};
|
||||
use rcgen::CustomExtension;
|
||||
use temp_env::async_with_vars;
|
||||
use tempfile::{NamedTempFile, TempDir};
|
||||
@@ -12,6 +14,7 @@ use url::Url;
|
||||
use uv_cache::Cache;
|
||||
use uv_client::BaseClientBuilder;
|
||||
use uv_client::RegistryClientBuilder;
|
||||
use uv_fs::Simplified;
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
use uv_static::EnvVars;
|
||||
|
||||
@@ -37,6 +40,13 @@ struct TestCertificate {
|
||||
client_cert_path: PathBuf,
|
||||
}
|
||||
|
||||
fn temp_dir_filter(path: &Path) -> String {
|
||||
format!(
|
||||
r"{}\\?/?",
|
||||
regex::escape(&path.simplified_display().to_string()).replace(r"\\", r"(\\|/)")
|
||||
)
|
||||
}
|
||||
|
||||
impl TestCertificate {
|
||||
/// Generate a fresh CA, server cert, and client cert, persisting the
|
||||
/// relevant PEM files to a temporary directory.
|
||||
@@ -59,6 +69,19 @@ impl TestCertificate {
|
||||
Self::persist(ca, server, &client)
|
||||
}
|
||||
|
||||
/// Generate a fresh certificate set whose CA contains a duplicate
|
||||
/// `basicConstraints` extension, which webpki rejects as an invalid trust
|
||||
/// anchor.
|
||||
fn new_with_duplicate_basic_constraints_ca_extension() -> Result<Self> {
|
||||
let duplicate_basic_constraints =
|
||||
CustomExtension::from_oid_content(&[2, 5, 29, 19], vec![0x30, 0x00]);
|
||||
|
||||
let (ca, server, client) = generate_self_signed_certs_with_ca_custom_extensions(vec![
|
||||
duplicate_basic_constraints,
|
||||
])?;
|
||||
Self::persist(ca, server, &client)
|
||||
}
|
||||
|
||||
fn persist(ca: SelfSigned, server: SelfSigned, client: &SelfSigned) -> Result<Self> {
|
||||
let cert_dir = test_cert_dir();
|
||||
fs_err::create_dir_all(&cert_dir)?;
|
||||
@@ -461,16 +484,58 @@ async fn test_ssl_cert_file_unsupported_critical_extension_returns_error() -> Re
|
||||
let test_client = client().ssl_cert_file(&cert.trust_path);
|
||||
let vars = test_client.ssl_vars();
|
||||
|
||||
async_with_vars(vars, async {
|
||||
let temp_dir_filter = temp_dir_filter(cert._temp_dir.path());
|
||||
|
||||
async_with_vars(vars, async move {
|
||||
let err = BaseClientBuilder::default()
|
||||
.build()
|
||||
.expect_err("expected client build to fail");
|
||||
|
||||
assert!(err.is_builder(), "expected builder error, got: {err:?}");
|
||||
assert!(
|
||||
format!("{err:?}").contains("UnsupportedCriticalExtension"),
|
||||
"expected error to mention UnsupportedCriticalExtension, got: {err:?}"
|
||||
);
|
||||
let source = err.source().expect("expected client build error source");
|
||||
let display = format!("{err}\nCaused by: {source}");
|
||||
|
||||
with_settings!({
|
||||
filters => vec![(temp_dir_filter.as_str(), "[TMP]/")]
|
||||
}, {
|
||||
assert_snapshot!(display, @r#"
|
||||
failed to build HTTP client
|
||||
Caused by: certificate in `[TMP]/ca.pem` (from `SSL_CERT_FILE`) uses an unsupported critical extension on certificate `CN=uv-test-ca, O=Astral Software Inc.`; critical extensions: `2.5.29.15`, `2.5.29.19`, `1.2.3.4`
|
||||
"#);
|
||||
});
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An invalid trust anchor in `SSL_CERT_FILE` returns a builder error with a
|
||||
/// generic trust-anchor message.
|
||||
#[tokio::test]
|
||||
async fn test_ssl_cert_file_invalid_trust_anchor_returns_error() -> Result<()> {
|
||||
let cert = TestCertificate::new_with_duplicate_basic_constraints_ca_extension()?;
|
||||
let test_client = client().ssl_cert_file(&cert.trust_path);
|
||||
let vars = test_client.ssl_vars();
|
||||
|
||||
let temp_dir_filter = temp_dir_filter(cert._temp_dir.path());
|
||||
|
||||
async_with_vars(vars, async move {
|
||||
let err = BaseClientBuilder::default()
|
||||
.build()
|
||||
.expect_err("expected client build to fail");
|
||||
|
||||
let source = err.source().expect("expected client build error source");
|
||||
let next_source = source.source().expect("expected trust anchor validation cause");
|
||||
let display = format!("{err}\nCaused by: {source}\nCaused by: {next_source}");
|
||||
|
||||
with_settings!({
|
||||
filters => vec![(temp_dir_filter.as_str(), "[TMP]/")]
|
||||
}, {
|
||||
assert_snapshot!(display, @r#"
|
||||
failed to build HTTP client
|
||||
Caused by: certificate in `[TMP]/ca.pem` (from `SSL_CERT_FILE`) could not be used as a trust anchor on certificate `CN=uv-test-ca, O=Astral Software Inc.`
|
||||
Caused by: ExtensionValueInvalid
|
||||
"#);
|
||||
});
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ pub enum Error {
|
||||
Download(#[from] downloads::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
ClientBuild(#[from] uv_client::ClientBuildError),
|
||||
|
||||
// TODO(zanieb) We might want to ensure this is always wrapped in another type
|
||||
#[error(transparent)]
|
||||
|
||||
@@ -46,7 +46,7 @@ use unscanny::{Pattern, Scanner};
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
use uv_client::BaseClient;
|
||||
use uv_client::{BaseClient, ClientBuildError};
|
||||
use uv_client::{BaseClientBuilder, Connectivity};
|
||||
use uv_configuration::{NoBinary, NoBuild, PackageNameSpecifier};
|
||||
use uv_distribution_types::{
|
||||
@@ -302,7 +302,7 @@ impl RequirementsTxt {
|
||||
.build()
|
||||
.map_err(|err| RequirementsTxtFileError {
|
||||
file: requirements_txt.to_path_buf(),
|
||||
error: RequirementsTxtParserError::from_reqwest(url, err),
|
||||
error: RequirementsTxtParserError::ClientBuild(url.clone(), Box::new(err)),
|
||||
})?;
|
||||
let content = read_url_to_string(&requirements_txt, client)
|
||||
.await
|
||||
@@ -1209,6 +1209,8 @@ pub enum RequirementsTxtParserError {
|
||||
#[cfg(feature = "http")]
|
||||
Reqwest(DisplaySafeUrl, reqwest_middleware::Error),
|
||||
#[cfg(feature = "http")]
|
||||
ClientBuild(DisplaySafeUrl, Box<ClientBuildError>),
|
||||
#[cfg(feature = "http")]
|
||||
InvalidUrl(String, DisplaySafeUrlError),
|
||||
}
|
||||
|
||||
@@ -1280,6 +1282,10 @@ impl Display for RequirementsTxtParserError {
|
||||
write!(f, "Error while accessing remote requirements file: `{url}`")
|
||||
}
|
||||
#[cfg(feature = "http")]
|
||||
Self::ClientBuild(url, _err) => {
|
||||
write!(f, "Error while accessing remote requirements file: `{url}`")
|
||||
}
|
||||
#[cfg(feature = "http")]
|
||||
Self::InvalidUrl(url, err) => {
|
||||
match err {
|
||||
DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
|
||||
@@ -1318,6 +1324,8 @@ impl std::error::Error for RequirementsTxtParserError {
|
||||
#[cfg(feature = "http")]
|
||||
Self::Reqwest(_, err) => err.source(),
|
||||
#[cfg(feature = "http")]
|
||||
Self::ClientBuild(_, err) => Some(err.as_ref()),
|
||||
#[cfg(feature = "http")]
|
||||
Self::InvalidUrl(_, err) => err.source(),
|
||||
}
|
||||
}
|
||||
@@ -1448,6 +1456,10 @@ impl Display for RequirementsTxtFileError {
|
||||
write!(f, "Error while accessing remote requirements file: `{url}`")
|
||||
}
|
||||
#[cfg(feature = "http")]
|
||||
RequirementsTxtParserError::ClientBuild(url, _err) => {
|
||||
write!(f, "Error while accessing remote requirements file: `{url}`")
|
||||
}
|
||||
#[cfg(feature = "http")]
|
||||
RequirementsTxtParserError::InvalidUrl(url, err) => match err {
|
||||
DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
|
||||
DisplaySafeUrlError::AmbiguousAuthority(_) => {
|
||||
|
||||
@@ -61,7 +61,7 @@ enum Error {
|
||||
#[error(transparent)]
|
||||
FlatIndex(#[from] uv_client::FlatIndexError),
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
ClientBuild(#[from] uv_client::ClientBuildError),
|
||||
#[error(transparent)]
|
||||
BuildPlan(anyhow::Error),
|
||||
#[error(transparent)]
|
||||
|
||||
@@ -279,7 +279,7 @@ pub(crate) enum ProjectError {
|
||||
Client(#[from] uv_client::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
ClientBuild(#[from] uv_client::ClientBuildError),
|
||||
|
||||
#[error(transparent)]
|
||||
Python(#[from] uv_python::Error),
|
||||
|
||||
Reference in New Issue
Block a user