Skip to main content

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