use std::fs::File;
use std::io::BufReader;
use std::sync::Arc;
use rustls::crypto::ring;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::ServerConfig;
use bamboo_config::TlsConfig;
pub(super) fn build_rustls_config(tls: &TlsConfig) -> Result<ServerConfig, String> {
let cert_path = tls.cert_file.display();
let key_path = tls.key_file.display();
let cert_file = File::open(&tls.cert_file)
.map_err(|e| format!("TLS: failed to open cert_file '{cert_path}': {e}"))?;
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
.collect::<Result<_, _>>()
.map_err(|e| format!("TLS: failed to parse certificates from '{cert_path}': {e}"))?;
if certs.is_empty() {
return Err(format!(
"TLS: no certificates found in cert_file '{cert_path}' (expected PEM CERTIFICATE blocks)"
));
}
let key = load_private_key(&tls.key_file)
.map_err(|e| format!("TLS: failed to load key_file '{key_path}': {e}"))?;
let provider = Arc::new(ring::default_provider());
ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| format!("TLS: rustls protocol version setup failed: {e}"))?
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| {
format!("TLS: rustls rejected the cert/key pair (cert '{cert_path}', key '{key_path}'): {e}")
})
}
fn load_private_key(path: &std::path::Path) -> Result<PrivateKeyDer<'static>, String> {
let file = File::open(path).map_err(|e| format!("open: {e}"))?;
match rustls_pemfile::private_key(&mut BufReader::new(file)) {
Ok(Some(key)) => Ok(key),
Ok(None) => {
Err("no private key found (expected a PKCS#8, RSA, or SEC1 PEM block)".to_string())
}
Err(e) => Err(format!("parse: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
use std::process::Command;
fn tmp_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"bamboo-tls-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn gen_self_signed(dir: &Path) -> Result<Option<(PathBuf, PathBuf)>, String> {
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
let output = Command::new("openssl")
.args(["req", "-x509", "-newkey", "rsa:2048", "-keyout"])
.arg(&key)
.arg("-out")
.arg(&cert)
.args([
"-days",
"1",
"-nodes",
"-subj",
"/CN=localhost",
"-addext",
"basicConstraints=critical,CA:FALSE",
])
.output();
let output = match output {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(format!("failed to launch openssl: {error}")),
};
if !output.status.success() {
return Err(format!(
"openssl failed to generate the TLS fixture ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(Some((cert, key)))
}
fn assert_x509_v3(cert: &Path) {
let output = Command::new("openssl")
.args(["x509", "-in"])
.arg(cert)
.args(["-noout", "-text"])
.output()
.expect("openssl that generated the fixture should remain available");
assert!(
output.status.success(),
"openssl failed to inspect generated TLS fixture ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
let certificate_text = String::from_utf8_lossy(&output.stdout);
assert!(
certificate_text
.lines()
.any(|line| line.trim() == "Version: 3 (0x2)"),
"expected generated TLS fixture to be X.509 v3:\n{certificate_text}"
);
}
#[test]
fn build_rustls_config_errors_on_missing_cert_file() {
let tls = TlsConfig {
cert_file: PathBuf::from("/nonexistent/bamboo-tls/cert.pem"),
key_file: PathBuf::from("/nonexistent/bamboo-tls/key.pem"),
};
let err = build_rustls_config(&tls).expect_err("missing cert must fail");
assert!(
err.contains("cert_file"),
"error should name cert_file: {err}"
);
assert!(
err.contains("/nonexistent/bamboo-tls/cert.pem"),
"error should include the path: {err}"
);
}
#[test]
fn build_rustls_config_errors_on_empty_cert_file() {
let dir = tmp_dir();
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
std::fs::write(&cert, "not a pem certificate\n").unwrap();
std::fs::write(&key, "not a pem key\n").unwrap();
let tls = TlsConfig {
cert_file: cert,
key_file: key,
};
let err = build_rustls_config(&tls).expect_err("garbage cert must fail");
assert!(
err.contains("no certificates found"),
"error should explain empty cert: {err}"
);
}
#[test]
fn build_rustls_config_errors_on_missing_key_in_keyfile() {
let dir = tmp_dir();
let Some((cert, _key)) =
gen_self_signed(&dir).expect("present openssl should generate the TLS fixture")
else {
eprintln!("skipping: openssl unavailable");
return;
};
let bad_key = dir.join("bad_key.pem");
std::fs::write(
&bad_key,
"-----BEGIN GARBAGE-----\nzz\n-----END GARBAGE-----\n",
)
.unwrap();
let tls = TlsConfig {
cert_file: cert,
key_file: bad_key,
};
let err = build_rustls_config(&tls).expect_err("no key must fail");
assert!(
err.contains("key_file"),
"error should name key_file: {err}"
);
}
#[test]
fn build_rustls_config_succeeds_on_valid_self_signed_cert() {
let dir = tmp_dir();
let Some((cert, key)) =
gen_self_signed(&dir).expect("present openssl should generate the TLS fixture")
else {
eprintln!("skipping build_rustls_config success test: openssl unavailable");
return;
};
assert_x509_v3(&cert);
let tls = TlsConfig {
cert_file: cert,
key_file: key,
};
let cfg = build_rustls_config(&tls).expect("valid self-signed cert should build a config");
assert!(
!cfg.crypto_provider().cipher_suites.is_empty(),
"expected a non-empty cipher suite set from the ring provider"
);
}
}