Skip to main content

acme_proxy/challenge/
mod.rs

1//! Challenge-validation abstraction.
2//!
3//! Triggering a challenge is the moment a client proves it controls the name it
4//! asked a certificate for (RFC 8555 §8). *How* that proof is checked is
5//! pluggable: the [`ChallengeValidator`] trait hides each type behind a single
6//! [`validate`](ChallengeValidator::validate) call, and [`from_config`] builds
7//! the configured set at startup.
8//!
9//! The shape mirrors the [`signer`](crate::signer) and [`filter`](crate::filter)
10//! subsystems — a trait, an error enum the *caller* maps to a
11//! [`Problem`](crate::error::Problem), and a `from_config` selector that fails
12//! fast. Like them, this module never mentions `error.rs`: what a failed
13//! validation means in HTTP terms is the handler's business.
14//!
15//! ## Two independent knobs
16//!
17//! [`ChallengeConfig::enabled`](crate::config::ChallengeConfig::enabled) shapes
18//! the **authorization object** — which challenges a client is offered, and
19//! therefore which it may choose. [`bypass`](crate::config::ChallengeConfig::bypass)
20//! decides whether triggering one does any work.
21//!
22//! They are separate because they answer different questions, and because
23//! `bypass` has to short-circuit *construction*: building the real validators
24//! reads `/etc/resolv.conf` and installs a TLS client configuration, which a
25//! bypassing server (the default, and every test) must not do.
26//!
27//! ## Bypass is opt-in
28//!
29//! With `bypass = true` a triggered challenge is accepted with no network check
30//! at all. That means an open CA: anyone who can reach the server can obtain a
31//! certificate for any name, and the [`filter`](crate::filter) subsystem is
32//! then the only access control. [`from_config`] says so, loudly, at startup.
33//!
34//! It used to be the default — the behaviour predating real validation, kept so
35//! an existing deployment survived that upgrade. It no longer is, because
36//! combined with an empty `filter.enabled` and a default bind on every
37//! interface it made a zero-config server an open CA. `ChallengeRegistry::default()`
38//! still bypasses: that is a test convenience the server never reaches.
39//!
40//! ## One budget for the whole attempt
41//!
42//! Validation runs **inside** the `POST /chall/{id}` request — there is no
43//! `processing` state and no detached task — so [`ChallengeRegistry::validate`]
44//! wraps every attempt in `challenge.timeout_ms`. Without it a wedged target
45//! would pin a request, and a `SQLite` connection, open indefinitely.
46
47use std::sync::Arc;
48use std::time::Duration;
49
50use async_trait::async_trait;
51use tokio::time::timeout;
52use tracing::{debug, info, warn};
53
54use crate::config::{ChallengeConfig, DnsConfig};
55use crate::dns::{HickoryResolver, Resolver, resolver_addr};
56
57pub mod dns_01;
58pub mod http_01;
59pub mod tls_alpn_01;
60
61/// The RFC 8555 §8.3 challenge type: fetch a file over HTTP.
62pub const HTTP_01: &str = "http-01";
63/// The RFC 8555 §8.4 challenge type: look up a TXT record. The only type that
64/// can prove a wildcard.
65pub const DNS_01: &str = "dns-01";
66/// The RFC 8737 challenge type: a TLS handshake carrying the proof in the
67/// responder's certificate.
68pub const TLS_ALPN_01: &str = "tls-alpn-01";
69
70/// Every challenge type this server knows how to offer.
71pub const KNOWN_TYPES: &[&str] = &[HTTP_01, DNS_01, TLS_ALPN_01];
72
73/// A pluggable challenge-validation policy: one type of proof.
74#[async_trait]
75pub trait ChallengeValidator: Send + Sync {
76    /// The RFC challenge `type` this validator answers for.
77    ///
78    /// Doubles as the registry's dispatch key, deliberately: a validator cannot
79    /// end up registered under a name it does not actually handle.
80    fn typ(&self) -> &'static str;
81
82    /// Proves the client controls `ctx.identifier`. `Ok(())` is a pass.
83    async fn validate(&self, ctx: &ValidationContext<'_>) -> Result<(), ChallengeError>;
84}
85
86/// What a [`ChallengeValidator`] needs to know to check one challenge.
87#[derive(Debug)]
88pub struct ValidationContext<'a> {
89    /// The DNS name to probe. For a wildcard authorization this is the **base**
90    /// name (`example.com`), never `*.example.com`: a wildcard is proved by
91    /// controlling the zone, so the record and the handshake both live at the
92    /// base name.
93    pub identifier: &'a str,
94
95    /// Whether the authorization this challenge belongs to covers the wildcard.
96    /// Only `dns-01` ever sees `true` — [`ChallengeRegistry::types_for`] never
97    /// creates the other types for a wildcard authorization.
98    pub wildcard: bool,
99
100    /// The challenge's own token.
101    pub token: &'a str,
102
103    /// `token || "." || base64url(SHA256(JWK thumbprint))`, RFC 8555 §8.1.
104    ///
105    /// Computed once by the handler from the account key: every validator needs
106    /// it and none of them should re-derive it.
107    pub key_authorization: &'a str,
108
109    /// For log correlation only.
110    pub challenge_id: &'a str,
111}
112
113/// Why a validation failed, mapped by the caller to the right ACME error.
114///
115/// The split mirrors [`Verdict`](crate::filter::Verdict): a statement
116/// about the client's setup is not the same as the server being unable to reach
117/// a verdict at all.
118#[derive(Debug)]
119pub enum ChallengeError {
120    /// The validation target could not be reached: TCP refused, no route, a
121    /// redirect chain that never ended, the attempt timing out.
122    Connection(String),
123    /// A DNS query the challenge needed failed or returned nothing.
124    Dns(String),
125    /// The target answered, but not with what the challenge requires — a TXT
126    /// record that does not match, a certificate missing the expected extension.
127    IncorrectResponse(String),
128    /// A TLS-level failure during a `tls-alpn-01` handshake.
129    Tls(String),
130    /// The target served something that is not the key authorization. RFC 8555
131    /// §8.3 uses this type for exactly that case.
132    Unauthorized(String),
133    /// The validator itself failed — a bug, or a type with no validator behind
134    /// it. The client may retry.
135    Internal(String),
136}
137
138impl ChallengeError {
139    /// Short label for logs.
140    #[must_use]
141    pub fn kind(&self) -> &'static str {
142        match self {
143            Self::Connection(_) => "connection",
144            Self::Dns(_) => "dns",
145            Self::IncorrectResponse(_) => "incorrectResponse",
146            Self::Tls(_) => "tls",
147            Self::Unauthorized(_) => "unauthorized",
148            Self::Internal(_) => "internal",
149        }
150    }
151
152    /// The human-readable detail, shown to the client.
153    #[must_use]
154    pub fn detail(&self) -> &str {
155        match self {
156            Self::Connection(detail)
157            | Self::Dns(detail)
158            | Self::IncorrectResponse(detail)
159            | Self::Tls(detail)
160            | Self::Unauthorized(detail)
161            | Self::Internal(detail) => detail,
162        }
163    }
164}
165
166/// The configured validators plus the settings the handlers need to apply them.
167///
168/// Cheap to clone behind the `Arc` it is always held in.
169pub struct ChallengeRegistry {
170    /// Empty when [`Self::bypass`] is set — the real validators are never built.
171    validators: Vec<Arc<dyn ChallengeValidator>>,
172    /// Always populated: this is what shapes each new authorization, bypass or
173    /// not.
174    enabled: Vec<String>,
175    bypass: bool,
176    timeout: Duration,
177}
178
179impl std::fmt::Debug for ChallengeRegistry {
180    /// `dyn ChallengeValidator` is not `Debug`, and `enabled` says everything
181    /// the validator list would.
182    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        formatter
184            .debug_struct("ChallengeRegistry")
185            .field("enabled", &self.enabled)
186            .field("bypass", &self.bypass)
187            .field("timeout", &self.timeout)
188            .finish()
189    }
190}
191
192impl Default for ChallengeRegistry {
193    /// Bypassing, offering `http-01` alone.
194    ///
195    /// This is a **test** default and deliberately no longer matches
196    /// `from_config(&ChallengeConfig::default())`, which validates for real.
197    /// The server never reaches this impl — `Profile::build_all` always goes
198    /// through `from_config` — and a test suite that has no `http-01` responder
199    /// on port 80 needs a registry that answers without one. The divergence is
200    /// pinned by `the_test_default_bypasses_where_the_configured_default_does_not`
201    /// so it stays a decision rather than a drift.
202    fn default() -> Self {
203        Self {
204            validators: Vec::new(),
205            enabled: vec![HTTP_01.to_string()],
206            bypass: true,
207            timeout: Duration::from_secs(5),
208        }
209    }
210}
211
212impl ChallengeRegistry {
213    /// Builds a registry directly from parts. Mostly useful to tests; production
214    /// goes through [`from_config`].
215    #[must_use]
216    pub fn new(
217        validators: Vec<Arc<dyn ChallengeValidator>>,
218        enabled: Vec<String>,
219        bypass: bool,
220        timeout: Duration,
221    ) -> Self {
222        Self {
223            validators,
224            enabled,
225            bypass,
226            timeout,
227        }
228    }
229
230    /// The challenge types offered, in the order they were configured.
231    #[must_use]
232    pub fn enabled_types(&self) -> &[String] {
233        &self.enabled
234    }
235
236    /// Whether triggering a challenge skips validation entirely.
237    #[must_use]
238    pub fn is_bypassed(&self) -> bool {
239        self.bypass
240    }
241
242    /// The challenge types to create on a new authorization.
243    ///
244    /// A wildcard can only be proved by `dns-01` (RFC 8555 §8.4): control of one
245    /// host says nothing about the rest of the zone. The other types are
246    /// therefore filtered out for a wildcard authorization — possibly leaving
247    /// **nothing**, which the caller turns into a `rejectedIdentifier` rather
248    /// than creating an authorization no client could ever satisfy.
249    pub fn types_for(&self, wildcard: bool) -> Vec<&str> {
250        self.enabled
251            .iter()
252            .map(String::as_str)
253            .filter(|typ| !wildcard || *typ == DNS_01)
254            .collect()
255    }
256
257    /// Runs the validator for `typ` within the configured budget.
258    pub async fn validate(
259        &self,
260        typ: &str,
261        ctx: &ValidationContext<'_>,
262    ) -> Result<(), ChallengeError> {
263        if self.bypass {
264            debug!(
265                event = "challenge_bypassed",
266                outcome = "success",
267                typ,
268                identifier = ctx.identifier,
269                challenge_id = ctx.challenge_id,
270            );
271            return Ok(());
272        }
273
274        let validator = self
275            .validators
276            .iter()
277            .find(|validator| validator.typ() == typ)
278            .ok_or_else(|| {
279                ChallengeError::Internal(format!("no validator registered for {typ}"))
280            })?;
281
282        match timeout(self.timeout, validator.validate(ctx)).await {
283            Ok(result) => result.inspect_err(|error| {
284                warn!(
285                    event = "challenge_validation_failed",
286                    outcome = "failure",
287                    typ,
288                    identifier = ctx.identifier,
289                    challenge_id = ctx.challenge_id,
290                    kind = error.kind(),
291                    detail = %error.detail(),
292                );
293            }),
294            // A timeout never reaches the `inspect_err` above, so without this
295            // the one failure mode most worth seeing — the responder never
296            // answered at all — was the only one not to emit an event.
297            Err(_) => {
298                warn!(
299                    event = "challenge_validation_timeout",
300                    outcome = "failure",
301                    typ,
302                    identifier = ctx.identifier,
303                    challenge_id = ctx.challenge_id,
304                    timeout_ms = crate::millis(self.timeout),
305                );
306                Err(ChallengeError::Connection(format!(
307                    "{typ} validation of {} timed out after {}ms",
308                    ctx.identifier,
309                    self.timeout.as_millis()
310                )))
311            }
312        }
313    }
314}
315/// Refuses a `challenge.enabled` this server cannot act on.
316///
317/// Run before anything else and **regardless of `bypass`**: a typo would
318/// otherwise sit unnoticed until someone turned validation on, and in the
319/// meantime it would silently shape every authorization this server hands out.
320fn validate_enabled(enabled: &[String]) -> anyhow::Result<()> {
321    if enabled.is_empty() {
322        anyhow::bail!(
323            "challenge.enabled is empty: authorizations would carry no challenges, \
324             so no client could ever prove control of a name"
325        );
326    }
327    for name in enabled {
328        if !KNOWN_TYPES.contains(&name.as_str()) {
329            anyhow::bail!("unknown challenge type: {name}");
330        }
331    }
332    Ok(())
333}
334
335/// The one resolver every validator shares: `dns-01`'s TXT lookup, and the
336/// connect target `http-01`/`tls-alpn-01` resolve before reaching out.
337///
338/// Built once rather than per-validator, since it is the same answer for all of
339/// them — and **uncached**, because a client that publishes a `dns-01` record
340/// moments before triggering would otherwise be defeated by a cached negative.
341pub fn build_resolver(addr: Option<std::net::SocketAddr>) -> anyhow::Result<Arc<dyn Resolver>> {
342    Ok(Arc::new(match addr {
343        Some(addr) => HickoryResolver::from_address_uncached(addr)
344            .map_err(|error| anyhow::anyhow!("dns.resolver: {error}"))?,
345        None => HickoryResolver::from_system_uncached()
346            .map_err(|error| anyhow::anyhow!("challenge: {error}"))?,
347    }))
348}
349
350/// Builds the configured challenge registry. Called once at startup, so it may
351/// fail fast (the caller exits on error).
352///
353/// `dns` is [`crate::config::Config::dns`], not a field of `cfg`: the resolver
354/// it selects is shared with [`filter::from_config`](crate::filter::from_config)
355/// (`reverse_dns`), since both answer the same question — which nameserver this
356/// process trusts — and a deployment overriding it wants that answered
357/// consistently everywhere, not per-subsystem.
358pub fn from_config(
359    cfg: &ChallengeConfig,
360    dns: &DnsConfig,
361    proxies: Arc<crate::proxy::OutboundProxies>,
362) -> anyhow::Result<Arc<ChallengeRegistry>> {
363    validate_enabled(&cfg.enabled)?;
364
365    // Parsed bypass or not, for the same reason: a typo in `dns.resolver` must
366    // not sit silent until someone turns validation on. Building the resolver
367    // it names is what actually reaches the network (or /etc/resolv.conf), so
368    // that part still waits for the `!cfg.bypass` branch below.
369    let addr = resolver_addr(dns)?;
370
371    let timeout = Duration::from_millis(cfg.timeout_ms);
372
373    if cfg.bypass {
374        // Worth being noisy about, for the same reason `filter_disabled` is:
375        // this is the setting that makes the server an open CA.
376        warn!(
377            event = "challenge_validation_bypassed",
378            outcome = "advisory",
379            enabled = ?cfg.enabled,
380            "challenge.bypass is on: triggering a challenge marks it valid with no network \
381             check, so any client that can reach this server can obtain a certificate for \
382             any name (set challenge.bypass = false)"
383        );
384        return Ok(Arc::new(ChallengeRegistry::new(
385            Vec::new(),
386            cfg.enabled.clone(),
387            true,
388            timeout,
389        )));
390    }
391
392    let resolver = build_resolver(addr)?;
393    // Assembled here rather than passed in: the resolver only exists past the
394    // bypass branch above, since building it is what reads `/etc/resolv.conf`.
395    let outbound = crate::http_client::Outbound::new(resolver.clone(), proxies);
396
397    let mut validators: Vec<Arc<dyn ChallengeValidator>> = Vec::with_capacity(cfg.enabled.len());
398    for name in &cfg.enabled {
399        let validator: Arc<dyn ChallengeValidator> = match name.as_str() {
400            HTTP_01 => Arc::new(http_01::Http01Validator::from_config(
401                &cfg.http_01,
402                outbound.clone(),
403            )?),
404            DNS_01 => Arc::new(dns_01::Dns01Validator::from_config(resolver.clone())),
405            TLS_ALPN_01 => Arc::new(tls_alpn_01::TlsAlpn01Validator::from_config(
406                &cfg.tls_alpn_01,
407                outbound.clone(),
408            )?),
409            // Unreachable: the loop above rejected every unknown name.
410            other => anyhow::bail!("unknown challenge type: {other}"),
411        };
412        validators.push(validator);
413    }
414
415    info!(
416        event = "challenge_validation_enabled",
417        outcome = "success",
418        enabled = ?cfg.enabled,
419        timeout_ms = cfg.timeout_ms,
420    );
421
422    Ok(Arc::new(ChallengeRegistry::new(
423        validators,
424        cfg.enabled.clone(),
425        false,
426        timeout,
427    )))
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use std::sync::atomic::{AtomicUsize, Ordering};
434
435    /// A validator answering from a canned outcome, never touching the network.
436    struct StubValidator {
437        typ: &'static str,
438        outcome: Option<&'static str>,
439        hang: bool,
440        calls: Arc<AtomicUsize>,
441    }
442
443    impl StubValidator {
444        fn passing(typ: &'static str) -> Self {
445            Self {
446                typ,
447                outcome: None,
448                hang: false,
449                calls: Arc::new(AtomicUsize::new(0)),
450            }
451        }
452    }
453
454    #[async_trait]
455    impl ChallengeValidator for StubValidator {
456        fn typ(&self) -> &'static str {
457            self.typ
458        }
459
460        async fn validate(&self, _ctx: &ValidationContext<'_>) -> Result<(), ChallengeError> {
461            self.calls.fetch_add(1, Ordering::SeqCst);
462            if self.hang {
463                // Outlives any timeout the tests set.
464                tokio::time::sleep(Duration::from_secs(3600)).await;
465            }
466            match self.outcome {
467                Some(detail) => Err(ChallengeError::IncorrectResponse(detail.to_string())),
468                None => Ok(()),
469            }
470        }
471    }
472
473    fn context<'a>(identifier: &'a str, key_authorization: &'a str) -> ValidationContext<'a> {
474        ValidationContext {
475            identifier,
476            wildcard: false,
477            token: "tok",
478            key_authorization,
479            challenge_id: "chall-1",
480        }
481    }
482
483    fn cfg(enabled: &[&str], bypass: bool) -> ChallengeConfig {
484        ChallengeConfig {
485            enabled: enabled
486                .iter()
487                .map(std::string::ToString::to_string)
488                .collect(),
489            bypass,
490            ..ChallengeConfig::default()
491        }
492    }
493
494    /// The out-of-the-box posture: `http-01`, really validated.
495    ///
496    /// It used to bypass. A server started with no configuration binds every
497    /// interface and has an empty `filter.enabled`, so bypassing by default made
498    /// the zero-config case an open certificate authority.
499    #[test]
500    fn the_default_config_validates_and_offers_http_01_alone() {
501        let registry = from_config(
502            &ChallengeConfig::default(),
503            &DnsConfig::default(),
504            crate::testutil::no_proxies(),
505        )
506        .unwrap();
507        assert!(!registry.is_bypassed());
508        assert_eq!(registry.enabled_types(), [HTTP_01.to_string()]);
509        // The real validator is built, since nothing is being bypassed.
510        assert_eq!(registry.validators.len(), 1);
511    }
512
513    /// Bypassing still builds nothing: constructing the real validators reads
514    /// the system resolver and builds a TLS client configuration, which is why
515    /// bypass is a flag on the registry rather than a `NoopValidator`.
516    #[test]
517    fn bypassing_constructs_no_validators() {
518        let registry = from_config(
519            &cfg(&["http-01"], true),
520            &DnsConfig::default(),
521            crate::testutil::no_proxies(),
522        )
523        .unwrap();
524        assert!(registry.is_bypassed());
525        assert!(registry.validators.is_empty());
526    }
527
528    /// `ChallengeRegistry::default()` is a test convenience and deliberately
529    /// differs from the configured default now that the latter validates. The
530    /// server never reaches it — `Profile::build_all` always goes through
531    /// `from_config` — but a suite with no `http-01` responder on port 80 needs
532    /// a registry that answers without one. Asserted so the difference stays a
533    /// decision rather than a drift somebody discovers later.
534    #[test]
535    fn the_test_default_bypasses_where_the_configured_default_does_not() {
536        let configured = from_config(
537            &ChallengeConfig::default(),
538            &DnsConfig::default(),
539            crate::testutil::no_proxies(),
540        )
541        .unwrap();
542        let direct = ChallengeRegistry::default();
543
544        assert!(!configured.is_bypassed());
545        assert!(direct.is_bypassed());
546        // Everything else must still agree.
547        assert_eq!(configured.enabled_types(), direct.enabled_types());
548        assert_eq!(configured.timeout, direct.timeout);
549    }
550
551    /// A typo must stop the server, not hide behind the bypass until someone
552    /// turns validation on months later.
553    #[test]
554    fn an_unknown_type_is_a_startup_error_even_when_bypassing() {
555        for bypass in [true, false] {
556            let error = from_config(
557                &cfg(&["http-01", "htttp-01"], bypass),
558                &DnsConfig::default(),
559                crate::testutil::no_proxies(),
560            )
561            .unwrap_err()
562            .to_string();
563            assert!(
564                error.contains("unknown challenge type") && error.contains("htttp-01"),
565                "{error}"
566            );
567        }
568    }
569
570    /// An enabled subsystem configured into a no-op is an operator mistake — the
571    /// same reasoning that makes `allowed_ip` refuse two empty lists.
572    #[test]
573    fn an_empty_enabled_list_is_a_startup_error() {
574        let error = from_config(
575            &cfg(&[], true),
576            &DnsConfig::default(),
577            crate::testutil::no_proxies(),
578        )
579        .unwrap_err()
580        .to_string();
581        assert!(error.contains("challenge.enabled is empty"), "{error}");
582    }
583
584    /// `dns.resolver` is validated bypass or not, the same as `enabled`'s
585    /// names — a typo must not sit silent until someone turns validation on.
586    #[test]
587    fn an_invalid_dns_resolver_is_a_startup_error_even_when_bypassing() {
588        let dns = DnsConfig {
589            resolver: Some("not-a-socket-address".to_string()),
590        };
591        for bypass in [true, false] {
592            let error = from_config(
593                &cfg(&["http-01"], bypass),
594                &dns,
595                crate::testutil::no_proxies(),
596            )
597            .unwrap_err()
598            .to_string();
599            assert!(error.contains("dns.resolver"), "{error}");
600        }
601    }
602
603    /// A valid override builds without touching the system resolver.
604    #[test]
605    fn a_valid_dns_resolver_is_used_to_build_the_real_validators() {
606        let dns = DnsConfig {
607            resolver: Some("127.0.0.1:5300".to_string()),
608        };
609        let registry = from_config(
610            &cfg(&["dns-01"], false),
611            &dns,
612            crate::testutil::no_proxies(),
613        )
614        .unwrap();
615        assert!(!registry.is_bypassed());
616    }
617
618    #[tokio::test]
619    async fn bypass_accepts_every_type_without_a_validator() {
620        let registry = ChallengeRegistry::default();
621        for typ in KNOWN_TYPES {
622            assert!(
623                registry
624                    .validate(typ, &context("example.com", "tok.thumb"))
625                    .await
626                    .is_ok()
627            );
628        }
629    }
630
631    #[tokio::test]
632    async fn validate_dispatches_on_the_challenge_type() {
633        let http = StubValidator::passing(HTTP_01);
634        let dns = StubValidator {
635            outcome: Some("no matching TXT record"),
636            ..StubValidator::passing(DNS_01)
637        };
638        let (http_calls, dns_calls) = (http.calls.clone(), dns.calls.clone());
639
640        let registry = ChallengeRegistry::new(
641            vec![Arc::new(http), Arc::new(dns)],
642            vec![HTTP_01.to_string(), DNS_01.to_string()],
643            false,
644            Duration::from_secs(5),
645        );
646        let ctx = context("example.com", "tok.thumb");
647
648        assert!(registry.validate(HTTP_01, &ctx).await.is_ok());
649        assert!(matches!(
650            registry.validate(DNS_01, &ctx).await,
651            Err(ChallengeError::IncorrectResponse(_))
652        ));
653
654        assert_eq!(http_calls.load(Ordering::SeqCst), 1);
655        assert_eq!(dns_calls.load(Ordering::SeqCst), 1);
656    }
657
658    /// A challenge whose type has no validator is a server bug, not a client
659    /// error — it means an authorization outlived a configuration change.
660    #[tokio::test]
661    async fn a_type_without_a_validator_is_internal() {
662        let registry = ChallengeRegistry::new(
663            vec![Arc::new(StubValidator::passing(HTTP_01))],
664            vec![HTTP_01.to_string()],
665            false,
666            Duration::from_secs(5),
667        );
668        assert!(matches!(
669            registry
670                .validate(DNS_01, &context("example.com", "tok.thumb"))
671                .await,
672            Err(ChallengeError::Internal(detail)) if detail.contains("dns-01")
673        ));
674    }
675
676    /// Validation runs inside the request, so a wedged target must not be able
677    /// to pin one open.
678    #[tokio::test]
679    async fn a_wedged_validator_times_out_rather_than_hanging() {
680        let registry = ChallengeRegistry::new(
681            vec![Arc::new(StubValidator {
682                hang: true,
683                ..StubValidator::passing(HTTP_01)
684            })],
685            vec![HTTP_01.to_string()],
686            false,
687            Duration::from_millis(10),
688        );
689
690        assert!(matches!(
691            registry
692                .validate(HTTP_01, &context("example.com", "tok.thumb"))
693                .await,
694            Err(ChallengeError::Connection(detail)) if detail.contains("timed out")
695        ));
696    }
697
698    /// A wildcard authorization offers `dns-01` and nothing else, whatever is
699    /// enabled — and offers nothing at all when `dns-01` is not.
700    #[test]
701    fn types_for_restricts_a_wildcard_to_dns_01() {
702        let all = ChallengeRegistry::new(
703            Vec::new(),
704            KNOWN_TYPES
705                .iter()
706                .map(std::string::ToString::to_string)
707                .collect(),
708            true,
709            Duration::from_secs(5),
710        );
711        assert_eq!(all.types_for(false), KNOWN_TYPES);
712        assert_eq!(all.types_for(true), [DNS_01]);
713
714        let without_dns = ChallengeRegistry::default();
715        assert_eq!(without_dns.types_for(false), [HTTP_01]);
716        assert!(without_dns.types_for(true).is_empty());
717    }
718
719    #[test]
720    fn challenge_error_labels_and_details() {
721        let errors = [
722            ChallengeError::Connection("a".into()),
723            ChallengeError::Dns("b".into()),
724            ChallengeError::IncorrectResponse("c".into()),
725            ChallengeError::Tls("d".into()),
726            ChallengeError::Unauthorized("e".into()),
727            ChallengeError::Internal("f".into()),
728        ];
729        let kinds: Vec<_> = errors.iter().map(ChallengeError::kind).collect();
730        assert_eq!(
731            kinds,
732            [
733                "connection",
734                "dns",
735                "incorrectResponse",
736                "tls",
737                "unauthorized",
738                "internal"
739            ]
740        );
741        let details: Vec<_> = errors.iter().map(ChallengeError::detail).collect();
742        assert_eq!(details, ["a", "b", "c", "d", "e", "f"]);
743    }
744
745    #[test]
746    fn debug_shows_the_policy_not_the_validators() {
747        let rendered = format!("{:?}", ChallengeRegistry::default());
748        assert!(rendered.contains("http-01"), "{rendered}");
749        assert!(rendered.contains("bypass: true"), "{rendered}");
750    }
751}