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