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// ── Cell model: CertRecord + StateStore ───────────────────────────────────
491
492/// A single certificate tracked by the reconciler.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct CertRecord {
495    pub hostname: String,
496    pub state: CertState,
497    /// Retry attempt counter, reset to 0 on successful state advance.
498    pub attempt: u32,
499    pub last_updated: DateTime<Utc>,
500}
501
502impl CertRecord {
503    pub fn new(hostname: String) -> Self {
504        Self {
505            hostname,
506            state: CertState::Requested,
507            attempt: 0,
508            last_updated: Utc::now(),
509        }
510    }
511}
512
513/// File-backed collection of `CertRecord`s.
514/// Single-writer assumption — concurrent cell runs must not share the same file.
515#[derive(Debug, Default, Serialize, Deserialize)]
516pub struct StateStore {
517    pub certs: Vec<CertRecord>,
518}
519
520impl StateStore {
521    /// Load from a JSON file. Returns an empty store when the file does not exist.
522    pub fn load(path: &Path) -> anyhow::Result<Self> {
523        match std::fs::read_to_string(path) {
524            Ok(s) => Ok(serde_json::from_str(&s)?),
525            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
526            Err(e) => Err(e.into()),
527        }
528    }
529
530    /// Write to a JSON file atomically (write to `.tmp`, then rename).
531    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
532        if let Some(parent) = path.parent() {
533            std::fs::create_dir_all(parent)?;
534        }
535        let tmp = path.with_extension("json.tmp");
536        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
537        std::fs::rename(&tmp, path)?;
538        Ok(())
539    }
540}
541
542/// The next state in the forward-progress pipeline for `Proceed` decisions.
543/// Returns `None` for terminal or wait states.
544pub fn next_state(current: CertState) -> Option<CertState> {
545    match current {
546        CertState::Requested => Some(CertState::PolicyValidated),
547        CertState::PolicyValidated => Some(CertState::DnsChallengePrepared),
548        CertState::DnsChallengePrepared => Some(CertState::DnsChallengePropagating),
549        CertState::DnsChallengePropagating => Some(CertState::Issuing),
550        CertState::Issuing => Some(CertState::Issued),
551        CertState::RenewalDue => Some(CertState::Renewing),
552        CertState::Renewing => Some(CertState::Issued),
553        // Terminal / wait — no automatic next state
554        CertState::Issued | CertState::Failed | CertState::Revoked => None,
555    }
556}
557
558/// Advance a `CertRecord` by one reconcile step using the given backoff config.
559///
560/// Returns a `StateTransition` when the state changes, `None` when no change
561/// is made (Done, Wait, or backoff not yet exhausted without a retry-triggering
562/// condition).
563///
564/// Mutates `record` in place — caller must save the `StateStore` afterward.
565pub fn advance_record(record: &mut CertRecord, config: &BackoffConfig) -> Option<StateTransition> {
566    match reconcile_step(record.state) {
567        ReconcileDecision::Done => None,
568        ReconcileDecision::Wait => None,
569        ReconcileDecision::Proceed => {
570            let to = next_state(record.state)?;
571            let from = record.state;
572            record.state = to;
573            record.attempt = 0;
574            record.last_updated = Utc::now();
575            Some(StateTransition {
576                from,
577                to,
578                reason: "reconcile: proceed".to_string(),
579                at: record.last_updated,
580            })
581        }
582        ReconcileDecision::RetryOrAbandon => {
583            let bs = BackoffState {
584                attempt: record.attempt,
585            };
586            if next_backoff_delay(config, &bs).is_some() {
587                record.attempt += 1;
588                record.last_updated = Utc::now();
589                // No state change — caller schedules retry after backoff delay
590                None
591            } else {
592                // Retries exhausted — transition to Failed
593                let from = record.state;
594                record.state = CertState::Failed;
595                record.last_updated = Utc::now();
596                Some(StateTransition {
597                    from,
598                    to: CertState::Failed,
599                    reason: "reconcile: retries exhausted".to_string(),
600                    at: record.last_updated,
601                })
602            }
603        }
604    }
605}
606
607fn state_name(state: CertState) -> &'static str {
608    match state {
609        CertState::Requested => "requested",
610        CertState::PolicyValidated => "policy_validated",
611        CertState::DnsChallengePrepared => "dns_challenge_prepared",
612        CertState::DnsChallengePropagating => "dns_challenge_propagating",
613        CertState::Issuing => "issuing",
614        CertState::Issued => "issued",
615        CertState::RenewalDue => "renewal_due",
616        CertState::Renewing => "renewing",
617        CertState::Failed => "failed",
618        CertState::Revoked => "revoked",
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn dry_run_transitions_to_issued() {
628        let req = IssuanceRequest {
629            request_id: "req-1".to_string(),
630            hostname: "app.example.com".to_string(),
631            actor: "test".to_string(),
632            requested_at: Utc::now(),
633        };
634
635        let report = dry_run_issue(&req).expect("dry run should pass");
636        let last = report.transitions.last().expect("has transitions");
637        assert_eq!(last.to, CertState::Issued);
638    }
639
640    #[test]
641    fn invalid_hostname_fails_validation() {
642        let req = IssuanceRequest {
643            request_id: "req-2".to_string(),
644            hostname: "localhost".to_string(),
645            actor: "test".to_string(),
646            requested_at: Utc::now(),
647        };
648
649        let result = validate_request(&req);
650        assert!(result.is_err());
651    }
652
653    #[test]
654    fn hostname_with_invalid_characters_fails_validation() {
655        let req = IssuanceRequest {
656            request_id: "req-3".to_string(),
657            hostname: "bad host.example.com".to_string(),
658            actor: "test".to_string(),
659            requested_at: Utc::now(),
660        };
661
662        let result = validate_request(&req);
663        assert!(matches!(
664            result,
665            Err(RequestValidationError::InvalidHostnameFormat)
666        ));
667    }
668
669    #[test]
670    fn hostname_with_leading_dash_label_fails_validation() {
671        let req = IssuanceRequest {
672            request_id: "req-4".to_string(),
673            hostname: "-bad.example.com".to_string(),
674            actor: "test".to_string(),
675            requested_at: Utc::now(),
676        };
677
678        let result = validate_request(&req);
679        assert!(matches!(
680            result,
681            Err(RequestValidationError::InvalidHostnameFormat)
682        ));
683    }
684
685    #[test]
686    fn hostname_with_valid_dns_labels_passes_validation() {
687        let req = IssuanceRequest {
688            request_id: "req-5".to_string(),
689            hostname: "api-1.example.com".to_string(),
690            actor: "test".to_string(),
691            requested_at: Utc::now(),
692        };
693
694        assert!(validate_request(&req).is_ok());
695    }
696
697    #[test]
698    fn static_config_contains_cloudflare_acme_resolver() {
699        let cfg = TraefikStaticConfigInput {
700            email: "ops@acme.test".to_string(),
701            resolver_name: "cloudflare".to_string(),
702            cert_storage_file: "/var/traefik/certs/acme.json".to_string(),
703            cloudflare_token_env: "CF_DNS_API_TOKEN".to_string(),
704            log_level: "DEBUG".to_string(),
705        };
706        let rendered = render_traefik_static_yaml(&cfg);
707        assert!(rendered.contains("certificatesResolvers:"));
708        assert!(rendered.contains("provider: cloudflare"));
709        assert!(rendered.contains("CF_DNS_API_TOKEN"));
710    }
711
712    #[test]
713    fn generated_labels_include_https_certresolver() {
714        let cfg = TraefikRouterConfig {
715            service_name: "issuer-api".to_string(),
716            hostname: "issuer.example.com".to_string(),
717            service_port: 8080,
718            resolver_name: "cloudflare".to_string(),
719        };
720        let labels = generate_router_labels(&cfg);
721        assert!(labels
722            .iter()
723            .any(|x| x == "traefik.http.routers.issuer-api-https.tls.certresolver=cloudflare"));
724        assert!(labels
725            .iter()
726            .any(|x| { x == "traefik.http.services.issuer-api.loadbalancer.server.port=8080" }));
727    }
728
729    // ── M3 reconcile tests ──────────────────────────────────────────────────
730
731    #[test]
732    fn reconcile_requested_returns_proceed() {
733        assert_eq!(
734            reconcile_step(CertState::Requested),
735            ReconcileDecision::Proceed
736        );
737    }
738
739    #[test]
740    fn reconcile_policy_validated_returns_proceed() {
741        assert_eq!(
742            reconcile_step(CertState::PolicyValidated),
743            ReconcileDecision::Proceed
744        );
745    }
746
747    #[test]
748    fn reconcile_dns_challenge_prepared_returns_proceed() {
749        assert_eq!(
750            reconcile_step(CertState::DnsChallengePrepared),
751            ReconcileDecision::Proceed
752        );
753    }
754
755    #[test]
756    fn reconcile_dns_challenge_propagating_returns_wait() {
757        assert_eq!(
758            reconcile_step(CertState::DnsChallengePropagating),
759            ReconcileDecision::Wait
760        );
761    }
762
763    #[test]
764    fn reconcile_issuing_returns_proceed() {
765        assert_eq!(
766            reconcile_step(CertState::Issuing),
767            ReconcileDecision::Proceed
768        );
769    }
770
771    #[test]
772    fn reconcile_issued_returns_done() {
773        assert_eq!(reconcile_step(CertState::Issued), ReconcileDecision::Done);
774    }
775
776    #[test]
777    fn reconcile_renewal_due_returns_proceed() {
778        assert_eq!(
779            reconcile_step(CertState::RenewalDue),
780            ReconcileDecision::Proceed
781        );
782    }
783
784    #[test]
785    fn reconcile_failed_returns_retry_or_abandon() {
786        assert_eq!(
787            reconcile_step(CertState::Failed),
788            ReconcileDecision::RetryOrAbandon
789        );
790    }
791
792    #[test]
793    fn reconcile_revoked_returns_done() {
794        assert_eq!(reconcile_step(CertState::Revoked), ReconcileDecision::Done);
795    }
796
797    // ── M3 backoff tests ────────────────────────────────────────────────────
798
799    #[test]
800    fn backoff_attempt_0_returns_base_delay() {
801        let cfg = BackoffConfig {
802            max_retries: 3,
803            base_delay_ms: 1_000,
804            max_delay_ms: 60_000,
805        };
806        let state = BackoffState { attempt: 0 };
807        assert_eq!(
808            next_backoff_delay(&cfg, &state),
809            Some(Duration::from_millis(1_000))
810        );
811    }
812
813    #[test]
814    fn backoff_attempt_1_doubles_delay() {
815        let cfg = BackoffConfig {
816            max_retries: 3,
817            base_delay_ms: 1_000,
818            max_delay_ms: 60_000,
819        };
820        let state = BackoffState { attempt: 1 };
821        assert_eq!(
822            next_backoff_delay(&cfg, &state),
823            Some(Duration::from_millis(2_000))
824        );
825    }
826
827    #[test]
828    fn backoff_caps_at_max_delay() {
829        let cfg = BackoffConfig {
830            max_retries: 10,
831            base_delay_ms: 1_000,
832            max_delay_ms: 5_000,
833        };
834        let state = BackoffState { attempt: 5 };
835        assert_eq!(
836            next_backoff_delay(&cfg, &state),
837            Some(Duration::from_millis(5_000))
838        );
839    }
840
841    #[test]
842    fn backoff_returns_none_after_max_retries() {
843        let cfg = BackoffConfig {
844            max_retries: 3,
845            base_delay_ms: 1_000,
846            max_delay_ms: 60_000,
847        };
848        let state = BackoffState { attempt: 3 };
849        assert_eq!(next_backoff_delay(&cfg, &state), None);
850    }
851
852    // ── M3 metrics tests ────────────────────────────────────────────────────
853
854    #[test]
855    fn metrics_record_start_increments_in_flight() {
856        let mut m = CertMetrics::default();
857        m.record_start();
858        assert_eq!(m.in_flight, 1);
859    }
860
861    #[test]
862    fn metrics_record_success_increments_issued_and_decrements_in_flight() {
863        let mut m = CertMetrics::default();
864        m.record_start();
865        m.record_success();
866        assert_eq!(m.issued_total, 1);
867        assert_eq!(m.in_flight, 0);
868    }
869
870    #[test]
871    fn metrics_record_failure_increments_failed_and_decrements_in_flight() {
872        let mut m = CertMetrics::default();
873        m.record_start();
874        m.record_failure();
875        assert_eq!(m.failed_total, 1);
876        assert_eq!(m.in_flight, 0);
877    }
878
879    #[test]
880    fn metrics_in_flight_does_not_underflow() {
881        let mut m = CertMetrics::default();
882        m.record_success(); // no prior start — saturating_sub must not panic
883        assert_eq!(m.in_flight, 0);
884    }
885
886    #[test]
887    fn metrics_snapshot_serializes_to_json() {
888        let mut m = CertMetrics::default();
889        m.record_start();
890        m.record_success();
891        let json = serde_json::to_string(&m).expect("serialization must succeed");
892        assert!(json.contains("issued_total"));
893        assert!(json.contains("in_flight"));
894    }
895
896    #[test]
897    fn state_store_save_creates_missing_parent_directories() {
898        let unique = format!("tencrypt-test-{}", Uuid::new_v4());
899        let path = std::env::temp_dir()
900            .join(unique)
901            .join("nested")
902            .join("certs.json");
903
904        let store = StateStore {
905            certs: vec![CertRecord::new("app.example.com".to_string())],
906        };
907
908        store
909            .save(&path)
910            .expect("save should create parent directories");
911        let reloaded = StateStore::load(&path).expect("load should succeed");
912        assert_eq!(reloaded.certs.len(), 1);
913
914        std::fs::remove_dir_all(
915            path.parent()
916                .and_then(|p| p.parent())
917                .expect("test directory layout should exist"),
918        )
919        .expect("temporary directory cleanup should succeed");
920    }
921
922    // ── AAA-2 jittered backoff tests ────────────────────────────────────────
923
924    #[test]
925    fn jittered_backoff_zero_jitter_matches_plain() {
926        let cfg = BackoffConfig {
927            max_retries: 5,
928            base_delay_ms: 1_000,
929            max_delay_ms: 60_000,
930        };
931        let state = BackoffState { attempt: 0 };
932        assert_eq!(
933            next_backoff_delay_jittered(&cfg, &state, 0),
934            next_backoff_delay(&cfg, &state)
935        );
936    }
937
938    #[test]
939    fn jittered_backoff_adds_jitter_ms() {
940        let cfg = BackoffConfig {
941            max_retries: 5,
942            base_delay_ms: 1_000,
943            max_delay_ms: 60_000,
944        };
945        let state = BackoffState { attempt: 0 };
946        assert_eq!(
947            next_backoff_delay_jittered(&cfg, &state, 250),
948            Some(Duration::from_millis(1_250))
949        );
950    }
951
952    #[test]
953    fn jittered_backoff_caps_at_max_delay() {
954        let cfg = BackoffConfig {
955            max_retries: 5,
956            base_delay_ms: 1_000,
957            max_delay_ms: 3_000,
958        };
959        let state = BackoffState { attempt: 2 }; // 4_000ms base
960        assert_eq!(
961            next_backoff_delay_jittered(&cfg, &state, 500),
962            Some(Duration::from_millis(3_000))
963        );
964    }
965
966    #[test]
967    fn jittered_backoff_returns_none_when_exhausted() {
968        let cfg = BackoffConfig::default();
969        let state = BackoffState {
970            attempt: cfg.max_retries,
971        };
972        assert_eq!(next_backoff_delay_jittered(&cfg, &state, 100), None);
973    }
974
975    // ── AAA-2 domain policy tests ────────────────────────────────────────────
976
977    #[test]
978    fn resolve_backoff_returns_default_when_no_policies() {
979        let default = BackoffConfig::default();
980        let result = resolve_backoff("foo.example.com", &[], &default);
981        assert_eq!(result.max_retries, default.max_retries);
982    }
983
984    #[test]
985    fn resolve_backoff_returns_override_for_matching_hostname() {
986        let default = BackoffConfig::default();
987        let override_cfg = BackoffConfig {
988            max_retries: 2,
989            base_delay_ms: 500,
990            max_delay_ms: 5_000,
991        };
992        let policies = vec![DomainPolicy {
993            hostname_pattern: "strict.example.com".to_string(),
994            backoff: Some(override_cfg.clone()),
995        }];
996        let result = resolve_backoff("strict.example.com", &policies, &default);
997        assert_eq!(result.max_retries, 2);
998        assert_eq!(result.base_delay_ms, 500);
999    }
1000
1001    #[test]
1002    fn resolve_backoff_skips_none_override() {
1003        let default = BackoffConfig::default();
1004        let policies = vec![DomainPolicy {
1005            hostname_pattern: "opt-out.example.com".to_string(),
1006            backoff: None,
1007        }];
1008        let result = resolve_backoff("opt-out.example.com", &policies, &default);
1009        assert_eq!(result.max_retries, default.max_retries);
1010    }
1011
1012    // ── Cell model tests ────────────────────────────────────────────────────
1013
1014    #[test]
1015    fn state_store_load_returns_empty_when_file_missing() {
1016        let store = StateStore::load(Path::new("/tmp/tencrypt-nonexistent-state.json"))
1017            .expect("missing file should return empty store");
1018        assert!(store.certs.is_empty());
1019    }
1020
1021    #[test]
1022    fn state_store_roundtrip() {
1023        let dir = std::env::temp_dir();
1024        let path = dir.join("tencrypt-test-state.json");
1025        let mut store = StateStore::default();
1026        store
1027            .certs
1028            .push(CertRecord::new("rt.example.com".to_string()));
1029        store.save(&path).expect("save should succeed");
1030        let loaded = StateStore::load(&path).expect("load should succeed");
1031        assert_eq!(loaded.certs.len(), 1);
1032        assert_eq!(loaded.certs[0].hostname, "rt.example.com");
1033        drop(loaded);
1034        std::fs::remove_file(&path).ok();
1035    }
1036
1037    #[test]
1038    fn advance_record_proceeds_from_requested_to_policy_validated() {
1039        let mut record = CertRecord::new("adv.example.com".to_string());
1040        let cfg = BackoffConfig::default();
1041        let t = advance_record(&mut record, &cfg).expect("should produce a transition");
1042        assert_eq!(t.from, CertState::Requested);
1043        assert_eq!(t.to, CertState::PolicyValidated);
1044        assert_eq!(record.state, CertState::PolicyValidated);
1045        assert_eq!(record.attempt, 0);
1046    }
1047
1048    #[test]
1049    fn advance_record_returns_none_for_issued() {
1050        let mut record = CertRecord {
1051            hostname: "done.example.com".to_string(),
1052            state: CertState::Issued,
1053            attempt: 0,
1054            last_updated: Utc::now(),
1055        };
1056        let cfg = BackoffConfig::default();
1057        assert!(advance_record(&mut record, &cfg).is_none());
1058        assert_eq!(record.state, CertState::Issued);
1059    }
1060
1061    #[test]
1062    fn advance_record_returns_none_for_wait() {
1063        let mut record = CertRecord {
1064            hostname: "wait.example.com".to_string(),
1065            state: CertState::DnsChallengePropagating,
1066            attempt: 0,
1067            last_updated: Utc::now(),
1068        };
1069        let cfg = BackoffConfig::default();
1070        assert!(advance_record(&mut record, &cfg).is_none());
1071        assert_eq!(record.state, CertState::DnsChallengePropagating);
1072    }
1073
1074    #[test]
1075    fn advance_record_increments_attempt_on_retry_or_abandon() {
1076        let mut record = CertRecord {
1077            hostname: "fail.example.com".to_string(),
1078            state: CertState::Failed,
1079            attempt: 0,
1080            last_updated: Utc::now(),
1081        };
1082        let cfg = BackoffConfig {
1083            max_retries: 3,
1084            base_delay_ms: 100,
1085            max_delay_ms: 1_000,
1086        };
1087        // First call: retries remain, increment attempt, no transition
1088        let t = advance_record(&mut record, &cfg);
1089        assert!(t.is_none());
1090        assert_eq!(record.attempt, 1);
1091        assert_eq!(record.state, CertState::Failed);
1092    }
1093
1094    #[test]
1095    fn advance_record_transitions_to_failed_when_retries_exhausted() {
1096        let mut record = CertRecord {
1097            hostname: "exhaust.example.com".to_string(),
1098            state: CertState::Failed,
1099            attempt: 3,
1100            last_updated: Utc::now(),
1101        };
1102        let cfg = BackoffConfig {
1103            max_retries: 3,
1104            base_delay_ms: 100,
1105            max_delay_ms: 1_000,
1106        };
1107        let t = advance_record(&mut record, &cfg).expect("should transition to Failed");
1108        assert_eq!(t.from, CertState::Failed);
1109        assert_eq!(t.to, CertState::Failed);
1110    }
1111}