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 destination to receive this placeholder without substitution.
613    ///
614    /// Exact hosts, `*.example.com`, and `*` are accepted. Repeated calls are additive.
615    pub fn allow_passthrough_for(mut self, host: impl AsRef<str>) -> Self {
616        self.passthrough_hosts
617            .push(HostPattern::parse(host.as_ref()));
618        self
619    }
620
621    /// Set the blocking action for this secret.
622    pub fn violation_action(mut self, action: SecretViolationAction) -> Self {
623        self.violation_action = Some(action);
624        self
625    }
626
627    /// Require verified TLS identity before substituting (default: true).
628    pub fn require_tls_identity(mut self, enabled: bool) -> Self {
629        self.require_tls_identity = enabled;
630        self
631    }
632
633    /// Configure header substitution (default: true).
634    pub fn substitute_in_headers(mut self, enabled: bool) -> Self {
635        self.substitution.headers = enabled;
636        self
637    }
638
639    /// Configure query parameter substitution (default: false).
640    pub fn substitute_in_query(mut self, enabled: bool) -> Self {
641        self.substitution.query = enabled;
642        self
643    }
644
645    /// Configure HTTP/1 body substitution (default: false).
646    ///
647    /// Fixed-length bodies up to 16 MiB update `Content-Length`; larger
648    /// fixed-length bodies are blocked. Chunked bodies are decoded and
649    /// re-encoded with fresh chunk sizes. Encoded bodies pass through
650    /// unchanged.
651    pub fn substitute_in_body(mut self, enabled: bool) -> Self {
652        self.substitution.body = enabled;
653        self
654    }
655
656    /// Consume the builder and return a [`SecretEntry`].
657    ///
658    /// Exactly one of [`value`](Self::value) or [`source`](Self::source) must
659    /// be set. A source-backed entry carries an empty durable value; it is
660    /// resolved host-side at spawn time.
661    ///
662    /// # Panics
663    /// Panics if `env` or at least one allowed host was not set, or if neither
664    /// (or both) of `value`/`source` was set.
665    pub fn build(self) -> SecretEntry {
666        let env_var = self.env_var.expect("SecretBuilder: .env() is required");
667        assert!(
668            self.value.is_some() ^ self.source.is_some(),
669            "SecretBuilder: exactly one of .value() or .source() is required"
670        );
671        assert!(
672            !self.allowed_hosts.is_empty(),
673            "SecretBuilder: at least one allowed host is required; use .allow_any_host_dangerous(true) for an explicit any-host secret"
674        );
675        let placeholder = self
676            .placeholder
677            .unwrap_or_else(|| microsandbox_utils::secret::default_placeholder(&env_var));
678
679        SecretEntry {
680            env_var,
681            value: Zeroizing::new(self.value.unwrap_or_default()),
682            source: self.source,
683            placeholder,
684            allowed_hosts: self.allowed_hosts,
685            substitution: self.substitution,
686            passthrough_hosts: self.passthrough_hosts,
687            violation_action: self.violation_action,
688            require_tls_identity: self.require_tls_identity,
689        }
690    }
691}
692
693impl NetworkRateLimiterBuilder {
694    fn new() -> Self {
695        Self::default()
696    }
697
698    /// Limit guest-to-runtime (egress) traffic.
699    pub fn egress(mut self, f: impl FnOnce(RateLimiterBuilder) -> RateLimiterBuilder) -> Self {
700        match f(RateLimiterBuilder::new(NetworkRateLimitDirection::Egress)).build() {
701            Ok(limiter) => self.config.egress = Some(limiter),
702            Err(err) => self.errors.push(err),
703        }
704        self
705    }
706
707    /// Limit runtime-to-guest (ingress) traffic.
708    pub fn ingress(mut self, f: impl FnOnce(RateLimiterBuilder) -> RateLimiterBuilder) -> Self {
709        match f(RateLimiterBuilder::new(NetworkRateLimitDirection::Ingress)).build() {
710            Ok(limiter) => self.config.ingress = Some(limiter),
711            Err(err) => self.errors.push(err),
712        }
713        self
714    }
715
716    /// Consume the builder and return both configured directions.
717    pub fn build(mut self) -> Result<NetworkRateLimiterConfig, BuildError> {
718        if let Some(error) = self.errors.drain(..).next() {
719            return Err(error);
720        }
721        if self.config.egress.is_none() && self.config.ingress.is_none() {
722            return Err(BuildError::EmptyNetworkRateLimiter);
723        }
724        Ok(self.config)
725    }
726}
727
728impl RateLimiterBuilder {
729    fn new(direction: NetworkRateLimitDirection) -> Self {
730        Self {
731            direction,
732            bandwidth: None,
733            ops: None,
734            bandwidth_burst: None,
735            ops_burst: None,
736            refill_error: None,
737        }
738    }
739
740    /// Cap bandwidth at `size` bytes per `refill_time`.
741    ///
742    /// `refill_time` must be at least one millisecond and exactly representable
743    /// as a whole number of milliseconds.
744    ///
745    /// ```ignore
746    /// .bandwidth(1.mib(), Duration::from_secs(1))
747    /// ```
748    pub fn bandwidth(mut self, size: impl Into<Bytes>, refill_time: Duration) -> Self {
749        match refill_time_ms(refill_time) {
750            Ok(refill_time_ms) => {
751                self.bandwidth = Some(TokenBucketConfig {
752                    size: size.into().as_u64(),
753                    refill_time_ms,
754                    one_time_burst: 0,
755                });
756            }
757            Err(error) => {
758                self.refill_error.get_or_insert(("bandwidth", error));
759            }
760        }
761        self
762    }
763
764    /// Grant a one-time startup burst of `burst` bytes on top of the
765    /// bandwidth bucket. Requires [`bandwidth`](Self::bandwidth).
766    pub fn bandwidth_burst(mut self, burst: impl Into<Bytes>) -> Self {
767        self.bandwidth_burst = Some(burst.into().as_u64());
768        self
769    }
770
771    /// Cap packet rate at `count` frames per `refill_time`.
772    ///
773    /// `refill_time` must be at least one millisecond and exactly representable
774    /// as a whole number of milliseconds.
775    ///
776    /// ```ignore
777    /// .ops(1_000, Duration::from_secs(1))
778    /// ```
779    pub fn ops(mut self, count: u64, refill_time: Duration) -> Self {
780        match refill_time_ms(refill_time) {
781            Ok(refill_time_ms) => {
782                self.ops = Some(TokenBucketConfig {
783                    size: count,
784                    refill_time_ms,
785                    one_time_burst: 0,
786                });
787            }
788            Err(error) => {
789                self.refill_error.get_or_insert(("ops", error));
790            }
791        }
792        self
793    }
794
795    /// Grant a one-time startup burst of `count` frames on top of the ops
796    /// bucket. Requires [`ops`](Self::ops).
797    pub fn ops_burst(mut self, count: u64) -> Self {
798        self.ops_burst = Some(count);
799        self
800    }
801
802    /// Consume the builder and return the validated configuration.
803    pub fn build(self) -> Result<RateLimiterConfig, BuildError> {
804        let direction = self.direction;
805        if let Some((bucket, error)) = self.refill_error {
806            return Err(match error {
807                RefillTimeError::TooShort => {
808                    BuildError::RateLimitRefillTooShort { direction, bucket }
809                }
810                RefillTimeError::Precision => {
811                    BuildError::RateLimitRefillPrecision { direction, bucket }
812                }
813                RefillTimeError::TooLong => {
814                    BuildError::RateLimitRefillTooLong { direction, bucket }
815                }
816            });
817        }
818
819        let mut config = RateLimiterConfig {
820            bandwidth: self.bandwidth,
821            ops: self.ops,
822        };
823        if let Some(burst) = self.bandwidth_burst {
824            let Some(bandwidth) = &mut config.bandwidth else {
825                return Err(BuildError::RateLimitBurstWithoutBucket {
826                    direction,
827                    bucket: "bandwidth",
828                });
829            };
830            bandwidth.one_time_burst = burst;
831        }
832        if let Some(burst) = self.ops_burst {
833            let Some(ops) = &mut config.ops else {
834                return Err(BuildError::RateLimitBurstWithoutBucket {
835                    direction,
836                    bucket: "ops",
837                });
838            };
839            ops.one_time_burst = burst;
840        }
841
842        config
843            .validate()
844            .map_err(|source| BuildError::InvalidRateLimitConfig { direction, source })?;
845        Ok(config)
846    }
847}
848
849//--------------------------------------------------------------------------------------------------
850// Functions
851//--------------------------------------------------------------------------------------------------
852
853/// Convert a refill interval to its exact whole-millisecond wire value.
854fn refill_time_ms(refill_time: Duration) -> Result<u64, RefillTimeError> {
855    if refill_time < Duration::from_millis(1) {
856        return Err(RefillTimeError::TooShort);
857    }
858    let refill_time_ms =
859        u64::try_from(refill_time.as_millis()).map_err(|_| RefillTimeError::TooLong)?;
860    if !refill_time.subsec_nanos().is_multiple_of(1_000_000) {
861        return Err(RefillTimeError::Precision);
862    }
863    Ok(refill_time_ms)
864}
865
866//--------------------------------------------------------------------------------------------------
867// Trait Implementations
868//--------------------------------------------------------------------------------------------------
869
870impl Default for NetworkBuilder {
871    fn default() -> Self {
872        Self::new()
873    }
874}
875
876impl Default for TlsBuilder {
877    fn default() -> Self {
878        Self::new()
879    }
880}
881
882impl Default for SecretBuilder {
883    fn default() -> Self {
884        Self::new()
885    }
886}
887//--------------------------------------------------------------------------------------------------
888// Tests
889//--------------------------------------------------------------------------------------------------
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894
895    #[test]
896    #[allow(deprecated)]
897    fn deprecated_tcp_builder_delegates_to_the_new_name() {
898        let canonical_last = NetworkBuilder::new()
899            .max_connections(0)
900            .max_tcp_connections(64)
901            .build()
902            .unwrap();
903        assert_eq!(
904            canonical_last.max_tcp_connections,
905            Some(ConnectionLimit::from(64))
906        );
907        let legacy_last = NetworkBuilder::new()
908            .max_tcp_connections(64)
909            .max_connections(0)
910            .build()
911            .unwrap();
912        assert_eq!(
913            legacy_last.max_tcp_connections,
914            Some(ConnectionLimit::Unlimited)
915        );
916        let config = NetworkBuilder::new()
917            .max_connections(0)
918            .max_udp_connections(7)
919            .build()
920            .unwrap();
921        assert_eq!(config.max_tcp_connections, Some(ConnectionLimit::Unlimited));
922        assert_eq!(config.max_udp_connections, Some(ConnectionLimit::from(7)));
923        assert!(
924            NetworkBuilder::new()
925                .max_tcp_connections(1)
926                .max_tcp_connections(2)
927                .build()
928                .is_ok()
929        );
930    }
931
932    /// Network builder happy path returns the config unchanged.
933    #[test]
934    fn network_builder_happy_path_returns_config() {
935        let cfg = NetworkBuilder::new()
936            .dns(|d| d.rebind_protection(false))
937            .build()
938            .unwrap();
939        assert!(!cfg.dns.rebind_protection);
940    }
941
942    #[test]
943    fn network_builder_preserves_explicit_large_caps() {
944        for limit in [10000, usize::MAX] {
945            let config = NetworkBuilder::new()
946                .max_tcp_connections(limit)
947                .build()
948                .unwrap();
949            assert_eq!(
950                config.max_tcp_connections,
951                Some(ConnectionLimit::from(limit))
952            );
953        }
954    }
955
956    #[test]
957    fn network_builder_rejects_incomplete_intercept_ca_config() {
958        let err = NetworkBuilder::new()
959            .tls(|t| t.intercept_ca_cert("/tmp/ca.crt"))
960            .build()
961            .unwrap_err();
962
963        assert!(matches!(err, BuildError::IncompleteInterceptCaConfig));
964    }
965
966    #[test]
967    fn port_bind_sets_host_bind() {
968        let bind = "0.0.0.0".parse().unwrap();
969        let cfg = NetworkBuilder::new()
970            .port_bind(bind, 8080, 80)
971            .port_udp_bind(bind, 5353, 53)
972            .build()
973            .unwrap();
974
975        assert_eq!(cfg.ports[0].host_bind, bind);
976        assert_eq!(cfg.ports[0].host_port, 8080);
977        assert_eq!(cfg.ports[0].guest_port, 80);
978        assert_eq!(cfg.ports[0].protocol, PortProtocol::Tcp);
979        assert_eq!(cfg.ports[1].host_bind, bind);
980        assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp);
981    }
982
983    #[test]
984    fn port_helpers_default_to_loopback() {
985        let cfg = NetworkBuilder::new()
986            .port(8080, 80)
987            .port_udp(5353, 53)
988            .build()
989            .unwrap();
990
991        assert_eq!(
992            cfg.ports[0].host_bind,
993            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
994        );
995        assert_eq!(cfg.ports[0].protocol, PortProtocol::Tcp);
996        assert_eq!(
997            cfg.ports[1].host_bind,
998            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
999        );
1000        assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp);
1001    }
1002
1003    #[test]
1004    fn outbound_proxy_defaults_to_none() {
1005        let cfg = NetworkBuilder::new().build().unwrap();
1006        assert_eq!(cfg.outbound_proxy, None);
1007    }
1008
1009    #[test]
1010    fn network_builder_sets_strict_mode() {
1011        let cfg = NetworkBuilder::new().strict(true).build().unwrap();
1012        assert!(cfg.strict);
1013    }
1014
1015    #[test]
1016    fn network_builder_sets_global_violation_action() {
1017        let cfg = NetworkBuilder::new()
1018            .secret_violation_action(SecretViolationAction::BlockAndTerminate)
1019            .build()
1020            .unwrap();
1021
1022        assert_eq!(
1023            cfg.secrets.violation_action,
1024            SecretViolationAction::BlockAndTerminate
1025        );
1026    }
1027
1028    #[test]
1029    fn secret_builder_sets_passthrough_and_violation_policies() {
1030        let secret = SecretBuilder::new()
1031            .env("TOKEN")
1032            .value("secret-value")
1033            .allow("api.github.com")
1034            .allow_passthrough_for("api.anthropic.com")
1035            .allow_passthrough_for("*.anthropic.com")
1036            .violation_action(SecretViolationAction::BlockAndTerminate)
1037            .build();
1038
1039        assert_eq!(
1040            secret.violation_action,
1041            Some(SecretViolationAction::BlockAndTerminate),
1042        );
1043        assert_eq!(
1044            secret.passthrough_hosts,
1045            vec![
1046                HostPattern::Exact("api.anthropic.com".into()),
1047                HostPattern::Wildcard("*.anthropic.com".into()),
1048            ],
1049        );
1050    }
1051
1052    #[test]
1053    #[should_panic(expected = "SecretBuilder: at least one allowed host is required")]
1054    fn secret_builder_rejects_empty_allowed_hosts() {
1055        let _ = SecretBuilder::new()
1056            .env("TOKEN")
1057            .value("secret-value")
1058            .build();
1059    }
1060
1061    #[test]
1062    fn secret_builder_source_yields_reference_and_empty_value() {
1063        let secret = SecretBuilder::new()
1064            .env("API_KEY")
1065            .source(SecretSource::Env {
1066                var: "HOST_API_KEY".into(),
1067            })
1068            .allow("api.example.com")
1069            .build();
1070
1071        assert!(secret.value.is_empty());
1072        assert_eq!(
1073            secret.source,
1074            Some(SecretSource::Env {
1075                var: "HOST_API_KEY".into()
1076            })
1077        );
1078
1079        // Serialized durable form carries the reference, not a value.
1080        let json = serde_json::to_string(&secret).unwrap();
1081        assert!(json.contains("\"var\":\"HOST_API_KEY\""));
1082    }
1083
1084    #[test]
1085    #[should_panic(expected = "exactly one of .value() or .source()")]
1086    fn secret_builder_rejects_both_value_and_source() {
1087        let _ = SecretBuilder::new()
1088            .env("API_KEY")
1089            .value("inline")
1090            .source(SecretSource::Env {
1091                var: "HOST_API_KEY".into(),
1092            })
1093            .allow("api.example.com")
1094            .build();
1095    }
1096
1097    #[test]
1098    fn network_builder_rejects_invalid_secret_config() {
1099        let err = NetworkBuilder::new()
1100            .secret_entry(SecretEntry {
1101                env_var: "API=KEY".into(),
1102                value: Zeroizing::new("secret-value".into()),
1103                source: None,
1104                placeholder: "$MSB_API_KEY".into(),
1105                allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
1106                substitution: SecretSubstitution::default(),
1107                passthrough_hosts: Vec::new(),
1108                violation_action: None,
1109                require_tls_identity: true,
1110            })
1111            .build()
1112            .unwrap_err();
1113
1114        assert!(err.to_string().contains("env_var must not contain `=`"));
1115    }
1116
1117    #[test]
1118    fn rate_limiter_builder_sets_buckets_and_bursts() {
1119        use microsandbox_utils::size::SizeExt;
1120
1121        let cfg = NetworkBuilder::new()
1122            .rate_limiter(|r| {
1123                r.egress(|r| {
1124                    r.bandwidth(1.mib(), Duration::from_secs(1))
1125                        .bandwidth_burst(512.kib())
1126                        .ops(1_000, Duration::from_secs(1))
1127                        .ops_burst(500)
1128                })
1129                .ingress(|r| r.bandwidth(2.mib(), Duration::from_millis(500)))
1130            })
1131            .build()
1132            .unwrap();
1133
1134        let rate_limiter = cfg.rate_limiter.unwrap();
1135        let egress = rate_limiter.egress.unwrap();
1136        let bandwidth = egress.bandwidth.unwrap();
1137        assert_eq!(bandwidth.size, 1024 * 1024);
1138        assert_eq!(bandwidth.refill_time_ms, 1000);
1139        assert_eq!(bandwidth.one_time_burst, 512 * 1024);
1140        let ops = egress.ops.unwrap();
1141        assert_eq!(ops.size, 1_000);
1142        assert_eq!(ops.refill_time_ms, 1000);
1143        assert_eq!(ops.one_time_burst, 500);
1144
1145        let ingress = rate_limiter.ingress.unwrap();
1146        assert_eq!(ingress.bandwidth.unwrap().refill_time_ms, 500);
1147        assert!(ingress.ops.is_none());
1148    }
1149
1150    #[test]
1151    fn rate_limiters_default_to_unlimited() {
1152        let cfg = NetworkBuilder::new().build().unwrap();
1153        assert!(cfg.rate_limiter.is_none());
1154    }
1155
1156    #[test]
1157    fn rate_limiter_builder_rejects_empty_limiter() {
1158        let err = NetworkBuilder::new()
1159            .rate_limiter(|r| r.egress(|r| r))
1160            .build()
1161            .unwrap_err();
1162        assert_eq!(
1163            err.to_string(),
1164            "egress rate limiter: rate limiter must configure at least one of bandwidth or ops"
1165        );
1166    }
1167
1168    #[test]
1169    fn network_rate_limiter_builder_rejects_missing_directions() {
1170        let err = NetworkBuilder::new()
1171            .rate_limiter(|r| r)
1172            .build()
1173            .unwrap_err();
1174        assert_eq!(
1175            err.to_string(),
1176            "rate limiter must configure at least one of egress or ingress"
1177        );
1178    }
1179
1180    #[test]
1181    fn rate_limiter_builder_rejects_zero_size_and_unrepresentable_refill() {
1182        let err = NetworkBuilder::new()
1183            .rate_limiter(|r| r.ingress(|r| r.bandwidth(0u64, Duration::from_secs(1))))
1184            .build()
1185            .unwrap_err();
1186        assert_eq!(
1187            err.to_string(),
1188            "ingress rate limiter: bandwidth bucket: size must be greater than zero"
1189        );
1190
1191        let err = NetworkBuilder::new()
1192            .rate_limiter(|r| r.egress(|r| r.ops(10, Duration::ZERO)))
1193            .build()
1194            .unwrap_err();
1195        assert_eq!(
1196            err.to_string(),
1197            "egress rate limiter: ops refill interval must be at least one millisecond"
1198        );
1199
1200        let err = NetworkBuilder::new()
1201            .rate_limiter(|r| r.egress(|r| r.ops(10, Duration::from_micros(1_500))))
1202            .build()
1203            .unwrap_err();
1204        assert_eq!(
1205            err.to_string(),
1206            "egress rate limiter: ops refill interval must be a whole number of milliseconds"
1207        );
1208    }
1209
1210    #[test]
1211    fn rate_limiter_builder_rejects_burst_without_bucket() {
1212        use microsandbox_utils::size::SizeExt;
1213
1214        let err = NetworkBuilder::new()
1215            .rate_limiter(|r| r.egress(|r| r.bandwidth_burst(512.kib())))
1216            .build()
1217            .unwrap_err();
1218        assert_eq!(
1219            err.to_string(),
1220            "egress rate limiter: bandwidth_burst requires the bandwidth bucket"
1221        );
1222
1223        let err = NetworkBuilder::new()
1224            .rate_limiter(|r| {
1225                r.ingress(|r| r.bandwidth(1.mib(), Duration::from_secs(1)).ops_burst(5))
1226            })
1227            .build()
1228            .unwrap_err();
1229        assert_eq!(
1230            err.to_string(),
1231            "ingress rate limiter: ops_burst requires the ops bucket"
1232        );
1233    }
1234
1235    #[test]
1236    fn rate_limiter_builder_rejects_refill_interval_overflow() {
1237        let err = NetworkBuilder::new()
1238            .rate_limiter(|r| r.egress(|r| r.ops(10, Duration::MAX)))
1239            .build()
1240            .unwrap_err();
1241        assert_eq!(
1242            err.to_string(),
1243            "egress rate limiter: ops refill interval overflows u64 milliseconds"
1244        );
1245    }
1246}