Skip to main content

microsandbox_network/model/policy/
builder.rs

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