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;
7
8use ipnetwork::{Ipv4Network, Ipv6Network};
9
10use crate::config::{DnsConfig, InterfaceOverrides, NetworkConfig, PortProtocol, PublishedPort};
11use crate::dns::Nameserver;
12use crate::policy::{BuildError, NetworkPolicy};
13use zeroize::Zeroizing;
14
15use crate::secrets::config::{
16    HostPattern, SecretEntry, SecretInjection, SecretSource, ViolationAction,
17};
18use crate::tls::{ScopedUpstreamCaCert, ScopedVerifyUpstream, TlsConfig};
19
20//--------------------------------------------------------------------------------------------------
21// Types
22//--------------------------------------------------------------------------------------------------
23
24/// Fluent builder for [`NetworkConfig`].
25#[derive(Clone)]
26pub struct NetworkBuilder {
27    config: NetworkConfig,
28    errors: Vec<BuildError>,
29}
30
31/// Fluent builder for [`DnsConfig`].
32pub struct DnsBuilder {
33    config: DnsConfig,
34}
35
36/// Fluent builder for [`TlsConfig`].
37pub struct TlsBuilder {
38    config: TlsConfig,
39}
40
41/// Fluent builder for a single [`SecretEntry`].
42///
43/// ```ignore
44/// SecretBuilder::new()
45///     .env("OPENAI_API_KEY")
46///     .value(api_key)
47///     .allow_host("api.openai.com")
48///     .build()
49/// ```
50pub struct SecretBuilder {
51    env_var: Option<String>,
52    value: Option<String>,
53    source: Option<SecretSource>,
54    placeholder: Option<String>,
55    allowed_hosts: Vec<HostPattern>,
56    injection: SecretInjection,
57    on_violation: Option<ViolationAction>,
58    require_tls_identity: bool,
59}
60
61/// Fluent builder for a [`ViolationAction`].
62#[derive(Default)]
63pub struct ViolationActionBuilder {
64    action: ViolationAction,
65}
66
67//--------------------------------------------------------------------------------------------------
68// Methods
69//--------------------------------------------------------------------------------------------------
70
71impl NetworkBuilder {
72    /// Start building a network configuration with defaults.
73    pub fn new() -> Self {
74        Self {
75            config: NetworkConfig::default(),
76            errors: Vec::new(),
77        }
78    }
79
80    /// Start building from an existing network configuration.
81    pub fn from_config(config: NetworkConfig) -> Self {
82        Self {
83            config,
84            errors: Vec::new(),
85        }
86    }
87
88    /// Enable or disable networking.
89    pub fn enabled(mut self, enabled: bool) -> Self {
90        self.config.enabled = enabled;
91        self
92    }
93
94    /// Publish a TCP port: `host_port` on the host maps to `guest_port` in the guest.
95    pub fn port(self, host_port: u16, guest_port: u16) -> Self {
96        self.port_bind(
97            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
98            host_port,
99            guest_port,
100        )
101    }
102
103    /// Publish a UDP port.
104    pub fn port_udp(self, host_port: u16, guest_port: u16) -> Self {
105        self.port_udp_bind(
106            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
107            host_port,
108            guest_port,
109        )
110    }
111
112    /// Publish a TCP port on a specific host bind address.
113    pub fn port_bind(self, host_bind: IpAddr, host_port: u16, guest_port: u16) -> Self {
114        self.add_port(host_bind, host_port, guest_port, PortProtocol::Tcp)
115    }
116
117    /// Publish a UDP port on a specific host bind address.
118    pub fn port_udp_bind(self, host_bind: IpAddr, host_port: u16, guest_port: u16) -> Self {
119        self.add_port(host_bind, host_port, guest_port, PortProtocol::Udp)
120    }
121
122    fn add_port(
123        mut self,
124        host_bind: IpAddr,
125        host_port: u16,
126        guest_port: u16,
127        protocol: PortProtocol,
128    ) -> Self {
129        self.config.ports.push(PublishedPort {
130            host_port,
131            guest_port,
132            protocol,
133            host_bind,
134        });
135        self
136    }
137
138    /// Set the network policy.
139    pub fn policy(mut self, policy: NetworkPolicy) -> Self {
140        self.config.policy = policy;
141        self
142    }
143
144    /// Configure DNS interception via a closure.
145    ///
146    /// ```ignore
147    /// .dns(|d| d
148    ///     .nameservers(["1.1.1.1".parse::<Nameserver>()?])
149    ///     .rebind_protection(false)
150    /// )
151    /// ```
152    pub fn dns(mut self, f: impl FnOnce(DnsBuilder) -> DnsBuilder) -> Self {
153        self.config.dns = f(DnsBuilder::new()).build();
154        self
155    }
156
157    /// Configure TLS interception via a closure.
158    pub fn tls(mut self, f: impl FnOnce(TlsBuilder) -> TlsBuilder) -> Self {
159        self.config.tls = f(TlsBuilder::new()).build();
160        self
161    }
162
163    /// Add a secret via a closure builder.
164    ///
165    /// ```ignore
166    /// .secret(|s| s
167    ///     .env("OPENAI_API_KEY")
168    ///     .value(api_key)
169    ///     .allow_host("api.openai.com")
170    /// )
171    /// ```
172    pub fn secret(self, f: impl FnOnce(SecretBuilder) -> SecretBuilder) -> Self {
173        self.secret_entry(f(SecretBuilder::new()).build())
174    }
175
176    /// Add a materialized secret entry.
177    pub fn secret_entry(mut self, entry: SecretEntry) -> Self {
178        self.config.secrets.secrets.push(entry);
179        self
180    }
181
182    /// Shorthand: add a secret with env var, value, placeholder, and allowed host.
183    pub fn secret_env(
184        mut self,
185        env_var: impl Into<String>,
186        value: impl Into<String>,
187        placeholder: impl Into<String>,
188        allowed_host: impl Into<String>,
189    ) -> Self {
190        self.config.secrets.secrets.push(SecretEntry {
191            env_var: env_var.into(),
192            value: Zeroizing::new(value.into()),
193            source: None,
194            placeholder: placeholder.into(),
195            allowed_hosts: vec![HostPattern::Exact(allowed_host.into())],
196            injection: SecretInjection::default(),
197            on_violation: None,
198            require_tls_identity: true,
199        });
200        self
201    }
202
203    /// Set the violation action for secrets.
204    pub fn on_secret_violation(
205        mut self,
206        f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder,
207    ) -> Self {
208        self.config.secrets.on_violation = f(ViolationActionBuilder::default()).build();
209        self
210    }
211
212    /// Set the maximum number of concurrent connections.
213    pub fn max_connections(mut self, max: usize) -> Self {
214        self.config.max_connections = Some(max);
215        self
216    }
217
218    /// Set guest interface overrides.
219    pub fn interface(mut self, overrides: InterfaceOverrides) -> Self {
220        self.config.interface = overrides;
221        self
222    }
223
224    /// Set the IPv4 pool used to derive per-sandbox `/30` guest subnets.
225    ///
226    /// The default is `172.16.0.0/12`. Pools must be at least `/30`.
227    pub fn ipv4_pool(mut self, pool: Ipv4Network) -> Self {
228        if pool.prefix() > 30 {
229            self.errors.push(BuildError::InvalidIpv4Pool {
230                raw: pool.to_string(),
231            });
232        } else {
233            self.config.interface.ipv4_pool = Some(pool);
234        }
235        self
236    }
237
238    /// Set the IPv6 pool used to derive per-sandbox `/64` guest prefixes.
239    ///
240    /// The default is `fd42:6d73:62::/48`. Pools must be at least `/64`.
241    pub fn ipv6_pool(mut self, pool: Ipv6Network) -> Self {
242        if pool.prefix() > 64 {
243            self.errors.push(BuildError::InvalidIpv6Pool {
244                raw: pool.to_string(),
245            });
246        } else {
247            self.config.interface.ipv6_pool = Some(pool);
248        }
249        self
250    }
251
252    /// Whether to ship the host's trusted root CAs into the guest at
253    /// boot. Default: false. Opt in when running behind a corporate
254    /// TLS-inspecting proxy (Cloudflare Warp Zero Trust, Zscaler,
255    /// Netskope, ...) whose gateway CA is trusted on the host but
256    /// unknown to the guest's stock Mozilla bundle.
257    pub fn trust_host_cas(mut self, enabled: bool) -> Self {
258        self.config.trust_host_cas = enabled;
259        self
260    }
261
262    /// Consume the builder and return the configuration.
263    ///
264    /// Surfaces the first [`BuildError`] accumulated by any nested
265    /// builder (currently [`DnsBuilder`]). Errors stored on the
266    /// network builder itself flow through here too.
267    pub fn build(mut self) -> Result<NetworkConfig, BuildError> {
268        if let Some(err) = self.errors.drain(..).next() {
269            return Err(err);
270        }
271        self.config.secrets.validate()?;
272        Ok(self.config)
273    }
274}
275
276impl DnsBuilder {
277    /// Start building DNS configuration with defaults.
278    pub fn new() -> Self {
279        Self {
280            config: DnsConfig::default(),
281        }
282    }
283
284    /// Enable or disable DNS rebinding protection. Default: true.
285    pub fn rebind_protection(mut self, enabled: bool) -> Self {
286        self.config.rebind_protection = enabled;
287        self
288    }
289
290    /// Set the upstream nameservers to forward queries to. When one or
291    /// more are set, the interceptor uses these instead of the
292    /// nameservers in the host's `/etc/resolv.conf`. Replaces any
293    /// previously-set nameservers. Each element is any type convertible
294    /// into [`Nameserver`] (`SocketAddr`, `IpAddr`, or a parsed
295    /// string via `"dns.google:53".parse::<Nameserver>()?`).
296    pub fn nameservers<I>(mut self, nameservers: I) -> Self
297    where
298        I: IntoIterator,
299        I::Item: Into<Nameserver>,
300    {
301        self.config.nameservers = nameservers.into_iter().map(Into::into).collect();
302        self
303    }
304
305    /// Set the per-DNS-query timeout in milliseconds. Default: 5000.
306    pub fn query_timeout_ms(mut self, ms: u64) -> Self {
307        self.config.query_timeout_ms = ms;
308        self
309    }
310
311    /// Consume the builder and return the configuration.
312    pub fn build(self) -> DnsConfig {
313        self.config
314    }
315}
316
317impl Default for DnsBuilder {
318    fn default() -> Self {
319        Self::new()
320    }
321}
322
323impl TlsBuilder {
324    /// Start building TLS configuration.
325    pub fn new() -> Self {
326        Self {
327            config: TlsConfig {
328                enabled: true,
329                ..TlsConfig::default()
330            },
331        }
332    }
333
334    /// Add a domain to the bypass list (no MITM). Supports `*.suffix` wildcards.
335    pub fn bypass(mut self, pattern: impl Into<String>) -> Self {
336        self.config.bypass.push(pattern.into());
337        self
338    }
339
340    /// Enable or disable upstream server certificate verification.
341    pub fn verify_upstream(mut self, verify: bool) -> Self {
342        self.config.verify_upstream = verify;
343        self
344    }
345
346    /// Enable or disable upstream server certificate verification only
347    /// when the upstream SNI matches `pattern`.
348    ///
349    /// Pattern syntax matches [`Self::bypass`]: exact hosts and `*.suffix`
350    /// wildcards are supported.
351    pub fn verify_upstream_for(mut self, pattern: impl Into<String>, verify: bool) -> Self {
352        self.config
353            .scoped_verify_upstream
354            .push(ScopedVerifyUpstream {
355                pattern: pattern.into(),
356                verify,
357            });
358        self
359    }
360
361    /// Set the ports to intercept.
362    pub fn intercepted_ports(mut self, ports: Vec<u16>) -> Self {
363        self.config.intercepted_ports = ports;
364        self
365    }
366
367    /// Enable or disable QUIC blocking on intercepted ports.
368    pub fn block_quic(mut self, block: bool) -> Self {
369        self.config.block_quic_on_intercept = block;
370        self
371    }
372
373    /// Add a CA certificate PEM file to trust for upstream server verification.
374    ///
375    /// Useful when the upstream server uses a self-signed or private CA certificate.
376    /// Can be called multiple times to add several CAs.
377    pub fn upstream_ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
378        self.config.upstream_ca_cert.push(path.into());
379        self
380    }
381
382    /// Add a CA certificate PEM file to trust for upstream server verification
383    /// only when the upstream SNI matches `pattern`.
384    ///
385    /// Pattern syntax matches [`Self::bypass`]: exact hosts and `*.suffix`
386    /// wildcards are supported. Can be called multiple times to add several
387    /// CAs for the same host pattern.
388    pub fn upstream_ca_cert_for(
389        mut self,
390        pattern: impl Into<String>,
391        path: impl Into<PathBuf>,
392    ) -> Self {
393        self.config
394            .scoped_upstream_ca_cert
395            .push(ScopedUpstreamCaCert {
396                pattern: pattern.into(),
397                path: path.into(),
398            });
399        self
400    }
401
402    /// Set a custom interception CA certificate PEM file path.
403    pub fn intercept_ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
404        self.config.intercept_ca.cert_path = Some(path.into());
405        self
406    }
407
408    /// Set a custom interception CA private key PEM file path.
409    pub fn intercept_ca_key(mut self, path: impl Into<PathBuf>) -> Self {
410        self.config.intercept_ca.key_path = Some(path.into());
411        self
412    }
413
414    /// Consume the builder and return the configuration.
415    pub fn build(self) -> TlsConfig {
416        self.config
417    }
418}
419
420impl SecretBuilder {
421    /// Start building a secret.
422    pub fn new() -> Self {
423        Self {
424            env_var: None,
425            value: None,
426            source: None,
427            placeholder: None,
428            allowed_hosts: Vec::new(),
429            injection: SecretInjection::default(),
430            on_violation: None,
431            require_tls_identity: true,
432        }
433    }
434
435    /// Set the environment variable to expose the placeholder as (required).
436    ///
437    /// Names must be non-empty and must not contain `=` or NUL. They are
438    /// not restricted to shell-identifier syntax.
439    pub fn env(mut self, var: impl Into<String>) -> Self {
440        self.env_var = Some(var.into());
441        self
442    }
443
444    /// Set the secret value inline (mutually exclusive with [`source`](Self::source)).
445    ///
446    /// Prefer [`source`](Self::source) for durable configs: an inline value is
447    /// persisted verbatim in the sandbox spec, whereas a source reference is
448    /// resolved host-side at spawn time and never stored at rest.
449    pub fn value(mut self, value: impl Into<String>) -> Self {
450        self.value = Some(value.into());
451        self
452    }
453
454    /// Resolve the value from a host-side source reference at spawn time
455    /// (mutually exclusive with [`value`](Self::value)).
456    ///
457    /// The durable config records only the reference; the plaintext is read
458    /// from the host environment when the sandbox starts, so it never lands
459    /// in the database.
460    pub fn source(mut self, source: SecretSource) -> Self {
461        self.source = Some(source);
462        self
463    }
464
465    /// Set a custom placeholder string.
466    ///
467    /// Placeholders must be non-empty, at most 1024 bytes, and must not
468    /// contain NUL, CR, or LF.
469    /// If not set, auto-generated as `$MSB_<env_var>`.
470    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
471        self.placeholder = Some(placeholder.into());
472        self
473    }
474
475    /// Add an allowed host (exact match).
476    pub fn allow_host(mut self, host: impl Into<String>) -> Self {
477        self.allowed_hosts.push(HostPattern::Exact(host.into()));
478        self
479    }
480
481    /// Add an allowed host with wildcard pattern (e.g., `*.openai.com`).
482    pub fn allow_host_pattern(mut self, pattern: impl Into<String>) -> Self {
483        self.allowed_hosts
484            .push(HostPattern::Wildcard(pattern.into()));
485        self
486    }
487
488    /// Allow for any host. **Dangerous**: secret can be exfiltrated to any
489    /// destination. Requires explicit acknowledgment.
490    pub fn allow_any_host_dangerous(mut self, i_understand_the_risk: bool) -> Self {
491        if i_understand_the_risk {
492            self.allowed_hosts.push(HostPattern::Any);
493        }
494        self
495    }
496
497    /// Set the violation action for this secret.
498    pub fn on_violation(
499        mut self,
500        f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder,
501    ) -> Self {
502        self.on_violation = Some(f(ViolationActionBuilder::default()).build());
503        self
504    }
505
506    /// Require verified TLS identity before substituting (default: true).
507    pub fn require_tls_identity(mut self, enabled: bool) -> Self {
508        self.require_tls_identity = enabled;
509        self
510    }
511
512    /// Configure header injection (default: true).
513    pub fn inject_headers(mut self, enabled: bool) -> Self {
514        self.injection.headers = enabled;
515        self
516    }
517
518    /// Configure Basic Auth injection (default: true).
519    pub fn inject_basic_auth(mut self, enabled: bool) -> Self {
520        self.injection.basic_auth = enabled;
521        self
522    }
523
524    /// Configure query parameter injection (default: false).
525    pub fn inject_query(mut self, enabled: bool) -> Self {
526        self.injection.query_params = enabled;
527        self
528    }
529
530    /// Configure HTTP/1 body injection (default: false).
531    ///
532    /// Fixed-length bodies up to 16 MiB update `Content-Length`; larger
533    /// fixed-length bodies are blocked. Chunked bodies are decoded and
534    /// re-encoded with fresh chunk sizes. Encoded bodies pass through
535    /// unchanged.
536    pub fn inject_body(mut self, enabled: bool) -> Self {
537        self.injection.body = enabled;
538        self
539    }
540
541    /// Consume the builder and return a [`SecretEntry`].
542    ///
543    /// Exactly one of [`value`](Self::value) or [`source`](Self::source) must
544    /// be set. A source-backed entry carries an empty durable value; it is
545    /// resolved host-side at spawn time.
546    ///
547    /// # Panics
548    /// Panics if `env` or at least one allowed host was not set, or if neither
549    /// (or both) of `value`/`source` was set.
550    pub fn build(self) -> SecretEntry {
551        let env_var = self.env_var.expect("SecretBuilder: .env() is required");
552        assert!(
553            self.value.is_some() ^ self.source.is_some(),
554            "SecretBuilder: exactly one of .value() or .source() is required"
555        );
556        assert!(
557            !self.allowed_hosts.is_empty(),
558            "SecretBuilder: at least one allowed host is required; use .allow_any_host_dangerous(true) for an explicit any-host secret"
559        );
560        let placeholder = self
561            .placeholder
562            .unwrap_or_else(|| format!("$MSB_{env_var}"));
563
564        SecretEntry {
565            env_var,
566            value: Zeroizing::new(self.value.unwrap_or_default()),
567            source: self.source,
568            placeholder,
569            allowed_hosts: self.allowed_hosts,
570            injection: self.injection,
571            on_violation: self.on_violation,
572            require_tls_identity: self.require_tls_identity,
573        }
574    }
575}
576
577impl ViolationActionBuilder {
578    /// Start building a violation action.
579    pub fn new() -> Self {
580        Self::default()
581    }
582
583    /// Start building from an existing action.
584    pub fn from_action(action: ViolationAction) -> Self {
585        action.into()
586    }
587
588    /// Block the request silently.
589    pub fn block(mut self) -> Self {
590        self.action = ViolationAction::Block;
591        self
592    }
593
594    /// Block the request and emit a warning log.
595    pub fn block_and_log(mut self) -> Self {
596        self.action = ViolationAction::BlockAndLog;
597        self
598    }
599
600    /// Block the request and terminate the sandbox.
601    pub fn block_and_terminate(mut self) -> Self {
602        self.action = ViolationAction::BlockAndTerminate;
603        self
604    }
605
606    /// Allow a host to receive secret placeholders without substitution.
607    pub fn passthrough_host(mut self, host: impl Into<String>) -> Self {
608        self.push_passthrough_host(HostPattern::Exact(host.into()));
609        self
610    }
611
612    /// Allow hosts matching a wildcard pattern to receive secret placeholders without substitution.
613    pub fn passthrough_host_pattern(mut self, pattern: impl Into<String>) -> Self {
614        self.push_passthrough_host(HostPattern::Wildcard(pattern.into()));
615        self
616    }
617
618    /// Allow any host to receive secret placeholders without substitution.
619    pub fn passthrough_all_hosts(mut self, i_understand_the_risk: bool) -> Self {
620        if i_understand_the_risk {
621            self.push_passthrough_host(HostPattern::Any);
622        }
623        self
624    }
625
626    /// Helper to accumulate passthrough hosts into the current action.
627    fn push_passthrough_host(&mut self, host: HostPattern) {
628        match self.action {
629            ViolationAction::Passthrough(ref mut hosts) => hosts.push(host),
630            _ => self.action = ViolationAction::Passthrough(vec![host]),
631        }
632    }
633
634    /// Consume the builder and return the action.
635    pub fn build(self) -> ViolationAction {
636        self.action
637    }
638}
639
640//--------------------------------------------------------------------------------------------------
641// Trait Implementations
642//--------------------------------------------------------------------------------------------------
643
644impl Default for NetworkBuilder {
645    fn default() -> Self {
646        Self::new()
647    }
648}
649
650impl Default for TlsBuilder {
651    fn default() -> Self {
652        Self::new()
653    }
654}
655
656impl Default for SecretBuilder {
657    fn default() -> Self {
658        Self::new()
659    }
660}
661impl From<ViolationAction> for ViolationActionBuilder {
662    fn from(action: ViolationAction) -> Self {
663        Self { action }
664    }
665}
666
667//--------------------------------------------------------------------------------------------------
668// Tests
669//--------------------------------------------------------------------------------------------------
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    /// Network builder happy path returns the config unchanged.
676    #[test]
677    fn network_builder_happy_path_returns_config() {
678        let cfg = NetworkBuilder::new()
679            .dns(|d| d.rebind_protection(false))
680            .build()
681            .unwrap();
682        assert!(!cfg.dns.rebind_protection);
683    }
684
685    #[test]
686    fn port_bind_sets_host_bind() {
687        let bind = "0.0.0.0".parse().unwrap();
688        let cfg = NetworkBuilder::new()
689            .port_bind(bind, 8080, 80)
690            .port_udp_bind(bind, 5353, 53)
691            .build()
692            .unwrap();
693
694        assert_eq!(cfg.ports[0].host_bind, bind);
695        assert_eq!(cfg.ports[0].host_port, 8080);
696        assert_eq!(cfg.ports[0].guest_port, 80);
697        assert_eq!(cfg.ports[0].protocol, PortProtocol::Tcp);
698        assert_eq!(cfg.ports[1].host_bind, bind);
699        assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp);
700    }
701
702    #[test]
703    fn port_helpers_default_to_loopback() {
704        let cfg = NetworkBuilder::new()
705            .port(8080, 80)
706            .port_udp(5353, 53)
707            .build()
708            .unwrap();
709
710        assert_eq!(
711            cfg.ports[0].host_bind,
712            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
713        );
714        assert_eq!(cfg.ports[0].protocol, PortProtocol::Tcp);
715        assert_eq!(
716            cfg.ports[1].host_bind,
717            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
718        );
719        assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp);
720    }
721
722    #[test]
723    fn network_builder_sets_global_passthrough_action() {
724        let cfg = NetworkBuilder::new()
725            .on_secret_violation(|v| {
726                v.passthrough_host("api.anthropic.com")
727                    .passthrough_host_pattern("*.anthropic.com")
728            })
729            .build()
730            .unwrap();
731
732        assert_eq!(
733            cfg.secrets.on_violation,
734            ViolationAction::Passthrough(vec![
735                HostPattern::Exact("api.anthropic.com".into()),
736                HostPattern::Wildcard("*.anthropic.com".into()),
737            ])
738        );
739    }
740
741    #[test]
742    fn secret_builder_sets_violation_action() {
743        let secret = SecretBuilder::new()
744            .env("TOKEN")
745            .value("secret-value")
746            .allow_host("api.github.com")
747            .on_violation(|v| {
748                v.passthrough_host("api.anthropic.com")
749                    .passthrough_host_pattern("*.anthropic.com")
750            })
751            .build();
752
753        assert_eq!(
754            secret.on_violation,
755            Some(ViolationAction::Passthrough(vec![
756                HostPattern::Exact("api.anthropic.com".into()),
757                HostPattern::Wildcard("*.anthropic.com".into()),
758            ])),
759        );
760    }
761
762    #[test]
763    #[should_panic(expected = "SecretBuilder: at least one allowed host is required")]
764    fn secret_builder_rejects_empty_allowed_hosts() {
765        let _ = SecretBuilder::new()
766            .env("TOKEN")
767            .value("secret-value")
768            .build();
769    }
770
771    #[test]
772    fn secret_builder_source_yields_reference_and_empty_value() {
773        let secret = SecretBuilder::new()
774            .env("API_KEY")
775            .source(SecretSource::Env {
776                var: "HOST_API_KEY".into(),
777            })
778            .allow_host("api.example.com")
779            .build();
780
781        assert!(secret.value.is_empty());
782        assert_eq!(
783            secret.source,
784            Some(SecretSource::Env {
785                var: "HOST_API_KEY".into()
786            })
787        );
788
789        // Serialized durable form carries the reference, not a value.
790        let json = serde_json::to_string(&secret).unwrap();
791        assert!(json.contains("\"var\":\"HOST_API_KEY\""));
792    }
793
794    #[test]
795    #[should_panic(expected = "exactly one of .value() or .source()")]
796    fn secret_builder_rejects_both_value_and_source() {
797        let _ = SecretBuilder::new()
798            .env("API_KEY")
799            .value("inline")
800            .source(SecretSource::Env {
801                var: "HOST_API_KEY".into(),
802            })
803            .allow_host("api.example.com")
804            .build();
805    }
806
807    #[test]
808    fn network_builder_rejects_invalid_secret_config() {
809        let err = NetworkBuilder::new()
810            .secret_entry(SecretEntry {
811                env_var: "API=KEY".into(),
812                value: Zeroizing::new("secret-value".into()),
813                source: None,
814                placeholder: "$MSB_API_KEY".into(),
815                allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
816                injection: SecretInjection::default(),
817                on_violation: None,
818                require_tls_identity: true,
819            })
820            .build()
821            .unwrap_err();
822
823        assert!(err.to_string().contains("env_var must not contain `=`"));
824    }
825
826    #[test]
827    fn violation_action_builder_blocking_call_replaces_passthrough_policy() {
828        let action = ViolationActionBuilder::default()
829            .passthrough_host("google.com")
830            .block_and_terminate()
831            .passthrough_host("facebook.com")
832            .build();
833
834        assert_eq!(
835            action,
836            ViolationAction::Passthrough(vec![HostPattern::Exact("facebook.com".into())])
837        );
838    }
839
840    #[test]
841    fn violation_action_builder_accumulates_passthrough_hosts() {
842        let action = ViolationActionBuilder::default()
843            .block()
844            .passthrough_host("google.com")
845            .passthrough_host("facebook.com")
846            .build();
847
848        assert_eq!(
849            action,
850            ViolationAction::Passthrough(vec![
851                HostPattern::Exact("google.com".into()),
852                HostPattern::Exact("facebook.com".into()),
853            ]),
854        );
855    }
856}