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