Skip to main content

tencrypt_core/
lib.rs

1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CertState {
12    Requested,
13    PolicyValidated,
14    DnsChallengePrepared,
15    DnsChallengePropagating,
16    Issuing,
17    Issued,
18    RenewalDue,
19    Renewing,
20    Failed,
21    Revoked,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct IssuanceRequest {
26    pub request_id: String,
27    pub hostname: String,
28    pub actor: String,
29    pub requested_at: DateTime<Utc>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct StateTransition {
34    pub from: CertState,
35    pub to: CertState,
36    pub reason: String,
37    pub at: DateTime<Utc>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct IssuanceReport {
42    pub request_id: String,
43    pub hostname: String,
44    pub actor: String,
45    pub status: String,
46    pub started_at: DateTime<Utc>,
47    pub completed_at: DateTime<Utc>,
48    pub transitions: Vec<StateTransition>,
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum RequestValidationError {
53    #[error("hostname must contain at least one dot")]
54    InvalidHostname,
55    #[error("hostname must be a valid RFC 1123-style DNS name")]
56    InvalidHostnameFormat,
57}
58
59pub fn new_request(hostname: String, actor: String) -> IssuanceRequest {
60    IssuanceRequest {
61        request_id: Uuid::new_v4().to_string(),
62        hostname,
63        actor,
64        requested_at: Utc::now(),
65    }
66}
67
68pub fn validate_hostname(hostname: &str) -> Result<(), RequestValidationError> {
69    if !hostname.contains('.') {
70        return Err(RequestValidationError::InvalidHostname);
71    }
72
73    if hostname.is_empty() || hostname.len() > 253 {
74        return Err(RequestValidationError::InvalidHostnameFormat);
75    }
76
77    for label in hostname.split('.') {
78        if label.is_empty() || label.len() > 63 {
79            return Err(RequestValidationError::InvalidHostnameFormat);
80        }
81
82        let bytes = label.as_bytes();
83        if bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') {
84            return Err(RequestValidationError::InvalidHostnameFormat);
85        }
86
87        if !bytes
88            .iter()
89            .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
90        {
91            return Err(RequestValidationError::InvalidHostnameFormat);
92        }
93    }
94
95    Ok(())
96}
97
98pub fn validate_request(req: &IssuanceRequest) -> Result<(), RequestValidationError> {
99    validate_hostname(&req.hostname)
100}
101
102pub fn dry_run_issue(req: &IssuanceRequest) -> Result<IssuanceReport, RequestValidationError> {
103    validate_request(req)?;
104
105    let started = Utc::now();
106    let transitions = vec![
107        StateTransition {
108            from: CertState::Requested,
109            to: CertState::PolicyValidated,
110            reason: "policy checks passed".to_string(),
111            at: Utc::now(),
112        },
113        StateTransition {
114            from: CertState::PolicyValidated,
115            to: CertState::DnsChallengePrepared,
116            reason: "acme dns challenge prepared".to_string(),
117            at: Utc::now(),
118        },
119        StateTransition {
120            from: CertState::DnsChallengePrepared,
121            to: CertState::DnsChallengePropagating,
122            reason: "waiting for dns propagation".to_string(),
123            at: Utc::now(),
124        },
125        StateTransition {
126            from: CertState::DnsChallengePropagating,
127            to: CertState::Issuing,
128            reason: "acme order started".to_string(),
129            at: Utc::now(),
130        },
131        StateTransition {
132            from: CertState::Issuing,
133            to: CertState::Issued,
134            reason: "certificate issued (dry run)".to_string(),
135            at: Utc::now(),
136        },
137    ];
138
139    Ok(IssuanceReport {
140        request_id: req.request_id.clone(),
141        hostname: req.hostname.clone(),
142        actor: req.actor.clone(),
143        status: "success".to_string(),
144        started_at: started,
145        completed_at: Utc::now(),
146        transitions,
147    })
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AuditRecord {
152    pub id: String,
153    pub timestamp: DateTime<Utc>,
154    pub actor: String,
155    pub operation: String,
156    pub resource: String,
157    pub status: String,
158    pub message: String,
159}
160
161pub fn write_audit_jsonl(path: &Path, report: &IssuanceReport) -> anyhow::Result<()> {
162    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
163    let rec = AuditRecord {
164        id: Uuid::new_v4().to_string(),
165        timestamp: Utc::now(),
166        actor: report.actor.clone(),
167        operation: "dry_run_issue".to_string(),
168        resource: report.hostname.clone(),
169        status: report.status.clone(),
170        message: format!("request_id={}", report.request_id),
171    };
172    let line = serde_json::to_string(&rec)?;
173    writeln!(file, "{line}")?;
174    Ok(())
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CloudEvent {
179    pub specversion: String,
180    pub id: String,
181    pub source: String,
182    #[serde(rename = "type")]
183    pub event_type: String,
184    pub time: DateTime<Utc>,
185    pub subject: String,
186    pub datacontenttype: String,
187    pub data: serde_json::Value,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct TraefikStaticConfigInput {
192    pub email: String,
193    pub resolver_name: String,
194    pub cert_storage_file: String,
195    pub cloudflare_token_env: String,
196    pub log_level: String,
197}
198
199impl Default for TraefikStaticConfigInput {
200    fn default() -> Self {
201        Self {
202            email: "ops@example.com".to_string(),
203            resolver_name: "cloudflare".to_string(),
204            cert_storage_file: "/var/traefik/certs/acme.json".to_string(),
205            cloudflare_token_env: "CF_DNS_API_TOKEN".to_string(),
206            log_level: "INFO".to_string(),
207        }
208    }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct TraefikRouterConfig {
213    pub service_name: String,
214    pub hostname: String,
215    pub service_port: u16,
216    pub resolver_name: String,
217}
218
219impl TraefikRouterConfig {
220    pub fn router_base_name(&self) -> String {
221        self.service_name.replace('_', "-")
222    }
223}
224
225pub fn render_traefik_static_yaml(input: &TraefikStaticConfigInput) -> String {
226    format!(
227        "global:\n  checkNewVersion: false\n  sendAnonymousUsage: false\n\nlog:\n  level: {log_level}\n\napi:\n  dashboard: true\n\nentryPoints:\n  web:\n    address: \":80\"\n    http:\n      redirections:\n        entryPoint:\n          to: websecure\n          scheme: https\n          permanent: true\n  websecure:\n    address: \":443\"\n\nproviders:\n  docker:\n    endpoint: \"unix:///var/run/docker.sock\"\n    exposedByDefault: false\n\ncertificatesResolvers:\n  {resolver_name}:\n    acme:\n      email: \"{email}\"\n      storage: \"{storage}\"\n      dnsChallenge:\n        provider: cloudflare\n        resolvers:\n          - \"1.1.1.1:53\"\n          - \"8.8.8.8:53\"\n\n# Required environment variable at runtime:\n# {token_env}=<cloudflare_dns_api_token>\n",
228        log_level = input.log_level,
229        resolver_name = input.resolver_name,
230        email = input.email,
231        storage = input.cert_storage_file,
232        token_env = input.cloudflare_token_env,
233    )
234}
235
236pub fn generate_router_labels(config: &TraefikRouterConfig) -> Vec<String> {
237    let base = config.router_base_name();
238    vec![
239        "traefik.enable=true".to_string(),
240        format!(
241            "traefik.http.routers.{base}-http.rule=Host(`{host}`)",
242            host = config.hostname
243        ),
244        format!("traefik.http.routers.{base}-http.entrypoints=web"),
245        format!(
246            "traefik.http.routers.{base}-https.rule=Host(`{host}`)",
247            host = config.hostname
248        ),
249        format!("traefik.http.routers.{base}-https.entrypoints=websecure"),
250        format!("traefik.http.routers.{base}-https.tls=true"),
251        format!(
252            "traefik.http.routers.{base}-https.tls.certresolver={resolver}",
253            resolver = config.resolver_name
254        ),
255        format!(
256            "traefik.http.services.{base}.loadbalancer.server.port={port}",
257            port = config.service_port
258        ),
259    ]
260}
261
262pub fn cert_state_names() -> Vec<&'static str> {
263    vec![
264        "requested",
265        "policy_validated",
266        "dns_challenge_prepared",
267        "dns_challenge_propagating",
268        "issuing",
269        "issued",
270        "renewal_due",
271        "renewing",
272        "failed",
273        "revoked",
274    ]
275}
276
277pub fn events_from_report(report: &IssuanceReport) -> Vec<CloudEvent> {
278    report
279        .transitions
280        .iter()
281        .map(|t| CloudEvent {
282            specversion: "1.0".to_string(),
283            id: Uuid::new_v4().to_string(),
284            source: "com.tencrypt.issuer".to_string(),
285            event_type: format!(
286                "com.tencrypt.certificate.{}.v1",
287                state_name(t.to).replace('_', "-")
288            ),
289            time: t.at,
290            subject: report.hostname.clone(),
291            datacontenttype: "application/json".to_string(),
292            data: serde_json::json!({
293                "request_id": report.request_id,
294                "hostname": report.hostname,
295                "actor": report.actor,
296                "from": state_name(t.from),
297                "to": state_name(t.to),
298                "reason": t.reason,
299            }),
300        })
301        .collect()
302}
303
304pub fn write_events_jsonl(path: &Path, report: &IssuanceReport) -> anyhow::Result<()> {
305    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
306    for event in events_from_report(report) {
307        let line = serde_json::to_string(&event)?;
308        writeln!(file, "{line}")?;
309    }
310    Ok(())
311}
312
313/// Parse a `CertState` from its snake_case name string.
314pub fn cert_state_from_str(s: &str) -> Option<CertState> {
315    match s {
316        "requested" => Some(CertState::Requested),
317        "policy_validated" => Some(CertState::PolicyValidated),
318        "dns_challenge_prepared" => Some(CertState::DnsChallengePrepared),
319        "dns_challenge_propagating" => Some(CertState::DnsChallengePropagating),
320        "issuing" => Some(CertState::Issuing),
321        "issued" => Some(CertState::Issued),
322        "renewal_due" => Some(CertState::RenewalDue),
323        "renewing" => Some(CertState::Renewing),
324        "failed" => Some(CertState::Failed),
325        "revoked" => Some(CertState::Revoked),
326        _ => None,
327    }
328}
329
330// ── M3: Idempotent Reconcile Loop ──────────────────────────────────────────
331
332/// The action the reconciler should take given the current certificate state.
333/// `reconcile_step` is a pure function: same input always produces the same output.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum ReconcileDecision {
337    /// Advance to the next lifecycle step.
338    Proceed,
339    /// Pause and re-check after a propagation delay.
340    Wait,
341    /// Certificate is in a terminal state — nothing to do.
342    Done,
343    /// Certificate failed; caller should apply backoff then retry or abandon.
344    RetryOrAbandon,
345}
346
347/// Pure reconcile function: given current state, return the required action.
348/// Makes no I/O, mutates nothing, has no side effects.
349pub fn reconcile_step(state: CertState) -> ReconcileDecision {
350    match state {
351        CertState::Requested => ReconcileDecision::Proceed,
352        CertState::PolicyValidated => ReconcileDecision::Proceed,
353        CertState::DnsChallengePrepared => ReconcileDecision::Proceed,
354        CertState::DnsChallengePropagating => ReconcileDecision::Wait,
355        CertState::Issuing => ReconcileDecision::Proceed,
356        CertState::Issued => ReconcileDecision::Done,
357        CertState::RenewalDue => ReconcileDecision::Proceed,
358        CertState::Renewing => ReconcileDecision::Proceed,
359        CertState::Failed => ReconcileDecision::RetryOrAbandon,
360        CertState::Revoked => ReconcileDecision::Done,
361    }
362}
363
364// ── M3: Retry / Backoff Policy ─────────────────────────────────────────────
365
366use std::time::Duration;
367
368/// Configuration for exponential backoff without jitter.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct BackoffConfig {
371    /// Maximum number of retry attempts before giving up.
372    pub max_retries: u32,
373    /// Base delay in milliseconds for the first retry.
374    pub base_delay_ms: u64,
375    /// Maximum delay cap in milliseconds after exponential growth.
376    pub max_delay_ms: u64,
377}
378
379impl Default for BackoffConfig {
380    fn default() -> Self {
381        Self {
382            max_retries: 5,
383            base_delay_ms: 1_000,
384            max_delay_ms: 60_000,
385        }
386    }
387}
388
389/// Tracks how many attempts have been made so far.
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391pub struct BackoffState {
392    /// Zero-based attempt counter. `0` means first retry has not yet occurred.
393    pub attempt: u32,
394}
395
396/// Returns the delay before the next attempt, or `None` if retries are exhausted.
397/// Delay doubles each attempt and is capped at `config.max_delay_ms`.
398pub fn next_backoff_delay(config: &BackoffConfig, state: &BackoffState) -> Option<Duration> {
399    if state.attempt >= config.max_retries {
400        return None;
401    }
402    let multiplier = 1u64.checked_shl(state.attempt).unwrap_or(u64::MAX);
403    let delay_ms = config
404        .base_delay_ms
405        .saturating_mul(multiplier)
406        .min(config.max_delay_ms);
407    Some(Duration::from_millis(delay_ms))
408}
409
410/// Jittered variant of [`next_backoff_delay`].
411///
412/// The caller provides `jitter_ms` (e.g. computed from a PRNG) so this
413/// function remains pure and deterministic in tests (pass `0` for no jitter).
414/// The jitter is added to the base exponential delay *before* the cap is
415/// applied.
416pub fn next_backoff_delay_jittered(
417    config: &BackoffConfig,
418    state: &BackoffState,
419    jitter_ms: u64,
420) -> Option<Duration> {
421    if state.attempt >= config.max_retries {
422        return None;
423    }
424    let multiplier = 1u64.checked_shl(state.attempt).unwrap_or(u64::MAX);
425    let base_ms = config.base_delay_ms.saturating_mul(multiplier);
426    let delay_ms = base_ms.saturating_add(jitter_ms).min(config.max_delay_ms);
427    Some(Duration::from_millis(delay_ms))
428}
429
430// ── AAA-2: Per-domain policy overrides ────────────────────────────────────
431
432/// Per-domain backoff policy override.
433/// `hostname_pattern` is an exact hostname match for AAA-1/2; wildcard
434/// support is deferred to AAA-3.
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct DomainPolicy {
437    pub hostname_pattern: String,
438    pub backoff: Option<BackoffConfig>,
439}
440
441/// Resolve the effective backoff config for a hostname.
442///
443/// Returns the first matching `DomainPolicy.backoff` when `hostname_pattern`
444/// equals `hostname`, otherwise returns `default`.
445pub fn resolve_backoff<'a>(
446    hostname: &str,
447    policies: &'a [DomainPolicy],
448    default: &'a BackoffConfig,
449) -> &'a BackoffConfig {
450    policies
451        .iter()
452        .find(|p| p.hostname_pattern == hostname)
453        .and_then(|p| p.backoff.as_ref())
454        .unwrap_or(default)
455}
456
457// ── M3: SLO and Metrics Baseline ───────────────────────────────────────────
458
459/// In-process certificate operation counters (single-threaded).
460/// Serialize to JSON for periodic log-line metrics export.
461#[derive(Debug, Default, Clone, Serialize, Deserialize)]
462pub struct CertMetrics {
463    /// Total certificates successfully issued since process start.
464    pub issued_total: u64,
465    /// Total certificate issuances that failed since process start.
466    pub failed_total: u64,
467    /// Issuances currently in progress.
468    pub in_flight: u64,
469}
470
471impl CertMetrics {
472    /// Call when a new issuance request begins.
473    pub fn record_start(&mut self) {
474        self.in_flight += 1;
475    }
476
477    /// Call when an issuance completes successfully.
478    pub fn record_success(&mut self) {
479        self.issued_total += 1;
480        self.in_flight = self.in_flight.saturating_sub(1);
481    }
482
483    /// Call when an issuance fails terminally (after all retries).
484    pub fn record_failure(&mut self) {
485        self.failed_total += 1;
486        self.in_flight = self.in_flight.saturating_sub(1);
487    }
488}
489
490// ── Traefik ACME JSON polling ──────────────────────────────────────────────
491
492/// Minimal deserialization of Traefik's `acme.json`.
493/// Structure: `{ "<resolver>": { "Certificates": [ { "domain": { "main": "...", "sans": [...] } } ] } }`
494#[derive(Debug, Deserialize)]
495struct AcmeFile {
496    #[serde(flatten)]
497    resolvers: std::collections::HashMap<String, AcmeResolver>,
498}
499
500#[derive(Debug, Deserialize)]
501struct AcmeResolver {
502    #[serde(rename = "Certificates")]
503    certificates: Option<Vec<AcmeCert>>,
504}
505
506#[derive(Debug, Deserialize)]
507struct AcmeCert {
508    domain: AcmeDomain,
509}
510
511#[derive(Debug, Deserialize)]
512struct AcmeDomain {
513    main: String,
514    #[serde(default)]
515    sans: Vec<String>,
516}
517
518/// Returns `true` if `acme.json` at `path` contains a certificate entry whose
519/// `domain.main` or `domain.sans` matches `hostname` under the given `resolver`.
520///
521/// Returns `false` (not an error) when:
522/// - The file does not exist yet (Traefik hasn't started the challenge)
523/// - The resolver section is absent
524/// - The certificates array is empty
525///
526/// Returns `Err` only for genuine I/O or JSON parse failures.
527pub fn acme_cert_present(path: &Path, resolver: &str, hostname: &str) -> anyhow::Result<bool> {
528    let raw = match std::fs::read_to_string(path) {
529        Ok(s) => s,
530        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
531        Err(e) => return Err(e.into()),
532    };
533    if raw.trim().is_empty() {
534        return Ok(false);
535    }
536    let acme: AcmeFile = serde_json::from_str(&raw)?;
537    let Some(res) = acme.resolvers.get(resolver) else {
538        return Ok(false);
539    };
540    let certs = res.certificates.as_deref().unwrap_or(&[]);
541    Ok(certs
542        .iter()
543        .any(|c| c.domain.main == hostname || c.domain.sans.iter().any(|s| s == hostname)))
544}
545
546// ── Cell model: CertRecord + StateStore ───────────────────────────────────
547
548/// A single certificate tracked by the reconciler.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct CertRecord {
551    pub hostname: String,
552    pub state: CertState,
553    /// Retry attempt counter, reset to 0 on successful state advance.
554    pub attempt: u32,
555    pub last_updated: DateTime<Utc>,
556}
557
558impl CertRecord {
559    pub fn new(hostname: String) -> Self {
560        Self {
561            hostname,
562            state: CertState::Requested,
563            attempt: 0,
564            last_updated: Utc::now(),
565        }
566    }
567}
568
569/// File-backed collection of `CertRecord`s.
570/// Single-writer assumption — concurrent cell runs must not share the same file.
571#[derive(Debug, Default, Serialize, Deserialize)]
572pub struct StateStore {
573    pub certs: Vec<CertRecord>,
574}
575
576impl StateStore {
577    /// Load from a JSON file. Returns an empty store when the file does not exist.
578    pub fn load(path: &Path) -> anyhow::Result<Self> {
579        match std::fs::read_to_string(path) {
580            Ok(s) => Ok(serde_json::from_str(&s)?),
581            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
582            Err(e) => Err(e.into()),
583        }
584    }
585
586    /// Write to a JSON file atomically (write to `.tmp`, then rename).
587    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
588        if let Some(parent) = path.parent() {
589            std::fs::create_dir_all(parent)?;
590        }
591        let tmp = path.with_extension("json.tmp");
592        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
593        std::fs::rename(&tmp, path)?;
594        Ok(())
595    }
596}
597
598/// The next state in the forward-progress pipeline for `Proceed` decisions.
599/// Returns `None` for terminal or wait states.
600pub fn next_state(current: CertState) -> Option<CertState> {
601    match current {
602        CertState::Requested => Some(CertState::PolicyValidated),
603        CertState::PolicyValidated => Some(CertState::DnsChallengePrepared),
604        CertState::DnsChallengePrepared => Some(CertState::DnsChallengePropagating),
605        CertState::DnsChallengePropagating => Some(CertState::Issuing),
606        CertState::Issuing => Some(CertState::Issued),
607        CertState::RenewalDue => Some(CertState::Renewing),
608        CertState::Renewing => Some(CertState::Issued),
609        // Terminal / wait — no automatic next state
610        CertState::Issued | CertState::Failed | CertState::Revoked => None,
611    }
612}
613
614/// Advance a `CertRecord` by one reconcile step using the given backoff config.
615///
616/// Returns a `StateTransition` when the state changes, `None` when no change
617/// is made (Done, Wait, or backoff not yet exhausted without a retry-triggering
618/// condition).
619///
620/// Mutates `record` in place — caller must save the `StateStore` afterward.
621pub fn advance_record(record: &mut CertRecord, config: &BackoffConfig) -> Option<StateTransition> {
622    match reconcile_step(record.state) {
623        ReconcileDecision::Done => None,
624        ReconcileDecision::Wait => None,
625        ReconcileDecision::Proceed => {
626            let to = next_state(record.state)?;
627            let from = record.state;
628            record.state = to;
629            record.attempt = 0;
630            record.last_updated = Utc::now();
631            Some(StateTransition {
632                from,
633                to,
634                reason: "reconcile: proceed".to_string(),
635                at: record.last_updated,
636            })
637        }
638        ReconcileDecision::RetryOrAbandon => {
639            let bs = BackoffState {
640                attempt: record.attempt,
641            };
642            if next_backoff_delay(config, &bs).is_some() {
643                record.attempt += 1;
644                record.last_updated = Utc::now();
645                // No state change — caller schedules retry after backoff delay
646                None
647            } else {
648                // Retries exhausted — transition to Failed
649                let from = record.state;
650                record.state = CertState::Failed;
651                record.last_updated = Utc::now();
652                Some(StateTransition {
653                    from,
654                    to: CertState::Failed,
655                    reason: "reconcile: retries exhausted".to_string(),
656                    at: record.last_updated,
657                })
658            }
659        }
660    }
661}
662
663fn state_name(state: CertState) -> &'static str {
664    match state {
665        CertState::Requested => "requested",
666        CertState::PolicyValidated => "policy_validated",
667        CertState::DnsChallengePrepared => "dns_challenge_prepared",
668        CertState::DnsChallengePropagating => "dns_challenge_propagating",
669        CertState::Issuing => "issuing",
670        CertState::Issued => "issued",
671        CertState::RenewalDue => "renewal_due",
672        CertState::Renewing => "renewing",
673        CertState::Failed => "failed",
674        CertState::Revoked => "revoked",
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn dry_run_transitions_to_issued() {
684        let req = IssuanceRequest {
685            request_id: "req-1".to_string(),
686            hostname: "app.example.com".to_string(),
687            actor: "test".to_string(),
688            requested_at: Utc::now(),
689        };
690
691        let report = dry_run_issue(&req).expect("dry run should pass");
692        let last = report.transitions.last().expect("has transitions");
693        assert_eq!(last.to, CertState::Issued);
694    }
695
696    #[test]
697    fn invalid_hostname_fails_validation() {
698        let req = IssuanceRequest {
699            request_id: "req-2".to_string(),
700            hostname: "localhost".to_string(),
701            actor: "test".to_string(),
702            requested_at: Utc::now(),
703        };
704
705        let result = validate_request(&req);
706        assert!(result.is_err());
707    }
708
709    #[test]
710    fn hostname_with_invalid_characters_fails_validation() {
711        let req = IssuanceRequest {
712            request_id: "req-3".to_string(),
713            hostname: "bad host.example.com".to_string(),
714            actor: "test".to_string(),
715            requested_at: Utc::now(),
716        };
717
718        let result = validate_request(&req);
719        assert!(matches!(
720            result,
721            Err(RequestValidationError::InvalidHostnameFormat)
722        ));
723    }
724
725    #[test]
726    fn hostname_with_leading_dash_label_fails_validation() {
727        let req = IssuanceRequest {
728            request_id: "req-4".to_string(),
729            hostname: "-bad.example.com".to_string(),
730            actor: "test".to_string(),
731            requested_at: Utc::now(),
732        };
733
734        let result = validate_request(&req);
735        assert!(matches!(
736            result,
737            Err(RequestValidationError::InvalidHostnameFormat)
738        ));
739    }
740
741    #[test]
742    fn hostname_with_valid_dns_labels_passes_validation() {
743        let req = IssuanceRequest {
744            request_id: "req-5".to_string(),
745            hostname: "api-1.example.com".to_string(),
746            actor: "test".to_string(),
747            requested_at: Utc::now(),
748        };
749
750        assert!(validate_request(&req).is_ok());
751    }
752
753    #[test]
754    fn static_config_contains_cloudflare_acme_resolver() {
755        let cfg = TraefikStaticConfigInput {
756            email: "ops@acme.test".to_string(),
757            resolver_name: "cloudflare".to_string(),
758            cert_storage_file: "/var/traefik/certs/acme.json".to_string(),
759            cloudflare_token_env: "CF_DNS_API_TOKEN".to_string(),
760            log_level: "DEBUG".to_string(),
761        };
762        let rendered = render_traefik_static_yaml(&cfg);
763        assert!(rendered.contains("certificatesResolvers:"));
764        assert!(rendered.contains("provider: cloudflare"));
765        assert!(rendered.contains("CF_DNS_API_TOKEN"));
766    }
767
768    #[test]
769    fn generated_labels_include_https_certresolver() {
770        let cfg = TraefikRouterConfig {
771            service_name: "issuer-api".to_string(),
772            hostname: "issuer.example.com".to_string(),
773            service_port: 8080,
774            resolver_name: "cloudflare".to_string(),
775        };
776        let labels = generate_router_labels(&cfg);
777        assert!(labels
778            .iter()
779            .any(|x| x == "traefik.http.routers.issuer-api-https.tls.certresolver=cloudflare"));
780        assert!(labels
781            .iter()
782            .any(|x| { x == "traefik.http.services.issuer-api.loadbalancer.server.port=8080" }));
783    }
784
785    // ── M3 reconcile tests ──────────────────────────────────────────────────
786
787    #[test]
788    fn reconcile_requested_returns_proceed() {
789        assert_eq!(
790            reconcile_step(CertState::Requested),
791            ReconcileDecision::Proceed
792        );
793    }
794
795    #[test]
796    fn reconcile_policy_validated_returns_proceed() {
797        assert_eq!(
798            reconcile_step(CertState::PolicyValidated),
799            ReconcileDecision::Proceed
800        );
801    }
802
803    #[test]
804    fn reconcile_dns_challenge_prepared_returns_proceed() {
805        assert_eq!(
806            reconcile_step(CertState::DnsChallengePrepared),
807            ReconcileDecision::Proceed
808        );
809    }
810
811    #[test]
812    fn reconcile_dns_challenge_propagating_returns_wait() {
813        assert_eq!(
814            reconcile_step(CertState::DnsChallengePropagating),
815            ReconcileDecision::Wait
816        );
817    }
818
819    #[test]
820    fn reconcile_issuing_returns_proceed() {
821        assert_eq!(
822            reconcile_step(CertState::Issuing),
823            ReconcileDecision::Proceed
824        );
825    }
826
827    #[test]
828    fn reconcile_issued_returns_done() {
829        assert_eq!(reconcile_step(CertState::Issued), ReconcileDecision::Done);
830    }
831
832    #[test]
833    fn reconcile_renewal_due_returns_proceed() {
834        assert_eq!(
835            reconcile_step(CertState::RenewalDue),
836            ReconcileDecision::Proceed
837        );
838    }
839
840    #[test]
841    fn reconcile_failed_returns_retry_or_abandon() {
842        assert_eq!(
843            reconcile_step(CertState::Failed),
844            ReconcileDecision::RetryOrAbandon
845        );
846    }
847
848    #[test]
849    fn reconcile_revoked_returns_done() {
850        assert_eq!(reconcile_step(CertState::Revoked), ReconcileDecision::Done);
851    }
852
853    // ── M3 backoff tests ────────────────────────────────────────────────────
854
855    #[test]
856    fn backoff_attempt_0_returns_base_delay() {
857        let cfg = BackoffConfig {
858            max_retries: 3,
859            base_delay_ms: 1_000,
860            max_delay_ms: 60_000,
861        };
862        let state = BackoffState { attempt: 0 };
863        assert_eq!(
864            next_backoff_delay(&cfg, &state),
865            Some(Duration::from_millis(1_000))
866        );
867    }
868
869    #[test]
870    fn backoff_attempt_1_doubles_delay() {
871        let cfg = BackoffConfig {
872            max_retries: 3,
873            base_delay_ms: 1_000,
874            max_delay_ms: 60_000,
875        };
876        let state = BackoffState { attempt: 1 };
877        assert_eq!(
878            next_backoff_delay(&cfg, &state),
879            Some(Duration::from_millis(2_000))
880        );
881    }
882
883    #[test]
884    fn backoff_caps_at_max_delay() {
885        let cfg = BackoffConfig {
886            max_retries: 10,
887            base_delay_ms: 1_000,
888            max_delay_ms: 5_000,
889        };
890        let state = BackoffState { attempt: 5 };
891        assert_eq!(
892            next_backoff_delay(&cfg, &state),
893            Some(Duration::from_millis(5_000))
894        );
895    }
896
897    #[test]
898    fn backoff_returns_none_after_max_retries() {
899        let cfg = BackoffConfig {
900            max_retries: 3,
901            base_delay_ms: 1_000,
902            max_delay_ms: 60_000,
903        };
904        let state = BackoffState { attempt: 3 };
905        assert_eq!(next_backoff_delay(&cfg, &state), None);
906    }
907
908    // ── M3 metrics tests ────────────────────────────────────────────────────
909
910    #[test]
911    fn metrics_record_start_increments_in_flight() {
912        let mut m = CertMetrics::default();
913        m.record_start();
914        assert_eq!(m.in_flight, 1);
915    }
916
917    #[test]
918    fn metrics_record_success_increments_issued_and_decrements_in_flight() {
919        let mut m = CertMetrics::default();
920        m.record_start();
921        m.record_success();
922        assert_eq!(m.issued_total, 1);
923        assert_eq!(m.in_flight, 0);
924    }
925
926    #[test]
927    fn metrics_record_failure_increments_failed_and_decrements_in_flight() {
928        let mut m = CertMetrics::default();
929        m.record_start();
930        m.record_failure();
931        assert_eq!(m.failed_total, 1);
932        assert_eq!(m.in_flight, 0);
933    }
934
935    #[test]
936    fn metrics_in_flight_does_not_underflow() {
937        let mut m = CertMetrics::default();
938        m.record_success(); // no prior start — saturating_sub must not panic
939        assert_eq!(m.in_flight, 0);
940    }
941
942    #[test]
943    fn metrics_snapshot_serializes_to_json() {
944        let mut m = CertMetrics::default();
945        m.record_start();
946        m.record_success();
947        let json = serde_json::to_string(&m).expect("serialization must succeed");
948        assert!(json.contains("issued_total"));
949        assert!(json.contains("in_flight"));
950    }
951
952    #[test]
953    fn state_store_save_creates_missing_parent_directories() {
954        let unique = format!("tencrypt-test-{}", Uuid::new_v4());
955        let path = std::env::temp_dir()
956            .join(unique)
957            .join("nested")
958            .join("certs.json");
959
960        let store = StateStore {
961            certs: vec![CertRecord::new("app.example.com".to_string())],
962        };
963
964        store
965            .save(&path)
966            .expect("save should create parent directories");
967        let reloaded = StateStore::load(&path).expect("load should succeed");
968        assert_eq!(reloaded.certs.len(), 1);
969
970        std::fs::remove_dir_all(
971            path.parent()
972                .and_then(|p| p.parent())
973                .expect("test directory layout should exist"),
974        )
975        .expect("temporary directory cleanup should succeed");
976    }
977
978    // ── AAA-2 jittered backoff tests ────────────────────────────────────────
979
980    #[test]
981    fn jittered_backoff_zero_jitter_matches_plain() {
982        let cfg = BackoffConfig {
983            max_retries: 5,
984            base_delay_ms: 1_000,
985            max_delay_ms: 60_000,
986        };
987        let state = BackoffState { attempt: 0 };
988        assert_eq!(
989            next_backoff_delay_jittered(&cfg, &state, 0),
990            next_backoff_delay(&cfg, &state)
991        );
992    }
993
994    #[test]
995    fn jittered_backoff_adds_jitter_ms() {
996        let cfg = BackoffConfig {
997            max_retries: 5,
998            base_delay_ms: 1_000,
999            max_delay_ms: 60_000,
1000        };
1001        let state = BackoffState { attempt: 0 };
1002        assert_eq!(
1003            next_backoff_delay_jittered(&cfg, &state, 250),
1004            Some(Duration::from_millis(1_250))
1005        );
1006    }
1007
1008    #[test]
1009    fn jittered_backoff_caps_at_max_delay() {
1010        let cfg = BackoffConfig {
1011            max_retries: 5,
1012            base_delay_ms: 1_000,
1013            max_delay_ms: 3_000,
1014        };
1015        let state = BackoffState { attempt: 2 }; // 4_000ms base
1016        assert_eq!(
1017            next_backoff_delay_jittered(&cfg, &state, 500),
1018            Some(Duration::from_millis(3_000))
1019        );
1020    }
1021
1022    #[test]
1023    fn jittered_backoff_returns_none_when_exhausted() {
1024        let cfg = BackoffConfig::default();
1025        let state = BackoffState {
1026            attempt: cfg.max_retries,
1027        };
1028        assert_eq!(next_backoff_delay_jittered(&cfg, &state, 100), None);
1029    }
1030
1031    // ── AAA-2 domain policy tests ────────────────────────────────────────────
1032
1033    #[test]
1034    fn resolve_backoff_returns_default_when_no_policies() {
1035        let default = BackoffConfig::default();
1036        let result = resolve_backoff("foo.example.com", &[], &default);
1037        assert_eq!(result.max_retries, default.max_retries);
1038    }
1039
1040    #[test]
1041    fn resolve_backoff_returns_override_for_matching_hostname() {
1042        let default = BackoffConfig::default();
1043        let override_cfg = BackoffConfig {
1044            max_retries: 2,
1045            base_delay_ms: 500,
1046            max_delay_ms: 5_000,
1047        };
1048        let policies = vec![DomainPolicy {
1049            hostname_pattern: "strict.example.com".to_string(),
1050            backoff: Some(override_cfg.clone()),
1051        }];
1052        let result = resolve_backoff("strict.example.com", &policies, &default);
1053        assert_eq!(result.max_retries, 2);
1054        assert_eq!(result.base_delay_ms, 500);
1055    }
1056
1057    #[test]
1058    fn resolve_backoff_skips_none_override() {
1059        let default = BackoffConfig::default();
1060        let policies = vec![DomainPolicy {
1061            hostname_pattern: "opt-out.example.com".to_string(),
1062            backoff: None,
1063        }];
1064        let result = resolve_backoff("opt-out.example.com", &policies, &default);
1065        assert_eq!(result.max_retries, default.max_retries);
1066    }
1067
1068    // ── Cell model tests ────────────────────────────────────────────────────
1069
1070    #[test]
1071    fn state_store_load_returns_empty_when_file_missing() {
1072        let store = StateStore::load(Path::new("/tmp/tencrypt-nonexistent-state.json"))
1073            .expect("missing file should return empty store");
1074        assert!(store.certs.is_empty());
1075    }
1076
1077    #[test]
1078    fn state_store_roundtrip() {
1079        let dir = std::env::temp_dir();
1080        let path = dir.join("tencrypt-test-state.json");
1081        let mut store = StateStore::default();
1082        store
1083            .certs
1084            .push(CertRecord::new("rt.example.com".to_string()));
1085        store.save(&path).expect("save should succeed");
1086        let loaded = StateStore::load(&path).expect("load should succeed");
1087        assert_eq!(loaded.certs.len(), 1);
1088        assert_eq!(loaded.certs[0].hostname, "rt.example.com");
1089        drop(loaded);
1090        std::fs::remove_file(&path).ok();
1091    }
1092
1093    #[test]
1094    fn advance_record_proceeds_from_requested_to_policy_validated() {
1095        let mut record = CertRecord::new("adv.example.com".to_string());
1096        let cfg = BackoffConfig::default();
1097        let t = advance_record(&mut record, &cfg).expect("should produce a transition");
1098        assert_eq!(t.from, CertState::Requested);
1099        assert_eq!(t.to, CertState::PolicyValidated);
1100        assert_eq!(record.state, CertState::PolicyValidated);
1101        assert_eq!(record.attempt, 0);
1102    }
1103
1104    #[test]
1105    fn advance_record_returns_none_for_issued() {
1106        let mut record = CertRecord {
1107            hostname: "done.example.com".to_string(),
1108            state: CertState::Issued,
1109            attempt: 0,
1110            last_updated: Utc::now(),
1111        };
1112        let cfg = BackoffConfig::default();
1113        assert!(advance_record(&mut record, &cfg).is_none());
1114        assert_eq!(record.state, CertState::Issued);
1115    }
1116
1117    #[test]
1118    fn advance_record_returns_none_for_wait() {
1119        let mut record = CertRecord {
1120            hostname: "wait.example.com".to_string(),
1121            state: CertState::DnsChallengePropagating,
1122            attempt: 0,
1123            last_updated: Utc::now(),
1124        };
1125        let cfg = BackoffConfig::default();
1126        assert!(advance_record(&mut record, &cfg).is_none());
1127        assert_eq!(record.state, CertState::DnsChallengePropagating);
1128    }
1129
1130    #[test]
1131    fn advance_record_increments_attempt_on_retry_or_abandon() {
1132        let mut record = CertRecord {
1133            hostname: "fail.example.com".to_string(),
1134            state: CertState::Failed,
1135            attempt: 0,
1136            last_updated: Utc::now(),
1137        };
1138        let cfg = BackoffConfig {
1139            max_retries: 3,
1140            base_delay_ms: 100,
1141            max_delay_ms: 1_000,
1142        };
1143        // First call: retries remain, increment attempt, no transition
1144        let t = advance_record(&mut record, &cfg);
1145        assert!(t.is_none());
1146        assert_eq!(record.attempt, 1);
1147        assert_eq!(record.state, CertState::Failed);
1148    }
1149
1150    #[test]
1151    fn advance_record_transitions_to_failed_when_retries_exhausted() {
1152        let mut record = CertRecord {
1153            hostname: "exhaust.example.com".to_string(),
1154            state: CertState::Failed,
1155            attempt: 3,
1156            last_updated: Utc::now(),
1157        };
1158        let cfg = BackoffConfig {
1159            max_retries: 3,
1160            base_delay_ms: 100,
1161            max_delay_ms: 1_000,
1162        };
1163        let t = advance_record(&mut record, &cfg).expect("should transition to Failed");
1164        assert_eq!(t.from, CertState::Failed);
1165        assert_eq!(t.to, CertState::Failed);
1166    }
1167}