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