autumn-web 0.9.0

An opinionated, convention-over-configuration web framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
//! DNS-01 challenge support: wildcard certificates for subdomain-per-tenant
//! deployments (issue #1620).
//!
//! [#1608](https://github.com/autumn-foundation/autumn/issues/1608) shipped ACME
//! over **HTTP-01**, which no CA will accept for a wildcard identifier. This
//! module adds the other half: proving control of a zone by publishing a
//! `_acme-challenge.<domain>` TXT record, so one `*.myapp.com` certificate
//! serves every tenant subdomain — including tenants that do not exist yet.
//!
//! Everything else — the order flow, the store, the renewal loop, the hot-swap,
//! staging selection, health — is [#1608's](super) and is reused verbatim. Only
//! the challenge answer changes.
//!
//! # Shape
//!
//! - [`DnsProvider`] is the one seam: write a TXT record, delete a TXT record.
//!   Implementations live in [`cloudflare`], [`route53`], and [`exec`] (the
//!   documented escape hatch for every other provider).
//! - [`DnsCredential`] is the provider's API credential, read from the encrypted
//!   credentials store or the documented environment variables — never from
//!   `autumn.toml`. Its secret fields are [`SecretString`], whose `Debug` and
//!   `Display` are redacted so a token cannot reach a log line, an error
//!   message, or actuator output by accident.
//! - [`resolver`] answers "is the record visible yet?" against public resolvers,
//!   bounded by a configured timeout whose error names the exact record.
//!
//! # Two authorizations, one record name
//!
//! An order for `myapp.com` **and** `*.myapp.com` yields two authorizations
//! whose DNS-01 records share the name `_acme-challenge.myapp.com` but carry
//! **different** values. Both must be live simultaneously, so every provider
//! here appends a value and deletes by `(name, value)` — never "replace the
//! record set" or "delete by name".

pub mod cloudflare;
pub mod exec;
pub mod http;
pub mod resolver;
pub mod route53;

use std::sync::Arc;

use futures::future::BoxFuture;

use crate::config::{AcmeDnsConfig, AcmeDnsProvider};

/// The DNS-01 record name for `domain`, per RFC 8555 §8.4.
///
/// The identifier is always the **base** domain: an authorization for
/// `*.myapp.com` carries the identifier `myapp.com`, so both the apex and the
/// wildcard land on `_acme-challenge.myapp.com`.
#[must_use]
pub fn challenge_fqdn(domain: &str) -> String {
    format!(
        "_acme-challenge.{}",
        domain.trim().trim_end_matches('.').to_ascii_lowercase()
    )
}

/// One `_acme-challenge` TXT record to publish or remove.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct TxtRecord {
    /// Fully-qualified record name, e.g. `_acme-challenge.myapp.com`.
    pub fqdn: String,
    /// The challenge value — `base64url(sha256(key_authorization))`. Public by
    /// construction (it is published in DNS), so it is safe in error messages.
    pub value: String,
}

impl TxtRecord {
    /// Build a record for `domain`'s DNS-01 challenge with `value`.
    #[must_use]
    pub fn new(domain: &str, value: impl Into<String>) -> Self {
        Self {
            fqdn: challenge_fqdn(domain),
            value: value.into(),
        }
    }
}

/// Writes and removes the ephemeral `_acme-challenge` TXT records an ACME
/// DNS-01 challenge needs.
///
/// Implementations MUST be additive: [`upsert_txt`](Self::upsert_txt) adds a
/// value alongside any already present at the same name, and
/// [`delete_txt`](Self::delete_txt) removes only the `(name, value)` pair it is
/// given. An order for an apex plus its wildcard publishes two different values
/// at one name, and clobbering either fails the order.
///
/// Errors are plain strings that reach logs, `/actuator/health`, and the
/// operator-alert payload — so an implementation must never interpolate a
/// credential into one.
pub trait DnsProvider: Send + Sync {
    /// The provider's stable name, for logs and health details.
    fn name(&self) -> &'static str;

    /// Publish `record`, leaving any other value at the same name in place.
    fn upsert_txt<'a>(&'a self, record: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>>;

    /// Remove exactly the `(name, value)` pair in `record`.
    fn delete_txt<'a>(&'a self, record: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>>;

    /// Publish every record in `records`, which may share names.
    ///
    /// The default applies [`upsert_txt`](Self::upsert_txt) in order, which is
    /// correct for a provider whose unit of write is one *record*. A provider
    /// whose unit of write is the whole record **set** — Route 53's
    /// `ChangeResourceRecordSets` replaces a record set wholesale, so publishing is
    /// a read-modify-write — MUST override this and apply every value sharing a
    /// name in a single change. Sequential read-modify-writes race: the read
    /// backing the second change may still return the pre-change values, so the
    /// second write would carry only its own value and silently drop the first
    /// (issue #1620).
    ///
    /// On error the caller must assume every record in `records` may have been
    /// written and clean all of them up; `delete_txt` for a value that was never
    /// published is a no-op.
    fn upsert_txt_batch<'a>(
        &'a self,
        records: &'a [TxtRecord],
    ) -> BoxFuture<'a, Result<(), String>> {
        Box::pin(async move {
            for record in records {
                self.upsert_txt(record).await?;
            }
            Ok(())
        })
    }

    /// Remove every `(name, value)` pair in `records`, which may share names.
    ///
    /// Same contract as [`upsert_txt_batch`](Self::upsert_txt_batch) in reverse:
    /// a set-replacing provider must remove all values sharing a name in one
    /// change, or the second removal's stale read resurrects the first value.
    ///
    /// Best-effort: every record is attempted. The failures come back together
    /// in one error (issue #2478).
    fn delete_txt_batch<'a>(
        &'a self,
        records: &'a [TxtRecord],
    ) -> BoxFuture<'a, Result<(), String>> {
        Box::pin(async move {
            let mut errors = Vec::new();
            for record in records {
                if let Err(e) = self.delete_txt(record).await {
                    errors.push(format!("{}: {e}", record.fqdn));
                }
            }
            join_errors(&errors)
        })
    }
}

/// `Ok` when `errors` is empty, else one error that lists them all.
///
/// The list is cut to [`MAX_JOINED_ERROR_CHARS`], because the text reaches
/// `/actuator/health` and the alert destination.
pub(crate) fn join_errors(errors: &[String]) -> Result<(), String> {
    if errors.is_empty() {
        return Ok(());
    }
    let joined = errors.join("; ");
    if joined.chars().count() <= MAX_JOINED_ERROR_CHARS {
        return Err(joined);
    }
    let cut: String = joined.chars().take(MAX_JOINED_ERROR_CHARS).collect();
    Err(format!("{cut}…"))
}

/// The most text `join_errors` returns.
const MAX_JOINED_ERROR_CHARS: usize = 4 * UPSTREAM_EXCERPT_CHARS;

/// How much text copied from a DNS provider (an API error body, a hook's
/// `stderr`) may reach an operator-facing message.
///
/// Bounded because that message is published on `/actuator/health` and pushed to
/// the operator's alert destination; an unbounded upstream body would become an
/// unbounded health payload.
pub const UPSTREAM_EXCERPT_CHARS: usize = 400;

/// What a redacted secret is replaced with.
const REDACTED: &str = "<redacted>";

/// Values shorter than this are never redacted: they collide with ordinary
/// words often enough to make a diagnostic useless, and no supported provider
/// issues a credential this short.
const MIN_REDACTABLE_SECRET: usize = 8;

/// Make upstream text safe to publish in an issuance error.
///
/// Autumn's own error strings never contain a credential, but the text it copies
/// **in** from a provider is not under its control, and that text is published
/// verbatim on the unauthenticated `/actuator/health` and to the operator's
/// alert destination (Slack, `PagerDuty`, email). Two real shapes make that
/// dangerous:
///
/// - AWS answers a `SignatureDoesNotMatch` by echoing the canonical request it
///   expected — which contains every signed header line, including
///   `x-amz-security-token: <the STS session token>`;
/// - a shell hook written with `set -x` traces its own
///   `curl -H "Authorization: Bearer $TOKEN"` to `stderr`.
///
/// So every secret still live in this process is replaced with `<redacted>`
/// before publishing, control characters (which would let upstream text forge
/// log lines or inject ANSI escapes into `autumn doctor` output) are stripped,
/// and the result is truncated to [`UPSTREAM_EXCERPT_CHARS`].
///
/// Redaction runs **before** control characters are folded, and again after
/// each normalization step. Order matters: a credential can itself contain a
/// control character — an RFC 2136 TSIG key or a PEM private key is multi-line
/// — and folding first rewrites that secret *inside the text* so it no longer
/// equals the value being searched for, letting it survive into the published
/// message. Each secret is also matched in its folded and whitespace-collapsed
/// forms, so a value that reached `text` through a channel that normalized it
/// first is caught too (issue #1620).
#[must_use]
pub fn sanitize_upstream(text: &str, secrets: &[&str]) -> String {
    let forms: Vec<String> = secrets
        .iter()
        .flat_map(|secret| redaction_forms(secret))
        // A very short "secret" would redact half the message; the shortest
        // credential any supported provider issues is far longer than this.
        .filter(|form| form.len() >= MIN_REDACTABLE_SECRET)
        .collect();
    let redact = |mut text: String| {
        for form in &forms {
            text = text.replace(form.as_str(), REDACTED);
        }
        text
    };

    // Redact, normalize, redact again — see the note above on why the order is
    // not interchangeable.
    let raw = redact(text.to_owned());
    let folded: String = raw
        .chars()
        .map(|c| if c.is_control() { ' ' } else { c })
        .collect();
    let collapsed = redact(folded)
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ");
    let cleaned = redact(collapsed);

    if cleaned.chars().count() <= UPSTREAM_EXCERPT_CHARS {
        return cleaned;
    }
    let mut truncated: String = cleaned.chars().take(UPSTREAM_EXCERPT_CHARS).collect();
    truncated.push('…');
    truncated
}

/// The spellings of `secret` that must all be redacted.
///
/// The raw value, the value with control characters folded to spaces, and the
/// value with whitespace runs collapsed — matching the three states the text
/// passes through in [`sanitize_upstream`], so a multi-line credential is caught
/// whichever of them it appears in.
fn redaction_forms(secret: &str) -> Vec<String> {
    let trimmed = secret.trim();
    if trimmed.is_empty() {
        return Vec::new();
    }
    let mut forms = vec![trimmed.to_owned()];

    let folded: String = trimmed
        .chars()
        .map(|c| if c.is_control() { ' ' } else { c })
        .collect();
    let folded = folded.trim().to_owned();
    if !folded.is_empty() && !forms.contains(&folded) {
        forms.push(folded.clone());
    }

    let collapsed = folded.split_whitespace().collect::<Vec<_>>().join(" ");
    if !collapsed.is_empty() && !forms.contains(&collapsed) {
        forms.push(collapsed);
    }
    forms
}

/// A string that must not reach a log line, an error message, or actuator
/// output.
///
/// `Debug` and `Display` render `<redacted>`; the value is reachable only
/// through [`expose`](Self::expose), which is deliberately noisy at the call
/// site. AC: "tokens never appear in logs, error messages, or actuator output".
#[derive(Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(transparent)]
pub struct SecretString(String);

impl SecretString {
    /// Wrap `value` as a secret.
    #[must_use]
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    /// The underlying secret. Only for handing to the provider's transport.
    #[must_use]
    pub fn expose(&self) -> &str {
        &self.0
    }

    /// Whether the secret is empty or whitespace-only.
    #[must_use]
    pub fn is_blank(&self) -> bool {
        self.0.trim().is_empty()
    }
}

impl std::fmt::Debug for SecretString {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("<redacted>")
    }
}

impl std::fmt::Display for SecretString {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("<redacted>")
    }
}

/// A DNS provider's API credential, read from the encrypted credentials store
/// (`autumn credentials edit`) or the documented environment variables.
///
/// Every field is optional because the shape differs per provider;
/// [`validate_credential`] checks the ones a given provider actually needs and
/// names the missing key. Never deserialized from `autumn.toml` — the config
/// section has no field that could hold one.
#[derive(Clone, Default, serde::Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct DnsCredential {
    /// Cloudflare scoped API token (`Zone:DNS:Edit` on the zone).
    pub api_token: Option<SecretString>,
    /// AWS access key id (Route 53).
    pub access_key_id: Option<String>,
    /// AWS secret access key (Route 53).
    pub secret_access_key: Option<SecretString>,
    /// AWS session token, for temporary credentials (Route 53).
    pub session_token: Option<SecretString>,
    /// Explicit Route 53 hosted zone id, skipping the zone lookup.
    pub hosted_zone_id: Option<String>,
    /// AWS region used for `SigV4` signing. Route 53 is global; defaults to
    /// `us-east-1`.
    pub region: Option<String>,
}

impl std::fmt::Debug for DnsCredential {
    /// Redacted wholesale: even the non-secret fields are omitted so a
    /// `{:?}` of a config struct can never become a credential dump.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("DnsCredential(<redacted>)")
    }
}

/// Environment-variable names that can supply each credential field, for
/// operators who inject secrets through the environment rather than the
/// encrypted store. Documented in the TLS guide.
const ENV_API_TOKEN: &str = "AUTUMN_ACME_DNS_API_TOKEN";
const ENV_ACCESS_KEY_ID: &str = "AUTUMN_ACME_DNS_ACCESS_KEY_ID";
const ENV_SECRET_ACCESS_KEY: &str = "AUTUMN_ACME_DNS_SECRET_ACCESS_KEY";
const ENV_SESSION_TOKEN: &str = "AUTUMN_ACME_DNS_SESSION_TOKEN";
const ENV_HOSTED_ZONE_ID: &str = "AUTUMN_ACME_DNS_HOSTED_ZONE_ID";
const ENV_REGION: &str = "AUTUMN_ACME_DNS_REGION";

impl DnsCredential {
    /// Overlay any of the `AUTUMN_ACME_DNS_*` environment variables onto `self`.
    ///
    /// The environment wins over the encrypted store, so a container can inject
    /// a rotated token without rewriting the credentials file. `env` is injected
    /// rather than read from the process so this is unit-testable without
    /// mutating global state.
    #[must_use]
    pub fn with_env(mut self, env: &dyn Fn(&str) -> Option<String>) -> Self {
        fn non_blank(value: Option<String>) -> Option<String> {
            value.filter(|v| !v.trim().is_empty())
        }
        if let Some(v) = non_blank(env(ENV_API_TOKEN)) {
            self.api_token = Some(SecretString::new(v));
        }
        if let Some(v) = non_blank(env(ENV_ACCESS_KEY_ID)) {
            self.access_key_id = Some(v);
        }
        if let Some(v) = non_blank(env(ENV_SECRET_ACCESS_KEY)) {
            self.secret_access_key = Some(SecretString::new(v));
        }
        if let Some(v) = non_blank(env(ENV_SESSION_TOKEN)) {
            self.session_token = Some(SecretString::new(v));
        }
        if let Some(v) = non_blank(env(ENV_HOSTED_ZONE_ID)) {
            self.hosted_zone_id = Some(v);
        }
        if let Some(v) = non_blank(env(ENV_REGION)) {
            self.region = Some(v);
        }
        self
    }

    /// Read the credential for `dns_cfg` from `store`, then overlay the
    /// environment.
    #[must_use]
    pub fn resolve(
        dns_cfg: &AcmeDnsConfig,
        store: &crate::credentials::CredentialsStore,
        env: &dyn Fn(&str) -> Option<String>,
    ) -> Self {
        store
            .get::<Self>(dns_cfg.credential.trim())
            .unwrap_or_default()
            .with_env(env)
    }
}

impl DnsCredential {
    /// Every secret value this credential carries.
    ///
    /// Handed to the `exec` provider so anything a hook echoes — it inherits
    /// this process's environment, so it can echo these even though autumn
    /// passes it none — is redacted before the error reaches an operator.
    #[must_use]
    pub fn secret_values(&self) -> Vec<SecretString> {
        [
            self.api_token.as_ref(),
            self.secret_access_key.as_ref(),
            self.session_token.as_ref(),
        ]
        .into_iter()
        .flatten()
        .filter(|secret| !secret.is_blank())
        .cloned()
        .collect()
    }
}

/// Read an environment variable from the real process environment.
///
/// The default `env` argument for [`DnsCredential::resolve`] in production.
#[must_use]
pub fn process_env(name: &str) -> Option<String> {
    std::env::var(name).ok()
}

/// Check that `credential` carries the fields `provider` needs, naming the
/// missing key and where to put it.
///
/// Pure, so `autumn doctor` grades the same rule the runtime enforces
/// (issue #1620 AC: "`autumn doctor` diagnoses … missing or invalid provider
/// credential").
///
/// # Errors
///
/// Returns an operator-actionable message describing the first missing or blank
/// field. Never echoes a credential value.
pub fn validate_credential(
    provider: AcmeDnsProvider,
    credential_key: &str,
    credential: &DnsCredential,
) -> Result<(), String> {
    let where_to_put = |field: &str, env: &str| {
        format!(
            "add it under `[{credential_key}]` in the encrypted credentials store \
             (`autumn credentials edit`) as `{field} = \"...\"`, or set the {env} environment \
             variable"
        )
    };
    match provider {
        AcmeDnsProvider::Cloudflare => match &credential.api_token {
            Some(token) if !token.is_blank() => Ok(()),
            Some(_) => Err(format!(
                "the Cloudflare DNS credential `{credential_key}` has a blank `api_token`: {}",
                where_to_put("api_token", ENV_API_TOKEN)
            )),
            None => Err(format!(
                "no Cloudflare API token found for [server.tls.acme.dns] credential \
                     `{credential_key}`: {}",
                where_to_put("api_token", ENV_API_TOKEN)
            )),
        },
        AcmeDnsProvider::Route53 => {
            let id_ok = credential
                .access_key_id
                .as_ref()
                .is_some_and(|v| !v.trim().is_empty());
            if !id_ok {
                return Err(format!(
                    "no AWS access key id found for [server.tls.acme.dns] credential \
                     `{credential_key}`: {}",
                    where_to_put("access_key_id", ENV_ACCESS_KEY_ID)
                ));
            }
            let secret_ok = credential
                .secret_access_key
                .as_ref()
                .is_some_and(|v| !v.is_blank());
            if !secret_ok {
                return Err(format!(
                    "no AWS secret access key found for [server.tls.acme.dns] credential \
                     `{credential_key}`: {}",
                    where_to_put("secret_access_key", ENV_SECRET_ACCESS_KEY)
                ));
            }
            Ok(())
        }
        // The hook program authenticates itself (an `nsupdate` TSIG file, a
        // provider CLI's own profile); autumn holds no credential for it.
        AcmeDnsProvider::Exec => Ok(()),
    }
}

/// Build the [`DnsProvider`] for `dns_cfg`, using `credential` and `transport`.
///
/// # Errors
///
/// Returns a message when the credential does not carry what the provider needs.
pub fn build_provider(
    dns_cfg: &AcmeDnsConfig,
    credential: &DnsCredential,
    transport: Arc<dyn http::HttpTransport>,
) -> Result<Arc<dyn DnsProvider>, String> {
    validate_credential(dns_cfg.provider, dns_cfg.credential.trim(), credential)?;
    Ok(match dns_cfg.provider {
        AcmeDnsProvider::Cloudflare => Arc::new(cloudflare::CloudflareProvider::new(
            credential
                .api_token
                .clone()
                .unwrap_or_else(|| SecretString::new(String::new())),
            transport,
        )),
        AcmeDnsProvider::Route53 => Arc::new(route53::Route53Provider::new(
            route53::Route53Credentials {
                access_key_id: credential.access_key_id.clone().unwrap_or_default(),
                secret_access_key: credential
                    .secret_access_key
                    .clone()
                    .unwrap_or_else(|| SecretString::new(String::new())),
                session_token: credential.session_token.clone(),
                region: credential
                    .region
                    .clone()
                    .unwrap_or_else(|| "us-east-1".to_owned()),
                hosted_zone_id: credential.hosted_zone_id.clone(),
            },
            transport,
        )),
        // Every DNS secret this process holds, handed to the hook provider for
        // redaction. Autumn passes the hook no credential of its own, but the
        // hook INHERITS the process environment — so a script written with
        // `set -x` traces `AUTUMN_ACME_DNS_API_TOKEN` (or whatever it reads) to
        // stderr, and that stderr is copied into the issuance error, which is
        // published on `/actuator/health` and pushed to the operator's alert
        // destination.
        AcmeDnsProvider::Exec => Arc::new(
            exec::ExecProvider::new(dns_cfg.command.clone())
                .with_secrets(credential.secret_values()),
        ),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn challenge_fqdn_is_the_base_domain_prefixed() {
        assert_eq!(challenge_fqdn("myapp.com"), "_acme-challenge.myapp.com");
        // A trailing dot and mixed case normalize to the same record name.
        assert_eq!(challenge_fqdn("MyApp.com."), "_acme-challenge.myapp.com");
        // An authorization for `*.myapp.com` carries the identifier `myapp.com`,
        // so apex and wildcard share one record name — see the module docs.
        assert_eq!(
            challenge_fqdn("myapp.com"),
            TxtRecord::new("myapp.com", "v").fqdn
        );
    }

    // AWS answers a SigV4 mismatch by echoing the canonical request, which
    // contains every signed header — including the STS session token. That reply
    // is published on the unauthenticated `/actuator/health` and pushed to the
    // operator's alert destination, so it must be scrubbed on the way in.
    #[test]
    fn upstream_text_is_scrubbed_of_live_secrets() {
        let token = "FwoGZXIvYXdzEHwaDNOT-A-REAL-SESSION-TOKEN";
        let aws_reply = format!(
            "The request signature we calculated does not match. The Canonical String for this \
             request should have been 'POST /2013-04-01/hostedzone/Z1/rrset/ \
             host:route53.amazonaws.com x-amz-security-token:{token} '"
        );
        let safe = sanitize_upstream(&aws_reply, &[token]);
        assert!(!safe.contains(token), "leaked: {safe}");
        assert!(safe.contains("<redacted>"), "got: {safe}");
        // …and the operator-actionable part survives.
        assert!(
            safe.contains("signature we calculated does not match"),
            "got: {safe}"
        );
    }

    #[test]
    fn upstream_text_is_bounded_and_stripped_of_control_characters() {
        let long = "x".repeat(UPSTREAM_EXCERPT_CHARS * 3);
        let bounded = sanitize_upstream(&long, &[]);
        assert!(bounded.chars().count() <= UPSTREAM_EXCERPT_CHARS + 1);
        assert!(bounded.ends_with('…'));

        // A hook that writes newlines or ANSI escapes must not be able to forge
        // log lines or repaint `autumn doctor`'s terminal output.
        let hostile = "line one\n2026-01-01 ERROR forged\r\n\u{1b}[31mred\u{1b}[0m";
        let safe = sanitize_upstream(hostile, &[]);
        assert!(!safe.contains('\n'), "got: {safe:?}");
        assert!(!safe.contains('\r'), "got: {safe:?}");
        assert!(!safe.contains('\u{1b}'), "got: {safe:?}");
        assert!(safe.contains("forged"), "the text itself is kept: {safe:?}");
    }

    // A short string is not treated as a secret: redacting on a 3-character
    // "secret" would blank out most of any message.
    /// Regression (#1620): a credential containing control characters must be
    /// redacted, whatever order the sanitizer's steps run in.
    ///
    /// Folding control characters to spaces BEFORE redacting rewrote the secret
    /// inside the text, so the literal being searched for no longer occurred and
    /// the credential survived into a message published on the unauthenticated
    /// `/actuator/health` and pushed to the operator's alert destination. A TSIG
    /// key file and a PEM private key are both multi-line, and a `set -x` hook
    /// traces them verbatim.
    #[test]
    fn a_multiline_secret_is_redacted_despite_control_folding() {
        const KEY: &str = "-----BEGIN PRIVATE KEY-----\nMIIBVgIBADANBgkqhkiG9w0BAQ\nSECRETLINE9f3a\n-----END PRIVATE KEY-----";
        // A TSIG key file's shape: tab-separated rather than newline-separated.
        const TSIG: &str = "hmac-sha256\tautumn-key\tc3VwZXJzZWNyZXR2YWx1ZQ==";

        // The hook traced the key verbatim, newlines and all.
        let traced = format!("+ nsupdate -k {KEY} failed");
        let safe = sanitize_upstream(&traced, &[KEY]);
        assert!(
            !safe.contains("SECRETLINE9f3a"),
            "a multi-line credential leaked: {safe}"
        );
        assert!(safe.contains("<redacted>"), "got: {safe}");
        // The surrounding diagnostic survives.
        assert!(safe.contains("nsupdate"), "got: {safe}");

        // …and the mirror case: the text has already been normalized (newlines
        // folded to spaces) by whatever produced it, while the secret is still
        // raw. Matching the secret's folded spelling catches that too.
        let folded_text = traced.replace('\n', " ");
        let safe = sanitize_upstream(&folded_text, &[KEY]);
        assert!(
            !safe.contains("SECRETLINE9f3a"),
            "a pre-normalized trace leaked the credential: {safe}"
        );

        // A tab-separated credential (a TSIG key file's shape) is covered by
        // the same folding.
        let safe = sanitize_upstream(&format!("key rejected: {TSIG}"), &[TSIG]);
        assert!(
            !safe.contains("c3VwZXJzZWNyZXR2YWx1ZQ=="),
            "a tab-separated credential leaked: {safe}"
        );
    }

    #[test]
    fn a_too_short_secret_is_not_used_as_a_redaction_pattern() {
        assert_eq!(
            sanitize_upstream("the zone was not found", &["e"]),
            "the zone was not found"
        );
        assert_eq!(sanitize_upstream("a b c", &[""]), "a b c");
    }

    #[test]
    fn secret_string_never_renders_its_value() {
        let secret = SecretString::new("cf-super-secret-token");
        assert_eq!(format!("{secret:?}"), "<redacted>");
        assert_eq!(format!("{secret}"), "<redacted>");
        assert!(!format!("{secret:?} {secret}").contains("super-secret"));
        assert_eq!(secret.expose(), "cf-super-secret-token");
    }

    #[test]
    fn dns_credential_debug_is_redacted_wholesale() {
        let credential = DnsCredential {
            api_token: Some(SecretString::new("cf-super-secret-token")),
            access_key_id: Some("AKIAEXAMPLE".to_owned()),
            secret_access_key: Some(SecretString::new("aws-super-secret")),
            ..DnsCredential::default()
        };
        let rendered = format!("{credential:?}");
        assert!(!rendered.contains("super-secret"), "leaked: {rendered}");
        assert!(!rendered.contains("AKIAEXAMPLE"), "leaked: {rendered}");
    }

    #[test]
    fn env_overlays_the_stored_credential() {
        let credential = DnsCredential {
            api_token: Some(SecretString::new("from-store")),
            ..DnsCredential::default()
        };
        let env = |name: &str| match name {
            ENV_API_TOKEN => Some("from-env".to_owned()),
            ENV_REGION => Some("eu-west-1".to_owned()),
            // A blank environment variable must not shadow a real stored value.
            ENV_ACCESS_KEY_ID => Some("   ".to_owned()),
            _ => None,
        };
        let resolved = credential.with_env(&env);
        assert_eq!(resolved.api_token.as_ref().unwrap().expose(), "from-env");
        assert_eq!(resolved.region.as_deref(), Some("eu-west-1"));
        assert!(resolved.access_key_id.is_none());
    }

    // Regression (Codex, #1620): `ExecProvider::with_secrets` existed but
    // `build_provider` never called it, so the hook's redaction list was empty
    // in production. A hook inherits the process environment, so a `set -x`
    // script traces the very token this list exists to scrub.
    #[test]
    fn the_exec_provider_is_built_with_the_credentials_secrets() {
        let credential = DnsCredential {
            api_token: Some(SecretString::new("cf-live-token-DO-NOT-LEAK")),
            secret_access_key: Some(SecretString::new("aws-secret-DO-NOT-LEAK")),
            session_token: Some(SecretString::new("sts-token-DO-NOT-LEAK")),
            // Not a secret, and not in the list.
            access_key_id: Some("AKIAEXAMPLE".to_owned()),
            ..DnsCredential::default()
        };
        let secrets = credential.secret_values();
        assert_eq!(secrets.len(), 3, "every secret field must be redactable");
        assert!(secrets.iter().all(|s| s.expose().contains("DO-NOT-LEAK")));

        // A blank or absent field contributes nothing to redact — a short or
        // empty pattern would blank out unrelated text.
        let sparse = DnsCredential {
            api_token: Some(SecretString::new("   ")),
            ..DnsCredential::default()
        };
        assert!(sparse.secret_values().is_empty());
    }

    #[test]
    fn validate_credential_names_the_missing_field_and_where_to_put_it() {
        let empty = DnsCredential::default();

        let err = validate_credential(AcmeDnsProvider::Cloudflare, "acme_dns", &empty)
            .expect_err("cloudflare needs an api_token");
        assert!(err.contains("api_token"), "got: {err}");
        assert!(err.contains("autumn credentials edit"), "got: {err}");
        assert!(err.contains("AUTUMN_ACME_DNS_API_TOKEN"), "got: {err}");
        assert!(err.contains("acme_dns"), "must name the key: {err}");

        let err = validate_credential(AcmeDnsProvider::Route53, "acme_dns", &empty)
            .expect_err("route53 needs an access key");
        assert!(err.contains("access_key_id"), "got: {err}");

        let partial = DnsCredential {
            access_key_id: Some("AKIAEXAMPLE".to_owned()),
            ..DnsCredential::default()
        };
        let err = validate_credential(AcmeDnsProvider::Route53, "acme_dns", &partial)
            .expect_err("route53 needs a secret too");
        assert!(err.contains("secret_access_key"), "got: {err}");

        // A blank token is as missing as an absent one.
        let blank = DnsCredential {
            api_token: Some(SecretString::new("   ")),
            ..DnsCredential::default()
        };
        assert!(validate_credential(AcmeDnsProvider::Cloudflare, "acme_dns", &blank).is_err());

        // The exec hook authenticates itself.
        assert!(validate_credential(AcmeDnsProvider::Exec, "acme_dns", &empty).is_ok());
    }

    // A credential error is surfaced to operators verbatim; it must describe the
    // problem without ever echoing the value it read.
    #[test]
    fn validate_credential_errors_never_echo_a_secret() {
        let credential = DnsCredential {
            api_token: Some(SecretString::new("   ")),
            access_key_id: Some("AKIAEXAMPLE".to_owned()),
            ..DnsCredential::default()
        };
        let err = validate_credential(AcmeDnsProvider::Route53, "acme_dns", &credential)
            .expect_err("missing secret_access_key");
        assert!(!err.contains("AKIAEXAMPLE"), "leaked: {err}");
    }

    /// #2478: the default batch removal attempts every record.
    #[tokio::test]
    async fn default_batch_removal_attempts_every_record() {
        use std::sync::Mutex;

        struct Flaky(Mutex<Vec<String>>);
        impl DnsProvider for Flaky {
            fn name(&self) -> &'static str {
                "flaky"
            }
            fn upsert_txt<'a>(&'a self, _: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>> {
                Box::pin(async { Ok(()) })
            }
            fn delete_txt<'a>(&'a self, r: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>> {
                self.0.lock().unwrap().push(r.value.clone());
                let fail = r.value.starts_with("bad");
                Box::pin(async move {
                    if fail {
                        Err(format!("{} failed", r.value))
                    } else {
                        Ok(())
                    }
                })
            }
        }

        let provider = Flaky(Mutex::new(Vec::new()));
        assert_eq!(provider.name(), "flaky");
        provider
            .upsert_txt(&TxtRecord::new("a.example", "v"))
            .await
            .unwrap();
        let err = provider
            .delete_txt_batch(&[
                TxtRecord::new("a.example", "bad1"),
                TxtRecord::new("b.example", "good"),
                TxtRecord::new("c.example", "bad2"),
            ])
            .await
            .expect_err("two removals fail");

        assert_eq!(*provider.0.lock().unwrap(), ["bad1", "good", "bad2"]);
        assert!(err.contains("a.example: bad1 failed"), "{err}");
        assert!(
            err.contains("bad1 failed") && err.contains("bad2 failed"),
            "{err}"
        );
    }
    #[test]
    fn join_errors_joins_and_bounds_the_list() {
        assert_eq!(join_errors(&[]), Ok(()));
        assert_eq!(join_errors(&["a".into(), "b".into()]), Err("a; b".into()));
        let long = "x".repeat(MAX_JOINED_ERROR_CHARS);
        let err = join_errors(&[long.clone(), long]).unwrap_err();
        assert_eq!(err.chars().count(), MAX_JOINED_ERROR_CHARS + 1);
    }
}