Skip to main content

microsandbox_network/model/config/
builder.rs

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