Skip to main content

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