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
3 changes: 2 additions & 1 deletion crates/uv-client/src/base_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,8 @@ impl<'a> BaseClientBuilder<'a> {

// Configure the certificate source.
//
// Explicit certificates override the default certificate source.
// Non-empty `SSL_CERT_FILE` and `SSL_CERT_DIR` values override the default certificate
// source, even when no valid certificates can be loaded from their configured paths.
let client_builder = if let Some(custom_certs) = custom_certs {
client_builder.tls_certs_only(custom_certs)
} else if self.system_certs {
Expand Down
69 changes: 54 additions & 15 deletions crates/uv-client/src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,25 +243,30 @@ impl Certificates {

/// 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`].
/// Returns `None` if neither variable is set to a non-empty value. An explicitly configured
/// file or directory always replaces the default certificate roots, even when it is missing,
/// inaccessible, or contains no valid certificates. Delegates path loading to
/// [`rustls_native_certs::load_certs_from_paths`].
pub fn from_env() -> Option<Self> {
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)
&& !ssl_cert_file.is_empty()
{
has_source = true;
certs.merge(file_certs);
if let Some(file_certs) = Self::from_ssl_cert_file(&ssl_cert_file) {
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)
&& !ssl_cert_dir.is_empty()
{
has_source = true;
certs.merge(dir_certs);
if let Some(dir_certs) = Self::from_ssl_cert_dir(&ssl_cert_dir) {
certs.merge(dir_certs);
}
}

if has_source { Some(certs) } else { None }
Expand Down Expand Up @@ -290,7 +295,7 @@ impl Certificates {
.filter_invalid(&CertificateSource::SslCertFile(file.clone()));
if certs.0.is_empty() {
warn_user_once!(
"Ignoring `SSL_CERT_FILE`. No valid certificates found in: {}.",
"No valid certificates found in `SSL_CERT_FILE`: {}. No default certificates will be trusted.",
file.simplified_display().cyan()
);
return None;
Expand All @@ -299,21 +304,21 @@ impl Certificates {
}
Ok(_) => {
warn_user_once!(
"Ignoring invalid `SSL_CERT_FILE`. Path is not a file: {}.",
"Invalid `SSL_CERT_FILE`. Path is not a file: {}. No default certificates will be trusted.",
file.simplified_display().cyan()
);
None
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
warn_user_once!(
"Ignoring invalid `SSL_CERT_FILE`. Path does not exist: {}.",
"Invalid `SSL_CERT_FILE`. Path does not exist: {}. No default certificates will be trusted.",
file.simplified_display().cyan()
);
None
}
Err(err) => {
warn_user_once!(
"Ignoring invalid `SSL_CERT_FILE`. Path is not accessible: {} ({err}).",
"Invalid `SSL_CERT_FILE`. Path is not accessible: {} ({err}). No default certificates will be trusted.",
file.simplified_display().cyan()
);
None
Expand All @@ -338,12 +343,12 @@ impl Certificates {

if existing.is_empty() {
let end_note = if missing.len() == 1 {
"The directory does not exist."
"The directory does not exist"
} else {
"The entries do not exist."
"The entries do not exist"
};
warn_user_once!(
"Ignoring invalid `SSL_CERT_DIR`. {end_note}: {}.",
"Invalid `SSL_CERT_DIR`. {end_note}: {}. No default certificates will be trusted.",
missing
.iter()
.map(Simplified::simplified_display)
Expand Down Expand Up @@ -389,7 +394,7 @@ impl Certificates {
// Unlike `SSL_CERT_FILE`, it's plausible for this to be intentionally set to an
// empty directory that a user _could_ put certificates in.
warn!(
"Ignoring `SSL_CERT_DIR`. No valid certificates found in: {}.",
"No valid certificates found in `SSL_CERT_DIR`: {}. No default certificates will be trusted.",
existing
.iter()
.map(Simplified::simplified_display)
Expand Down Expand Up @@ -514,6 +519,23 @@ mod tests {
assert!(certs.is_none());
}

#[test]
fn test_from_env_missing_ssl_cert_file_returns_empty_roots() {
let dir = tempfile::tempdir().unwrap();
let missing_file = dir.path().join("missing.pem");

temp_env::with_vars(
[
(EnvVars::SSL_CERT_FILE, Some(missing_file.as_os_str())),
(EnvVars::SSL_CERT_DIR, None),
],
|| {
let certs = Certificates::from_env().expect("explicit file should override roots");
assert_eq!(certs.iter().count(), 0);
},
);
}

#[test]
fn test_from_ssl_cert_file_empty_value_returns_none() {
let certs = Certificates::from_ssl_cert_file(OsString::new().as_os_str());
Expand Down Expand Up @@ -555,6 +577,23 @@ mod tests {
assert!(certs.is_none());
}

#[test]
fn test_from_env_empty_ssl_cert_dir_returns_empty_roots() {
let dir = tempfile::tempdir().unwrap();

temp_env::with_vars(
[
(EnvVars::SSL_CERT_FILE, None),
(EnvVars::SSL_CERT_DIR, Some(dir.path().as_os_str())),
],
|| {
let certs =
Certificates::from_env().expect("explicit directory should override roots");
assert_eq!(certs.iter().count(), 0);
},
);
}

#[test]
fn test_merge_deduplicates() {
let dir = tempfile::tempdir().unwrap();
Expand Down
18 changes: 12 additions & 6 deletions crates/uv-client/tests/it/ssl_certs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,31 +611,37 @@ async fn test_ssl_cert_file_wrong_cert_rejected() -> Result<()> {
Ok(())
}

/// A nonexistent `SSL_CERT_FILE` is ignored; the client falls back to webpki
/// roots which don't include our test CA.
/// A nonexistent `SSL_CERT_FILE` replaces the default roots with an empty trust store.
#[tokio::test]
async fn test_ssl_cert_file_nonexistent_falls_back() -> Result<()> {
async fn test_ssl_cert_file_nonexistent_overrides_default_roots() -> Result<()> {
let cert = TestCertificate::new()?;
let dir = TempDir::new()?;
let missing = dir.path().join("missing.pem");
client()
.ssl_cert_file(&missing)
.expect_https_connect_fails(&cert)
.await;
client()
.ssl_cert_file(&missing)
.expect_index_fetch_system_certs_hint(&cert, false)
.await;
Ok(())
}

/// A nonexistent `SSL_CERT_DIR` is ignored; the client falls back to webpki
/// roots which don't include our test CA.
/// A nonexistent `SSL_CERT_DIR` replaces the default roots with an empty trust store.
#[tokio::test]
async fn test_ssl_cert_dir_nonexistent_falls_back() -> Result<()> {
async fn test_ssl_cert_dir_nonexistent_overrides_default_roots() -> Result<()> {
let cert = TestCertificate::new()?;
let dir = TempDir::new()?;
let missing = dir.path().join("missing-certs");
client()
.ssl_cert_dir(&missing)
.expect_https_connect_fails(&cert)
.await;
client()
.ssl_cert_dir(&missing)
.expect_index_fetch_system_certs_hint(&cert, false)
.await;
Ok(())
}

Expand Down
46 changes: 46 additions & 0 deletions crates/uv/tests/it/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,52 @@ fn read_timeout_server() -> (String, impl Drop) {
(server, shutdown_tx)
}

/// Invalid explicit certificate files disable the default trust roots rather than being ignored.
#[tokio::test]
async fn invalid_ssl_cert_file_warns_default_roots_are_disabled() {
let context = uv_test::test_context!("3.12");
let (_server_drop_guard, mock_server_uri) = http_error_server().await;

uv_snapshot!(context.filters(), context
.pip_install()
.arg("tqdm")
.arg("--index-url")
.arg(&mock_server_uri)
.env(EnvVars::SSL_CERT_FILE, context.temp_dir.join("missing.pem"))
.env_remove(EnvVars::SSL_CERT_DIR)
.env(EnvVars::UV_TEST_NO_HTTP_RETRY_DELAY, "true"), @"
exit_code: 2 (failure)
----- stderr -----
warning: Invalid `SSL_CERT_FILE`. Path does not exist: [TEMP_DIR]/missing.pem. No default certificates will be trusted.
error: Request failed after 3 retries in [TIME]
Caused by: Failed to fetch: `http://[LOCALHOST]/tqdm/`
Caused by: HTTP status server error (500 Internal Server Error) for url (http://[LOCALHOST]/tqdm/)
");
}

/// Invalid explicit certificate directories disable the default trust roots rather than being ignored.
#[tokio::test]
async fn invalid_ssl_cert_dir_warns_default_roots_are_disabled() {
let context = uv_test::test_context!("3.12");
let (_server_drop_guard, mock_server_uri) = http_error_server().await;

uv_snapshot!(context.filters(), context
.pip_install()
.arg("tqdm")
.arg("--index-url")
.arg(&mock_server_uri)
.env_remove(EnvVars::SSL_CERT_FILE)
.env(EnvVars::SSL_CERT_DIR, context.temp_dir.join("missing-certs"))
.env(EnvVars::UV_TEST_NO_HTTP_RETRY_DELAY, "true"), @"
exit_code: 2 (failure)
----- stderr -----
warning: Invalid `SSL_CERT_DIR`. The directory does not exist: [TEMP_DIR]/missing-certs. No default certificates will be trusted.
error: Request failed after 3 retries in [TIME]
Caused by: Failed to fetch: `http://[LOCALHOST]/tqdm/`
Caused by: HTTP status server error (500 Internal Server Error) for url (http://[LOCALHOST]/tqdm/)
");
}

/// Check the simple index error message when the server returns HTTP status 500, a retryable error.
#[tokio::test]
async fn simple_http_500() {
Expand Down
5 changes: 3 additions & 2 deletions docs/concepts/authentication/certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ dangling symlinks.

DER-encoded files are not supported.

When set, these environment variables **override** the default certificate source entirely — only
the provided certificates will be trusted.
When set to non-empty values, these environment variables **override** the default certificate
source entirely — only the provided certificates will be trusted. If a configured file or directory
does not exist or contains no valid certificates, no default certificates will be trusted.

`SSL_CERT_FILE` can point to a single certificate or a bundle containing multiple certificates.
`SSL_CERT_DIR` can include multiple directory entries; uv will load all valid certificates from each
Expand Down
Loading