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 crate::secrets::config::SecretConfigError;
37
38use super::{
39    Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, NetworkPolicy,
40    PortRange, Protocol, Rule,
41};
42
43//--------------------------------------------------------------------------------------------------
44// Errors
45//--------------------------------------------------------------------------------------------------
46
47/// Errors surfaced by [`NetworkPolicyBuilder::build`] and the related
48/// nested builders ([`crate::builder::DnsBuilder::build`],
49/// [`crate::builder::NetworkBuilder::build`]).
50///
51/// All these builders accumulate errors lazily — string inputs are
52/// stored raw and only parsed at `.build()` time, where the first
53/// failure is returned. The same enum covers both rule-grammar
54/// failures (with a `rule_index`) and DNS-block-list failures (no
55/// rule index, since DNS blocks aren't rules).
56#[derive(Debug, Clone, thiserror::Error)]
57pub enum BuildError {
58    /// A rule was committed without setting a direction first.
59    #[error(
60        "rule #{rule_index}: direction not set; call .egress(), .ingress(), or .any() before the rule-adder"
61    )]
62    DirectionNotSet { rule_index: usize },
63
64    /// A rule was committed via `.allow()` / `.deny()` but no destination
65    /// method was called on the resulting `RuleDestinationBuilder`.
66    #[error(
67        "rule #{rule_index}: destination not set; call .ip(), .cidr(), .domain(), .domain_suffix(), .group(), or .any() on the rule-destination builder"
68    )]
69    MissingDestination { rule_index: usize },
70
71    /// `.ip(&str)` received a value that doesn't parse as an IPv4 or
72    /// IPv6 address.
73    #[error("rule #{rule_index}: invalid IP address `{raw}`")]
74    InvalidIp { rule_index: usize, raw: String },
75
76    /// `.cidr(&str)` received a value that doesn't parse as a CIDR.
77    #[error("rule #{rule_index}: invalid CIDR `{raw}`")]
78    InvalidCidr { rule_index: usize, raw: String },
79
80    /// The configured guest IPv4 pool cannot hold a `/30` sandbox subnet.
81    #[error("invalid IPv4 pool `{raw}`: prefix must be /30 or shorter")]
82    InvalidIpv4Pool { raw: String },
83
84    /// The configured guest IPv6 pool cannot hold a `/64` sandbox prefix.
85    #[error("invalid IPv6 pool `{raw}`: prefix must be /64 or shorter")]
86    InvalidIpv6Pool { raw: String },
87
88    /// `.domain(&str)` or `.domain_suffix(&str)` received a value that
89    /// doesn't parse as a [`DomainName`].
90    #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
91    InvalidDomain {
92        rule_index: usize,
93        raw: String,
94        #[source]
95        source: DomainNameError,
96    },
97
98    /// `.port_range(lo, hi)` received `lo > hi`.
99    #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
100    InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
101
102    /// An ICMP protocol (`icmpv4` / `icmpv6`) appears in a rule whose
103    /// direction is `Ingress` or `Any`. `publisher.rs` has no inbound
104    /// ICMP path; ingress ICMP rules would be dead code.
105    #[error(
106        "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
107    )]
108    IngressDoesNotSupportIcmp { rule_index: usize },
109
110    /// A secret entry failed validation.
111    #[error("{source}")]
112    InvalidSecretConfig {
113        /// Underlying secret validation error.
114        #[from]
115        source: SecretConfigError,
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    // -- bulk-domain shortcuts --------------------------------------
508
509    /// Allow each name as a `Destination::Domain` rule.
510    pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
511    where
512        I: IntoIterator<Item = S>,
513        S: Into<String>,
514    {
515        for name in names {
516            self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
517        }
518        self
519    }
520
521    /// Deny each name as a `Destination::Domain` rule.
522    pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
523    where
524        I: IntoIterator<Item = S>,
525        S: Into<String>,
526    {
527        for name in names {
528            self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
529        }
530        self
531    }
532
533    /// Allow each suffix as a `Destination::DomainSuffix` rule.
534    pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
535    where
536        I: IntoIterator<Item = S>,
537        S: Into<String>,
538    {
539        for suffix in suffixes {
540            self.commit_rule(
541                Action::Allow,
542                PendingDestination::DomainSuffix(suffix.into()),
543            );
544        }
545        self
546    }
547
548    /// Deny each suffix as a `Destination::DomainSuffix` rule.
549    pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
550    where
551        I: IntoIterator<Item = S>,
552        S: Into<String>,
553    {
554        for suffix in suffixes {
555            self.commit_rule(
556                Action::Deny,
557                PendingDestination::DomainSuffix(suffix.into()),
558            );
559        }
560        self
561    }
562
563    // -- explicit-rule entry ----------------------------------------
564
565    /// Begin an explicit-destination rule with action `Allow`. Returns
566    /// an [`RuleDestinationBuilder`] that requires a destination call
567    /// (`.ip`, `.cidr`, `.domain`, `.domain_suffix`, `.group`, `.any`)
568    /// to commit the rule.
569    pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
570        RuleDestinationBuilder {
571            rule_builder: self,
572            action: Action::Allow,
573        }
574    }
575
576    /// Begin an explicit-destination rule with action `Deny`.
577    pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
578        RuleDestinationBuilder {
579            rule_builder: self,
580            action: Action::Deny,
581        }
582    }
583
584    // -- internal commit helpers ------------------------------------
585
586    fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
587        self.commit_rule(
588            action,
589            PendingDestination::Resolved(Destination::Group(group)),
590        );
591        self
592    }
593
594    fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
595        self.pending_rules.push(PendingRule {
596            direction: self.direction,
597            destination,
598            protocols: self.protocols.clone(),
599            ports: self.ports.clone(),
600            action,
601        });
602    }
603}
604
605//--------------------------------------------------------------------------------------------------
606// RuleDestinationBuilder
607//--------------------------------------------------------------------------------------------------
608
609/// Returned by [`RuleBuilder::allow`] / [`RuleBuilder::deny`]. Requires
610/// exactly one destination method call to commit the rule.
611///
612/// Dropping without a destination call silently does nothing — no rule
613/// is added. The `#[must_use]` attribute warns at compile time.
614#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
615pub struct RuleDestinationBuilder<'a> {
616    rule_builder: &'a mut RuleBuilder,
617    action: Action,
618}
619
620impl<'a> RuleDestinationBuilder<'a> {
621    /// Commit the rule with destination `Ip(<addr>)`. The string is
622    /// stored raw and parsed at [`NetworkPolicyBuilder::build`] time;
623    /// invalid IPs surface as [`BuildError::InvalidIp`].
624    pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
625        self.rule_builder
626            .commit_rule(self.action, PendingDestination::Ip(ip.into()));
627        self.rule_builder
628    }
629
630    /// Commit the rule with destination `Cidr(<network>)`.
631    pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
632        self.rule_builder
633            .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
634        self.rule_builder
635    }
636
637    /// Commit the rule with destination `Domain(<name>)`. Matches only
638    /// when a cached hostname for the remote IP equals this name
639    /// (after canonicalization).
640    pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
641        self.rule_builder
642            .commit_rule(self.action, PendingDestination::Domain(domain.into()));
643        self.rule_builder
644    }
645
646    /// Commit the rule with destination `DomainSuffix(<name>)`. Matches
647    /// the apex domain itself and any subdomain.
648    pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
649        self.rule_builder
650            .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
651        self.rule_builder
652    }
653
654    /// Commit the rule with destination `Group(<group>)`.
655    pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
656        self.rule_builder.commit_rule(
657            self.action,
658            PendingDestination::Resolved(Destination::Group(group)),
659        );
660        self.rule_builder
661    }
662
663    /// Commit the rule with destination `Any` (matches every remote).
664    pub fn any(self) -> &'a mut RuleBuilder {
665        self.rule_builder
666            .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
667        self.rule_builder
668    }
669}
670
671//--------------------------------------------------------------------------------------------------
672// Pending data
673//--------------------------------------------------------------------------------------------------
674
675#[derive(Debug, Clone)]
676struct PendingRule {
677    direction: Option<Direction>,
678    destination: PendingDestination,
679    protocols: Vec<Protocol>,
680    ports: Vec<PortRange>,
681    action: Action,
682}
683
684#[derive(Debug, Clone)]
685enum PendingDestination {
686    /// Already a fully-formed `Destination` — nothing to parse later.
687    Resolved(Destination),
688    Ip(String),
689    Cidr(String),
690    Domain(String),
691    DomainSuffix(String),
692}
693
694impl PendingDestination {
695    fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
696        match self {
697            PendingDestination::Resolved(d) => Ok(d.clone()),
698            PendingDestination::Ip(raw) => {
699                let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
700                    rule_index: idx,
701                    raw: raw.clone(),
702                })?;
703                // Express a single IP as a /32 (v4) or /128 (v6) CIDR so
704                // it lives in `Destination::Cidr` alongside the rest.
705                let prefix = if ip.is_ipv4() { 32 } else { 128 };
706                let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
707                    rule_index: idx,
708                    raw: raw.clone(),
709                })?;
710                Ok(Destination::Cidr(net))
711            }
712            PendingDestination::Cidr(raw) => {
713                let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
714                    rule_index: idx,
715                    raw: raw.clone(),
716                })?;
717                Ok(Destination::Cidr(net))
718            }
719            PendingDestination::Domain(raw) => {
720                let name =
721                    DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
722                        rule_index: idx,
723                        raw: raw.clone(),
724                        source,
725                    })?;
726                Ok(Destination::Domain(name))
727            }
728            PendingDestination::DomainSuffix(raw) => {
729                let name =
730                    DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
731                        rule_index: idx,
732                        raw: raw.clone(),
733                        source,
734                    })?;
735                let name = name
736                    .try_into_suffix()
737                    .map_err(|source| BuildError::InvalidDomain {
738                        rule_index: idx,
739                        raw: raw.clone(),
740                        source,
741                    })?;
742                Ok(Destination::DomainSuffix(name))
743            }
744        }
745    }
746}
747
748//--------------------------------------------------------------------------------------------------
749// Shadow detection
750//--------------------------------------------------------------------------------------------------
751
752/// Walk the rules list and emit a `tracing::warn!` for each rule
753/// whose match set is fully contained in an earlier rule's match set
754/// in a compatible direction.
755///
756/// Coverage: `Ip` / `Cidr` / `Group` destinations only. `Domain` /
757/// `DomainSuffix` shadowing is out of scope (depends on the runtime
758/// DNS cache).
759fn warn_about_shadows(rules: &[Rule]) {
760    for (i, later) in rules.iter().enumerate() {
761        for (j, earlier) in rules.iter().take(i).enumerate() {
762            if shadows(earlier, later) {
763                tracing::warn!(
764                    shadowed_index = i,
765                    shadowed_by = j,
766                    "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
767                    later.direction,
768                    later.action,
769                    later.destination,
770                    earlier.direction,
771                    earlier.action,
772                    earlier.destination,
773                );
774            }
775        }
776    }
777}
778
779/// Returns `true` if `earlier`'s match set covers all of `later`'s,
780/// such that `later` will never fire when evaluated after `earlier`.
781fn shadows(earlier: &Rule, later: &Rule) -> bool {
782    direction_covers(earlier.direction, later.direction)
783        && destination_covers(&earlier.destination, &later.destination)
784        && protocol_set_covers(&earlier.protocols, &later.protocols)
785        && port_set_covers(&earlier.ports, &later.ports)
786}
787
788fn direction_covers(earlier: Direction, later: Direction) -> bool {
789    matches!(
790        (earlier, later),
791        (Direction::Any, _)
792            | (Direction::Egress, Direction::Egress)
793            | (Direction::Ingress, Direction::Ingress)
794    )
795}
796
797fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
798    match (earlier, later) {
799        (Destination::Any, _) => true,
800        (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
801        (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
802        // Domain shadowing is intentionally out of scope.
803        _ => false,
804    }
805}
806
807fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
808    match (outer, inner) {
809        (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
810        (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
811        _ => false,
812    }
813}
814
815fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
816    if earlier.is_empty() {
817        return true; // empty = any
818    }
819    if later.is_empty() {
820        return false; // later matches all, earlier doesn't
821    }
822    later.iter().all(|p| earlier.contains(p))
823}
824
825fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
826    if earlier.is_empty() {
827        return true;
828    }
829    if later.is_empty() {
830        return false;
831    }
832    later.iter().all(|lp| {
833        earlier
834            .iter()
835            .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
836    })
837}
838
839//--------------------------------------------------------------------------------------------------
840// NetworkPolicy::builder() entry
841//--------------------------------------------------------------------------------------------------
842
843impl NetworkPolicy {
844    /// Start building a [`NetworkPolicy`] via the fluent builder.
845    pub fn builder() -> NetworkPolicyBuilder {
846        NetworkPolicyBuilder::new()
847    }
848}
849
850//--------------------------------------------------------------------------------------------------
851// Tests
852//--------------------------------------------------------------------------------------------------
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    /// Empty builder produces today's asymmetric default
859    /// (`default_egress = Deny`, `default_ingress = Allow`, no rules).
860    #[test]
861    fn empty_builder_yields_asymmetric_default() {
862        let p = NetworkPolicy::builder().build().unwrap();
863        assert!(matches!(p.default_egress, Action::Deny));
864        assert!(matches!(p.default_ingress, Action::Allow));
865        assert!(p.rules.is_empty());
866    }
867
868    /// `.default_deny()` flips both directions to `Deny`; per-direction
869    /// override can re-flip one of them.
870    #[test]
871    fn defaults_set_and_override() {
872        let p = NetworkPolicy::builder()
873            .default_deny()
874            .default_ingress(Action::Allow)
875            .build()
876            .unwrap();
877        assert!(matches!(p.default_egress, Action::Deny));
878        assert!(matches!(p.default_ingress, Action::Allow));
879    }
880
881    /// Egress sub-builder commits one rule per category shortcut, with
882    /// shared direction + protocols + ports state.
883    #[test]
884    fn egress_closure_commits_one_rule_per_shortcut() {
885        let p = NetworkPolicy::builder()
886            .egress(|e| e.tcp().port(443).allow_public().allow_private())
887            .build()
888            .unwrap();
889        assert_eq!(p.rules.len(), 2);
890        assert!(matches!(p.rules[0].direction, Direction::Egress));
891        assert!(matches!(p.rules[0].action, Action::Allow));
892        assert!(matches!(
893            p.rules[0].destination,
894            Destination::Group(DestinationGroup::Public)
895        ));
896        assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
897        assert_eq!(p.rules[0].ports.len(), 1);
898        assert!(matches!(
899            p.rules[1].destination,
900            Destination::Group(DestinationGroup::Private)
901        ));
902    }
903
904    /// `.allow_local()` commits three rules: Loopback, LinkLocal, Host.
905    #[test]
906    fn allow_local_expands_to_three_groups() {
907        let p = NetworkPolicy::builder()
908            .egress(|e| e.allow_local())
909            .build()
910            .unwrap();
911        assert_eq!(p.rules.len(), 3);
912        let groups: Vec<_> = p
913            .rules
914            .iter()
915            .map(|r| match &r.destination {
916                Destination::Group(g) => *g,
917                other => panic!("unexpected destination {other:?}"),
918            })
919            .collect();
920        assert_eq!(
921            groups,
922            vec![
923                DestinationGroup::Loopback,
924                DestinationGroup::LinkLocal,
925                DestinationGroup::Host,
926            ]
927        );
928    }
929
930    /// Explicit-rule builder takes a string IP and surfaces a parsed
931    /// `Destination::Cidr(/32)` after `.build()`.
932    #[test]
933    fn explicit_ip_parses_at_build() {
934        let p = NetworkPolicy::builder()
935            .any(|a| a.deny().ip("198.51.100.5"))
936            .build()
937            .unwrap();
938        assert_eq!(p.rules.len(), 1);
939        assert!(matches!(p.rules[0].direction, Direction::Any));
940        assert!(matches!(p.rules[0].action, Action::Deny));
941        match &p.rules[0].destination {
942            Destination::Cidr(net) => {
943                assert_eq!(net.to_string(), "198.51.100.5/32");
944            }
945            other => panic!("expected Cidr, got {other:?}"),
946        }
947    }
948
949    /// Invalid IP string surfaces as `BuildError::InvalidIp` at
950    /// `.build()` time, not at the method call.
951    #[test]
952    fn invalid_ip_surfaces_at_build() {
953        let result = NetworkPolicy::builder()
954            .egress(|e| e.allow().ip("not-an-ip"))
955            .build();
956        match result {
957            Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
958                assert_eq!(raw, "not-an-ip");
959            }
960            other => panic!("expected InvalidIp, got {other:?}"),
961        }
962    }
963
964    /// Domain string is parsed into a canonical `DomainName` at build time.
965    #[test]
966    fn domain_parses_to_canonical_form() {
967        let p = NetworkPolicy::builder()
968            .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
969            .build()
970            .unwrap();
971        match &p.rules[0].destination {
972            Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
973            other => panic!("expected Domain, got {other:?}"),
974        }
975    }
976
977    /// `.port_range(hi, lo)` records `BuildError::InvalidPortRange`.
978    #[test]
979    fn invalid_port_range_surfaces_at_build() {
980        let result = NetworkPolicy::builder()
981            .egress(|e| e.tcp().port_range(443, 80).allow_public())
982            .build();
983        match result {
984            Err(BuildError::InvalidPortRange {
985                lo: 443, hi: 80, ..
986            }) => {}
987            other => panic!("expected InvalidPortRange, got {other:?}"),
988        }
989    }
990
991    /// Direction omitted entirely → DirectionNotSet at build time.
992    #[test]
993    fn missing_direction_surfaces_at_build() {
994        let result = NetworkPolicy::builder()
995            .rule(|r| r.tcp().port(443).allow_public())
996            .build();
997        match result {
998            Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
999            other => panic!("expected DirectionNotSet, got {other:?}"),
1000        }
1001    }
1002
1003    /// ICMP in an ingress-direction rule is rejected at build time.
1004    #[test]
1005    fn icmp_in_ingress_rejected_at_build() {
1006        let result = NetworkPolicy::builder()
1007            .ingress(|i| i.icmpv4().allow_public())
1008            .build();
1009        match result {
1010            Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1011            other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1012        }
1013    }
1014
1015    /// ICMP in an any-direction rule is also rejected.
1016    #[test]
1017    fn icmp_in_any_direction_rejected_at_build() {
1018        let result = NetworkPolicy::builder()
1019            .any(|a| a.icmpv6().allow_public())
1020            .build();
1021        match result {
1022            Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1023            other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1024        }
1025    }
1026
1027    /// Set semantics: duplicate `.tcp().tcp()` collapses to one entry.
1028    #[test]
1029    fn duplicate_protocols_dedupe() {
1030        let p = NetworkPolicy::builder()
1031            .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1032            .build()
1033            .unwrap();
1034        assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1035    }
1036
1037    /// Mixing the typed `Destination::Group` setter via `.group(...)`
1038    /// works for users who already have a `DestinationGroup` value.
1039    #[test]
1040    fn explicit_group_uses_typed_argument() {
1041        let p = NetworkPolicy::builder()
1042            .egress(|e| e.allow().group(DestinationGroup::Multicast))
1043            .build()
1044            .unwrap();
1045        assert!(matches!(
1046            p.rules[0].destination,
1047            Destination::Group(DestinationGroup::Multicast)
1048        ));
1049    }
1050
1051    /// The closure return type lets a chain ending in a rule-adder
1052    /// satisfy the `FnOnce(&mut RuleBuilder) -> &mut RuleBuilder` bound
1053    /// without an explicit `r` return.
1054    #[test]
1055    fn chain_form_compiles_without_explicit_return() {
1056        let _ = NetworkPolicy::builder()
1057            .rule(|r| r.egress().tcp().allow_public())
1058            .build()
1059            .unwrap();
1060    }
1061
1062    /// `shadows()`: a CIDR-narrower rule placed *after* a CIDR-broader
1063    /// rule with the same direction/action shape is shadowed.
1064    /// Building a shadowed policy succeeds (the warning is emitted via
1065    /// `tracing::warn!`, not an error).
1066    #[test]
1067    fn shadowed_rule_builds_and_is_detected() {
1068        let broader = Rule {
1069            direction: Direction::Egress,
1070            destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1071            protocols: vec![],
1072            ports: vec![],
1073            action: Action::Allow,
1074        };
1075        let narrower = Rule {
1076            direction: Direction::Egress,
1077            destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1078            protocols: vec![],
1079            ports: vec![],
1080            action: Action::Allow,
1081        };
1082        assert!(
1083            shadows(&broader, &narrower),
1084            "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1085        );
1086        assert!(
1087            !shadows(&narrower, &broader),
1088            "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1089        );
1090
1091        // Build still succeeds; shadow detection is observability, not
1092        // an error path.
1093        let _ = NetworkPolicy::builder()
1094            .egress(|e| e.allow().cidr("10.0.0.0/8"))
1095            .egress(|e| e.allow().cidr("10.0.0.5/32"))
1096            .build()
1097            .unwrap();
1098    }
1099
1100    /// `direction_covers`: `Any` covers every direction;
1101    /// `Egress`/`Ingress` only cover their own.
1102    #[test]
1103    fn direction_cover_relations() {
1104        use Direction::*;
1105        assert!(direction_covers(Any, Egress));
1106        assert!(direction_covers(Any, Ingress));
1107        assert!(direction_covers(Any, Any));
1108        assert!(direction_covers(Egress, Egress));
1109        assert!(!direction_covers(Egress, Ingress));
1110        assert!(!direction_covers(Egress, Any)); // Any has an ingress side Egress doesn't cover
1111        assert!(direction_covers(Ingress, Ingress));
1112        assert!(!direction_covers(Ingress, Egress));
1113        assert!(!direction_covers(Ingress, Any));
1114    }
1115
1116    //----------------------------------------------------------------------------------------------
1117    // Bulk-domain shortcuts
1118    //----------------------------------------------------------------------------------------------
1119
1120    /// `deny_domains` produces one deny-Domain rule per input name,
1121    /// inheriting the closure's direction, protocol, and port state.
1122    #[test]
1123    fn deny_domains_produces_one_rule_per_name() {
1124        let p = NetworkPolicy::builder()
1125            .default_allow()
1126            .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1127            .build()
1128            .unwrap();
1129        assert_eq!(p.rules.len(), 2);
1130        for rule in &p.rules {
1131            assert_eq!(rule.action, Action::Deny);
1132            assert_eq!(rule.direction, Direction::Egress);
1133            assert!(rule.protocols.is_empty(), "no protocol filter");
1134            assert!(rule.ports.is_empty(), "no port filter");
1135        }
1136        assert!(matches!(
1137            &p.rules[0].destination,
1138            Destination::Domain(d) if d.as_str() == "evil.com",
1139        ));
1140        assert!(matches!(
1141            &p.rules[1].destination,
1142            Destination::Domain(d) if d.as_str() == "tracker.example",
1143        ));
1144    }
1145
1146    /// `deny_domain_suffixes` mirrors `deny_domains` but produces
1147    /// `Destination::DomainSuffix` rules.
1148    #[test]
1149    fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1150        let p = NetworkPolicy::builder()
1151            .default_allow()
1152            .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1153            .build()
1154            .unwrap();
1155        assert_eq!(p.rules.len(), 2);
1156        assert!(matches!(
1157            &p.rules[0].destination,
1158            Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1159        ));
1160        assert!(matches!(
1161            &p.rules[1].destination,
1162            Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1163        ));
1164    }
1165
1166    /// Bulk shortcuts inherit the closure's protocol and port state, so
1167    /// users can narrow the bulk in the same call.
1168    #[test]
1169    fn deny_domains_inherits_protocol_and_port_filter() {
1170        let p = NetworkPolicy::builder()
1171            .default_allow()
1172            .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1173            .build()
1174            .unwrap();
1175        assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1176        assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1177    }
1178
1179    /// `allow_domains` symmetric with `deny_domains` — same shape,
1180    /// `Action::Allow`.
1181    #[test]
1182    fn allow_domains_produces_allow_rules() {
1183        let p = NetworkPolicy::builder()
1184            .default_deny()
1185            .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1186            .build()
1187            .unwrap();
1188        assert_eq!(p.rules.len(), 2);
1189        for rule in &p.rules {
1190            assert_eq!(rule.action, Action::Allow);
1191        }
1192    }
1193
1194    /// Empty input is a no-op — no rules pushed.
1195    #[test]
1196    fn deny_domains_empty_input_is_noop() {
1197        let p = NetworkPolicy::builder()
1198            .default_allow()
1199            .egress(|e| e.deny_domains(Vec::<&str>::new()))
1200            .build()
1201            .unwrap();
1202        assert!(p.rules.is_empty());
1203    }
1204
1205    /// Invalid names accumulate as `BuildError::InvalidDomain` and the
1206    /// FIRST one surfaces from `.build()`. Mirrors the per-rule
1207    /// `.domain(...)` lazy-parse contract.
1208    #[test]
1209    fn deny_domains_invalid_input_surfaces_at_build() {
1210        let result = NetworkPolicy::builder()
1211            .default_allow()
1212            .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1213            .build();
1214        match result {
1215            Err(BuildError::InvalidDomain {
1216                raw, rule_index, ..
1217            }) => {
1218                assert_eq!(raw, "not a domain!");
1219                // The valid evil.com is rule 0; the invalid one is
1220                // rule 1, which is what the parser reports.
1221                assert_eq!(rule_index, 1);
1222            }
1223            other => panic!("expected InvalidDomain, got {other:?}"),
1224        }
1225    }
1226}