use std::path::{Path, PathBuf};
use std::sync::Arc;
use rustls::RootCertStore;
use rustls_pki_types::CertificateDer;
use rustls_pki_types::pem::PemObject;
use crate::error::{Error, Result};
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct TlsTrust {
pub native_roots: bool,
pub webpki_roots: bool,
pub extra_roots: Vec<PathBuf>,
pub extra_intermediates: Vec<PathBuf>,
pub exclusive: bool,
}
impl Default for TlsTrust {
fn default() -> Self {
Self {
native_roots: true,
webpki_roots: true,
extra_roots: Vec::new(),
extra_intermediates: Vec::new(),
exclusive: false,
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum TlsConfigSource {
Explicit(Arc<rustls::ClientConfig>),
Trust(TlsTrust),
}
fn add_pem_file_certs(store: &mut RootCertStore, path: &Path) -> Result<()> {
let mut certs: Vec<CertificateDer<'static>> = Vec::new();
let iter = CertificateDer::pem_file_iter(path)
.map_err(|e| Error::Custom(format!("tls: cannot read CA file {}: {e}", path.display())))?;
for cert in iter.flatten() {
certs.push(cert);
}
let (added, _ignored) = store.add_parsable_certificates(certs);
if added == 0 {
return Err(Error::Custom(format!(
"tls: no usable certificates in CA file {}",
path.display()
)));
}
Ok(())
}
fn build_root_store(trust: &TlsTrust) -> Result<RootCertStore> {
let mut store = RootCertStore::empty();
if !trust.exclusive {
if trust.native_roots {
let result = rustls_native_certs::load_native_certs();
let _ = store.add_parsable_certificates(result.certs);
}
if trust.webpki_roots {
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
} else if trust.extra_roots.is_empty() && trust.extra_intermediates.is_empty() {
return Err(Error::Custom(
"tls: exclusive trust requested but no explicit CA files were supplied".into(),
));
}
for path in &trust.extra_roots {
add_pem_file_certs(&mut store, path)?;
}
for path in &trust.extra_intermediates {
add_pem_file_certs(&mut store, path)?;
}
if store.is_empty() {
return Err(Error::Custom(
"tls: resulting trust store is empty (no roots loaded)".into(),
));
}
Ok(store)
}
fn build_config_with_roots(roots: RootCertStore) -> Result<Arc<rustls::ClientConfig>> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| Error::Custom(format!("tls: rustls config: {e}")))?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(Arc::new(cfg))
}
pub fn build_client_config(src: &TlsConfigSource) -> Result<Arc<rustls::ClientConfig>> {
match src {
TlsConfigSource::Explicit(cfg) => Ok(cfg.clone()),
TlsConfigSource::Trust(trust) => {
let roots = build_root_store(trust)?;
build_config_with_roots(roots)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_CA_PEM: &str = include_str!("testdata/test_ca.pem");
fn write_tmp(name: &str, body: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(name);
std::fs::write(&p, body).unwrap();
(dir, p)
}
#[test]
fn add_pem_file_adds_all_certs_in_bundle() {
let bundle = format!("{TEST_CA_PEM}\n{TEST_CA_PEM}");
let (_dir, path) = write_tmp("bundle.pem", &bundle);
let mut store = RootCertStore::empty();
add_pem_file_certs(&mut store, &path).unwrap();
assert_eq!(store.len(), 2, "both concatenated certs must be added");
}
#[test]
fn add_pem_file_is_lenient_skips_junk() {
let mixed = format!(
"-----BEGIN CERTIFICATE-----\nbm90YWNlcnQ=\n-----END CERTIFICATE-----\n{TEST_CA_PEM}"
);
let (_dir, path) = write_tmp("mixed.pem", &mixed);
let mut store = RootCertStore::empty();
add_pem_file_certs(&mut store, &path).unwrap();
assert_eq!(store.len(), 1, "valid cert added, junk block skipped");
}
#[test]
fn add_pem_file_errors_on_zero_certs() {
let (_dir, path) = write_tmp("empty.pem", "not a pem at all\n");
let mut store = RootCertStore::empty();
let err = add_pem_file_certs(&mut store, &path).expect_err("zero-cert file must error");
assert!(format!("{err}").contains("no usable certificates"));
}
#[test]
fn add_pem_file_errors_on_missing_path() {
let mut store = RootCertStore::empty();
let err = add_pem_file_certs(&mut store, Path::new("/no/such/ca.pem"))
.expect_err("missing file must error");
assert!(format!("{err}").contains("cannot read CA file"));
}
#[test]
fn build_root_store_augment_includes_extra() {
let (_dir, path) = write_tmp("root.pem", TEST_CA_PEM);
let trust = TlsTrust {
native_roots: false, webpki_roots: true,
extra_roots: vec![path],
extra_intermediates: Vec::new(),
exclusive: false,
};
let store = build_root_store(&trust).unwrap();
assert!(store.len() > 1);
}
#[test]
fn build_root_store_exclusive_only_extra() {
let (_dir, path) = write_tmp("only.pem", TEST_CA_PEM);
let trust = TlsTrust {
native_roots: true,
webpki_roots: true,
extra_roots: vec![path],
extra_intermediates: Vec::new(),
exclusive: true,
};
let store = build_root_store(&trust).unwrap();
assert_eq!(store.len(), 1, "exclusive trusts only the supplied CA");
}
#[test]
fn build_root_store_exclusive_no_files_errors() {
let trust = TlsTrust {
native_roots: true,
webpki_roots: true,
extra_roots: Vec::new(),
extra_intermediates: Vec::new(),
exclusive: true,
};
let err = build_root_store(&trust).expect_err("exclusive with no files must error");
assert!(format!("{err}").contains("no explicit CA files"));
}
#[test]
fn a_trust_that_resolves_to_no_roots_errors() {
let trust = TlsTrust {
native_roots: false,
webpki_roots: false,
extra_roots: Vec::new(),
extra_intermediates: Vec::new(),
exclusive: false,
};
let err = build_client_config(&TlsConfigSource::Trust(trust))
.expect_err("an empty trust store must error");
assert!(format!("{err}").contains("trust store is empty"), "{err}");
}
#[test]
fn explicit_config_passes_through_unchanged() {
let (_dir, path) = write_tmp("explicit.pem", TEST_CA_PEM);
let mut roots = RootCertStore::empty();
add_pem_file_certs(&mut roots, &path).unwrap();
let built = build_config_with_roots(roots).unwrap();
let out = build_client_config(&TlsConfigSource::Explicit(built.clone())).unwrap();
assert!(Arc::ptr_eq(&built, &out));
}
}