Skip to main content

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