Skip to main content

cloud/reconciler/
domain.rs

1//! Domain-level reconciler — `.yah/domains/*.toml` → live Cloudflare state.
2//!
3//! Today's only shape is the **R2 custom-domain** binding: a domain that
4//! declares `front_door = "bucket-direct"` (R594-F12) and names an R2 bucket
5//! in `cdn_bucket`. Cloudflare's R2 Custom Domains API binds the hostname to
6//! the bucket and writes the CNAME into the parent zone automatically when
7//! the zone lives on the same account — no DNS-side call is needed here.
8//!
9//! Domains with `front_door = "worker"` (e.g. `yah-dev.toml`,
10//! `app-yah-dev.toml`) are out of scope for this shape; they get DNS + route
11//! management through the Worker reconciler below. `front_door = "passway"`
12//! is the sovereign-ingress path (W267) and has no reconciler here yet.
13//!
14//! Before R594-F12 the shape was *inferred* from the absence of `[[routes]]`,
15//! which meant a route-carrying manifest bound straight to R2 was accepted
16//! and silently skipped by this pass. The discriminator is now declared and
17//! validated at load.
18//!
19//! @yah:relay(R859, "Sovereign-edge automation ring: DNS reconciled from declared intent, public addressing follows placement (W267 audit followups)")
20//! @yah:at(2026-09-04T19:06:24Z)
21//! @yah:status(handoff)
22//! @yah:assignee(agent:user-custom-char-gul2)
23//! @arch:see(.yah/docs/working/W267-sovereign-public-ingress.md)
24//! @yah:next("Filed from the 2026-09-04 HA/ingress audit (chat session, operator-reviewed). The grey door is live but the automation ring around it is manual: DNS flips ride scripts/cf-apex-mode.sh by hand, and node loss leaves a dead A record taking ~half the round-robin traffic until a human edits DNS.")
25//! @yah:next("Order: F1 (DNS reconciler) before F2 (floating-IP wiring) — F2's DNS-withdrawal half needs F1's record-rendering to exist. Sibling track R844-F23 (poll-N door discovery) is independent and filed under R844 where door discovery lives.")
26//! @yah:next("Deliberately out of scope here: replica layer (W284/R626), kamaji healthcheck execution, generic appliance backup/migrate (R742-F3), external synthetic prober — each is its own track; this relay is only 'public addressing follows declared intent'.")
27
28use std::path::Path;
29
30use anyhow::{Context, Result};
31use tracing::{debug, info};
32
33use crate::CloudflareClient;
34
35/// Bind `domain` as a custom domain on `bucket_name`. Idempotent — does
36/// nothing when the binding is already present, regardless of `enabled`
37/// state (CF reflects newly-added bindings as `enabled: true` immediately;
38/// disabling is an explicit dashboard action we don't undo here).
39///
40/// Resolves account_id + the Cloudflare API token from the named provider
41/// (`.yah/infra/providers/<provider_id>.toml`, see
42/// [`super::cf_creds::CfProvider`]). Mirrors the list-first pattern of
43/// `static_asset::ensure_r2_bucket` so the apply loop is safe to re-run.
44///
45/// Required token scopes: `Workers R2 Storage: Edit` and `Zone: Read`
46/// (the latter because the API requires the zone id of the parent zone —
47/// CF writes the CNAME there). The caller does NOT need `DNS: Edit`; CF
48/// provisions the record itself when the binding is created.
49pub async fn ensure_r2_custom_domain(
50    workspace_root: &Path,
51    provider_id: &str,
52    bucket_name: &str,
53    domain: &str,
54) -> Result<()> {
55    let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
56    let account_id = cf_provider.account_id.clone();
57    let cf = CloudflareClient::new(cf_provider.api_token()?);
58    let existing = cf
59        .list_r2_custom_domains(&account_id, bucket_name)
60        .await
61        .with_context(|| format!("listing R2 custom domains on bucket {bucket_name:?}"))?;
62    if existing.iter().any(|d| d.domain == domain) {
63        debug!(
64            domain,
65            bucket_name, "R2 custom domain already bound — skipping"
66        );
67        return Ok(());
68    }
69    let zone_name = parent_zone_name(domain);
70    let zone_id = cf
71        .zone_id_for_name(zone_name)
72        .await
73        .with_context(|| format!("resolving zone id for {zone_name:?}"))?;
74    cf.add_r2_custom_domain(&account_id, bucket_name, domain, &zone_id)
75        .await
76        .with_context(|| format!("binding R2 custom domain {domain:?} → bucket {bucket_name:?}"))?;
77    info!(domain, bucket_name, zone_name, "R2 custom domain bound");
78    Ok(())
79}
80
81/// Heuristic: the parent CF zone of `domain` is its last two labels.
82///
83/// `cdn.yah.dev` → `yah.dev`; `yah.dev` → `yah.dev` (already apex). This is
84/// correct for every yah-owned zone today (all are two-label apexes). If a
85/// future workspace registers a three-label zone (e.g. `staging.yah.dev`
86/// as its own CF zone) and binds a subdomain under it, this will resolve
87/// to the wrong zone — swap in a longest-suffix match against
88/// `list_zones()` when that day arrives.
89fn parent_zone_name(domain: &str) -> &str {
90    let last_dot = domain.rfind('.');
91    let Some(last_dot) = last_dot else {
92        return domain; // single-label — let CF surface the bad input
93    };
94    if let Some(prev_dot) = domain[..last_dot].rfind('.') {
95        &domain[prev_dot + 1..]
96    } else {
97        domain // already a two-label apex
98    }
99}
100
101// ─── R561-F3: domain-manifest-driven static Worker ──────────────────────────
102//
103// A per-tenant alias-tier manifest (e.g. scrabcake.net.yah.dev) carries a
104// `static` route → `<service>/<component>`. Serving it means deploying the
105// shared router bundle (`WORKER_SCRIPT`) configured to fetch the tenant's
106// assets from its R2 prefix, then binding the subdomain to that Worker via the
107// Workers Custom Domains API. This is the routed-domain shape the apply loop
108// currently Skips.
109
110use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
111use crate::provider::cloudflare::WorkerBinding;
112use crate::reconciler::mesofact_static::WORKER_SCRIPT;
113use std::collections::BTreeMap;
114
115/// The plan for deploying one subdomain's static Worker — everything decided
116/// before any Cloudflare call. Pure output of [`plan_domain_worker`], so the
117/// decision logic is unit-testable offline; [`deploy_domain_worker`] performs
118/// the I/O.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct DomainWorkerPlan {
121    /// Worker script name (the domain manifest's file stem / `name`).
122    pub worker_name: String,
123    /// Hostname to bind via Workers Custom Domains, e.g. `scrabcake.net.yah.dev`.
124    pub custom_domain: String,
125    /// `ASSET_ORIGIN` the Worker fetches from: `<cdn_base>/<service>/<env>`.
126    pub asset_origin: String,
127    /// `plain_text` Worker bindings (mirrors the Static-mode shape of
128    /// mesofact_static's `worker_config_bindings`).
129    pub bindings: Vec<(String, String)>,
130}
131
132/// Static-mode Worker bindings for an alias-tier subdomain. Kept in lockstep
133/// with `mesofact_static::worker_config_bindings(WorkerMode::Static, …)`.
134///
135/// `route_headers` is the manifest's own `DomainConfig::route_headers_json` —
136/// this planner already holds the domain, so unlike the mirror-driven path it
137/// needs no lookup to find it (R746).
138fn static_worker_bindings(asset_origin: &str, route_headers: String) -> Vec<(String, String)> {
139    vec![
140        ("ASSET_ORIGIN".to_string(), asset_origin.to_string()),
141        ("UPLOAD_ORIGIN".to_string(), String::new()),
142        ("WORKER_MODE".to_string(), "static".to_string()),
143        ("SSR_ORIGIN".to_string(), String::new()),
144        ("SSR_PREFIXES".to_string(), "[]".to_string()),
145        ("ROUTE_HEADERS".to_string(), route_headers),
146    ]
147}
148
149/// Build the [`DomainWorkerPlan`] for a per-tenant alias-tier manifest.
150///
151/// `cdn_base` is the tier's CDN origin (an R2 custom domain bound to the
152/// tier bucket, e.g. `https://cdn.net.yah.dev`); `env` is the mirror env the
153/// publisher wrote under (e.g. `cloud`). The publisher lays assets down at
154/// `<bucket>/<service>/<env>/<key>` and the Worker fetches
155/// `${ASSET_ORIGIN}/<key>`, so `ASSET_ORIGIN = <cdn_base>/<service>/<env>`.
156///
157/// Fails fast (before any network call) when the manifest has no `static`
158/// route or its component ref is malformed.
159pub fn plan_domain_worker(
160    domain: &DomainConfig,
161    cdn_base: &str,
162    env: &str,
163) -> Result<DomainWorkerPlan> {
164    // R594-F12: refuse to synthesize a Worker for a domain that declared a
165    // different front door. Without this the plan succeeds and deploys a
166    // Worker that never receives traffic (bucket-direct) or that duplicates
167    // the sovereign ingress (passway).
168    if domain.front_door != FrontDoor::Worker {
169        anyhow::bail!(
170            "domain {} ({}) declares front_door = \"{}\" — only \"worker\" \
171             domains get a generated Cloudflare Worker",
172            domain.name,
173            domain.domain,
174            domain.front_door.as_str()
175        );
176    }
177
178    // First static route wins (v1 alias tier serves one site per subdomain).
179    let component = domain
180        .routes
181        .iter()
182        .find_map(|r| match &r.mode {
183            RouteMode::Static { component } => Some(component.as_str()),
184            _ => None,
185        })
186        .with_context(|| {
187            format!(
188                "domain {} ({}) has no `static` route — nothing for a static Worker to serve",
189                domain.name, domain.domain
190            )
191        })?;
192
193    let service = component
194        .split_once('/')
195        .map(|(svc, _)| svc)
196        .with_context(|| {
197            format!(
198                "domain {}: route component {component:?} — expected \"<service>/<component-id>\"",
199                domain.name
200            )
201        })?;
202
203    let asset_origin = format!("{}/{}/{}", cdn_base.trim_end_matches('/'), service, env);
204
205    Ok(DomainWorkerPlan {
206        worker_name: domain.name.clone(),
207        custom_domain: domain.domain.clone(),
208        bindings: static_worker_bindings(&asset_origin, domain.route_headers_json()),
209        asset_origin,
210    })
211}
212
213/// Deploy the planned Worker and bind the subdomain (R561-F3, live I/O).
214///
215/// Reuses the shared `WORKER_SCRIPT` bundle + the existing CloudflareClient
216/// deploy/custom-domain methods. Gated on `cloudflare-api-token`. The decision
217/// logic is covered by `plan_domain_worker`'s tests; this I/O path has NOT been
218/// exercised against a live account yet (no creds in CI) — treat as untested
219/// until a live `yah cloud apply` confirms it.
220///
221/// CAVEAT (zone resolution): `parent_zone_name` uses a two-label heuristic, so
222/// for `scrabcake.net.yah.dev` it returns `yah.dev`. But the alias tiers
223/// `net.yah.dev` / `com.yah.dev` are their own Cloudflare zones — the binding
224/// must target `net.yah.dev`, not `yah.dev`. Before this goes live, swap in a
225/// longest-suffix match against the account's zones (the upgrade path
226/// `parent_zone_name`'s doc already names). Tracked on R561-F3.
227pub async fn deploy_domain_worker(
228    workspace_root: &Path,
229    provider_id: &str,
230    plan: &DomainWorkerPlan,
231) -> Result<()> {
232    let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
233    let account_id = cf_provider.account_id.clone();
234    let cf = CloudflareClient::new(cf_provider.api_token()?);
235
236    let worker_bindings: Vec<WorkerBinding<'_>> = plan
237        .bindings
238        .iter()
239        .map(|(k, v)| WorkerBinding::PlainText {
240            name: k.as_str(),
241            text: v.as_str(),
242        })
243        .collect();
244
245    cf.deploy_worker_script(
246        &account_id,
247        &plan.worker_name,
248        WORKER_SCRIPT,
249        &worker_bindings,
250    )
251    .await
252    .with_context(|| format!("deploying static Worker {}", plan.worker_name))?;
253    info!(worker = %plan.worker_name, "alias-tier Worker deployed");
254
255    let zone = parent_zone_name(&plan.custom_domain);
256    let zone_id = cf
257        .zone_id_for_name(zone)
258        .await
259        .with_context(|| format!("resolving zone id for {zone:?}"))?;
260    cf.upsert_worker_custom_domain(
261        &account_id,
262        &zone_id,
263        &plan.custom_domain,
264        &plan.worker_name,
265    )
266    .await
267    .with_context(|| {
268        format!(
269            "binding {} to Worker {}",
270            plan.custom_domain, plan.worker_name
271        )
272    })?;
273    info!(domain = %plan.custom_domain, worker = %plan.worker_name, "alias-tier custom domain bound");
274    Ok(())
275}
276
277// ─── R561-F4: alias-tier registration ───────────────────────────────────────
278//
279// "Claim <name>.{com,net}.yah.dev": validate the label, check it's free, and
280// produce the per-tenant DomainConfig (the F2 manifest) the registration flow
281// writes. Pure + offline — the SaaS "sign up, get a subdomain" moment.
282
283/// The two wildcard alias tiers. `Com` = managed/commercial, `Net` = community.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum AliasTier {
286    Com,
287    Net,
288}
289
290impl AliasTier {
291    /// The tier's Cloudflare zone, e.g. `net.yah.dev`.
292    pub fn zone(self) -> &'static str {
293        match self {
294            AliasTier::Com => "com.yah.dev",
295            AliasTier::Net => "net.yah.dev",
296        }
297    }
298
299    /// The tier's shared R2 bucket (per-tenant `<svc>/<env>` prefixes within).
300    pub fn bucket(self) -> &'static str {
301        match self {
302            AliasTier::Com => "com-yah-dev",
303            AliasTier::Net => "net-yah-dev",
304        }
305    }
306
307    /// Manifest-name infix, e.g. `net` → `<name>-net-yah-dev` file stem.
308    fn slug(self) -> &'static str {
309        match self {
310            AliasTier::Com => "com",
311            AliasTier::Net => "net",
312        }
313    }
314}
315
316/// A valid DNS label: 1–63 chars, lowercase alphanumeric or hyphen, no
317/// leading/trailing hyphen. (Subdomain names a tenant can claim.)
318pub fn valid_subdomain_label(name: &str) -> bool {
319    !name.is_empty()
320        && name.len() <= 63
321        && !name.starts_with('-')
322        && !name.ends_with('-')
323        && name
324            .bytes()
325            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
326}
327
328/// Validate and build the per-tenant alias manifest for a claim.
329///
330/// `name` is the requested subdomain label (e.g. `scrabcake`); `component` is
331/// the tenant's static component ref (`"<service>/<component-id>"`). `existing`
332/// is the current domain map (from `CloudConfig`) — the claim fails if the
333/// resulting host or manifest name is already taken. Returns the
334/// [`DomainConfig`] to write at `.yah/domains/<stem>.toml`.
335pub fn plan_alias_claim(
336    tier: AliasTier,
337    name: &str,
338    component: &str,
339    existing: &BTreeMap<String, DomainConfig>,
340) -> Result<DomainConfig> {
341    if !valid_subdomain_label(name) {
342        anyhow::bail!(
343            "invalid subdomain {name:?} — must be 1–63 chars, lowercase \
344             alphanumeric or hyphen, no leading/trailing hyphen"
345        );
346    }
347    if component.split_once('/').is_none() {
348        anyhow::bail!("component {component:?} — expected \"<service>/<component-id>\"");
349    }
350
351    let domain = format!("{name}.{}", tier.zone());
352    let stem = format!("{name}-{}-yah-dev", tier.slug());
353
354    if existing.values().any(|d| d.domain == domain) {
355        anyhow::bail!("{domain} is already claimed");
356    }
357    if existing.contains_key(&stem) {
358        anyhow::bail!("domain manifest {stem:?} already exists");
359    }
360
361    Ok(DomainConfig {
362        schema_version: 1,
363        name: stem,
364        domain,
365        // R594-F12: every tenant manifest is Worker-served (R561-F3 binds the
366        // subdomain as a custom domain on the generated Worker).
367        front_door: FrontDoor::Worker,
368        cdn_bucket: tier.bucket().to_string(),
369        worker_bundle_path: None,
370        routes: vec![DomainRoute {
371            path: "/*".into(),
372            // A claimed alias serves one bundle at the root with no special
373            // header needs; a tenant that wants some edits its own manifest.
374            headers: BTreeMap::new(),
375            mode: RouteMode::Static {
376                component: component.to_string(),
377            },
378        }],
379    })
380}
381
382#[cfg(test)]
383mod tests {
384    use super::parent_zone_name;
385
386    #[test]
387    fn parent_zone_strips_one_label_off_subdomain() {
388        assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
389        assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
390    }
391
392    #[test]
393    fn parent_zone_returns_self_for_apex() {
394        assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
395    }
396
397    #[test]
398    fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
399        // Two-label heuristic: yah-side zones are all two-label apexes today.
400        assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
401    }
402
403    use super::{plan_domain_worker, DomainWorkerPlan};
404    use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
405
406    fn net_tier_manifest() -> DomainConfig {
407        // Mirrors .yah/domains/scrabcake-net-yah-dev.toml.
408        DomainConfig {
409            schema_version: 1,
410            name: "scrabcake-net-yah-dev".into(),
411            domain: "scrabcake.net.yah.dev".into(),
412            front_door: FrontDoor::Worker,
413            cdn_bucket: "net-yah-dev".into(),
414            worker_bundle_path: None,
415            routes: vec![DomainRoute {
416                headers: Default::default(),
417                path: "/*".into(),
418                mode: RouteMode::Static {
419                    component: "scrabcake/site".into(),
420                },
421            }],
422        }
423    }
424
425    #[test]
426    fn plan_resolves_worker_name_domain_and_asset_origin() {
427        let plan =
428            plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev", "cloud").unwrap();
429        assert_eq!(
430            plan,
431            DomainWorkerPlan {
432                worker_name: "scrabcake-net-yah-dev".into(),
433                custom_domain: "scrabcake.net.yah.dev".into(),
434                asset_origin: "https://cdn.net.yah.dev/scrabcake/cloud".into(),
435                bindings: vec![
436                    (
437                        "ASSET_ORIGIN".into(),
438                        "https://cdn.net.yah.dev/scrabcake/cloud".into()
439                    ),
440                    ("UPLOAD_ORIGIN".into(), String::new()),
441                    ("WORKER_MODE".into(), "static".into()),
442                    ("SSR_ORIGIN".into(), String::new()),
443                    ("SSR_PREFIXES".into(), "[]".into()),
444                    ("ROUTE_HEADERS".into(), "[]".into()),
445                ],
446            }
447        );
448    }
449
450    /// R746: headers declared on a route reach the deployed Worker as the
451    /// ROUTE_HEADERS binding. Without this the manifest could declare them and
452    /// the Worker would serve without them — the exact silent gap the primitive
453    /// exists to close.
454    #[test]
455    fn plan_carries_declared_route_headers_into_the_bindings() {
456        let mut dom = net_tier_manifest();
457        dom.routes[0].headers = [
458            ("Cross-Origin-Opener-Policy".to_string(), "same-origin".to_string()),
459            (
460                "Cross-Origin-Embedder-Policy".to_string(),
461                "require-corp".to_string(),
462            ),
463        ]
464        .into_iter()
465        .collect();
466        let plan = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap();
467        let binding = plan
468            .bindings
469            .iter()
470            .find(|(k, _)| k == "ROUTE_HEADERS")
471            .expect("ROUTE_HEADERS binding");
472        assert!(binding.1.contains("same-origin"), "{}", binding.1);
473        assert!(binding.1.contains("require-corp"), "{}", binding.1);
474        assert!(binding.1.contains("/*"), "{}", binding.1);
475    }
476
477    #[test]
478    fn plan_trims_trailing_slash_on_cdn_base() {
479        let plan =
480            plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev/", "cloud").unwrap();
481        assert_eq!(plan.asset_origin, "https://cdn.net.yah.dev/scrabcake/cloud");
482    }
483
484    /// R594-F12: a manifest that declares a different front door must not
485    /// get a Cloudflare Worker synthesized behind its back — the Worker
486    /// would deploy and then never receive traffic.
487    #[test]
488    fn plan_bails_when_front_door_is_not_worker() {
489        for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
490            let mut dom = net_tier_manifest();
491            dom.front_door = door;
492            let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
493            let msg = format!("{err:#}");
494            assert!(msg.contains("front_door"), "{msg}");
495            assert!(msg.contains(door.as_str()), "{msg}");
496        }
497    }
498
499    #[test]
500    fn plan_bails_when_no_static_route() {
501        let mut dom = net_tier_manifest();
502        dom.routes = vec![DomainRoute {
503            headers: Default::default(),
504            path: "/old".into(),
505            mode: RouteMode::Redirect {
506                target: "https://elsewhere".into(),
507                status: 308,
508            },
509        }];
510        let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
511        assert!(
512            format!("{err:#}").contains("no `static` route"),
513            "got: {err:#}"
514        );
515    }
516
517    // ── R561-F4: alias-tier registration ──
518    use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
519    use std::collections::BTreeMap;
520
521    #[test]
522    fn label_validation_rules() {
523        assert!(valid_subdomain_label("scrabcake"));
524        assert!(valid_subdomain_label("my-repo-1"));
525        assert!(!valid_subdomain_label("")); // empty
526        assert!(!valid_subdomain_label("-lead")); // leading hyphen
527        assert!(!valid_subdomain_label("trail-")); // trailing hyphen
528        assert!(!valid_subdomain_label("Caps")); // uppercase
529        assert!(!valid_subdomain_label("under_score")); // underscore
530    }
531
532    #[test]
533    fn claim_builds_net_tier_manifest() {
534        let dom = plan_alias_claim(
535            AliasTier::Net,
536            "scrabcake",
537            "scrabcake/site",
538            &BTreeMap::new(),
539        )
540        .unwrap();
541        assert_eq!(dom.name, "scrabcake-net-yah-dev");
542        assert_eq!(dom.domain, "scrabcake.net.yah.dev");
543        assert_eq!(dom.cdn_bucket, "net-yah-dev");
544        assert_eq!(dom.routes.len(), 1);
545        assert!(
546            matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
547        );
548    }
549
550    #[test]
551    fn claim_uses_com_tier_zone_and_bucket() {
552        let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
553        assert_eq!(dom.domain, "acme.com.yah.dev");
554        assert_eq!(dom.cdn_bucket, "com-yah-dev");
555    }
556
557    #[test]
558    fn claim_bails_on_duplicate_host() {
559        let existing: BTreeMap<String, DomainConfig> =
560            [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
561                .into_iter()
562                .collect();
563        let err =
564            plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
565        assert!(format!("{err:#}").contains("already"), "got: {err:#}");
566    }
567
568    #[test]
569    fn claim_bails_on_invalid_label() {
570        let err =
571            plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
572        assert!(
573            format!("{err:#}").contains("invalid subdomain"),
574            "got: {err:#}"
575        );
576    }
577}