#![cfg(feature = "file-storage")]
use std::sync::Arc;
use certmagic::handshake::ClientHelloInfo;
use certmagic::solvers::{SolvableChallenge, Solver};
use certmagic::{
Cache, CacheOptions, Config, ConfigOptions, Error, Http01Solver, TlsAlpnSolver,
make_certificate,
};
use rcgen::{CertificateParams, KeyPair};
use tokio_util::sync::CancellationToken;
fn test_storage() -> Arc<dyn certmagic::storage::Storage> {
let directory = tempfile::tempdir().expect("temporary storage directory");
let path = directory.keep();
certmagic::storage::FileStorage::new(path)
}
fn self_signed(name: &str) -> certmagic::Certificate {
let key = KeyPair::generate().expect("test key generation");
let params = CertificateParams::new(vec![name.to_owned()]).expect("test SAN");
let cert = params.self_signed(&key).expect("test certificate");
make_certificate(cert.pem().as_bytes(), key.serialize_pem().as_bytes())
.expect("parse test certificate")
}
fn config_with_names(default_name: &str, fallback_name: &str) -> Arc<Config> {
Config::new(
Cache::new_without_maintenance(CacheOptions::default()).expect("test cache"),
ConfigOptions {
storage: Some(test_storage()),
default_server_name: default_name.to_owned(),
fallback_server_name: fallback_name.to_owned(),
..Default::default()
},
)
.expect("test config")
}
#[tokio::test]
async fn empty_sni_uses_default_server_name_from_cache() {
let config = config_with_names("default.example.com", "fallback.example.com");
config
.cache()
.cache_certificate(self_signed("default.example.com"));
let hello = ClientHelloInfo::default();
let (matched, resolved_name, defaulted) = config.get_certificate_from_cache(&hello);
assert_eq!(resolved_name, "default.example.com");
assert!(!defaulted);
assert!(
matched
.expect("default certificate")
.names
.contains(&"default.example.com".to_owned())
);
let selected = config
.get_cert_during_handshake(&CancellationToken::new(), &hello, false)
.await
.expect("default certificate should be selected");
assert!(selected.names.contains(&"default.example.com".to_owned()));
config.cache().stop_and_wait().await;
}
#[tokio::test]
async fn unknown_sni_uses_fallback_only_after_cache_and_on_demand_paths() {
let config = config_with_names("default.example.com", "fallback.example.com");
config
.cache()
.cache_certificate(self_signed("fallback.example.com"));
let hello = ClientHelloInfo {
server_name: Some("unknown.example.com".into()),
..Default::default()
};
let (matched, resolved_name, defaulted) = config.get_certificate_from_cache(&hello);
assert!(matched.is_none());
assert_eq!(resolved_name, "unknown.example.com");
assert!(defaulted, "fallback must be reported as available");
let selected = config
.get_cert_during_handshake(&CancellationToken::new(), &hello, false)
.await
.expect("fallback certificate should be selected");
assert!(selected.names.contains(&"fallback.example.com".to_owned()));
config.cache().stop_and_wait().await;
}
#[tokio::test]
async fn on_demand_without_an_explicit_gate_fails_closed() {
let config = Config::new(
Cache::new_without_maintenance(CacheOptions::default()).expect("test cache"),
ConfigOptions {
storage: Some(test_storage()),
on_demand: Some(certmagic::OnDemandConfig::default()),
..Default::default()
},
)
.expect("test config");
let error = config
.get_cert_during_handshake(
&CancellationToken::new(),
&ClientHelloInfo {
server_name: Some("unguarded.example.com".into()),
..Default::default()
},
true,
)
.await
.expect_err("missing on-demand gate must be rejected");
assert!(matches!(error, Error::Certificate(_)));
config.cache().stop_and_wait().await;
}
#[tokio::test]
async fn tls_alpn_challenge_is_visible_to_handshake_until_cleanup() {
let config = config_with_names("default.example.com", "fallback.example.com");
let solver = TlsAlpnSolver::default();
let challenge = SolvableChallenge {
kind: "tls-alpn-01".into(),
token: "offline-token".into(),
url: "https://ca.invalid/challenge/offline-token".into(),
identifier: "challenge.example.com".into(),
key_authorization: "offline-token.thumbprint".into(),
};
solver
.present(&CancellationToken::new(), &challenge)
.await
.expect("register challenge certificate");
let hello = ClientHelloInfo {
server_name: Some(challenge.identifier.clone()),
alpn: vec![b"acme-tls/1".to_vec()],
..Default::default()
};
let served = config
.get_cert_during_handshake(&CancellationToken::new(), &hello, false)
.await
.expect("registered challenge should be served");
assert_eq!(served.chain.len(), 1);
assert!(served.private_key.is_none());
solver.cleanup(&challenge).await;
assert!(
config
.get_cert_during_handshake(&CancellationToken::new(), &hello, false)
.await
.is_err(),
"cleanup must make the challenge unavailable"
);
config.cache().stop_and_wait().await;
}
#[allow(dead_code)]
fn _http_solver_is_constructible() -> Http01Solver {
Http01Solver::new(0)
}