Skip to main content

microsandbox_network/model/policy/
builder.rs

1//! Fluent builder for [`NetworkPolicy`].
2//!
3//! Lets callers compose a policy via chained method calls inside
4//! rule-batch closures:
5//!
6//! ```ignore
7//! let policy = NetworkPolicy::builder()
8//!     .default_deny()
9//!     .egress(|e| e.tcp().port(443).allow_public().allow_private())
10//!     .rule(|r| r.any().deny().ip("198.51.100.5"))
11//!     .build()?;
12//! ```
13//!
14//! ## Lazy parse
15//!
16//! Methods that take string inputs (`.ip(&str)`, `.cidr(&str)`,
17//! `.domain(&str)`, `.domain_suffix(&str)`) **do not parse at the
18//! method call**. They store the raw input along with intent, returning
19//! a chain-friendly reference. At [`NetworkPolicyBuilder::build`] time,
20//! the builder walks the accumulated entries, parses each, validates
21//! invariants (direction set, ICMP-not-in-ingress, port range
22//! ordering), and surfaces the first failure as [`BuildError`].
23//!
24//! ## State accumulation
25//!
26//! Inside a `.rule(|r| ...)`, `.egress(|e| ...)`, `.ingress(|i| ...)`,
27//! or `.any(|a| ...)` closure, state setters (`.tcp()`, `.port(N)`,
28//! etc.) accumulate eagerly. Each rule-adder commits a rule using the
29//! current state. State is **not reset** between rule-adders — callers
30//! who want different state per rule use separate `.rule()` calls.
31
32use std::str::FromStr;
33
34use ipnetwork::IpNetwork;
35use microsandbox_types::{NetworkRateLimitDirection, RateLimitConfigError};
36
37use crate::secrets::config::SecretConfigError;
38
39use super::{
40    Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, NetworkPolicy,
41    PortRange, Protocol, Rule,
42};
43
44//--------------------------------------------------------------------------------------------------
45// Errors
46//--------------------------------------------------------------------------------------------------
47
48/// Errors surfaced by [`NetworkPolicyBuilder::build`] and the related
49/// nested builders ([`crate::config::DnsBuilder::build`],
50/// [`crate::config::NetworkBuilder::build`]).
51///
52/// All these builders accumulate errors lazily — string inputs are
53/// stored raw and only parsed at `.build()` time, where the first
54/// failure is returned. The same enum covers both rule-grammar
55/// failures (with a `rule_index`) and DNS-block-list failures (no
56/// rule index, since DNS blocks aren't rules).
57#[derive(Debug, Clone, thiserror::Error)]
58pub enum BuildError {
59    /// A rule was committed without setting a direction first.
60    #[error(
61        "rule #{rule_index}: direction not set; call .egress(), .ingress(), or .any() before the rule-adder"
62    )]
63    DirectionNotSet { rule_index: usize },
64
65    /// A rule was committed via `.allow()` / `.deny()` but no destination
66    /// method was called on the resulting `RuleDestinationBuilder`.
67    #[error(
68        "rule #{rule_index}: destination not set; call .ip(), .cidr(), .domain(), .domain_suffix(), .group(), or .any() on the rule-destination builder"
69    )]
70    MissingDestination { rule_index: usize },
71
72    /// `.ip(&str)` received a value that doesn't parse as an IPv4 or
73    /// IPv6 address.
74    #[error("rule #{rule_index}: invalid IP address `{raw}`")]
75    InvalidIp { rule_index: usize, raw: String },
76
77    /// `.cidr(&str)` received a value that doesn't parse as a CIDR.
78    #[error("rule #{rule_index}: invalid CIDR `{raw}`")]
79    InvalidCidr { rule_index: usize, raw: String },
80
81    /// The configured guest IPv4 pool cannot hold a `/30` sandbox subnet.
82    #[error("invalid IPv4 pool `{raw}`: prefix must be /30 or shorter")]
83    InvalidIpv4Pool { raw: String },
84
85    /// The configured guest IPv6 pool cannot hold a `/64` sandbox prefix.
86    #[error("invalid IPv6 pool `{raw}`: prefix must be /64 or shorter")]
87    InvalidIpv6Pool { raw: String },
88
89    /// An outbound proxy builder received an invalid configuration.
90    #[error("invalid outbound proxy: {reason}")]
91    InvalidOutboundProxy {
92        /// Protocol-specific builder error.
93        reason: String,
94    },
95
96    /// Exactly one TLS intercept CA path was configured.
97    #[error("intercept CA config is incomplete; set both cert_path and key_path")]
98    IncompleteInterceptCaConfig,
99
100    /// `.domain(&str)` or `.domain_suffix(&str)` received a value that
101    /// doesn't parse as a [`DomainName`].
102    #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
103    InvalidDomain {
104        rule_index: usize,
105        raw: String,
106        #[source]
107        source: DomainNameError,
108    },
109
110    /// `.port_range(lo, hi)` received `lo > hi`.
111    #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
112    InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
113
114    /// An ICMP protocol (`icmpv4` / `icmpv6`) appears in a rule whose
115    /// direction is `Ingress` or `Any`. `publisher.rs` has no inbound
116    /// ICMP path; ingress ICMP rules would be dead code.
117    #[error(
118        "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
119    )]
120    IngressDoesNotSupportIcmp { rule_index: usize },
121
122    /// A secret entry failed validation.
123    #[error("{source}")]
124    InvalidSecretConfig {
125        /// Underlying secret validation error.
126        #[from]
127        source: SecretConfigError,
128    },
129
130    /// A rate limiter failed validation.
131    #[error("{direction} rate limiter: {source}")]
132    InvalidRateLimitConfig {
133        /// Which limiter is invalid: `egress` or `ingress`.
134        direction: NetworkRateLimitDirection,
135        /// Underlying rate limit validation error.
136        #[source]
137        source: RateLimitConfigError,
138    },
139
140    /// A network rate limiter was configured without either direction.
141    #[error("rate limiter must configure at least one of egress or ingress")]
142    EmptyNetworkRateLimiter,
143
144    /// A one-time burst was set without its corresponding bucket.
145    #[error("{direction} rate limiter: {bucket}_burst requires the {bucket} bucket")]
146    RateLimitBurstWithoutBucket {
147        /// Which limiter is invalid: `egress` or `ingress`.
148        direction: NetworkRateLimitDirection,
149        /// The bucket the burst belongs to: `bandwidth` or `ops`.
150        bucket: &'static str,
151    },
152
153    /// A rate limiter refill interval is shorter than the wire format supports.
154    #[error("{direction} rate limiter: {bucket} refill interval must be at least one millisecond")]
155    RateLimitRefillTooShort {
156        /// Which limiter is invalid: `egress` or `ingress`.
157        direction: NetworkRateLimitDirection,
158        /// The bucket with the short interval: `bandwidth` or `ops`.
159        bucket: &'static str,
160    },
161
162    /// A rate limiter refill interval cannot be represented exactly in milliseconds.
163    #[error(
164        "{direction} rate limiter: {bucket} refill interval must be a whole number of milliseconds"
165    )]
166    RateLimitRefillPrecision {
167        /// Which limiter is invalid: `egress` or `ingress`.
168        direction: NetworkRateLimitDirection,
169        /// The bucket with the fractional-millisecond interval: `bandwidth` or `ops`.
170        bucket: &'static str,
171    },
172
173    /// A rate limiter refill interval does not fit in u64 milliseconds.
174    #[error("{direction} rate limiter: {bucket} refill interval overflows u64 milliseconds")]
175    RateLimitRefillTooLong {
176        /// Which limiter is invalid: `egress` or `ingress`.
177        direction: NetworkRateLimitDirection,
178        /// The bucket with the overflowing interval: `bandwidth` or `ops`.
179        bucket: &'static str,
180    },
181}
182
183//--------------------------------------------------------------------------------------------------
184// Top-level builder
185//--------------------------------------------------------------------------------------------------
186
187/// Fluent builder for [`NetworkPolicy`].
188///
189/// Construct via [`NetworkPolicy::builder`].
190#[derive(Debug, Default)]
191pub struct NetworkPolicyBuilder {
192    default_egress: Option<Action>,
193    default_ingress: Option<Action>,
194    pending_rules: Vec<PendingRule>,
195    errors: Vec<BuildError>,
196}
197
198impl NetworkPolicyBuilder {
199    /// Create an empty builder.
200    pub fn new() -> Self {
201        Self::default()
202    }
203
204    /// Set both `default_egress` and `default_ingress` to `Allow`.
205    pub fn default_allow(mut self) -> Self {
206        self.default_egress = Some(Action::Allow);
207        self.default_ingress = Some(Action::Allow);
208        self
209    }
210
211    /// Set both `default_egress` and `default_ingress` to `Deny`.
212    pub fn default_deny(mut self) -> Self {
213        self.default_egress = Some(Action::Deny);
214        self.default_ingress = Some(Action::Deny);
215        self
216    }
217
218    /// Per-direction override for the egress default action.
219    pub fn default_egress(mut self, action: Action) -> Self {
220        self.default_egress = Some(action);
221        self
222    }
223
224    /// Per-direction override for the ingress default action.
225    pub fn default_ingress(mut self, action: Action) -> Self {
226        self.default_ingress = Some(action);
227        self
228    }
229
230    /// Open a multi-rule batch closure. Direction must be set inside
231    /// via `.egress()`, `.ingress()`, or `.any()` before any rule-adder.
232    pub fn rule<F>(self, f: F) -> Self
233    where
234        F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
235    {
236        self.with_rule_builder(None, f)
237    }
238
239    /// Sugar for [`Self::rule`] with direction pre-set to `Egress`.
240    pub fn egress<F>(self, f: F) -> Self
241    where
242        F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
243    {
244        self.with_rule_builder(Some(Direction::Egress), f)
245    }
246
247    /// Sugar for [`Self::rule`] with direction pre-set to `Ingress`.
248    pub fn ingress<F>(self, f: F) -> Self
249    where
250        F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
251    {
252        self.with_rule_builder(Some(Direction::Ingress), f)
253    }
254
255    /// Sugar for [`Self::rule`] with direction pre-set to `Any`. Rules
256    /// committed inside apply in both directions.
257    pub fn any<F>(self, f: F) -> Self
258    where
259        F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
260    {
261        self.with_rule_builder(Some(Direction::Any), f)
262    }
263
264    fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
265    where
266        F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
267    {
268        let mut rb = RuleBuilder {
269            direction: initial_direction,
270            protocols: Vec::new(),
271            ports: Vec::new(),
272            pending_rules: Vec::new(),
273            errors: Vec::new(),
274        };
275        let _ = f(&mut rb);
276        self.pending_rules.append(&mut rb.pending_rules);
277        self.errors.append(&mut rb.errors);
278        self
279    }
280
281    /// Consume the builder and produce a [`NetworkPolicy`].
282    ///
283    /// Lazy-parses every `.ip()` / `.cidr()` / `.domain()` /
284    /// `.domain_suffix()` input, validates direction-set and
285    /// ICMP-egress-only invariants, and emits a `tracing::warn!` for
286    /// each shadowed rule pair detected.
287    ///
288    /// Returns the first [`BuildError`] encountered.
289    pub fn build(self) -> Result<NetworkPolicy, BuildError> {
290        if let Some(err) = self.errors.into_iter().next() {
291            return Err(err);
292        }
293
294        let mut rules = Vec::with_capacity(self.pending_rules.len());
295        for (idx, pending) in self.pending_rules.into_iter().enumerate() {
296            let direction = pending
297                .direction
298                .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
299            let destination = pending.destination.parse(idx)?;
300
301            if matches!(direction, Direction::Ingress | Direction::Any)
302                && pending
303                    .protocols
304                    .iter()
305                    .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
306            {
307                return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
308            }
309
310            rules.push(Rule {
311                direction,
312                destination,
313                protocols: pending.protocols,
314                ports: pending.ports,
315                action: pending.action,
316            });
317        }
318
319        warn_about_shadows(&rules);
320
321        Ok(NetworkPolicy {
322            default_egress: self.default_egress.unwrap_or_else(default_egress_default),
323            default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
324            rules,
325        })
326    }
327}
328
329/// Default for `default_egress` when neither
330/// [`NetworkPolicyBuilder::default_allow`] nor
331/// [`NetworkPolicyBuilder::default_deny`] is called.
332fn default_egress_default() -> Action {
333    Action::Deny
334}
335
336/// Default for `default_ingress` when neither
337/// [`NetworkPolicyBuilder::default_allow`] nor
338/// [`NetworkPolicyBuilder::default_deny`] is called.
339fn default_ingress_default() -> Action {
340    Action::Allow
341}
342
343//--------------------------------------------------------------------------------------------------
344// RuleBuilder
345//--------------------------------------------------------------------------------------------------
346
347/// Per-closure state and rule accumulator.
348///
349/// Lives only within a `.rule()` / `.egress()` / `.ingress()` /
350/// `.any()` closure; its accumulated rules and errors are drained into
351/// the parent [`NetworkPolicyBuilder`] when the closure returns.
352#[derive(Debug)]
353pub struct RuleBuilder {
354    direction: Option<Direction>,
355    protocols: Vec<Protocol>,
356    ports: Vec<PortRange>,
357    pending_rules: Vec<PendingRule>,
358    errors: Vec<BuildError>,
359}
360
361impl RuleBuilder {
362    // -- direction setters -------------------------------------------
363
364    /// Set direction to `Egress` for subsequent rule-adders. Last-write-wins.
365    pub fn egress(&mut self) -> &mut Self {
366        self.direction = Some(Direction::Egress);
367        self
368    }
369
370    /// Set direction to `Ingress` for subsequent rule-adders. Last-write-wins.
371    pub fn ingress(&mut self) -> &mut Self {
372        self.direction = Some(Direction::Ingress);
373        self
374    }
375
376    /// Set direction to `Any` for subsequent rule-adders.
377    /// Rules committed after this apply in both directions. Last-write-wins.
378    pub fn any(&mut self) -> &mut Self {
379        self.direction = Some(Direction::Any);
380        self
381    }
382
383    // -- protocol setters --------------------------------------------
384
385    /// Add `Tcp` to the protocols set (set semantics; duplicates dedupe).
386    pub fn tcp(&mut self) -> &mut Self {
387        self.add_protocol(Protocol::Tcp)
388    }
389
390    /// Add `Udp` to the protocols set.
391    pub fn udp(&mut self) -> &mut Self {
392        self.add_protocol(Protocol::Udp)
393    }
394
395    /// Add `Icmpv4` to the protocols set. Egress-only at build-time
396    /// (commits will record an [`BuildError::IngressDoesNotSupportIcmp`]
397    /// if direction is `Ingress` or `Any`).
398    pub fn icmpv4(&mut self) -> &mut Self {
399        self.add_protocol(Protocol::Icmpv4)
400    }
401
402    /// Add `Icmpv6` to the protocols set. Egress-only.
403    pub fn icmpv6(&mut self) -> &mut Self {
404        self.add_protocol(Protocol::Icmpv6)
405    }
406
407    fn add_protocol(&mut self, p: Protocol) -> &mut Self {
408        if !self.protocols.contains(&p) {
409            self.protocols.push(p);
410        }
411        self
412    }
413
414    // -- port setters ------------------------------------------------
415
416    /// Add a single port to the ports set.
417    pub fn port(&mut self, port: u16) -> &mut Self {
418        let pr = PortRange::single(port);
419        if !self.ports.contains(&pr) {
420            self.ports.push(pr);
421        }
422        self
423    }
424
425    /// Add an inclusive port range to the ports set. `lo > hi` records
426    /// a [`BuildError::InvalidPortRange`] for `.build()` to surface.
427    pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
428        if lo > hi {
429            self.errors.push(BuildError::InvalidPortRange {
430                rule_index: self.pending_rules.len(),
431                lo,
432                hi,
433            });
434            return self;
435        }
436        let pr = PortRange::range(lo, hi);
437        if !self.ports.contains(&pr) {
438            self.ports.push(pr);
439        }
440        self
441    }
442
443    /// Add multiple single ports to the ports set. Equivalent to calling
444    /// [`Self::port`] once per element; duplicates dedupe via set semantics.
445    pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
446        for p in ports {
447            self.port(p);
448        }
449        self
450    }
451
452    // -- atomic rule-adders (per-category shortcuts) -----------------
453
454    /// Allow the `Public` group: any IP not in another named category.
455    pub fn allow_public(&mut self) -> &mut Self {
456        self.commit_group(Action::Allow, DestinationGroup::Public)
457    }
458
459    /// Deny the `Public` group.
460    pub fn deny_public(&mut self) -> &mut Self {
461        self.commit_group(Action::Deny, DestinationGroup::Public)
462    }
463
464    /// Allow the `Private` group (RFC1918 + ULA + CGN).
465    pub fn allow_private(&mut self) -> &mut Self {
466        self.commit_group(Action::Allow, DestinationGroup::Private)
467    }
468
469    /// Deny the `Private` group.
470    pub fn deny_private(&mut self) -> &mut Self {
471        self.commit_group(Action::Deny, DestinationGroup::Private)
472    }
473
474    /// Allow the `Loopback` group: `127.0.0.0/8` and `::1` — the
475    /// **guest's own loopback interface, not the host machine**.
476    /// Standard loopback traffic inside the guest stays in the guest
477    /// kernel and never reaches this rule; it only fires for crafted
478    /// packets that route loopback destinations out through the
479    /// gateway (e.g. raw sockets bound to `eth0` with `dst=127.0.0.1`).
480    /// To reach a service on the host's localhost, use
481    /// [`Self::allow_host`] instead.
482    pub fn allow_loopback(&mut self) -> &mut Self {
483        self.commit_group(Action::Allow, DestinationGroup::Loopback)
484    }
485
486    /// Deny the `Loopback` group. Useful in `default_egress = Allow`
487    /// configurations to block crafted-packet leaks where a process
488    /// inside the guest binds a raw socket to `eth0` and writes a
489    /// packet with `dst=127.0.0.1` directly. The packet bypasses the
490    /// guest's routing table, smoltcp on the host parses the
491    /// destination, and the connection lands on the host's loopback.
492    /// `.deny_loopback()` blocks that vector.
493    pub fn deny_loopback(&mut self) -> &mut Self {
494        self.commit_group(Action::Deny, DestinationGroup::Loopback)
495    }
496
497    /// Allow the `LinkLocal` group (`169.254.0.0/16`, `fe80::/10`).
498    /// Excludes the metadata IP `169.254.169.254` (categorized as
499    /// `Metadata`).
500    pub fn allow_link_local(&mut self) -> &mut Self {
501        self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
502    }
503
504    /// Deny the `LinkLocal` group.
505    pub fn deny_link_local(&mut self) -> &mut Self {
506        self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
507    }
508
509    /// Allow the `Metadata` group (`169.254.169.254`). **Dangerous on
510    /// cloud hosts** — exposes IAM credentials.
511    pub fn allow_meta(&mut self) -> &mut Self {
512        self.commit_group(Action::Allow, DestinationGroup::Metadata)
513    }
514
515    /// Deny the `Metadata` group.
516    pub fn deny_meta(&mut self) -> &mut Self {
517        self.commit_group(Action::Deny, DestinationGroup::Metadata)
518    }
519
520    /// Allow the `Multicast` group (`224.0.0.0/4`, `ff00::/8`).
521    pub fn allow_multicast(&mut self) -> &mut Self {
522        self.commit_group(Action::Allow, DestinationGroup::Multicast)
523    }
524
525    /// Deny the `Multicast` group.
526    pub fn deny_multicast(&mut self) -> &mut Self {
527        self.commit_group(Action::Deny, DestinationGroup::Multicast)
528    }
529
530    /// Allow the `Host` group: per-sandbox gateway IPs that back
531    /// `host.microsandbox.internal`. This is the right shortcut for
532    /// "let the sandbox reach my host's localhost" — not
533    /// [`Self::allow_loopback`].
534    pub fn allow_host(&mut self) -> &mut Self {
535        self.commit_group(Action::Allow, DestinationGroup::Host)
536    }
537
538    /// Deny the `Host` group.
539    pub fn deny_host(&mut self) -> &mut Self {
540        self.commit_group(Action::Deny, DestinationGroup::Host)
541    }
542
543    // -- composite sugar --------------------------------------------
544
545    /// Allow `Loopback + LinkLocal + Host` — the three "near the
546    /// sandbox" groups a developer typically wants together when
547    /// running locally. Adds **three rules** atomically, each using
548    /// the closure's current state.
549    ///
550    /// **`Metadata` is explicitly NOT included** — even though
551    /// `169.254.169.254` falls inside the link-local CIDR by raw
552    /// address, the schema's `Metadata` carve-out is preserved here.
553    /// Users wanting cloud metadata access add [`Self::allow_meta`]
554    /// separately.
555    pub fn allow_local(&mut self) -> &mut Self {
556        self.allow_loopback();
557        self.allow_link_local();
558        self.allow_host();
559        self
560    }
561
562    /// Deny `Loopback + LinkLocal + Host` (no `Metadata`). See
563    /// [`Self::allow_local`] for the membership rationale.
564    pub fn deny_local(&mut self) -> &mut Self {
565        self.deny_loopback();
566        self.deny_link_local();
567        self.deny_host();
568        self
569    }
570
571    // -- bulk-domain shortcuts --------------------------------------
572
573    /// Allow each name as a `Destination::Domain` rule.
574    pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
575    where
576        I: IntoIterator<Item = S>,
577        S: Into<String>,
578    {
579        for name in names {
580            self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
581        }
582        self
583    }
584
585    /// Deny each name as a `Destination::Domain` rule.
586    pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
587    where
588        I: IntoIterator<Item = S>,
589        S: Into<String>,
590    {
591        for name in names {
592            self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
593        }
594        self
595    }
596
597    /// Allow each suffix as a `Destination::DomainSuffix` rule.
598    pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
599    where
600        I: IntoIterator<Item = S>,
601        S: Into<String>,
602    {
603        for suffix in suffixes {
604            self.commit_rule(
605                Action::Allow,
606                PendingDestination::DomainSuffix(suffix.into()),
607            );
608        }
609        self
610    }
611
612    /// Deny each suffix as a `Destination::DomainSuffix` rule.
613    pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
614    where
615        I: IntoIterator<Item = S>,
616        S: Into<String>,
617    {
618        for suffix in suffixes {
619            self.commit_rule(
620                Action::Deny,
621                PendingDestination::DomainSuffix(suffix.into()),
622            );
623        }
624        self
625    }
626
627    // -- explicit-rule entry ----------------------------------------
628
629    /// Begin an explicit-destination rule with action `Allow`. Returns
630    /// an [`RuleDestinationBuilder`] that requires a destination call
631    /// (`.ip`, `.cidr`, `.domain`, `.domain_suffix`, `.group`, `.any`)
632    /// to commit the rule.
633    pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
634        RuleDestinationBuilder {
635            rule_builder: self,
636            action: Action::Allow,
637        }
638    }
639
640    /// Begin an explicit-destination rule with action `Deny`.
641    pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
642        RuleDestinationBuilder {
643            rule_builder: self,
644            action: Action::Deny,
645        }
646    }
647
648    // -- internal commit helpers ------------------------------------
649
650    fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
651        self.commit_rule(
652            action,
653            PendingDestination::Resolved(Destination::Group(group)),
654        );
655        self
656    }
657
658    fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
659        self.pending_rules.push(PendingRule {
660            direction: self.direction,
661            destination,
662            protocols: self.protocols.clone(),
663            ports: self.ports.clone(),
664            action,
665        });
666    }
667}
668
669//--------------------------------------------------------------------------------------------------
670// RuleDestinationBuilder
671//--------------------------------------------------------------------------------------------------
672
673/// Returned by [`RuleBuilder::allow`] / [`RuleBuilder::deny`]. Requires
674/// exactly one destination method call to commit the rule.
675///
676/// Dropping without a destination call silently does nothing — no rule
677/// is added. The `#[must_use]` attribute warns at compile time.
678#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
679pub struct RuleDestinationBuilder<'a> {
680    rule_builder: &'a mut RuleBuilder,
681    action: Action,
682}
683
684impl<'a> RuleDestinationBuilder<'a> {
685    /// Commit the rule with destination `Ip(<addr>)`. The string is
686    /// stored raw and parsed at [`NetworkPolicyBuilder::build`] time;
687    /// invalid IPs surface as [`BuildError::InvalidIp`].
688    pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
689        self.rule_builder
690            .commit_rule(self.action, PendingDestination::Ip(ip.into()));
691        self.rule_builder
692    }
693
694    /// Commit the rule with destination `Cidr(<network>)`.
695    pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
696        self.rule_builder
697            .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
698        self.rule_builder
699    }
700
701    /// Commit the rule with destination `Domain(<name>)`. Matches only
702    /// when a cached hostname for the remote IP equals this name
703    /// (after canonicalization).
704    pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
705        self.rule_builder
706            .commit_rule(self.action, PendingDestination::Domain(domain.into()));
707        self.rule_builder
708    }
709
710    /// Commit the rule with destination `DomainSuffix(<name>)`. Matches
711    /// the apex domain itself and any subdomain.
712    pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
713        self.rule_builder
714            .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
715        self.rule_builder
716    }
717
718    /// Commit the rule with destination `Group(<group>)`.
719    pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
720        self.rule_builder.commit_rule(
721            self.action,
722            PendingDestination::Resolved(Destination::Group(group)),
723        );
724        self.rule_builder
725    }
726
727    /// Commit the rule with destination `Any` (matches every remote).
728    pub fn any(self) -> &'a mut RuleBuilder {
729        self.rule_builder
730            .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
731        self.rule_builder
732    }
733}
734
735//--------------------------------------------------------------------------------------------------
736// Pending data
737//--------------------------------------------------------------------------------------------------
738
739#[derive(Debug, Clone)]
740struct PendingRule {
741    direction: Option<Direction>,
742    destination: PendingDestination,
743    protocols: Vec<Protocol>,
744    ports: Vec<PortRange>,
745    action: Action,
746}
747
748#[derive(Debug, Clone)]
749enum PendingDestination {
750    /// Already a fully-formed `Destination` — nothing to parse later.
751    Resolved(Destination),
752    Ip(String),
753    Cidr(String),
754    Domain(String),
755    DomainSuffix(String),
756}
757
758impl PendingDestination {
759    fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
760        match self {
761            PendingDestination::Resolved(d) => Ok(d.clone()),
762            PendingDestination::Ip(raw) => {
763                let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
764                    rule_index: idx,
765                    raw: raw.clone(),
766                })?;
767                // Express a single IP as a /32 (v4) or /128 (v6) CIDR so
768                // it lives in `Destination::Cidr` alongside the rest.
769                let prefix = if ip.is_ipv4() { 32 } else { 128 };
770                let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
771                    rule_index: idx,
772                    raw: raw.clone(),
773                })?;
774                Ok(Destination::Cidr(net))
775            }
776            PendingDestination::Cidr(raw) => {
777                let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
778                    rule_index: idx,
779                    raw: raw.clone(),
780                })?;
781                Ok(Destination::Cidr(net))
782            }
783            PendingDestination::Domain(raw) => {
784                let name =
785                    DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
786                        rule_index: idx,
787                        raw: raw.clone(),
788                        source,
789                    })?;
790                Ok(Destination::Domain(name))
791            }
792            PendingDestination::DomainSuffix(raw) => {
793                let name =
794                    DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
795                        rule_index: idx,
796                        raw: raw.clone(),
797                        source,
798                    })?;
799                let name = name
800                    .try_into_suffix()
801                    .map_err(|source| BuildError::InvalidDomain {
802                        rule_index: idx,
803                        raw: raw.clone(),
804                        source,
805                    })?;
806                Ok(Destination::DomainSuffix(name))
807            }
808        }
809    }
810}
811
812//--------------------------------------------------------------------------------------------------
813// Shadow detection
814//--------------------------------------------------------------------------------------------------
815
816/// Walk the rules list and emit a `tracing::warn!` for each rule
817/// whose match set is fully contained in an earlier rule's match set
818/// in a compatible direction.
819///
820/// Coverage: `Ip` / `Cidr` / `Group` destinations only. `Domain` /
821/// `DomainSuffix` shadowing is out of scope (depends on the runtime
822/// DNS cache).
823fn warn_about_shadows(rules: &[Rule]) {
824    for (i, later) in rules.iter().enumerate() {
825        for (j, earlier) in rules.iter().take(i).enumerate() {
826            if shadows(earlier, later) {
827                tracing::warn!(
828                    shadowed_index = i,
829                    shadowed_by = j,
830                    "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
831                    later.direction,
832                    later.action,
833                    later.destination,
834                    earlier.direction,
835                    earlier.action,
836                    earlier.destination,
837                );
838            }
839        }
840    }
841}
842
843/// Returns `true` if `earlier`'s match set covers all of `later`'s,
844/// such that `later` will never fire when evaluated after `earlier`.
845fn shadows(earlier: &Rule, later: &Rule) -> bool {
846    direction_covers(earlier.direction, later.direction)
847        && destination_covers(&earlier.destination, &later.destination)
848        && protocol_set_covers(&earlier.protocols, &later.protocols)
849        && port_set_covers(&earlier.ports, &later.ports)
850}
851
852fn direction_covers(earlier: Direction, later: Direction) -> bool {
853    matches!(
854        (earlier, later),
855        (Direction::Any, _)
856            | (Direction::Egress, Direction::Egress)
857            | (Direction::Ingress, Direction::Ingress)
858    )
859}
860
861fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
862    match (earlier, later) {
863        (Destination::Any, _) => true,
864        (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
865        (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
866        // Domain shadowing is intentionally out of scope.
867        _ => false,
868    }
869}
870
871fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
872    match (outer, inner) {
873        (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
874        (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
875        _ => false,
876    }
877}
878
879fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
880    if earlier.is_empty() {
881        return true; // empty = any
882    }
883    if later.is_empty() {
884        return false; // later matches all, earlier doesn't
885    }
886    later.iter().all(|p| earlier.contains(p))
887}
888
889fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
890    if earlier.is_empty() {
891        return true;
892    }
893    if later.is_empty() {
894        return false;
895    }
896    later.iter().all(|lp| {
897        earlier
898            .iter()
899            .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
900    })
901}
902
903//--------------------------------------------------------------------------------------------------
904// NetworkPolicy::builder() entry
905//--------------------------------------------------------------------------------------------------
906
907impl NetworkPolicy {
908    /// Start building a [`NetworkPolicy`] via the fluent builder.
909    pub fn builder() -> NetworkPolicyBuilder {
910        NetworkPolicyBuilder::new()
911    }
912}
913
914//--------------------------------------------------------------------------------------------------
915// Tests
916//--------------------------------------------------------------------------------------------------
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    /// Empty builder produces today's asymmetric default
923    /// (`default_egress = Deny`, `default_ingress = Allow`, no rules).
924    #[test]
925    fn empty_builder_yields_asymmetric_default() {
926        let p = NetworkPolicy::builder().build().unwrap();
927        assert!(matches!(p.default_egress, Action::Deny));
928        assert!(matches!(p.default_ingress, Action::Allow));
929        assert!(p.rules.is_empty());
930    }
931
932    /// `.default_deny()` flips both directions to `Deny`; per-direction
933    /// override can re-flip one of them.
934    #[test]
935    fn defaults_set_and_override() {
936        let p = NetworkPolicy::builder()
937            .default_deny()
938            .default_ingress(Action::Allow)
939            .build()
940            .unwrap();
941        assert!(matches!(p.default_egress, Action::Deny));
942        assert!(matches!(p.default_ingress, Action::Allow));
943    }
944
945    /// Egress sub-builder commits one rule per category shortcut, with
946    /// shared direction + protocols + ports state.
947    #[test]
948    fn egress_closure_commits_one_rule_per_shortcut() {
949        let p = NetworkPolicy::builder()
950            .egress(|e| e.tcp().port(443).allow_public().allow_private())
951            .build()
952            .unwrap();
953        assert_eq!(p.rules.len(), 2);
954        assert!(matches!(p.rules[0].direction, Direction::Egress));
955        assert!(matches!(p.rules[0].action, Action::Allow));
956        assert!(matches!(
957            p.rules[0].destination,
958            Destination::Group(DestinationGroup::Public)
959        ));
960        assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
961        assert_eq!(p.rules[0].ports.len(), 1);
962        assert!(matches!(
963            p.rules[1].destination,
964            Destination::Group(DestinationGroup::Private)
965        ));
966    }
967
968    /// `.allow_local()` commits three rules: Loopback, LinkLocal, Host.
969    #[test]
970    fn allow_local_expands_to_three_groups() {
971        let p = NetworkPolicy::builder()
972            .egress(|e| e.allow_local())
973            .build()
974            .unwrap();
975        assert_eq!(p.rules.len(), 3);
976        let groups: Vec<_> = p
977            .rules
978            .iter()
979            .map(|r| match &r.destination {
980                Destination::Group(g) => *g,
981                other => panic!("unexpected destination {other:?}"),
982            })
983            .collect();
984        assert_eq!(
985            groups,
986            vec![
987                DestinationGroup::Loopback,
988                DestinationGroup::LinkLocal,
989                DestinationGroup::Host,
990            ]
991        );
992    }
993
994    /// Explicit-rule builder takes a string IP and surfaces a parsed
995    /// `Destination::Cidr(/32)` after `.build()`.
996    #[test]
997    fn explicit_ip_parses_at_build() {
998        let p = NetworkPolicy::builder()
999            .any(|a| a.deny().ip("198.51.100.5"))
1000            .build()
1001            .unwrap();
1002        assert_eq!(p.rules.len(), 1);
1003        assert!(matches!(p.rules[0].direction, Direction::Any));
1004        assert!(matches!(p.rules[0].action, Action::Deny));
1005        match &p.rules[0].destination {
1006            Destination::Cidr(net) => {
1007                assert_eq!(net.to_string(), "198.51.100.5/32");
1008            }
1009            other => panic!("expected Cidr, got {other:?}"),
1010        }
1011    }
1012
1013    /// Invalid IP string surfaces as `BuildError::InvalidIp` at
1014    /// `.build()` time, not at the method call.
1015    #[test]
1016    fn invalid_ip_surfaces_at_build() {
1017        let result = NetworkPolicy::builder()
1018            .egress(|e| e.allow().ip("not-an-ip"))
1019            .build();
1020        match result {
1021            Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
1022                assert_eq!(raw, "not-an-ip");
1023            }
1024            other => panic!("expected InvalidIp, got {other:?}"),
1025        }
1026    }
1027
1028    /// Domain string is parsed into a canonical `DomainName` at build time.
1029    #[test]
1030    fn domain_parses_to_canonical_form() {
1031        let p = NetworkPolicy::builder()
1032            .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
1033            .build()
1034            .unwrap();
1035        match &p.rules[0].destination {
1036            Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
1037            other => panic!("expected Domain, got {other:?}"),
1038        }
1039    }
1040
1041    /// `.port_range(hi, lo)` records `BuildError::InvalidPortRange`.
1042    #[test]
1043    fn invalid_port_range_surfaces_at_build() {
1044        let result = NetworkPolicy::builder()
1045            .egress(|e| e.tcp().port_range(443, 80).allow_public())
1046            .build();
1047        match result {
1048            Err(BuildError::InvalidPortRange {
1049                lo: 443, hi: 80, ..
1050            }) => {}
1051            other => panic!("expected InvalidPortRange, got {other:?}"),
1052        }
1053    }
1054
1055    /// Direction omitted entirely → DirectionNotSet at build time.
1056    #[test]
1057    fn missing_direction_surfaces_at_build() {
1058        let result = NetworkPolicy::builder()
1059            .rule(|r| r.tcp().port(443).allow_public())
1060            .build();
1061        match result {
1062            Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
1063            other => panic!("expected DirectionNotSet, got {other:?}"),
1064        }
1065    }
1066
1067    /// ICMP in an ingress-direction rule is rejected at build time.
1068    #[test]
1069    fn icmp_in_ingress_rejected_at_build() {
1070        let result = NetworkPolicy::builder()
1071            .ingress(|i| i.icmpv4().allow_public())
1072            .build();
1073        match result {
1074            Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1075            other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1076        }
1077    }
1078
1079    /// ICMP in an any-direction rule is also rejected.
1080    #[test]
1081    fn icmp_in_any_direction_rejected_at_build() {
1082        let result = NetworkPolicy::builder()
1083            .any(|a| a.icmpv6().allow_public())
1084            .build();
1085        match result {
1086            Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1087            other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1088        }
1089    }
1090
1091    /// Set semantics: duplicate `.tcp().tcp()` collapses to one entry.
1092    #[test]
1093    fn duplicate_protocols_dedupe() {
1094        let p = NetworkPolicy::builder()
1095            .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1096            .build()
1097            .unwrap();
1098        assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1099    }
1100
1101    /// Mixing the typed `Destination::Group` setter via `.group(...)`
1102    /// works for users who already have a `DestinationGroup` value.
1103    #[test]
1104    fn explicit_group_uses_typed_argument() {
1105        let p = NetworkPolicy::builder()
1106            .egress(|e| e.allow().group(DestinationGroup::Multicast))
1107            .build()
1108            .unwrap();
1109        assert!(matches!(
1110            p.rules[0].destination,
1111            Destination::Group(DestinationGroup::Multicast)
1112        ));
1113    }
1114
1115    /// The closure return type lets a chain ending in a rule-adder
1116    /// satisfy the `FnOnce(&mut RuleBuilder) -> &mut RuleBuilder` bound
1117    /// without an explicit `r` return.
1118    #[test]
1119    fn chain_form_compiles_without_explicit_return() {
1120        let _ = NetworkPolicy::builder()
1121            .rule(|r| r.egress().tcp().allow_public())
1122            .build()
1123            .unwrap();
1124    }
1125
1126    /// `shadows()`: a CIDR-narrower rule placed *after* a CIDR-broader
1127    /// rule with the same direction/action shape is shadowed.
1128    /// Building a shadowed policy succeeds (the warning is emitted via
1129    /// `tracing::warn!`, not an error).
1130    #[test]
1131    fn shadowed_rule_builds_and_is_detected() {
1132        let broader = Rule {
1133            direction: Direction::Egress,
1134            destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1135            protocols: vec![],
1136            ports: vec![],
1137            action: Action::Allow,
1138        };
1139        let narrower = Rule {
1140            direction: Direction::Egress,
1141            destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1142            protocols: vec![],
1143            ports: vec![],
1144            action: Action::Allow,
1145        };
1146        assert!(
1147            shadows(&broader, &narrower),
1148            "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1149        );
1150        assert!(
1151            !shadows(&narrower, &broader),
1152            "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1153        );
1154
1155        // Build still succeeds; shadow detection is observability, not
1156        // an error path.
1157        let _ = NetworkPolicy::builder()
1158            .egress(|e| e.allow().cidr("10.0.0.0/8"))
1159            .egress(|e| e.allow().cidr("10.0.0.5/32"))
1160            .build()
1161            .unwrap();
1162    }
1163
1164    /// `direction_covers`: `Any` covers every direction;
1165    /// `Egress`/`Ingress` only cover their own.
1166    #[test]
1167    fn direction_cover_relations() {
1168        use Direction::*;
1169        assert!(direction_covers(Any, Egress));
1170        assert!(direction_covers(Any, Ingress));
1171        assert!(direction_covers(Any, Any));
1172        assert!(direction_covers(Egress, Egress));
1173        assert!(!direction_covers(Egress, Ingress));
1174        assert!(!direction_covers(Egress, Any)); // Any has an ingress side Egress doesn't cover
1175        assert!(direction_covers(Ingress, Ingress));
1176        assert!(!direction_covers(Ingress, Egress));
1177        assert!(!direction_covers(Ingress, Any));
1178    }
1179
1180    //----------------------------------------------------------------------------------------------
1181    // Bulk-domain shortcuts
1182    //----------------------------------------------------------------------------------------------
1183
1184    /// `deny_domains` produces one deny-Domain rule per input name,
1185    /// inheriting the closure's direction, protocol, and port state.
1186    #[test]
1187    fn deny_domains_produces_one_rule_per_name() {
1188        let p = NetworkPolicy::builder()
1189            .default_allow()
1190            .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1191            .build()
1192            .unwrap();
1193        assert_eq!(p.rules.len(), 2);
1194        for rule in &p.rules {
1195            assert_eq!(rule.action, Action::Deny);
1196            assert_eq!(rule.direction, Direction::Egress);
1197            assert!(rule.protocols.is_empty(), "no protocol filter");
1198            assert!(rule.ports.is_empty(), "no port filter");
1199        }
1200        assert!(matches!(
1201            &p.rules[0].destination,
1202            Destination::Domain(d) if d.as_str() == "evil.com",
1203        ));
1204        assert!(matches!(
1205            &p.rules[1].destination,
1206            Destination::Domain(d) if d.as_str() == "tracker.example",
1207        ));
1208    }
1209
1210    /// `deny_domain_suffixes` mirrors `deny_domains` but produces
1211    /// `Destination::DomainSuffix` rules.
1212    #[test]
1213    fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1214        let p = NetworkPolicy::builder()
1215            .default_allow()
1216            .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1217            .build()
1218            .unwrap();
1219        assert_eq!(p.rules.len(), 2);
1220        assert!(matches!(
1221            &p.rules[0].destination,
1222            Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1223        ));
1224        assert!(matches!(
1225            &p.rules[1].destination,
1226            Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1227        ));
1228    }
1229
1230    /// Bulk shortcuts inherit the closure's protocol and port state, so
1231    /// users can narrow the bulk in the same call.
1232    #[test]
1233    fn deny_domains_inherits_protocol_and_port_filter() {
1234        let p = NetworkPolicy::builder()
1235            .default_allow()
1236            .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1237            .build()
1238            .unwrap();
1239        assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1240        assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1241    }
1242
1243    /// `allow_domains` symmetric with `deny_domains` — same shape,
1244    /// `Action::Allow`.
1245    #[test]
1246    fn allow_domains_produces_allow_rules() {
1247        let p = NetworkPolicy::builder()
1248            .default_deny()
1249            .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1250            .build()
1251            .unwrap();
1252        assert_eq!(p.rules.len(), 2);
1253        for rule in &p.rules {
1254            assert_eq!(rule.action, Action::Allow);
1255        }
1256    }
1257
1258    /// Empty input is a no-op — no rules pushed.
1259    #[test]
1260    fn deny_domains_empty_input_is_noop() {
1261        let p = NetworkPolicy::builder()
1262            .default_allow()
1263            .egress(|e| e.deny_domains(Vec::<&str>::new()))
1264            .build()
1265            .unwrap();
1266        assert!(p.rules.is_empty());
1267    }
1268
1269    /// Invalid names accumulate as `BuildError::InvalidDomain` and the
1270    /// FIRST one surfaces from `.build()`. Mirrors the per-rule
1271    /// `.domain(...)` lazy-parse contract.
1272    #[test]
1273    fn deny_domains_invalid_input_surfaces_at_build() {
1274        let result = NetworkPolicy::builder()
1275            .default_allow()
1276            .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1277            .build();
1278        match result {
1279            Err(BuildError::InvalidDomain {
1280                raw, rule_index, ..
1281            }) => {
1282                assert_eq!(raw, "not a domain!");
1283                // The valid evil.com is rule 0; the invalid one is
1284                // rule 1, which is what the parser reports.
1285                assert_eq!(rule_index, 1);
1286            }
1287            other => panic!("expected InvalidDomain, got {other:?}"),
1288        }
1289    }
1290}