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}