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