Skip to main content

microsandbox_network/model/config/
builder.rs

1//! Fluent builder API for [`NetworkConfig`].
2//!
3//! Used by `SandboxBuilder::network(|n| n.port(8080, 80).policy(...))`.
4
5use std::net::IpAddr;
6use std::path::PathBuf;
7use std::time::Duration;
8
9use ipnetwork::{Ipv4Network, Ipv6Network};
10use microsandbox_types::{
11    NetworkRateLimitDirection, NetworkRateLimiterConfig, RateLimiterConfig, ScopedUpstreamCaCert,
12    ScopedVerifyUpstream, TlsConfig, TokenBucketConfig,
13};
14use microsandbox_utils::size::Bytes;
15use zeroize::Zeroizing;
16
17use crate::config::{
18    ConnectionLimit, DnsConfig, InterfaceOverrides, NetworkConfig, PortProtocol, PublishedPort,
19};
20use crate::dns::Nameserver;
21use crate::policy::{BuildError, NetworkPolicy};
22use crate::secrets::config::{
23    HostPattern, SecretEntry, SecretSource, SecretSubstitution, SecretViolationAction,
24};
25
26//--------------------------------------------------------------------------------------------------
27// Types
28//--------------------------------------------------------------------------------------------------
29
30/// Fluent builder for [`NetworkConfig`].
31#[derive(Clone)]
32pub struct NetworkBuilder {
33    config: NetworkConfig,
34    errors: Vec<BuildError>,
35}
36
37/// Fluent builder for [`DnsConfig`].
38pub struct DnsBuilder {
39    config: DnsConfig,
40}
41
42/// Fluent builder for [`TlsConfig`].
43pub struct TlsBuilder {
44    config: TlsConfig,
45}
46
47/// Fluent builder for a single [`SecretEntry`].
48///
49/// ```ignore
50/// SecretBuilder::new()
51///     .env("OPENAI_API_KEY")
52///     .value(api_key)
53///     .allow("api.openai.com")
54///     .build()
55/// ```
56pub struct SecretBuilder {
57    env_var: Option<String>,
58    value: Option<String>,
59    source: Option<SecretSource>,
60    placeholder: Option<String>,
61    allowed_hosts: Vec<HostPattern>,
62    substitution: SecretSubstitution,
63    passthrough_hosts: Vec<HostPattern>,
64    violation_action: Option<SecretViolationAction>,
65    require_tls_identity: bool,
66}
67
68/// Fluent builder for both directions of a [`NetworkRateLimiterConfig`].
69///
70/// ```ignore
71/// .rate_limiter(|r| r
72///     .egress(|r| r.bandwidth(1.mib(), Duration::from_secs(1)))
73///     .ingress(|r| r.ops(1_000, Duration::from_secs(1)))
74/// )
75/// ```
76#[derive(Default)]
77pub struct NetworkRateLimiterBuilder {
78    config: NetworkRateLimiterConfig,
79    errors: Vec<BuildError>,
80}
81
82/// Fluent builder for one direction's [`RateLimiterConfig`].
83///
84/// ```ignore
85/// .egress(|r| r
86///     .bandwidth(1.mib(), Duration::from_secs(1))
87///     .bandwidth_burst(512.kib())
88///     .ops(1_000, Duration::from_secs(1))
89///     .ops_burst(500)
90/// )
91/// ```
92pub struct RateLimiterBuilder {
93    direction: NetworkRateLimitDirection,
94    bandwidth: Option<TokenBucketConfig>,
95    ops: Option<TokenBucketConfig>,
96    bandwidth_burst: Option<u64>,
97    ops_burst: Option<u64>,
98    /// First bucket whose refill interval cannot be represented on the wire.
99    refill_error: Option<(&'static str, RefillTimeError)>,
100}
101
102#[derive(Clone, Copy, Debug)]
103enum RefillTimeError {
104    TooShort,
105    Precision,
106    TooLong,
107}
108
109//--------------------------------------------------------------------------------------------------
110// Methods
111//--------------------------------------------------------------------------------------------------
112
113impl NetworkBuilder {
114    /// Start building a network configuration with defaults.
115    pub fn new() -> Self {
116        Self {
117            config: NetworkConfig::default(),
118            errors: Vec::new(),
119        }
120    }
121
122    /// Start building from an existing network configuration.
123    pub fn from_config(config: NetworkConfig) -> Self {
124        Self {
125            config,
126            errors: Vec::new(),
127        }
128    }
129
130    /// Enable or disable networking.
131    pub fn enabled(mut self, enabled: bool) -> Self {
132        self.config.enabled = enabled;
133        self
134    }
135
136    /// Publish a TCP port: `host_port` on the host maps to `guest_port` in the guest.
137    pub fn port(self, host_port: u16, guest_port: u16) -> Self {
138        self.port_bind(
139            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
140            host_port,
141            guest_port,
142        )
143    }
144
145    /// Publish a UDP port.
146    pub fn port_udp(self, host_port: u16, guest_port: u16) -> Self {
147        self.port_udp_bind(
148            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
149            host_port,
150            guest_port,
151        )
152    }
153
154    /// Publish a TCP port on a specific host bind address.
155    pub fn port_bind(self, host_bind: IpAddr, host_port: u16, guest_port: u16) -> Self {
156        self.add_port(host_bind, host_port, guest_port, PortProtocol::Tcp)
157    }
158
159    /// Publish a UDP port on a specific host bind address.
160    pub fn port_udp_bind(self, host_bind: IpAddr, host_port: u16, guest_port: u16) -> Self {
161        self.add_port(host_bind, host_port, guest_port, PortProtocol::Udp)
162    }
163
164    fn add_port(
165        mut self,
166        host_bind: IpAddr,
167        host_port: u16,
168        guest_port: u16,
169        protocol: PortProtocol,
170    ) -> Self {
171        self.config.ports.push(PublishedPort {
172            host_port,
173            guest_port,
174            protocol,
175            host_bind,
176        });
177        self
178    }
179
180    /// Set the network policy.
181    pub fn policy(mut self, policy: NetworkPolicy) -> Self {
182        self.config.policy = policy;
183        self
184    }
185
186    /// Configure DNS interception via a closure.
187    ///
188    /// ```ignore
189    /// .dns(|d| d
190    ///     .nameservers(["1.1.1.1".parse::<Nameserver>()?])
191    ///     .rebind_protection(false)
192    /// )
193    /// ```
194    pub fn dns(mut self, f: impl FnOnce(DnsBuilder) -> DnsBuilder) -> Self {
195        self.config.dns = f(DnsBuilder::new()).build();
196        self
197    }
198
199    /// Configure DNS starting from the current values instead of defaults.
200    #[doc(hidden)]
201    pub fn dns_overlay(mut self, f: impl FnOnce(DnsBuilder) -> DnsBuilder) -> Self {
202        self.config.dns = f(DnsBuilder::from_config(self.config.dns)).build();
203        self
204    }
205
206    /// Configure TLS interception via a closure.
207    pub fn tls(mut self, f: impl FnOnce(TlsBuilder) -> TlsBuilder) -> Self {
208        self.config.tls = f(TlsBuilder::new()).build();
209        self
210    }
211
212    /// Configure TLS interception starting from the current values instead of defaults.
213    #[doc(hidden)]
214    pub fn tls_overlay(mut self, f: impl FnOnce(TlsBuilder) -> TlsBuilder) -> Self {
215        self.config.tls = f(TlsBuilder::from_config(self.config.tls)).build();
216        self
217    }
218
219    /// Enable or disable strict hostname-policy enforcement.
220    pub fn strict(mut self, enabled: bool) -> Self {
221        self.config.strict = enabled;
222        self
223    }
224
225    /// Add a secret via a closure builder.
226    ///
227    /// ```ignore
228    /// .secret(|s| s
229    ///     .env("OPENAI_API_KEY")
230    ///     .value(api_key)
231    ///     .allow("api.openai.com")
232    /// )
233    /// ```
234    pub fn secret(self, f: impl FnOnce(SecretBuilder) -> SecretBuilder) -> Self {
235        self.secret_entry(f(SecretBuilder::new()).build())
236    }
237
238    /// Add a materialized secret entry.
239    pub fn secret_entry(mut self, entry: SecretEntry) -> Self {
240        self.config.secrets.secrets.push(entry);
241        self
242    }
243
244    /// Shorthand: add a secret with env var, value, placeholder, and allowed host.
245    pub fn secret_env(
246        mut self,
247        env_var: impl Into<String>,
248        value: impl Into<String>,
249        placeholder: impl Into<String>,
250        allowed_host: impl Into<String>,
251    ) -> Self {
252        self.config.secrets.secrets.push(SecretEntry {
253            env_var: env_var.into(),
254            value: Zeroizing::new(value.into()),
255            source: None,
256            placeholder: placeholder.into(),
257            allowed_hosts: vec![HostPattern::Exact(allowed_host.into())],
258            substitution: SecretSubstitution::default(),
259            passthrough_hosts: Vec::new(),
260            violation_action: None,
261            require_tls_identity: true,
262        });
263        self
264    }
265
266    /// Set the default action for blocked secret placeholders.
267    pub fn secret_violation_action(mut self, action: SecretViolationAction) -> Self {
268        self.config.secrets.violation_action = action;
269        self
270    }
271
272    /// Deprecated alias for [`Self::max_tcp_connections`].
273    #[deprecated(note = "use max_tcp_connections instead")]
274    pub fn max_connections(self, max: usize) -> Self {
275        self.max_tcp_connections(max)
276    }
277
278    /// Set the TCP connection cap; zero explicitly selects unlimited.
279    pub fn max_tcp_connections(mut self, max: usize) -> Self {
280        self.config.max_tcp_connections = Some(ConnectionLimit::from(max));
281        self
282    }
283
284    /// Set the UDP relay session limit; zero selects unlimited. Defaults to unlimited for single-tenant and 1024 for multi-tenant.
285    pub fn max_udp_connections(mut self, max: usize) -> Self {
286        self.config.max_udp_connections = Some(ConnectionLimit::from(max));
287        self
288    }
289
290    /// Set guest interface overrides.
291    pub fn interface(mut self, overrides: InterfaceOverrides) -> Self {
292        self.config.interface = overrides;
293        self
294    }
295
296    /// Set the IPv4 pool used to derive per-sandbox `/30` guest subnets.
297    ///
298    /// The default is `172.16.0.0/12`. Pools must be at least `/30`.
299    pub fn ipv4_pool(mut self, pool: Ipv4Network) -> Self {
300        if pool.prefix() > 30 {
301            self.errors.push(BuildError::InvalidIpv4Pool {
302                raw: pool.to_string(),
303            });
304        } else {
305            self.config.interface.ipv4_pool = Some(pool);
306        }
307        self
308    }
309
310    /// Set the IPv6 pool used to derive per-sandbox `/64` guest prefixes.
311    ///
312    /// The default is `fd42:6d73:62::/48`. Pools must be at least `/64`.
313    pub fn ipv6_pool(mut self, pool: Ipv6Network) -> Self {
314        if pool.prefix() > 64 {
315            self.errors.push(BuildError::InvalidIpv6Pool {
316                raw: pool.to_string(),
317            });
318        } else {
319            self.config.interface.ipv6_pool = Some(pool);
320        }
321        self
322    }
323
324    /// Whether to ship the host's trusted root CAs into the guest at
325    /// boot. Default: false. Opt in when running behind a corporate
326    /// TLS-inspecting proxy (Cloudflare Warp Zero Trust, Zscaler,
327    /// Netskope, ...) whose gateway CA is trusted on the host but
328    /// unknown to the guest's stock Mozilla bundle.
329    pub fn trust_host_cas(mut self, enabled: bool) -> Self {
330        self.config.trust_host_cas = enabled;
331        self
332    }
333
334    /// Configure egress and ingress traffic rate limits. Applies on the next
335    /// sandbox start.
336    ///
337    /// ```ignore
338    /// .rate_limiter(|r| r
339    ///     .egress(|r| r
340    ///         .bandwidth(1.mib(), Duration::from_secs(1))
341    ///         .ops(1_000, Duration::from_secs(1)))
342    /// )
343    /// ```
344    pub fn rate_limiter(
345        mut self,
346        f: impl FnOnce(NetworkRateLimiterBuilder) -> NetworkRateLimiterBuilder,
347    ) -> Self {
348        match f(NetworkRateLimiterBuilder::new()).build() {
349            Ok(limiter) => self.config.rate_limiter = Some(limiter),
350            Err(err) => self.errors.push(err),
351        }
352        self
353    }
354
355    /// Consume the builder and return the configuration.
356    ///
357    /// Surfaces the first [`BuildError`] accumulated by any nested
358    /// builder (currently [`DnsBuilder`]). Errors stored on the
359    /// network builder itself flow through here too.
360    pub fn build(mut self) -> Result<NetworkConfig, BuildError> {
361        if let Some(err) = self.errors.drain(..).next() {
362            return Err(err);
363        }
364        if self.config.tls.enabled
365            && (self.config.tls.intercept_ca.cert_path.is_some()
366                != self.config.tls.intercept_ca.key_path.is_some())
367        {
368            return Err(BuildError::IncompleteInterceptCaConfig);
369        }
370        self.config.secrets.validate()?;
371        Ok(self.config)
372    }
373}
374
375impl DnsBuilder {
376    /// Start building DNS configuration with defaults.
377    pub fn new() -> Self {
378        Self {
379            config: DnsConfig::default(),
380        }
381    }
382
383    fn from_config(config: DnsConfig) -> Self {
384        Self { config }
385    }
386
387    /// Enable or disable DNS rebinding protection. Default: true.
388    pub fn rebind_protection(mut self, enabled: bool) -> Self {
389        self.config.rebind_protection = enabled;
390        self
391    }
392
393    /// Set the upstream nameservers to forward queries to. When one or
394    /// more are set, the interceptor uses these instead of the
395    /// nameservers in the host's `/etc/resolv.conf`. Replaces any
396    /// previously-set nameservers. Each element is any type convertible
397    /// into [`Nameserver`] (`SocketAddr`, `IpAddr`, or a parsed
398    /// string via `"dns.google:53".parse::<Nameserver>()?`).
399    pub fn nameservers<I>(mut self, nameservers: I) -> Self
400    where
401        I: IntoIterator,
402        I::Item: Into<Nameserver>,
403    {
404        self.config.nameservers = nameservers.into_iter().map(Into::into).collect();
405        self
406    }
407
408    /// Set the per-DNS-query timeout in milliseconds. Default: 5000.
409    pub fn query_timeout_ms(mut self, ms: u64) -> Self {
410        self.config.query_timeout_ms = ms;
411        self
412    }
413
414    /// Consume the builder and return the configuration.
415    pub fn build(self) -> DnsConfig {
416        self.config
417    }
418}
419
420impl Default for DnsBuilder {
421    fn default() -> Self {
422        Self::new()
423    }
424}
425
426impl TlsBuilder {
427    /// Start building TLS configuration.
428    pub fn new() -> Self {
429        Self {
430            config: TlsConfig {
431                enabled: true,
432                ..TlsConfig::default()
433            },
434        }
435    }
436
437    fn from_config(config: TlsConfig) -> Self {
438        Self { config }
439    }
440
441    /// Enable or disable TLS interception while retaining the remaining TLS settings.
442    pub fn enabled(mut self, enabled: bool) -> Self {
443        self.config.enabled = enabled;
444        self
445    }
446
447    /// Add a domain to the bypass list (no MITM). Supports `*.suffix` wildcards.
448    pub fn bypass(mut self, pattern: impl Into<String>) -> Self {
449        self.config.bypass.push(pattern.into());
450        self
451    }
452
453    /// Enable or disable upstream server certificate verification.
454    pub fn verify_upstream(mut self, verify: bool) -> Self {
455        self.config.verify_upstream = verify;
456        self
457    }
458
459    /// Enable or disable upstream server certificate verification only
460    /// when the upstream SNI matches `pattern`.
461    ///
462    /// Pattern syntax matches [`Self::bypass`]: exact hosts and `*.suffix`
463    /// wildcards are supported.
464    pub fn verify_upstream_for(mut self, pattern: impl Into<String>, verify: bool) -> Self {
465        self.config
466            .scoped_verify_upstream
467            .push(ScopedVerifyUpstream {
468                pattern: pattern.into(),
469                verify,
470            });
471        self
472    }
473
474    /// Set the ports to intercept.
475    pub fn intercepted_ports(mut self, ports: Vec<u16>) -> Self {
476        self.config.intercepted_ports = ports;
477        self
478    }
479
480    /// Enable or disable QUIC blocking on intercepted ports.
481    pub fn block_quic(mut self, block: bool) -> Self {
482        self.config.block_quic_on_intercept = block;
483        self
484    }
485
486    /// Add a CA certificate PEM file to trust for upstream server verification.
487    ///
488    /// Useful when the upstream server uses a self-signed or private CA certificate.
489    /// Can be called multiple times to add several CAs.
490    pub fn upstream_ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
491        self.config.upstream_ca_cert.push(path.into());
492        self
493    }
494
495    /// Add a CA certificate PEM file to trust for upstream server verification
496    /// only when the upstream SNI matches `pattern`.
497    ///
498    /// Pattern syntax matches [`Self::bypass`]: exact hosts and `*.suffix`
499    /// wildcards are supported. Can be called multiple times to add several
500    /// CAs for the same host pattern.
501    pub fn upstream_ca_cert_for(
502        mut self,
503        pattern: impl Into<String>,
504        path: impl Into<PathBuf>,
505    ) -> Self {
506        self.config
507            .scoped_upstream_ca_cert
508            .push(ScopedUpstreamCaCert {
509                pattern: pattern.into(),
510                path: path.into(),
511            });
512        self
513    }
514
515    /// Set a custom interception CA certificate PEM file path.
516    pub fn intercept_ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
517        self.config.intercept_ca.cert_path = Some(path.into());
518        self
519    }
520
521    /// Set a custom interception CA private key PEM file path.
522    pub fn intercept_ca_key(mut self, path: impl Into<PathBuf>) -> Self {
523        self.config.intercept_ca.key_path = Some(path.into());
524        self
525    }
526
527    /// Consume the builder and return the configuration.
528    pub fn build(self) -> TlsConfig {
529        self.config
530    }
531}
532
533impl SecretBuilder {
534    /// Start building a secret.
535    pub fn new() -> Self {
536        Self {
537            env_var: None,
538            value: None,
539            source: None,
540            placeholder: None,
541            allowed_hosts: Vec::new(),
542            substitution: SecretSubstitution::default(),
543            passthrough_hosts: Vec::new(),
544            violation_action: None,
545            require_tls_identity: true,
546        }
547    }
548
549    /// Set the environment variable to expose the placeholder as (required).
550    ///
551    /// Names must be non-empty and must not contain `=` or NUL. They are
552    /// not restricted to shell-identifier syntax.
553    pub fn env(mut self, var: impl Into<String>) -> Self {
554        self.env_var = Some(var.into());
555        self
556    }
557
558    /// Set the secret value inline (mutually exclusive with [`source`](Self::source)).
559    ///
560    /// Prefer [`source`](Self::source) for durable configs: an inline value is
561    /// persisted verbatim in the sandbox spec, whereas a source reference is
562    /// resolved host-side at spawn time and never stored at rest.
563    pub fn value(mut self, value: impl Into<String>) -> Self {
564        self.value = Some(value.into());
565        self
566    }
567
568    /// Resolve the value from a host-side source reference at spawn time
569    /// (mutually exclusive with [`value`](Self::value)).
570    ///
571    /// The durable config records only the reference; the plaintext is read
572    /// from the host environment when the sandbox starts, so it never lands
573    /// in the database.
574    pub fn source(mut self, source: SecretSource) -> Self {
575        self.source = Some(source);
576        self
577    }
578
579    /// Set a custom placeholder string.
580    ///
581    /// Placeholders must be non-empty, at most 1024 bytes, and must not
582    /// contain NUL, CR, or LF.
583    /// If not set, auto-generated as `$MSB_<env_var>`.
584    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
585        self.placeholder = Some(placeholder.into());
586        self
587    }
588
589    /// Add a host allowed to receive the substituted secret value.
590    ///
591    /// `*.example.com` matches the domain and its subdomains. Use
592    /// [`allow_any_host_dangerous`](Self::allow_any_host_dangerous) for `*`.
593    pub fn allow(mut self, host: impl AsRef<str>) -> Self {
594        let host = host.as_ref();
595        assert!(
596            host != "*",
597            "SecretBuilder: use .allow_any_host_dangerous(true) for an explicit any-host secret"
598        );
599        self.allowed_hosts.push(HostPattern::parse(host));
600        self
601    }
602
603    /// Allow for any host. **Dangerous**: secret can be exfiltrated to any
604    /// destination. Requires explicit acknowledgment.
605    pub fn allow_any_host_dangerous(mut self, i_understand_the_risk: bool) -> Self {
606        if i_understand_the_risk {
607            self.allowed_hosts.push(HostPattern::Any);
608        }
609        self
610    }
611
612    /// Allow a host to receive the unchanged placeholder where substitution does not apply.
613    ///
614    /// Exact hosts, `*.example.com`, and `*` are accepted. Repeated calls are additive.
615    /// Enabled substitution locations still receive the real secret on allowed hosts.
616    pub fn allow_placeholder_for(mut self, host: impl AsRef<str>) -> Self {
617        self.passthrough_hosts
618            .push(HostPattern::parse(host.as_ref()));
619        self
620    }
621
622    /// Deprecated alias for [`allow_placeholder_for`](Self::allow_placeholder_for).
623    #[deprecated(note = "use allow_placeholder_for instead")]
624    pub fn allow_passthrough_for(self, host: impl AsRef<str>) -> Self {
625        self.allow_placeholder_for(host)
626    }
627
628    /// Set the blocking action for this secret.
629    pub fn violation_action(mut self, action: SecretViolationAction) -> Self {
630        self.violation_action = Some(action);
631        self
632    }
633
634    /// Require verified TLS identity before substituting (default: true).
635    pub fn require_tls_identity(mut self, enabled: bool) -> Self {
636        self.require_tls_identity = enabled;
637        self
638    }
639
640    /// Configure header substitution (default: true).
641    pub fn substitute_in_headers(mut self, enabled: bool) -> Self {
642        self.substitution.headers = enabled;
643        self
644    }
645
646    /// Configure query parameter substitution (default: false).
647    pub fn substitute_in_query(mut self, enabled: bool) -> Self {
648        self.substitution.query = enabled;
649        self
650    }
651
652    /// Configure HTTP/1 body substitution (default: false).
653    ///
654    /// Fixed-length bodies up to 16 MiB update `Content-Length`; larger
655    /// fixed-length bodies are blocked. Chunked bodies are decoded and
656    /// re-encoded with fresh chunk sizes. Encoded bodies pass through
657    /// unchanged.
658    pub fn substitute_in_body(mut self, enabled: bool) -> Self {
659        self.substitution.body = enabled;
660        self
661    }
662
663    /// Consume the builder and return a [`SecretEntry`].
664    ///
665    /// Exactly one of [`value`](Self::value) or [`source`](Self::source) must
666    /// be set. A source-backed entry carries an empty durable value; it is
667    /// resolved host-side at spawn time.
668    ///
669    /// # Panics
670    /// Panics if `env` or at least one allowed host was not set, or if neither
671    /// (or both) of `value`/`source` was set.
672    pub fn build(self) -> SecretEntry {
673        let env_var = self.env_var.expect("SecretBuilder: .env() is required");
674        assert!(
675            self.value.is_some() ^ self.source.is_some(),
676            "SecretBuilder: exactly one of .value() or .source() is required"
677        );
678        assert!(
679            !self.allowed_hosts.is_empty(),
680            "SecretBuilder: at least one allowed host is required; use .allow_any_host_dangerous(true) for an explicit any-host secret"
681        );
682        let placeholder = self
683            .placeholder
684            .unwrap_or_else(|| microsandbox_utils::secret::default_placeholder(&env_var));
685
686        SecretEntry {
687            env_var,
688            value: Zeroizing::new(self.value.unwrap_or_default()),
689            source: self.source,
690            placeholder,
691            allowed_hosts: self.allowed_hosts,
692            substitution: self.substitution,
693            passthrough_hosts: self.passthrough_hosts,
694            violation_action: self.violation_action,
695            require_tls_identity: self.require_tls_identity,
696        }
697    }
698}
699
700impl NetworkRateLimiterBuilder {
701    fn new() -> Self {
702        Self::default()
703    }
704
705    /// Limit guest-to-runtime (egress) traffic.
706    pub fn egress(mut self, f: impl FnOnce(RateLimiterBuilder) -> RateLimiterBuilder) -> Self {
707        match f(RateLimiterBuilder::new(NetworkRateLimitDirection::Egress)).build() {
708            Ok(limiter) => self.config.egress = Some(limiter),
709            Err(err) => self.errors.push(err),
710        }
711        self
712    }
713
714    /// Limit runtime-to-guest (ingress) traffic.
715    pub fn ingress(mut self, f: impl FnOnce(RateLimiterBuilder) -> RateLimiterBuilder) -> Self {
716        match f(RateLimiterBuilder::new(NetworkRateLimitDirection::Ingress)).build() {
717            Ok(limiter) => self.config.ingress = Some(limiter),
718            Err(err) => self.errors.push(err),
719        }
720        self
721    }
722
723    /// Consume the builder and return both configured directions.
724    pub fn build(mut self) -> Result<NetworkRateLimiterConfig, BuildError> {
725        if let Some(error) = self.errors.drain(..).next() {
726            return Err(error);
727        }
728        if self.config.egress.is_none() && self.config.ingress.is_none() {
729            return Err(BuildError::EmptyNetworkRateLimiter);
730        }
731        Ok(self.config)
732    }
733}
734
735impl RateLimiterBuilder {
736    fn new(direction: NetworkRateLimitDirection) -> Self {
737        Self {
738            direction,
739            bandwidth: None,
740            ops: None,
741            bandwidth_burst: None,
742            ops_burst: None,
743            refill_error: None,
744        }
745    }
746
747    /// Cap bandwidth at `size` bytes per `refill_time`.
748    ///
749    /// `refill_time` must be at least one millisecond and exactly representable
750    /// as a whole number of milliseconds.
751    ///
752    /// ```ignore
753    /// .bandwidth(1.mib(), Duration::from_secs(1))
754    /// ```
755    pub fn bandwidth(mut self, size: impl Into<Bytes>, refill_time: Duration) -> Self {
756        match refill_time_ms(refill_time) {
757            Ok(refill_time_ms) => {
758                self.bandwidth = Some(TokenBucketConfig {
759                    size: size.into().as_u64(),
760                    refill_time_ms,
761                    one_time_burst: 0,
762                });
763            }
764            Err(error) => {
765                self.refill_error.get_or_insert(("bandwidth", error));
766            }
767        }
768        self
769    }
770
771    /// Grant a one-time startup burst of `burst` bytes on top of the
772    /// bandwidth bucket. Requires [`bandwidth`](Self::bandwidth).
773    pub fn bandwidth_burst(mut self, burst: impl Into<Bytes>) -> Self {
774        self.bandwidth_burst = Some(burst.into().as_u64());
775        self
776    }
777
778    /// Cap packet rate at `count` frames per `refill_time`.
779    ///
780    /// `refill_time` must be at least one millisecond and exactly representable
781    /// as a whole number of milliseconds.
782    ///
783    /// ```ignore
784    /// .ops(1_000, Duration::from_secs(1))
785    /// ```
786    pub fn ops(mut self, count: u64, refill_time: Duration) -> Self {
787        match refill_time_ms(refill_time) {
788            Ok(refill_time_ms) => {
789                self.ops = Some(TokenBucketConfig {
790                    size: count,
791                    refill_time_ms,
792                    one_time_burst: 0,
793                });
794            }
795            Err(error) => {
796                self.refill_error.get_or_insert(("ops", error));
797            }
798        }
799        self
800    }
801
802    /// Grant a one-time startup burst of `count` frames on top of the ops
803    /// bucket. Requires [`ops`](Self::ops).
804    pub fn ops_burst(mut self, count: u64) -> Self {
805        self.ops_burst = Some(count);
806        self
807    }
808
809    /// Consume the builder and return the validated configuration.
810    pub fn build(self) -> Result<RateLimiterConfig, BuildError> {
811        let direction = self.direction;
812        if let Some((bucket, error)) = self.refill_error {
813            return Err(match error {
814                RefillTimeError::TooShort => {
815                    BuildError::RateLimitRefillTooShort { direction, bucket }
816                }
817                RefillTimeError::Precision => {
818                    BuildError::RateLimitRefillPrecision { direction, bucket }
819                }
820                RefillTimeError::TooLong => {
821                    BuildError::RateLimitRefillTooLong { direction, bucket }
822                }
823            });
824        }
825
826        let mut config = RateLimiterConfig {
827            bandwidth: self.bandwidth,
828            ops: self.ops,
829        };
830        if let Some(burst) = self.bandwidth_burst {
831            let Some(bandwidth) = &mut config.bandwidth else {
832                return Err(BuildError::RateLimitBurstWithoutBucket {
833                    direction,
834                    bucket: "bandwidth",
835                });
836            };
837            bandwidth.one_time_burst = burst;
838        }
839        if let Some(burst) = self.ops_burst {
840            let Some(ops) = &mut config.ops else {
841                return Err(BuildError::RateLimitBurstWithoutBucket {
842                    direction,
843                    bucket: "ops",
844                });
845            };
846            ops.one_time_burst = burst;
847        }
848
849        config
850            .validate()
851            .map_err(|source| BuildError::InvalidRateLimitConfig { direction, source })?;
852        Ok(config)
853    }
854}
855
856//--------------------------------------------------------------------------------------------------
857// Functions
858//--------------------------------------------------------------------------------------------------
859
860/// Convert a refill interval to its exact whole-millisecond wire value.
861fn refill_time_ms(refill_time: Duration) -> Result<u64, RefillTimeError> {
862    if refill_time < Duration::from_millis(1) {
863        return Err(RefillTimeError::TooShort);
864    }
865    let refill_time_ms =
866        u64::try_from(refill_time.as_millis()).map_err(|_| RefillTimeError::TooLong)?;
867    if !refill_time.subsec_nanos().is_multiple_of(1_000_000) {
868        return Err(RefillTimeError::Precision);
869    }
870    Ok(refill_time_ms)
871}
872
873//--------------------------------------------------------------------------------------------------
874// Trait Implementations
875//--------------------------------------------------------------------------------------------------
876
877impl Default for NetworkBuilder {
878    fn default() -> Self {
879        Self::new()
880    }
881}
882
883impl Default for TlsBuilder {
884    fn default() -> Self {
885        Self::new()
886    }
887}
888
889impl Default for SecretBuilder {
890    fn default() -> Self {
891        Self::new()
892    }
893}
894//--------------------------------------------------------------------------------------------------
895// Tests
896//--------------------------------------------------------------------------------------------------
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    #[test]
903    #[allow(deprecated)]
904    fn deprecated_tcp_builder_delegates_to_the_new_name() {
905        let canonical_last = NetworkBuilder::new()
906            .max_connections(0)
907            .max_tcp_connections(64)
908            .build()
909            .unwrap();
910        assert_eq!(
911            canonical_last.max_tcp_connections,
912            Some(ConnectionLimit::from(64))
913        );
914        let legacy_last = NetworkBuilder::new()
915            .max_tcp_connections(64)
916            .max_connections(0)
917            .build()
918            .unwrap();
919        assert_eq!(
920            legacy_last.max_tcp_connections,
921            Some(ConnectionLimit::Unlimited)
922        );
923        let config = NetworkBuilder::new()
924            .max_connections(0)
925            .max_udp_connections(7)
926            .build()
927            .unwrap();
928        assert_eq!(config.max_tcp_connections, Some(ConnectionLimit::Unlimited));
929        assert_eq!(config.max_udp_connections, Some(ConnectionLimit::from(7)));
930        assert!(
931            NetworkBuilder::new()
932                .max_tcp_connections(1)
933                .max_tcp_connections(2)
934                .build()
935                .is_ok()
936        );
937    }
938
939    /// Network builder happy path returns the config unchanged.
940    #[test]
941    fn network_builder_happy_path_returns_config() {
942        let cfg = NetworkBuilder::new()
943            .dns(|d| d.rebind_protection(false))
944            .build()
945            .unwrap();
946        assert!(!cfg.dns.rebind_protection);
947    }
948
949    #[test]
950    fn network_builder_preserves_explicit_large_caps() {
951        for limit in [10000, usize::MAX] {
952            let config = NetworkBuilder::new()
953                .max_tcp_connections(limit)
954                .build()
955                .unwrap();
956            assert_eq!(
957                config.max_tcp_connections,
958                Some(ConnectionLimit::from(limit))
959            );
960        }
961    }
962
963    #[test]
964    fn network_builder_rejects_incomplete_intercept_ca_config() {
965        let err = NetworkBuilder::new()
966            .tls(|t| t.intercept_ca_cert("/tmp/ca.crt"))
967            .build()
968            .unwrap_err();
969
970        assert!(matches!(err, BuildError::IncompleteInterceptCaConfig));
971    }
972
973    #[test]
974    fn port_bind_sets_host_bind() {
975        let bind = "0.0.0.0".parse().unwrap();
976        let cfg = NetworkBuilder::new()
977            .port_bind(bind, 8080, 80)
978            .port_udp_bind(bind, 5353, 53)
979            .build()
980            .unwrap();
981
982        assert_eq!(cfg.ports[0].host_bind, bind);
983        assert_eq!(cfg.ports[0].host_port, 8080);
984        assert_eq!(cfg.ports[0].guest_port, 80);
985        assert_eq!(cfg.ports[0].protocol, PortProtocol::Tcp);
986        assert_eq!(cfg.ports[1].host_bind, bind);
987        assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp);
988    }
989
990    #[test]
991    fn port_helpers_default_to_loopback() {
992        let cfg = NetworkBuilder::new()
993            .port(8080, 80)
994            .port_udp(5353, 53)
995            .build()
996            .unwrap();
997
998        assert_eq!(
999            cfg.ports[0].host_bind,
1000            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1001        );
1002        assert_eq!(cfg.ports[0].protocol, PortProtocol::Tcp);
1003        assert_eq!(
1004            cfg.ports[1].host_bind,
1005            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
1006        );
1007        assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp);
1008    }
1009
1010    #[test]
1011    fn outbound_proxy_defaults_to_none() {
1012        let cfg = NetworkBuilder::new().build().unwrap();
1013        assert_eq!(cfg.outbound_proxy, None);
1014    }
1015
1016    #[test]
1017    fn network_builder_sets_strict_mode() {
1018        let cfg = NetworkBuilder::new().strict(true).build().unwrap();
1019        assert!(cfg.strict);
1020    }
1021
1022    #[test]
1023    fn network_builder_sets_global_violation_action() {
1024        let cfg = NetworkBuilder::new()
1025            .secret_violation_action(SecretViolationAction::BlockAndTerminate)
1026            .build()
1027            .unwrap();
1028
1029        assert_eq!(
1030            cfg.secrets.violation_action,
1031            SecretViolationAction::BlockAndTerminate
1032        );
1033    }
1034
1035    #[test]
1036    #[allow(deprecated)] // Exercise the retained alias alongside the preferred name.
1037    fn secret_builder_sets_passthrough_and_violation_policies() {
1038        let secret = SecretBuilder::new()
1039            .env("TOKEN")
1040            .value("secret-value")
1041            .allow("api.github.com")
1042            .allow_placeholder_for("api.anthropic.com")
1043            .allow_passthrough_for("*.anthropic.com")
1044            .violation_action(SecretViolationAction::BlockAndTerminate)
1045            .build();
1046
1047        assert_eq!(
1048            secret.violation_action,
1049            Some(SecretViolationAction::BlockAndTerminate),
1050        );
1051        assert_eq!(
1052            secret.passthrough_hosts,
1053            vec![
1054                HostPattern::Exact("api.anthropic.com".into()),
1055                HostPattern::Wildcard("*.anthropic.com".into()),
1056            ],
1057        );
1058    }
1059
1060    #[test]
1061    #[should_panic(expected = "SecretBuilder: at least one allowed host is required")]
1062    fn secret_builder_rejects_empty_allowed_hosts() {
1063        let _ = SecretBuilder::new()
1064            .env("TOKEN")
1065            .value("secret-value")
1066            .build();
1067    }
1068
1069    #[test]
1070    fn secret_builder_source_yields_reference_and_empty_value() {
1071        let secret = SecretBuilder::new()
1072            .env("API_KEY")
1073            .source(SecretSource::Env {
1074                var: "HOST_API_KEY".into(),
1075            })
1076            .allow("api.example.com")
1077            .build();
1078
1079        assert!(secret.value.is_empty());
1080        assert_eq!(
1081            secret.source,
1082            Some(SecretSource::Env {
1083                var: "HOST_API_KEY".into()
1084            })
1085        );
1086
1087        // Serialized durable form carries the reference, not a value.
1088        let json = serde_json::to_string(&secret).unwrap();
1089        assert!(json.contains("\"var\":\"HOST_API_KEY\""));
1090    }
1091
1092    #[test]
1093    #[should_panic(expected = "exactly one of .value() or .source()")]
1094    fn secret_builder_rejects_both_value_and_source() {
1095        let _ = SecretBuilder::new()
1096            .env("API_KEY")
1097            .value("inline")
1098            .source(SecretSource::Env {
1099                var: "HOST_API_KEY".into(),
1100            })
1101            .allow("api.example.com")
1102            .build();
1103    }
1104
1105    #[test]
1106    fn network_builder_rejects_invalid_secret_config() {
1107        let err = NetworkBuilder::new()
1108            .secret_entry(SecretEntry {
1109                env_var: "API=KEY".into(),
1110                value: Zeroizing::new("secret-value".into()),
1111                source: None,
1112                placeholder: "$MSB_API_KEY".into(),
1113                allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
1114                substitution: SecretSubstitution::default(),
1115                passthrough_hosts: Vec::new(),
1116                violation_action: None,
1117                require_tls_identity: true,
1118            })
1119            .build()
1120            .unwrap_err();
1121
1122        assert!(err.to_string().contains("env_var must not contain `=`"));
1123    }
1124
1125    #[test]
1126    fn rate_limiter_builder_sets_buckets_and_bursts() {
1127        use microsandbox_utils::size::SizeExt;
1128
1129        let cfg = NetworkBuilder::new()
1130            .rate_limiter(|r| {
1131                r.egress(|r| {
1132                    r.bandwidth(1.mib(), Duration::from_secs(1))
1133                        .bandwidth_burst(512.kib())
1134                        .ops(1_000, Duration::from_secs(1))
1135                        .ops_burst(500)
1136                })
1137                .ingress(|r| r.bandwidth(2.mib(), Duration::from_millis(500)))
1138            })
1139            .build()
1140            .unwrap();
1141
1142        let rate_limiter = cfg.rate_limiter.unwrap();
1143        let egress = rate_limiter.egress.unwrap();
1144        let bandwidth = egress.bandwidth.unwrap();
1145        assert_eq!(bandwidth.size, 1024 * 1024);
1146        assert_eq!(bandwidth.refill_time_ms, 1000);
1147        assert_eq!(bandwidth.one_time_burst, 512 * 1024);
1148        let ops = egress.ops.unwrap();
1149        assert_eq!(ops.size, 1_000);
1150        assert_eq!(ops.refill_time_ms, 1000);
1151        assert_eq!(ops.one_time_burst, 500);
1152
1153        let ingress = rate_limiter.ingress.unwrap();
1154        assert_eq!(ingress.bandwidth.unwrap().refill_time_ms, 500);
1155        assert!(ingress.ops.is_none());
1156    }
1157
1158    #[test]
1159    fn rate_limiters_default_to_unlimited() {
1160        let cfg = NetworkBuilder::new().build().unwrap();
1161        assert!(cfg.rate_limiter.is_none());
1162    }
1163
1164    #[test]
1165    fn rate_limiter_builder_rejects_empty_limiter() {
1166        let err = NetworkBuilder::new()
1167            .rate_limiter(|r| r.egress(|r| r))
1168            .build()
1169            .unwrap_err();
1170        assert_eq!(
1171            err.to_string(),
1172            "egress rate limiter: rate limiter must configure at least one of bandwidth or ops"
1173        );
1174    }
1175
1176    #[test]
1177    fn network_rate_limiter_builder_rejects_missing_directions() {
1178        let err = NetworkBuilder::new()
1179            .rate_limiter(|r| r)
1180            .build()
1181            .unwrap_err();
1182        assert_eq!(
1183            err.to_string(),
1184            "rate limiter must configure at least one of egress or ingress"
1185        );
1186    }
1187
1188    #[test]
1189    fn rate_limiter_builder_rejects_zero_size_and_unrepresentable_refill() {
1190        let err = NetworkBuilder::new()
1191            .rate_limiter(|r| r.ingress(|r| r.bandwidth(0u64, Duration::from_secs(1))))
1192            .build()
1193            .unwrap_err();
1194        assert_eq!(
1195            err.to_string(),
1196            "ingress rate limiter: bandwidth bucket: size must be greater than zero"
1197        );
1198
1199        let err = NetworkBuilder::new()
1200            .rate_limiter(|r| r.egress(|r| r.ops(10, Duration::ZERO)))
1201            .build()
1202            .unwrap_err();
1203        assert_eq!(
1204            err.to_string(),
1205            "egress rate limiter: ops refill interval must be at least one millisecond"
1206        );
1207
1208        let err = NetworkBuilder::new()
1209            .rate_limiter(|r| r.egress(|r| r.ops(10, Duration::from_micros(1_500))))
1210            .build()
1211            .unwrap_err();
1212        assert_eq!(
1213            err.to_string(),
1214            "egress rate limiter: ops refill interval must be a whole number of milliseconds"
1215        );
1216    }
1217
1218    #[test]
1219    fn rate_limiter_builder_rejects_burst_without_bucket() {
1220        use microsandbox_utils::size::SizeExt;
1221
1222        let err = NetworkBuilder::new()
1223            .rate_limiter(|r| r.egress(|r| r.bandwidth_burst(512.kib())))
1224            .build()
1225            .unwrap_err();
1226        assert_eq!(
1227            err.to_string(),
1228            "egress rate limiter: bandwidth_burst requires the bandwidth bucket"
1229        );
1230
1231        let err = NetworkBuilder::new()
1232            .rate_limiter(|r| {
1233                r.ingress(|r| r.bandwidth(1.mib(), Duration::from_secs(1)).ops_burst(5))
1234            })
1235            .build()
1236            .unwrap_err();
1237        assert_eq!(
1238            err.to_string(),
1239            "ingress rate limiter: ops_burst requires the ops bucket"
1240        );
1241    }
1242
1243    #[test]
1244    fn rate_limiter_builder_rejects_refill_interval_overflow() {
1245        let err = NetworkBuilder::new()
1246            .rate_limiter(|r| r.egress(|r| r.ops(10, Duration::MAX)))
1247            .build()
1248            .unwrap_err();
1249        assert_eq!(
1250            err.to_string(),
1251            "egress rate limiter: ops refill interval overflows u64 milliseconds"
1252        );
1253    }
1254}