Skip to main content

cratefield_adapter_cloudflare_saas/
lib.rs

1//! `cratefield-adapter-cloudflare-saas`: the [`CustomHostnames`] port over
2//! the Cloudflare for [`SaaS`](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/)
3//! custom-hostnames API (issue #590). Uses the runtime's [`HttpClient`]
4//! port — no `reqwest`, no vendor SDK — so the same adapter runs on Workers
5//! (`worker::Fetch`) and natively, and inherits that port's destination
6//! vetting, deadline and response-size caps.
7//!
8//! **Degraded mode.** With no zone or token configured the adapter answers
9//! [`CustomHostnameError::NotConfigured`] from every method without any
10//! network call, and a hostname the port would never send — an apex, an IP
11//! literal, a wildcard, a name inside the deployment's own zone — is
12//! [`CustomHostnameError::Refused`] before any request leaves the process.
13
14#![doc = include_str!("../README.md")]
15#![forbid(unsafe_code)]
16
17use async_trait::async_trait;
18use bytes::Bytes;
19use cratefield_core::{
20    CertificateStatus, CustomHostname, CustomHostnameError, CustomHostnames, DnsRecordType,
21    HostnameClaim, HttpClient, ProviderStatus, Validation, check_hostname, scrub_text,
22};
23use http::header::{AUTHORIZATION, CONTENT_TYPE};
24use http::{Request, StatusCode};
25use std::sync::Arc;
26
27/// The environment variable [`CloudflareSaas::from_env`] reads the zone id
28/// from.
29pub const ZONE_ID_VAR: &str = "CF_SAAS_ZONE_ID";
30/// The environment variable [`CloudflareSaas::from_env`] reads the zone name
31/// from.
32pub const ZONE_NAME_VAR: &str = "CF_SAAS_ZONE_NAME";
33/// The environment variable [`CloudflareSaas::from_env`] reads the API token
34/// from.
35pub const API_TOKEN_VAR: &str = "CF_SAAS_API_TOKEN";
36/// The environment variable [`CloudflareSaas::from_env`] reads the `CNAME`
37/// target from.
38pub const CNAME_TARGET_VAR: &str = "CF_SAAS_CNAME_TARGET";
39
40/// The Cloudflare API v4 base every request goes to.
41const API_BASE: &str = "https://api.cloudflare.com/client/v4";
42
43/// The certificate type Cloudflare for `SaaS` issues for a custom hostname:
44/// Domain Validation.
45const CERTIFICATE_TYPE: &str = "dv";
46
47/// The minimum TLS version every certificate is asked to allow.
48const MIN_TLS_VERSION: &str = "1.2";
49
50/// Where the deployment's Cloudflare for `SaaS` zone lives, and the token
51/// used to claim hostnames in it.
52#[derive(Clone)]
53pub struct CloudflareSaasConfig {
54    /// The zone's id, from the Cloudflare dashboard.
55    pub zone_id: String,
56    /// The zone's own name (e.g. `cratefield.app`). [`check_hostname`]
57    /// refuses any claimed hostname inside it.
58    pub zone_name: String,
59    /// A scoped API token with "SSL and Certificates: Edit" on this one
60    /// zone. Never logged or formatted.
61    pub api_token: String,
62    /// The record a customer points their `CNAME` at, shown while the
63    /// hostname is not yet live. `None` omits the `CNAME` hint from
64    /// [`CustomHostname::validation`].
65    pub cname_target: Option<String>,
66}
67
68impl CloudflareSaasConfig {
69    /// The config the four values describe, or `None` when a required one
70    /// (`zone_id`, `zone_name`, `api_token`) is missing or empty. The pure
71    /// core of [`CloudflareSaas::from_env`], split out so it is testable
72    /// without mutating the process environment (edition 2024 makes
73    /// `std::env::set_var` unsafe).
74    #[must_use]
75    pub fn from_vars(
76        zone_id: Option<String>,
77        zone_name: Option<String>,
78        api_token: Option<String>,
79        cname_target: Option<String>,
80    ) -> Option<Self> {
81        let zone_id = non_empty(zone_id)?;
82        if !is_safe_zone_id(&zone_id) {
83            return None;
84        }
85        Some(Self {
86            zone_id,
87            zone_name: non_empty(zone_name)?,
88            api_token: non_empty(api_token)?,
89            cname_target: non_empty(cname_target),
90        })
91    }
92}
93
94/// `Some(value)` only when `value` is present and not blank.
95fn non_empty(value: Option<String>) -> Option<String> {
96    value.filter(|text| !text.trim().is_empty())
97}
98
99/// The zone id is interpolated into a URL path, so only the characters
100/// Cloudflare uses for one (ASCII alphanumerics; in practice 32 hex
101/// characters) are accepted. Anything else — a slash, a dot-segment, an
102/// empty string — is a misconfiguration, not a path to build.
103fn is_safe_zone_id(zone_id: &str) -> bool {
104    !zone_id.is_empty()
105        && zone_id
106            .chars()
107            .all(|character| character.is_ascii_alphanumeric())
108}
109
110/// A provider-returned id, read from the wire and put into a URL path.
111/// Only the characters a Cloudflare id uses (`[A-Za-z0-9-]`) are accepted;
112/// anything else is a `Provider` error rather than a request to a path the
113/// provider did not name.
114fn checked_id(id: &str) -> Result<&str, CustomHostnameError> {
115    if !id.is_empty()
116        && id
117            .chars()
118            .all(|character| character.is_ascii_alphanumeric() || character == '-')
119    {
120        Ok(id)
121    } else {
122        Err(CustomHostnameError::Provider(format!(
123            "the provider returned an id that is not a safe path segment: {id:?}"
124        )))
125    }
126}
127
128/// Redacts the token: this `Debug` never prints `api_token`'s value, so a
129/// config logged by accident cannot leak it.
130impl std::fmt::Debug for CloudflareSaasConfig {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("CloudflareSaasConfig")
133            .field("zone_id", &self.zone_id)
134            .field("zone_name", &self.zone_name)
135            .field("api_token", &"[redacted]")
136            .field("cname_target", &self.cname_target)
137            .finish()
138    }
139}
140
141/// The [`CustomHostnames`] port over Cloudflare for `SaaS` custom hostnames.
142pub struct CloudflareSaas {
143    http: Arc<dyn HttpClient>,
144    config: Option<CloudflareSaasConfig>,
145}
146
147/// Redacts the token through [`CloudflareSaasConfig`]'s own `Debug`.
148impl std::fmt::Debug for CloudflareSaas {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("CloudflareSaas")
151            .field("config", &self.config)
152            .finish_non_exhaustive()
153    }
154}
155
156impl CloudflareSaas {
157    /// Claims hostnames in `config.zone_id` with `config.api_token`. A zone
158    /// id that is not safe to interpolate into a URL path degrades the
159    /// adapter to [`CustomHostnameError::NotConfigured`] — with a warning
160    /// naming the variable, never the token.
161    #[must_use]
162    pub fn new(http: Arc<dyn HttpClient>, config: CloudflareSaasConfig) -> Self {
163        if !is_safe_zone_id(&config.zone_id) {
164            tracing::warn!(
165                provider = "cloudflare-saas",
166                variable = ZONE_ID_VAR,
167                "the zone id is not alphanumeric; the adapter answers NotConfigured"
168            );
169            return Self { http, config: None };
170        }
171        Self {
172            http,
173            config: Some(config),
174        }
175    }
176
177    /// An adapter that answers [`CustomHostnameError::NotConfigured`] from
178    /// every method without any network call.
179    #[must_use]
180    pub fn not_configured(http: Arc<dyn HttpClient>) -> Self {
181        Self { http, config: None }
182    }
183
184    /// Reads [`ZONE_ID_VAR`], [`ZONE_NAME_VAR`] and [`API_TOKEN_VAR`]
185    /// (required, non-empty) and [`CNAME_TARGET_VAR`] (optional) from the
186    /// process environment; any missing required value yields the
187    /// `not_configured` adapter. On Workers, read the secrets from the
188    /// venture's `Env` and call [`CloudflareSaas::new`] instead (`std::env`
189    /// has no Workers vars).
190    #[must_use]
191    pub fn from_env(http: Arc<dyn HttpClient>) -> Self {
192        let config = CloudflareSaasConfig::from_vars(
193            std::env::var(ZONE_ID_VAR).ok(),
194            std::env::var(ZONE_NAME_VAR).ok(),
195            std::env::var(API_TOKEN_VAR).ok(),
196            std::env::var(CNAME_TARGET_VAR).ok(),
197        );
198        Self { http, config }
199    }
200
201    /// Whether a zone and token are configured.
202    #[must_use]
203    pub fn is_configured(&self) -> bool {
204        self.config.is_some()
205    }
206
207    /// The configured zone and token, or [`CustomHostnameError::NotConfigured`]
208    /// (logged, without the token) when the adapter is degraded.
209    fn require_config(&self) -> Result<&CloudflareSaasConfig, CustomHostnameError> {
210        self.config.as_ref().ok_or_else(|| {
211            tracing::info!(
212                provider = "cloudflare-saas",
213                outcome = "not_configured",
214                "custom hostname outcome"
215            );
216            CustomHostnameError::NotConfigured
217        })
218    }
219
220    /// Sends one request and returns the status and the body as text. The
221    /// token travels in the `Authorization` header and nowhere else.
222    async fn send(
223        &self,
224        config: &CloudflareSaasConfig,
225        method: http::Method,
226        uri: String,
227        body: Option<Vec<u8>>,
228    ) -> Result<(StatusCode, String), CustomHostnameError> {
229        let token = &config.api_token;
230        let mut builder = Request::builder()
231            .method(method)
232            .uri(uri)
233            .header(AUTHORIZATION, format!("Bearer {token}"));
234        if body.is_some() {
235            builder = builder.header(CONTENT_TYPE, "application/json");
236        }
237        let request = builder
238            .body(Bytes::from(body.unwrap_or_default()))
239            .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
240        let response = self
241            .http
242            .send(request)
243            .await
244            .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
245        let status = response.status();
246        let text = String::from_utf8_lossy(response.body()).to_string();
247        Ok((status, text))
248    }
249
250    /// The provider's error as a port error. The documented provider codes
251    /// name the condition more precisely than the bare status, so they are
252    /// read first; a 403 that carries a quota code (1404/1405) is a
253    /// `Rejected` — the request is understood and refused, not unauthorised.
254    fn map_error(status: StatusCode, errors: &[ApiError], body: &str) -> CustomHostnameError {
255        let code = errors.first().map(|error| error.code);
256        if status == StatusCode::FORBIDDEN && matches!(code, Some(1404 | 1405)) {
257            return CustomHostnameError::Rejected(Self::detail(errors, body));
258        }
259        match code {
260            Some(1406) => return CustomHostnameError::AlreadyExists,
261            Some(1436) => return CustomHostnameError::NotFound,
262            Some(1403 | 1000..=1005 | 10000 | 9109) => return CustomHostnameError::Unauthorized,
263            _ => {}
264        }
265        match status {
266            StatusCode::CONFLICT => CustomHostnameError::AlreadyExists,
267            StatusCode::NOT_FOUND => CustomHostnameError::NotFound,
268            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => CustomHostnameError::Unauthorized,
269            StatusCode::TOO_MANY_REQUESTS => CustomHostnameError::RateLimited,
270            status if status.is_client_error() => {
271                CustomHostnameError::Rejected(Self::detail(errors, body))
272            }
273            _ => CustomHostnameError::Provider(Self::detail(errors, body)),
274        }
275    }
276
277    /// The provider's words: the first error as `<code>: <message>`, the raw
278    /// body when the envelope carried no error, or a placeholder when both
279    /// are empty. `Display` scrubs a bearer token out of whatever this
280    /// returns.
281    fn detail(errors: &[ApiError], body: &str) -> String {
282        match errors.first() {
283            Some(error) if !error.message.is_empty() => {
284                let code = error.code;
285                let message = &error.message;
286                format!("{code}: {message}")
287            }
288            Some(error) => error.code.to_string(),
289            None => {
290                let trimmed = body.trim();
291                if trimmed.is_empty() {
292                    "the provider sent no error detail".to_owned()
293                } else {
294                    trimmed.to_owned()
295                }
296            }
297        }
298    }
299
300    /// The errors array of an error envelope, best-effort: a body that does
301    /// not parse is reported by its raw text instead.
302    fn parse_errors(body: &str) -> Vec<ApiError> {
303        match serde_json::from_str::<Envelope<serde_json::Value>>(body) {
304            Ok(envelope) => envelope.errors,
305            Err(_) => Vec::new(),
306        }
307    }
308
309    /// A non-success response as a port error.
310    fn error_from(status: StatusCode, body: &str) -> CustomHostnameError {
311        Self::map_error(status, &Self::parse_errors(body), body)
312    }
313
314    /// Looks a hostname up through the list endpoint, one page of 50. Only
315    /// a wire entry whose `hostname` equals `hostname` exactly is a match;
316    /// when the page holds no such entry but the filter matched further
317    /// pages, the lookup fails closed rather than guessing.
318    async fn find(
319        &self,
320        config: &CloudflareSaasConfig,
321        hostname: &str,
322    ) -> Result<Option<WireHostname>, CustomHostnameError> {
323        let collection = collection_uri(&config.zone_id);
324        let uri = format!("{collection}?hostname={hostname}&per_page=50");
325        let (status, text) = self.send(config, http::Method::GET, uri, None).await?;
326        if !status.is_success() {
327            return Err(Self::error_from(status, &text));
328        }
329        let envelope: Envelope<Vec<WireHostname>> =
330            serde_json::from_str(&text).map_err(|err| parse_error(&err))?;
331        if !envelope.success {
332            return Err(CustomHostnameError::Provider(Self::detail(
333                &envelope.errors,
334                &text,
335            )));
336        }
337        if let Some(wire) = envelope
338            .result
339            .unwrap_or_default()
340            .into_iter()
341            .find(|wire| wire.hostname == hostname)
342        {
343            return Ok(Some(wire));
344        }
345        // The exact name was not on the one page we read. If the filter
346        // matched more than one page there is no way to be sure it is
347        // absent, and `delete` must never report success for a claim it
348        // did not find.
349        if envelope
350            .result_info
351            .is_some_and(|info| info.total_pages > 1)
352        {
353            return Err(CustomHostnameError::Provider(
354                "the hostname filter matched more than one page; refusing to guess".to_owned(),
355            ));
356        }
357        Ok(None)
358    }
359
360    /// One parsed success envelope: `success: true` and a result, or the
361    /// matching port error.
362    fn success_body<T: serde::de::DeserializeOwned>(
363        status: StatusCode,
364        text: &str,
365    ) -> Result<T, CustomHostnameError> {
366        if !status.is_success() {
367            return Err(Self::error_from(status, text));
368        }
369        let envelope: Envelope<T> = serde_json::from_str(text).map_err(|err| parse_error(&err))?;
370        if !envelope.success {
371            return Err(CustomHostnameError::Provider(Self::detail(
372                &envelope.errors,
373                text,
374            )));
375        }
376        envelope.result.ok_or_else(|| {
377            CustomHostnameError::Provider("the provider's response carried no result".to_owned())
378        })
379    }
380}
381
382#[async_trait]
383impl CustomHostnames for CloudflareSaas {
384    async fn create(&self, claim: &HostnameClaim) -> Result<CustomHostname, CustomHostnameError> {
385        let config = self.require_config()?;
386        let hostname = check_hostname(&claim.hostname, &config.zone_name)
387            .map_err(CustomHostnameError::Refused)?;
388        let method = claim.method.as_str();
389        let body = serde_json::to_vec(&serde_json::json!({
390            "hostname": hostname,
391            "ssl": {
392                "method": method,
393                "type": CERTIFICATE_TYPE,
394                "settings": { "min_tls_version": MIN_TLS_VERSION },
395            },
396        }))
397        .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
398        let uri = collection_uri(&config.zone_id);
399        let (status, text) = self
400            .send(config, http::Method::POST, uri, Some(body))
401            .await?;
402        if !status.is_success() {
403            let error = Self::error_from(status, &text);
404            tracing::warn!(
405                provider = "cloudflare-saas",
406                zone = %config.zone_name,
407                hostname = %hostname,
408                code = status.as_u16(),
409                error = %error,
410                "custom hostname outcome"
411            );
412            return Err(error);
413        }
414        let wire = Self::success_body::<WireHostname>(status, &text)?;
415        tracing::info!(
416            provider = "cloudflare-saas",
417            zone = %config.zone_name,
418            hostname = %wire.hostname,
419            status = %wire.status,
420            "custom hostname outcome"
421        );
422        Ok(wire.into_custom_hostname(config.cname_target.as_deref()))
423    }
424
425    async fn get(&self, hostname: &str) -> Result<Option<CustomHostname>, CustomHostnameError> {
426        let config = self.require_config()?;
427        let hostname =
428            check_hostname(hostname, &config.zone_name).map_err(CustomHostnameError::Refused)?;
429        let found = self.find(config, &hostname).await?;
430        Ok(found.map(|wire| wire.into_custom_hostname(config.cname_target.as_deref())))
431    }
432
433    async fn delete(&self, hostname: &str) -> Result<(), CustomHostnameError> {
434        let config = self.require_config()?;
435        let hostname =
436            check_hostname(hostname, &config.zone_name).map_err(CustomHostnameError::Refused)?;
437        let Some(found) = self.find(config, &hostname).await? else {
438            // Not there is already the wanted end state (idempotent).
439            return Ok(());
440        };
441        let collection = collection_uri(&config.zone_id);
442        let id = checked_id(&found.id)?;
443        let uri = format!("{collection}/{id}");
444        let (status, text) = self.send(config, http::Method::DELETE, uri, None).await?;
445        if status.is_success() {
446            tracing::info!(
447                provider = "cloudflare-saas",
448                zone = %config.zone_name,
449                hostname = %hostname,
450                "custom hostname outcome"
451            );
452            return Ok(());
453        }
454        // Gone between the lookup and the delete: the same end state.
455        if status == StatusCode::NOT_FOUND
456            || Self::parse_errors(&text)
457                .first()
458                .is_some_and(|error| error.code == 1436)
459        {
460            return Ok(());
461        }
462        Err(Self::error_from(status, &text))
463    }
464
465    async fn refresh(&self, hostname: &str) -> Result<CustomHostname, CustomHostnameError> {
466        let config = self.require_config()?;
467        let hostname =
468            check_hostname(hostname, &config.zone_name).map_err(CustomHostnameError::Refused)?;
469        let Some(found) = self.find(config, &hostname).await? else {
470            return Err(CustomHostnameError::NotFound);
471        };
472        // Re-run domain control validation with the same method and
473        // certificate type the hostname was created with.
474        let method = found
475            .ssl
476            .as_ref()
477            .map(|ssl| ssl.method.as_str())
478            .filter(|method| !method.is_empty())
479            .unwrap_or("http");
480        let body = serde_json::to_vec(&serde_json::json!({
481            "ssl": { "method": method, "type": CERTIFICATE_TYPE },
482        }))
483        .map_err(|err| CustomHostnameError::Transport(err.to_string()))?;
484        let collection = collection_uri(&config.zone_id);
485        let id = checked_id(&found.id)?;
486        let uri = format!("{collection}/{id}");
487        let (status, text) = self
488            .send(config, http::Method::PATCH, uri, Some(body))
489            .await?;
490        if !status.is_success() {
491            let error = Self::error_from(status, &text);
492            tracing::warn!(
493                provider = "cloudflare-saas",
494                zone = %config.zone_name,
495                hostname = %hostname,
496                code = status.as_u16(),
497                error = %error,
498                "custom hostname outcome"
499            );
500            return Err(error);
501        }
502        let wire = Self::success_body::<WireHostname>(status, &text)?;
503        tracing::info!(
504            provider = "cloudflare-saas",
505            zone = %config.zone_name,
506            hostname = %wire.hostname,
507            status = %wire.status,
508            "custom hostname outcome"
509        );
510        Ok(wire.into_custom_hostname(config.cname_target.as_deref()))
511    }
512}
513
514/// The custom-hostnames collection URL for a zone.
515fn collection_uri(zone_id: &str) -> String {
516    format!("{API_BASE}/zones/{zone_id}/custom_hostnames")
517}
518
519/// A success body that does not parse is the JSON path breaking between the
520/// provider and here, not a refusal.
521fn parse_error(err: &serde_json::Error) -> CustomHostnameError {
522    CustomHostnameError::Provider(format!("the provider's response did not parse: {err}"))
523}
524
525/// The provider's envelope: `success` gates everything, `errors` carries the
526/// taxonomy, `result` the object (or array) on success.
527#[derive(serde::Deserialize)]
528// Serde would otherwise demand `T: Default` for the defaulted `result`
529// field, which says nothing about the wire and only narrows the bound.
530#[serde(bound(deserialize = "T: serde::de::Deserialize<'de>"))]
531struct Envelope<T> {
532    #[serde(default)]
533    success: bool,
534    #[serde(default)]
535    errors: Vec<ApiError>,
536    #[serde(default)]
537    result: Option<T>,
538    #[serde(default)]
539    result_info: Option<ResultInfo>,
540}
541
542/// The pagination block the list endpoint carries alongside `result`.
543#[derive(serde::Deserialize, Default)]
544struct ResultInfo {
545    #[serde(default)]
546    total_pages: u64,
547}
548
549#[derive(serde::Deserialize, Default)]
550struct ApiError {
551    #[serde(default)]
552    code: i64,
553    #[serde(default)]
554    message: String,
555}
556
557/// One custom hostname as the provider sends it. Every field defaults, so an
558/// unknown or absent one never fails the whole response.
559#[derive(serde::Deserialize, Default)]
560struct WireHostname {
561    #[serde(default)]
562    id: String,
563    #[serde(default)]
564    hostname: String,
565    #[serde(default)]
566    status: String,
567    #[serde(default)]
568    verification_errors: Vec<String>,
569    #[serde(default)]
570    ownership_verification: Option<OwnershipVerification>,
571    #[serde(default)]
572    ssl: Option<WireSsl>,
573}
574
575#[derive(serde::Deserialize, Default)]
576struct OwnershipVerification {
577    #[serde(default)]
578    name: String,
579    #[serde(default)]
580    value: String,
581}
582
583#[derive(serde::Deserialize, Default)]
584struct WireSsl {
585    #[serde(default)]
586    method: String,
587    #[serde(default)]
588    status: String,
589    #[serde(default)]
590    validation_errors: Vec<ValidationError>,
591    #[serde(default)]
592    validation_records: Vec<ValidationRecord>,
593}
594
595#[derive(serde::Deserialize, Default)]
596struct ValidationError {
597    #[serde(default)]
598    message: String,
599}
600
601#[derive(serde::Deserialize, Default)]
602struct ValidationRecord {
603    #[serde(default)]
604    txt_name: Option<String>,
605    #[serde(default)]
606    txt_value: Option<String>,
607}
608
609impl WireHostname {
610    /// The port's shape of this hostname. `cname_target` is the record the
611    /// customer points at, shown while the hostname is not live.
612    fn into_custom_hostname(self, cname_target: Option<&str>) -> CustomHostname {
613        let status = self.provider_status();
614        let certificate = self.certificate_status();
615        let validation = self.validation(cname_target);
616        CustomHostname {
617            id: self.id,
618            hostname: self.hostname,
619            status,
620            certificate,
621            validation,
622        }
623    }
624
625    /// `active` is serving; the provider's terminal failures say why they
626    /// stopped; anything else is still pending. `verification_errors` on a
627    /// pending hostname is informational, not a failure.
628    fn provider_status(&self) -> ProviderStatus {
629        match self.status.as_str() {
630            "active" => ProviderStatus::Active,
631            "blocked" | "moved" | "deleted" | "test_failed" | "pending_deletion" => {
632                ProviderStatus::Failed {
633                    reason: scrub_text(&self.failure_reason()),
634                }
635            }
636            _ => ProviderStatus::Pending,
637        }
638    }
639
640    /// The first verification error, or `hostname <status>` when the
641    /// provider sent none.
642    fn failure_reason(&self) -> String {
643        self.verification_errors
644            .first()
645            .cloned()
646            .unwrap_or_else(|| format!("hostname {}", self.status))
647    }
648
649    /// The certificate's own state, read from `ssl.status`. A missing `ssl`
650    /// block is still pending, not failed.
651    fn certificate_status(&self) -> CertificateStatus {
652        let Some(ssl) = self.ssl.as_ref() else {
653            return CertificateStatus::Pending;
654        };
655        match ssl.status.as_str() {
656            "active" => CertificateStatus::Active,
657            "validation_timed_out"
658            | "issuance_timed_out"
659            | "deployment_timed_out"
660            | "deletion_timed_out"
661            | "initializing_timed_out"
662            | "expired"
663            | "deleted" => CertificateStatus::Failed {
664                reason: scrub_text(&ssl.failure_reason()),
665            },
666            _ => CertificateStatus::Pending,
667        }
668    }
669
670    /// The DNS records to show the customer, only while the claim is not
671    /// live: the `CNAME` at this deployment's target, the ownership
672    /// verification `TXT`, and any `TXT` validation records the certificate
673    /// challenge wants.
674    fn validation(&self, cname_target: Option<&str>) -> Vec<Validation> {
675        let status = self.provider_status();
676        let certificate = self.certificate_status();
677        let mut records = Vec::new();
678        if !matches!(status, ProviderStatus::Active) {
679            if let Some(target) = cname_target {
680                records.push(Validation {
681                    record_type: DnsRecordType::Cname,
682                    name: self.hostname.clone(),
683                    value: target.to_owned(),
684                });
685            }
686            if let Some(ownership) = &self.ownership_verification {
687                records.push(Validation {
688                    record_type: DnsRecordType::Txt,
689                    name: ownership.name.clone(),
690                    value: ownership.value.clone(),
691                });
692            }
693        }
694        if !matches!(certificate, CertificateStatus::Active)
695            && let Some(ssl) = &self.ssl
696        {
697            for record in &ssl.validation_records {
698                if let (Some(name), Some(value)) = (&record.txt_name, &record.txt_value) {
699                    records.push(Validation {
700                        record_type: DnsRecordType::Txt,
701                        name: name.clone(),
702                        value: value.clone(),
703                    });
704                }
705            }
706        }
707        records
708    }
709}
710
711impl WireSsl {
712    /// The first non-empty validation error message, or
713    /// `certificate <status>` when the provider sent none.
714    fn failure_reason(&self) -> String {
715        self.validation_errors
716            .iter()
717            .map(|error| error.message.as_str())
718            .find(|message| !message.is_empty())
719            .map_or_else(|| format!("certificate {}", self.status), str::to_owned)
720    }
721}