use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use rand::RngExt;
use tokio::sync::{RwLock, watch};
use tracing::{debug, info};
use crate::certificates::Certificate;
use crate::error::Result;
pub const DEFAULT_RENEW_CHECK_INTERVAL: Duration = Duration::from_secs(10 * 60);
pub const DEFAULT_OCSP_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
type CertConfigFunc =
Arc<dyn Fn(&Certificate) -> Arc<dyn std::any::Any + Send + Sync> + Send + Sync>;
#[derive(Debug, Clone)]
pub enum CacheEvent {
Added {
names: Vec<String>,
hash: String,
},
Updated {
names: Vec<String>,
hash: String,
},
Removed {
names: Vec<String>,
hash: String,
},
}
#[derive(Debug, Clone)]
pub struct SubjectIssuer {
pub subject: String,
pub issuer_key: String,
}
#[derive(Clone)]
pub struct CacheOptions {
pub renew_check_interval: Duration,
pub ocsp_check_interval: Duration,
pub capacity: usize,
pub on_event: Option<Arc<dyn Fn(CacheEvent) + Send + Sync>>,
pub get_config_for_cert: Option<CertConfigFunc>,
}
impl Default for CacheOptions {
fn default() -> Self {
Self {
renew_check_interval: DEFAULT_RENEW_CHECK_INTERVAL,
ocsp_check_interval: DEFAULT_OCSP_CHECK_INTERVAL,
capacity: 0,
on_event: None,
get_config_for_cert: None,
}
}
}
impl std::fmt::Debug for CacheOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CacheOptions")
.field("renew_check_interval", &self.renew_check_interval)
.field("ocsp_check_interval", &self.ocsp_check_interval)
.field("capacity", &self.capacity)
.field("on_event", &self.on_event.as_ref().map(|_| "..."))
.field(
"get_config_for_cert",
&self.get_config_for_cert.as_ref().map(|_| "..."),
)
.finish()
}
}
pub struct CertCache {
cache: RwLock<HashMap<String, Certificate>>,
cache_index: RwLock<HashMap<String, Vec<String>>>,
options: RwLock<CacheOptions>,
stop_tx: watch::Sender<bool>,
done_rx: RwLock<watch::Receiver<bool>>,
done_tx: watch::Sender<bool>,
}
impl std::fmt::Debug for CertCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CertCache")
.field("cache", &"<RwLock<HashMap>>")
.field("cache_index", &"<RwLock<HashMap>>")
.finish()
}
}
impl CertCache {
pub fn new(options: CacheOptions) -> Arc<Self> {
let opts = normalize_options(options);
let (stop_tx, _stop_rx) = watch::channel(false);
let (done_tx, done_rx) = watch::channel(false);
Arc::new(Self {
cache: RwLock::new(HashMap::new()),
cache_index: RwLock::new(HashMap::new()),
options: RwLock::new(opts),
stop_tx,
done_rx: RwLock::new(done_rx),
done_tx,
})
}
pub async fn set_options(&self, options: CacheOptions) {
let opts = normalize_options(options);
let mut guard = self.options.write().await;
*guard = opts;
}
pub fn stop(&self) {
let _ = self.stop_tx.send(true);
}
pub fn subscribe_stop(&self) -> watch::Receiver<bool> {
self.stop_tx.subscribe()
}
pub async fn stop_and_wait(&self) {
let _ = self.stop_tx.send(true);
let mut done_rx = self.done_rx.write().await;
if *done_rx.borrow() {
return;
}
let _ = done_rx.changed().await;
}
pub fn signal_done(&self) {
let _ = self.done_tx.send(true);
}
pub async fn add(&self, cert: Certificate) {
let mut cache = self.cache.write().await;
let mut index = self.cache_index.write().await;
let options = self.options.read().await;
self.unsynced_add(&mut cache, &mut index, &options, cert)
.await;
}
async fn unsynced_add(
&self,
cache: &mut HashMap<String, Certificate>,
index: &mut HashMap<String, Vec<String>>,
options: &CacheOptions,
cert: Certificate,
) {
let cert_hash = cert.hash.clone();
if let Some(existing) = cache.get_mut(&cert_hash) {
let mut merged = false;
for tag in &cert.tags {
if !existing.tags.contains(tag) {
existing.tags.push(tag.clone());
merged = true;
}
}
let log_action = if merged {
"certificate already cached; appended missing tags"
} else {
"certificate already cached"
};
debug!(
subjects = ?cert.names,
managed = cert.managed,
issuer_key = %cert.issuer_key,
hash = %cert_hash,
log_action,
);
if let Some(ref on_event) = options.on_event {
on_event(CacheEvent::Updated {
names: cert.names.clone(),
hash: cert_hash,
});
}
return;
}
if options.capacity > 0 && cache.len() >= options.capacity {
let cache_size = cache.len();
let target_idx = rand::rng().random_range(0..cache_size);
let hashes: Vec<String> = cache.keys().cloned().collect();
for offset in 0..cache_size {
let idx = (target_idx + offset) % cache_size;
let hash = &hashes[idx];
if let Some(evict_cert) = cache.get(hash)
&& evict_cert.managed
{
let evict_names = evict_cert.names.clone();
let evict_hash = evict_cert.hash.clone();
debug!(
removing_subjects = ?evict_names,
removing_hash = %evict_hash,
inserting_subjects = ?cert.names,
inserting_hash = %cert_hash,
"cache full; evicting random managed certificate",
);
Self::unsynced_remove_by_hash(cache, index, &evict_hash);
if let Some(ref on_event) = options.on_event {
on_event(CacheEvent::Removed {
names: evict_names,
hash: evict_hash,
});
}
break;
}
}
}
for name in &cert.names {
let lower = name.to_lowercase();
index.entry(lower).or_default().push(cert_hash.clone());
}
let event_names = cert.names.clone();
let event_hash = cert_hash.clone();
info!(
subjects = ?cert.names,
managed = cert.managed,
issuer_key = %cert.issuer_key,
hash = %cert_hash,
expires = %cert.not_after,
cache_size = cache.len() + 1,
cache_capacity = options.capacity,
"added certificate to cache",
);
cache.insert(cert_hash, cert);
if let Some(ref on_event) = options.on_event {
on_event(CacheEvent::Added {
names: event_names,
hash: event_hash,
});
}
}
pub async fn remove(&self, hash: &str) {
let mut cache = self.cache.write().await;
let mut index = self.cache_index.write().await;
let options = self.options.read().await;
if let Some(cert) = cache.get(hash) {
let names = cert.names.clone();
let hash_owned = hash.to_owned();
Self::unsynced_remove_by_hash(&mut cache, &mut index, hash);
debug!(
subjects = ?names,
hash = %hash_owned,
cache_size = cache.len(),
"removed certificate from cache",
);
if let Some(ref on_event) = options.on_event {
on_event(CacheEvent::Removed {
names,
hash: hash_owned,
});
}
}
}
pub async fn remove_many(&self, hashes: &[String]) {
let mut cache = self.cache.write().await;
let mut index = self.cache_index.write().await;
let options = self.options.read().await;
for hash in hashes {
if let Some(cert) = cache.get(hash.as_str()) {
let names = cert.names.clone();
let hash_owned = hash.clone();
Self::unsynced_remove_by_hash(&mut cache, &mut index, hash);
if let Some(ref on_event) = options.on_event {
on_event(CacheEvent::Removed {
names,
hash: hash_owned,
});
}
}
}
}
pub async fn remove_managed(&self, subjects: &[SubjectIssuer]) {
let mut to_remove = Vec::new();
for subj in subjects {
let lower = subj.subject.to_lowercase();
let certs = self.get_all_matching_exact(&lower).await;
for cert in certs {
if !cert.managed {
continue;
}
if subj.issuer_key.is_empty() || cert.issuer_key == subj.issuer_key {
to_remove.push(cert.hash.clone());
}
}
}
self.remove_many(&to_remove).await;
}
fn unsynced_remove_by_hash(
cache: &mut HashMap<String, Certificate>,
index: &mut HashMap<String, Vec<String>>,
hash: &str,
) {
if let Some(cert) = cache.remove(hash) {
for name in &cert.names {
let lower = name.to_lowercase();
if let Some(hashes) = index.get_mut(&lower) {
hashes.retain(|h| h != hash);
if hashes.is_empty() {
index.remove(&lower);
}
}
}
}
}
pub async fn replace(&self, old_hash: &str, new_cert: Certificate) {
let mut cache = self.cache.write().await;
let mut index = self.cache_index.write().await;
let options = self.options.read().await;
if let Some(old_cert) = cache.get(old_hash) {
let old_names = old_cert.names.clone();
let old_hash_owned = old_hash.to_owned();
Self::unsynced_remove_by_hash(&mut cache, &mut index, old_hash);
if let Some(ref on_event) = options.on_event {
on_event(CacheEvent::Removed {
names: old_names,
hash: old_hash_owned,
});
}
}
let new_names = new_cert.names.clone();
let new_expires = new_cert.not_after;
self.unsynced_add(&mut cache, &mut index, &options, new_cert)
.await;
info!(
subjects = ?new_names,
expires = %new_expires,
"replaced certificate in cache",
);
}
pub async fn get_by_name(&self, name: &str) -> Option<Certificate> {
let candidates = self.all_matching_certificates(name).await;
select_best_certificate(candidates)
}
pub async fn get_by_hash(&self, hash: &str) -> Option<Certificate> {
let cache = self.cache.read().await;
cache.get(hash).cloned()
}
pub async fn all_matching_certificates(&self, name: &str) -> Vec<Certificate> {
let lower = name.to_lowercase();
let mut certs = self.get_all_matching_exact(&lower).await;
let labels: Vec<&str> = lower.split('.').collect();
let mut wildcard_labels: Vec<String> = labels.iter().map(|l| l.to_string()).collect();
for i in 0..wildcard_labels.len() {
let original = wildcard_labels[i].clone();
wildcard_labels[i] = "*".to_string();
let candidate = wildcard_labels.join(".");
let mut wildcard_certs = self.get_all_matching_exact(&candidate).await;
certs.append(&mut wildcard_certs);
wildcard_labels[i] = original;
}
certs
}
async fn get_all_matching_exact(&self, subject: &str) -> Vec<Certificate> {
let cache = self.cache.read().await;
let index = self.cache_index.read().await;
match index.get(subject) {
Some(hashes) => hashes
.iter()
.filter_map(|h| cache.get(h).cloned())
.collect(),
None => Vec::new(),
}
}
pub async fn count(&self) -> usize {
let cache = self.cache.read().await;
cache.len()
}
pub async fn get_all(&self) -> Vec<Certificate> {
let cache = self.cache.read().await;
cache.values().cloned().collect()
}
pub async fn get_managed_certificates(&self) -> Vec<Certificate> {
let cache = self.cache.read().await;
cache.values().filter(|c| c.managed).cloned().collect()
}
pub async fn cache_unmanaged_pem(
&self,
cert_pem: &[u8],
key_pem: &[u8],
tags: Vec<String>,
) -> Result<()> {
let mut cert = Certificate::from_pem(cert_pem, key_pem)?;
cert.managed = false;
cert.tags = tags;
self.add(cert).await;
Ok(())
}
pub async fn cache_unmanaged_certified_key(
&self,
cert_chain: Vec<rustls::pki_types::CertificateDer<'static>>,
private_key: rustls::pki_types::PrivateKeyDer<'static>,
tags: Vec<String>,
) -> Result<()> {
let mut cert = Certificate::from_der(cert_chain, Some(private_key))?;
cert.managed = false;
cert.tags = tags;
info!(
subjects = ?cert.names,
expires = %cert.not_after,
"caching unmanaged certified key"
);
self.add(cert).await;
Ok(())
}
pub async fn cache_unmanaged_replacing(
&self,
cert_pem: &[u8],
key_pem: &[u8],
tags: Vec<String>,
) -> Result<()> {
let mut cert = Certificate::from_pem(cert_pem, key_pem)?;
cert.managed = false;
cert.tags = tags;
let mut to_remove = Vec::new();
for name in &cert.names {
let existing = self.all_matching_certificates(name).await;
for existing_cert in existing {
if !to_remove.contains(&existing_cert.hash) {
to_remove.push(existing_cert.hash.clone());
}
}
}
if !to_remove.is_empty() {
self.remove_many(&to_remove).await;
}
self.add(cert).await;
Ok(())
}
}
fn select_best_certificate(mut candidates: Vec<Certificate>) -> Option<Certificate> {
if candidates.is_empty() {
return None;
}
if candidates.len() == 1 {
return Some(candidates.remove(0));
}
let now = Utc::now();
candidates.sort_by(|a, b| {
let a_expired = a.not_after < now;
let b_expired = b.not_after < now;
match (a_expired, b_expired) {
(false, true) => return std::cmp::Ordering::Less,
(true, false) => return std::cmp::Ordering::Greater,
_ => {}
}
match (a.managed, b.managed) {
(true, false) => return std::cmp::Ordering::Less,
(false, true) => return std::cmp::Ordering::Greater,
_ => {}
}
b.not_after.cmp(&a.not_after)
});
Some(candidates.remove(0))
}
fn normalize_options(mut opts: CacheOptions) -> CacheOptions {
if opts.renew_check_interval.is_zero() {
opts.renew_check_interval = DEFAULT_RENEW_CHECK_INTERVAL;
}
if opts.ocsp_check_interval.is_zero() {
opts.ocsp_check_interval = DEFAULT_OCSP_CHECK_INTERVAL;
}
opts
}
#[cfg(test)]
mod tests {
use chrono::{Duration as ChronoDuration, Utc};
use super::*;
fn make_cert(names: &[&str], hash: &str, managed: bool) -> Certificate {
let now = Utc::now();
Certificate {
cert_chain: Vec::new(),
private_key_der: None,
private_key_kind: crate::certificates::PrivateKeyKind::None,
names: names.iter().map(|n| n.to_string()).collect(),
tags: Vec::new(),
managed,
issuer_key: String::new(),
hash: hash.to_string(),
ocsp_response: None,
ocsp_status: None,
not_after: now + ChronoDuration::days(90),
not_before: now - ChronoDuration::days(1),
ari: None,
}
}
fn make_expired_cert(names: &[&str], hash: &str, managed: bool) -> Certificate {
let now = Utc::now();
Certificate {
cert_chain: Vec::new(),
private_key_der: None,
private_key_kind: crate::certificates::PrivateKeyKind::None,
names: names.iter().map(|n| n.to_string()).collect(),
tags: Vec::new(),
managed,
issuer_key: String::new(),
hash: hash.to_string(),
ocsp_response: None,
ocsp_status: None,
not_after: now - ChronoDuration::days(1),
not_before: now - ChronoDuration::days(91),
ari: None,
}
}
#[tokio::test]
async fn test_add_and_get_by_hash() {
let cache = CertCache::new(CacheOptions::default());
let cert = make_cert(&["example.com"], "hash1", true);
cache.add(cert.clone()).await;
let found = cache.get_by_hash("hash1").await;
assert!(found.is_some());
assert_eq!(found.unwrap().names, vec!["example.com".to_string()]);
}
#[tokio::test]
async fn test_add_and_get_by_name() {
let cache = CertCache::new(CacheOptions::default());
let cert = make_cert(&["example.com", "www.example.com"], "hash1", true);
cache.add(cert).await;
let found = cache.get_by_name("example.com").await;
assert!(found.is_some());
let found = cache.get_by_name("www.example.com").await;
assert!(found.is_some());
let found = cache.get_by_name("other.com").await;
assert!(found.is_none());
}
#[tokio::test]
async fn test_case_insensitive_lookup() {
let cache = CertCache::new(CacheOptions::default());
let cert = make_cert(&["Example.COM"], "hash1", true);
cache.add(cert).await;
let found = cache.get_by_name("example.com").await;
assert!(found.is_some());
let found = cache.get_by_name("EXAMPLE.COM").await;
assert!(found.is_some());
}
#[tokio::test]
async fn test_wildcard_lookup() {
let cache = CertCache::new(CacheOptions::default());
let cert = make_cert(&["*.example.com"], "hash1", true);
cache.add(cert).await;
let found = cache.get_by_name("*.example.com").await;
assert!(found.is_some());
let found = cache.get_by_name("sub.example.com").await;
assert!(found.is_some());
let found = cache.get_by_name("a.b.example.com").await;
assert!(found.is_none());
}
#[tokio::test]
async fn test_remove_by_hash() {
let cache = CertCache::new(CacheOptions::default());
let cert = make_cert(&["example.com"], "hash1", true);
cache.add(cert).await;
assert_eq!(cache.count().await, 1);
cache.remove("hash1").await;
assert_eq!(cache.count().await, 0);
assert!(cache.get_by_hash("hash1").await.is_none());
assert!(cache.get_by_name("example.com").await.is_none());
}
#[tokio::test]
async fn test_remove_many() {
let cache = CertCache::new(CacheOptions::default());
cache.add(make_cert(&["a.com"], "h1", true)).await;
cache.add(make_cert(&["b.com"], "h2", true)).await;
cache.add(make_cert(&["c.com"], "h3", true)).await;
assert_eq!(cache.count().await, 3);
cache
.remove_many(&["h1".to_string(), "h3".to_string()])
.await;
assert_eq!(cache.count().await, 1);
assert!(cache.get_by_hash("h2").await.is_some());
}
#[tokio::test]
async fn test_remove_managed() {
let cache = CertCache::new(CacheOptions::default());
cache.add(make_cert(&["a.com"], "h1", true)).await;
cache.add(make_cert(&["a.com"], "h2", false)).await;
cache.add(make_cert(&["b.com"], "h3", true)).await;
cache
.remove_managed(&[SubjectIssuer {
subject: "a.com".to_string(),
issuer_key: String::new(),
}])
.await;
assert!(cache.get_by_hash("h1").await.is_none());
assert!(cache.get_by_hash("h2").await.is_some()); assert!(cache.get_by_hash("h3").await.is_some()); }
#[tokio::test]
async fn test_replace() {
let cache = CertCache::new(CacheOptions::default());
let old = make_cert(&["example.com"], "old_hash", true);
cache.add(old).await;
let new = make_cert(&["example.com", "www.example.com"], "new_hash", true);
cache.replace("old_hash", new).await;
assert!(cache.get_by_hash("old_hash").await.is_none());
assert!(cache.get_by_hash("new_hash").await.is_some());
assert!(cache.get_by_name("www.example.com").await.is_some());
}
#[tokio::test]
async fn test_duplicate_add_merges_tags() {
let cache = CertCache::new(CacheOptions::default());
let mut cert1 = make_cert(&["example.com"], "hash1", true);
cert1.tags = vec!["tag-a".to_string()];
cache.add(cert1).await;
let mut cert2 = make_cert(&["example.com"], "hash1", true);
cert2.tags = vec!["tag-a".to_string(), "tag-b".to_string()];
cache.add(cert2).await;
assert_eq!(cache.count().await, 1);
let found = cache.get_by_hash("hash1").await.unwrap();
assert!(found.tags.contains(&"tag-a".to_string()));
assert!(found.tags.contains(&"tag-b".to_string()));
}
#[tokio::test]
async fn test_prefer_non_expired() {
let cache = CertCache::new(CacheOptions::default());
let expired = make_expired_cert(&["example.com"], "expired", true);
let valid = make_cert(&["example.com"], "valid", true);
cache.add(expired).await;
cache.add(valid).await;
let found = cache.get_by_name("example.com").await.unwrap();
assert_eq!(found.hash, "valid");
}
#[tokio::test]
async fn test_prefer_managed() {
let cache = CertCache::new(CacheOptions::default());
let unmanaged = make_cert(&["example.com"], "unmanaged", false);
let managed = make_cert(&["example.com"], "managed", true);
cache.add(unmanaged).await;
cache.add(managed).await;
let found = cache.get_by_name("example.com").await.unwrap();
assert_eq!(found.hash, "managed");
}
#[tokio::test]
async fn test_prefer_longer_lifetime() {
let cache = CertCache::new(CacheOptions::default());
let now = Utc::now();
let mut short = make_cert(&["example.com"], "short", true);
short.not_after = now + ChronoDuration::days(30);
let mut long = make_cert(&["example.com"], "long", true);
long.not_after = now + ChronoDuration::days(90);
cache.add(short).await;
cache.add(long).await;
let found = cache.get_by_name("example.com").await.unwrap();
assert_eq!(found.hash, "long");
}
#[tokio::test]
async fn test_capacity_eviction() {
let opts = CacheOptions {
capacity: 2,
..Default::default()
};
let cache = CertCache::new(opts);
cache.add(make_cert(&["a.com"], "h1", true)).await;
cache.add(make_cert(&["b.com"], "h2", true)).await;
cache.add(make_cert(&["c.com"], "h3", true)).await;
assert_eq!(cache.count().await, 2);
assert!(cache.get_by_hash("h3").await.is_some());
}
#[tokio::test]
async fn test_capacity_no_evict_unmanaged() {
let opts = CacheOptions {
capacity: 2,
..Default::default()
};
let cache = CertCache::new(opts);
cache.add(make_cert(&["a.com"], "h1", false)).await;
cache.add(make_cert(&["b.com"], "h2", false)).await;
cache.add(make_cert(&["c.com"], "h3", true)).await;
assert_eq!(cache.count().await, 3);
}
#[tokio::test]
async fn test_get_managed_certificates() {
let cache = CertCache::new(CacheOptions::default());
cache.add(make_cert(&["a.com"], "h1", true)).await;
cache.add(make_cert(&["b.com"], "h2", false)).await;
cache.add(make_cert(&["c.com"], "h3", true)).await;
let managed = cache.get_managed_certificates().await;
assert_eq!(managed.len(), 2);
}
#[tokio::test]
async fn test_get_all() {
let cache = CertCache::new(CacheOptions::default());
cache.add(make_cert(&["a.com"], "h1", true)).await;
cache.add(make_cert(&["b.com"], "h2", false)).await;
let all = cache.get_all().await;
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn test_count_empty() {
let cache = CertCache::new(CacheOptions::default());
assert_eq!(cache.count().await, 0);
}
#[tokio::test]
async fn test_stop_signal() {
let cache = CertCache::new(CacheOptions::default());
let mut rx = cache.subscribe_stop();
assert!(!*rx.borrow());
cache.stop();
rx.changed().await.unwrap();
assert!(*rx.borrow());
}
#[tokio::test]
async fn test_all_matching_certificates() {
let cache = CertCache::new(CacheOptions::default());
cache.add(make_cert(&["example.com"], "h1", true)).await;
cache.add(make_cert(&["*.example.com"], "h2", true)).await;
let certs = cache.all_matching_certificates("sub.example.com").await;
assert_eq!(certs.len(), 1);
assert_eq!(certs[0].hash, "h2");
let certs = cache.all_matching_certificates("example.com").await;
assert_eq!(certs.len(), 1);
assert_eq!(certs[0].hash, "h1");
}
#[tokio::test]
async fn test_event_callback() {
use std::sync::atomic::{AtomicUsize, Ordering};
let add_count = Arc::new(AtomicUsize::new(0));
let remove_count = Arc::new(AtomicUsize::new(0));
let add_clone = Arc::clone(&add_count);
let remove_clone = Arc::clone(&remove_count);
let opts = CacheOptions {
on_event: Some(Arc::new(move |event| match event {
CacheEvent::Added { .. } => {
add_clone.fetch_add(1, Ordering::SeqCst);
}
CacheEvent::Removed { .. } => {
remove_clone.fetch_add(1, Ordering::SeqCst);
}
CacheEvent::Updated { .. } => {}
})),
..Default::default()
};
let cache = CertCache::new(opts);
cache.add(make_cert(&["a.com"], "h1", true)).await;
cache.add(make_cert(&["b.com"], "h2", true)).await;
cache.remove("h1").await;
assert_eq!(add_count.load(Ordering::SeqCst), 2);
assert_eq!(remove_count.load(Ordering::SeqCst), 1);
}
#[test]
fn test_normalize_options_defaults() {
let opts = CacheOptions {
renew_check_interval: Duration::ZERO,
ocsp_check_interval: Duration::ZERO,
..Default::default()
};
let normalized = normalize_options(opts);
assert_eq!(
normalized.renew_check_interval,
DEFAULT_RENEW_CHECK_INTERVAL
);
assert_eq!(normalized.ocsp_check_interval, DEFAULT_OCSP_CHECK_INTERVAL);
}
#[test]
fn test_normalize_options_preserves_custom() {
let custom = Duration::from_secs(42);
let opts = CacheOptions {
renew_check_interval: custom,
ocsp_check_interval: custom,
..Default::default()
};
let normalized = normalize_options(opts);
assert_eq!(normalized.renew_check_interval, custom);
assert_eq!(normalized.ocsp_check_interval, custom);
}
#[test]
fn test_select_best_empty() {
assert!(select_best_certificate(vec![]).is_none());
}
#[test]
fn test_select_best_single() {
let cert = make_cert(&["example.com"], "h1", true);
let selected = select_best_certificate(vec![cert.clone()]).unwrap();
assert_eq!(selected.hash, "h1");
}
#[test]
fn test_cache_options_debug() {
let opts = CacheOptions::default();
let debug_str = format!("{:?}", opts);
assert!(debug_str.contains("CacheOptions"));
}
}