coil-tls 0.1.0

TLS management primitives for the Coil framework.
Documentation
use crate::{CertificateId, CertificateRecord, CertificateStatus, Hostname, TlsModelError};
use serde::{Deserialize, Serialize};

use super::planning::{ChallengeTicket, HotReloadEvent, RenewalPlan};

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TlsControlPlaneState {
    pub inventory: CertificateInventory,
    pub renewal_queue: Vec<RenewalPlan>,
    pub pending_challenges: Vec<ChallengeTicket>,
    pub hot_reload_events: Vec<HotReloadEvent>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct CertificateInventory {
    certificates: Vec<CertificateRecord>,
}

impl CertificateInventory {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn certificates(&self) -> &[CertificateRecord] {
        &self.certificates
    }

    pub fn active_for_hostname(&self, hostname: &Hostname) -> Option<&CertificateRecord> {
        self.certificates.iter().find(|record| {
            record
                .bindings
                .iter()
                .any(|binding| &binding.hostname == hostname)
                && matches!(
                    record.status,
                    CertificateStatus::Active
                        | CertificateStatus::RenewalDue
                        | CertificateStatus::Renewing
                )
        })
    }

    pub fn record(&self, certificate_id: &CertificateId) -> Option<&CertificateRecord> {
        self.certificates
            .iter()
            .find(|record| &record.id == certificate_id)
    }

    pub fn record_mut(&mut self, certificate_id: &CertificateId) -> Option<&mut CertificateRecord> {
        self.certificates
            .iter_mut()
            .find(|record| &record.id == certificate_id)
    }

    pub fn insert(&mut self, record: CertificateRecord) -> Result<(), TlsModelError> {
        self.ensure_unique_bindings(&record, None)?;
        self.certificates.push(record);
        Ok(())
    }

    pub fn activate_replacement(
        &mut self,
        certificate_id: &CertificateId,
        replacement: CertificateRecord,
    ) -> Result<(), TlsModelError> {
        let original =
            self.record(certificate_id)
                .ok_or_else(|| TlsModelError::UnknownCertificate {
                    certificate_id: certificate_id.to_string(),
                })?;
        if original.replacing_certificate.as_ref() != Some(&replacement.id) {
            return Err(TlsModelError::MissingReplacementCertificate {
                certificate_id: certificate_id.to_string(),
            });
        }

        self.ensure_unique_bindings(&replacement, Some(certificate_id))?;
        let original =
            self.record_mut(certificate_id)
                .ok_or_else(|| TlsModelError::UnknownCertificate {
                    certificate_id: certificate_id.to_string(),
                })?;
        original.status = CertificateStatus::Superseded;
        original.replacing_certificate = None;

        self.certificates.push(replacement);
        Ok(())
    }

    fn ensure_unique_bindings(
        &self,
        candidate: &CertificateRecord,
        allowing_replaced_certificate: Option<&CertificateId>,
    ) -> Result<(), TlsModelError> {
        for binding in &candidate.bindings {
            if let Some(existing) = self.active_for_hostname(&binding.hostname) {
                let allowed = allowing_replaced_certificate
                    .is_some_and(|certificate_id| &existing.id == certificate_id);
                if !allowed {
                    return Err(TlsModelError::DuplicateHostnameBinding {
                        hostname: binding.hostname.to_string(),
                        certificate_id: existing.id.to_string(),
                    });
                }
            }
        }

        Ok(())
    }
}

impl TlsControlPlaneState {
    pub fn new() -> Self {
        Self::default()
    }
}