Skip to main content

microsandbox_types/
domain.rs

1//! Shared sandbox domain types.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::net::{Ipv4Addr, Ipv6Addr};
6use std::path::PathBuf;
7use std::str::FromStr;
8
9use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
10use serde::{Deserialize, Serialize};
11use zeroize::Zeroizing;
12
13use crate::modify::SecretSource;
14
15//--------------------------------------------------------------------------------------------------
16// Constants
17//--------------------------------------------------------------------------------------------------
18
19/// Default number of virtual CPUs in a sandbox specification.
20pub const DEFAULT_SANDBOX_VCPUS: u8 = 1;
21
22/// Default guest memory in MiB in a sandbox specification.
23pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
24
25/// Default metrics sampling interval in milliseconds.
26pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
27
28//--------------------------------------------------------------------------------------------------
29// Types: Root Filesystems
30//--------------------------------------------------------------------------------------------------
31
32/// Disk image format for virtio-blk root filesystems and volume mounts.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
35#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
36#[serde(rename_all = "snake_case")]
37pub enum DiskImageFormat {
38    /// QEMU Copy-on-Write v2.
39    #[serde(alias = "Qcow2")]
40    Qcow2,
41    /// Raw disk image.
42    #[serde(alias = "Raw")]
43    Raw,
44    /// VMware Disk (FLAT/ZERO only, no delta links).
45    #[serde(alias = "Vmdk")]
46    Vmdk,
47}
48
49/// Root filesystem source for a sandbox.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
52#[serde(rename_all = "snake_case")]
53pub enum RootfsSource {
54    /// Use a host directory directly as the root filesystem.
55    #[serde(alias = "Bind")]
56    Bind(
57        /// Host path to bind mount.
58        #[cfg_attr(feature = "ts", ts(type = "string"))]
59        PathBuf,
60    ),
61
62    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
63    #[serde(alias = "Oci")]
64    Oci(OciRootfsSource),
65
66    /// Use a disk image file as the root filesystem via virtio-blk.
67    #[serde(alias = "DiskImage")]
68    DiskImage {
69        /// Path to the disk image file on the host.
70        #[cfg_attr(feature = "ts", ts(type = "string"))]
71        path: PathBuf,
72        /// Disk image format.
73        format: DiskImageFormat,
74        /// Inner filesystem type (optional; auto-detected if absent).
75        fstype: Option<String>,
76    },
77}
78
79/// OCI root filesystem source.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
82#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
83pub struct OciRootfsSource {
84    /// OCI image reference (e.g. `python`).
85    pub reference: String,
86
87    /// Writable overlay upper size in MiB.
88    #[serde(
89        default,
90        alias = "disk_size_mib",
91        skip_serializing_if = "Option::is_none"
92    )]
93    pub upper_size_mib: Option<u32>,
94}
95
96/// Controls when an OCI registry is contacted for manifest freshness.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
99#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
100#[serde(rename_all = "snake_case")]
101pub enum PullPolicy {
102    /// Use cached layers if complete, pull otherwise.
103    #[default]
104    #[serde(alias = "IfMissing")]
105    IfMissing,
106
107    /// Always fetch the manifest from the registry, reusing cached layers whose digests still match.
108    #[serde(alias = "Always")]
109    Always,
110
111    /// Never contact the registry. Error if the image is not fully cached locally.
112    #[serde(alias = "Never")]
113    Never,
114}
115
116//--------------------------------------------------------------------------------------------------
117// Types: Mounts
118//--------------------------------------------------------------------------------------------------
119
120/// Stat virtualization policy for a virtiofs-backed volume mount.
121///
122/// Serializes/deserializes as the lowercase variant name (`"strict"`, `"relaxed"`, `"off"`) so persisted JSON aligns with the CLI grammar (`stat-virt=strict|relaxed|off`) and the NAPI string contract.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
125#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
126#[serde(rename_all = "lowercase")]
127pub enum StatVirtualization {
128    /// Fail-closed: probe the host backing path; require xattr support.
129    Strict,
130    /// Opportunistic: apply the overlay when present; tolerate missing xattr support.
131    Relaxed,
132    /// Literal host metadata: do not read or apply the override xattr.
133    Off,
134}
135
136/// Host permission propagation policy for a virtiofs-backed volume mount.
137///
138/// Serializes/deserializes as the lowercase variant name (`"private"`, `"mirror"`) to align with the CLI and NAPI spellings.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
141#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
142#[serde(rename_all = "lowercase")]
143pub enum HostPermissions {
144    /// Guest chmod stays in the metadata overlay only.
145    Private,
146    /// Mirror ordinary rwx bits for regular files and directories to the host inode.
147    Mirror,
148}
149
150/// Sandbox-level in-guest security profile.
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
152#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
153#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
154#[serde(rename_all = "lowercase")]
155pub enum SecurityProfile {
156    /// Preserve normal guest-root semantics.
157    ///
158    /// Exec sessions do not set `no_new_privs` and keep `CAP_SYS_ADMIN`, so workflows such as `sudo`, package managers, and Docker-in-Docker work as they would in a regular VM.
159    #[default]
160    Default,
161
162    /// Harden guest exec sessions.
163    ///
164    /// Agentd sets `no_new_privs`, drops `CAP_SYS_ADMIN`, and forces `nosuid,nodev` on user mounts. Workloads that need privilege elevation or guest mount administration, such as `sudo` and Docker-in-Docker, are intentionally incompatible with this profile.
165    Restricted,
166}
167
168/// Guest mount behavior shared by every volume mount kind.
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
171#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
172#[serde(default)]
173pub struct MountOptions {
174    /// Whether the mount is read-only.
175    ///
176    /// Guest writes fail with the kernel's read-only filesystem behavior. Virtiofs-backed mounts also reject writes on the host-side filesystem server as defense in depth.
177    pub readonly: bool,
178
179    /// Whether direct execution from the mount is disabled.
180    ///
181    /// This prevents `execve` of binaries or scripts located on the mount. Interpreters can still read files from the mount, for example `sh /mnt/script.sh`, because the interpreter itself executes from a different filesystem.
182    pub noexec: bool,
183
184    /// Whether setuid and setgid privilege elevation from files on the mount is ignored.
185    pub nosuid: bool,
186
187    /// Whether device files on the mount are ignored.
188    pub nodev: bool,
189}
190
191/// Storage kind for a named volume.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
194#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
195#[serde(rename_all = "snake_case")]
196pub enum VolumeKind {
197    /// Directory-backed named volume mounted through virtiofs.
198    #[serde(alias = "Directory")]
199    Directory,
200
201    /// Raw ext4 disk-image named volume mounted through virtio-blk.
202    #[serde(alias = "Disk")]
203    Disk,
204}
205
206/// Configuration for creating a named volume.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
209#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
210pub struct VolumeSpec {
211    /// Volume name.
212    pub name: String,
213
214    /// Storage kind.
215    pub kind: VolumeKind,
216
217    /// Size quota in MiB. `None` means unlimited.
218    pub quota_mib: Option<u32>,
219
220    /// Disk capacity in MiB. Required for disk volumes.
221    pub capacity_mib: Option<u32>,
222
223    /// Labels for organization.
224    pub labels: Vec<(String, String)>,
225}
226
227/// Sandbox-time behavior for a named volume mount.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
230#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
231#[serde(rename_all = "snake_case")]
232pub enum NamedVolumeMode {
233    /// Require the named volume to already exist.
234    #[serde(alias = "Existing")]
235    Existing,
236
237    /// Create the named volume and fail if it already exists.
238    #[serde(alias = "Create")]
239    Create,
240
241    /// Ensure the named volume exists, or reuse a compatible existing volume.
242    #[serde(alias = "EnsureExists")]
243    EnsureExists,
244}
245
246/// Creation metadata for sandbox-time named volume provisioning.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
249#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
250pub struct NamedVolumeCreate {
251    /// Creation behavior for this named volume mount.
252    pub mode: NamedVolumeMode,
253
254    /// Volume name to create or ensure exists.
255    pub name: String,
256
257    /// Storage kind to create or ensure exists.
258    pub kind: VolumeKind,
259
260    /// Directory quota in MiB, if configured.
261    pub quota_mib: Option<u32>,
262
263    /// Disk capacity in MiB, if configured.
264    pub capacity_mib: Option<u32>,
265
266    /// Labels to attach to newly-created volumes.
267    pub labels: Vec<(String, String)>,
268}
269
270/// Default stat-virtualization policy (`Strict`) for a deserialized volume mount.
271fn default_strict() -> StatVirtualization {
272    StatVirtualization::Strict
273}
274
275/// Default host-permission policy (`Private`) for a deserialized volume mount.
276fn default_private() -> HostPermissions {
277    HostPermissions::Private
278}
279
280/// A volume mount specification for a sandbox.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
283#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
284#[serde(rename_all = "snake_case")]
285pub enum VolumeMount {
286    /// Bind mount a host directory into the guest.
287    #[serde(alias = "Bind")]
288    Bind {
289        /// Host path to bind mount.
290        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
291        #[cfg_attr(feature = "ts", ts(type = "string"))]
292        host: PathBuf,
293        /// Guest mount path.
294        guest: String,
295        /// Guest mount behavior.
296        #[serde(default)]
297        options: MountOptions,
298        /// Guest-visible stat virtualization policy.
299        #[serde(default = "default_strict")]
300        stat_virtualization: StatVirtualization,
301        /// Host permission propagation policy.
302        #[serde(default = "default_private")]
303        host_permissions: HostPermissions,
304        /// Guest-write byte budget in MiB.
305        ///
306        /// Bounds how much the guest may add beyond the directory's existing
307        /// contents. `None` applies the protective default at spawn time; set a
308        /// value to override it.
309        #[serde(default)]
310        quota_mib: Option<u32>,
311    },
312
313    /// Mount a named volume into the guest.
314    #[serde(alias = "Named")]
315    Named {
316        /// Volume name.
317        name: String,
318        /// Guest mount path.
319        guest: String,
320        /// Creation metadata for sandbox-time named volume provisioning.
321        ///
322        /// This is transient and intentionally skipped when sandbox configs are persisted; restarting a sandbox mounts the already-created volume.
323        #[serde(skip)]
324        create: Option<NamedVolumeCreate>,
325        /// Guest mount behavior.
326        #[serde(default)]
327        options: MountOptions,
328        /// Guest-visible stat virtualization policy.
329        #[serde(default = "default_strict")]
330        stat_virtualization: StatVirtualization,
331        /// Host permission propagation policy.
332        #[serde(default = "default_private")]
333        host_permissions: HostPermissions,
334    },
335
336    /// Temporary filesystem backed by guest memory.
337    #[serde(alias = "Tmpfs")]
338    Tmpfs {
339        /// Guest mount path.
340        guest: String,
341        /// Size limit in MiB.
342        #[serde(default)]
343        size_mib: Option<u32>,
344        /// Guest mount behavior.
345        #[serde(default)]
346        options: MountOptions,
347    },
348
349    /// Mount a disk image file as a virtio-blk device at a guest path.
350    #[serde(alias = "DiskImage")]
351    DiskImage {
352        /// Host path to the disk image file.
353        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
354        #[cfg_attr(feature = "ts", ts(type = "string"))]
355        host: PathBuf,
356        /// Guest mount path.
357        guest: String,
358        /// Disk image format.
359        format: DiskImageFormat,
360        /// Inner filesystem type. When `None`, agentd probes `/proc/filesystems`.
361        #[serde(default)]
362        fstype: Option<String>,
363        /// Guest mount behavior.
364        #[serde(default)]
365        options: MountOptions,
366    },
367}
368
369/// Rootfs patch applied before VM startup.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
372#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
373#[serde(rename_all = "snake_case")]
374pub enum Patch {
375    /// Write text content to a file.
376    #[serde(alias = "Text")]
377    Text {
378        /// Absolute guest path, such as `/etc/app.conf`.
379        path: String,
380        /// Text content to write.
381        content: String,
382        /// File permissions, such as `0o644`. `None` uses the default.
383        mode: Option<u32>,
384        /// Allow replacing a file that already exists in the rootfs.
385        replace: bool,
386    },
387
388    /// Write raw bytes to a file.
389    #[serde(alias = "File")]
390    File {
391        /// Absolute guest path.
392        path: String,
393        /// Raw byte content to write.
394        content: Vec<u8>,
395        /// File permissions, such as `0o644`. `None` uses the default.
396        mode: Option<u32>,
397        /// Allow replacing a file that already exists in the rootfs.
398        replace: bool,
399    },
400
401    /// Copy a file from the host into the rootfs.
402    #[serde(alias = "CopyFile")]
403    CopyFile {
404        /// Host path to copy from.
405        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
406        #[cfg_attr(feature = "ts", ts(type = "string"))]
407        src: PathBuf,
408        /// Absolute guest destination path.
409        dst: String,
410        /// File permissions. `None` preserves source permissions.
411        mode: Option<u32>,
412        /// Allow replacing a file that already exists in the rootfs.
413        replace: bool,
414    },
415
416    /// Copy a directory from the host into the rootfs.
417    #[serde(alias = "CopyDir")]
418    CopyDir {
419        /// Host directory to copy from.
420        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
421        #[cfg_attr(feature = "ts", ts(type = "string"))]
422        src: PathBuf,
423        /// Absolute guest destination path.
424        dst: String,
425        /// Allow replacing files that already exist in the rootfs.
426        replace: bool,
427    },
428
429    /// Create a symlink.
430    #[serde(alias = "Symlink")]
431    Symlink {
432        /// Symlink target path.
433        target: String,
434        /// Absolute guest path where the symlink is created.
435        link: String,
436        /// Allow replacing a path that already exists in the rootfs.
437        replace: bool,
438    },
439
440    /// Create a directory.
441    #[serde(alias = "Mkdir")]
442    Mkdir {
443        /// Absolute guest path.
444        path: String,
445        /// Directory permissions, such as `0o755`. `None` uses the default.
446        mode: Option<u32>,
447    },
448
449    /// Remove a file or directory.
450    #[serde(alias = "Remove")]
451    Remove {
452        /// Absolute guest path to remove.
453        path: String,
454    },
455
456    /// Append content to an existing file.
457    #[serde(alias = "Append")]
458    Append {
459        /// Absolute guest path of the file to append to.
460        path: String,
461        /// Content to append.
462        content: String,
463    },
464}
465
466//--------------------------------------------------------------------------------------------------
467// Types: Secret injection
468//--------------------------------------------------------------------------------------------------
469
470/// Maximum supported secret placeholder length in bytes.
471pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
472
473/// Placeholder-based secret injection for a sandbox's TLS-intercepted egress.
474///
475/// The sandbox only ever sees each secret's `placeholder`; the local network
476/// engine substitutes the real `value` into outbound requests bound for an
477/// allowed host (and blocks/forwards per [`ViolationAction`] otherwise). Carried
478/// in [`NetworkSpec::secrets`](NetworkSpec).
479#[derive(Debug, Clone, Default, Serialize, Deserialize)]
480#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
481#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
482pub struct SecretsConfig {
483    /// List of secrets to inject.
484    #[serde(default, alias = "secrets")]
485    pub entries: Vec<SecretEntry>,
486
487    /// Default action when a placeholder leaks to a disallowed host.
488    #[serde(default)]
489    pub on_violation: ViolationAction,
490}
491
492/// A single secret entry.
493///
494/// `value` is the sensitive material — it never enters the sandbox and is
495/// redacted by the [`Debug`](fmt::Debug) impl.
496#[derive(Clone, Serialize, Deserialize)]
497#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
498#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
499pub struct SecretEntry {
500    /// Environment variable name exposed to the sandbox (holds the placeholder).
501    ///
502    /// Must be non-empty and must not contain `=` or NUL. microsandbox does
503    /// not require shell-identifier syntax because Linux environment entries
504    /// only require a `NAME=value` shape.
505    pub env_var: String,
506
507    /// The actual secret value (never enters the sandbox).
508    ///
509    /// Empty when the entry carries a [`source`](Self::source) reference
510    /// instead: reference-model entries resolve the value host-side at spawn
511    /// time so the durable sandbox config never stores raw secret material.
512    ///
513    /// Wrapped in [`Zeroizing`] so the owned plaintext copy is wiped when the
514    /// entry drops.
515    #[serde(default = "empty_secret_value")]
516    #[cfg_attr(feature = "ts", ts(type = "string"))]
517    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
518    pub value: Zeroizing<String>,
519
520    /// Host-side source reference resolved into [`value`](Self::value) at
521    /// spawn time. `None` means `value` already carries the material (the
522    /// inline model used by value-based secrets).
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub source: Option<SecretSource>,
525
526    /// Placeholder string the sandbox sees instead of the real value.
527    ///
528    /// Must be non-empty, no longer than [`MAX_SECRET_PLACEHOLDER_BYTES`], and
529    /// must not contain NUL, CR, or LF.
530    pub placeholder: String,
531
532    /// Hosts allowed to receive this secret.
533    #[serde(default)]
534    pub allowed_hosts: Vec<HostPattern>,
535
536    /// Where the secret can be injected.
537    #[serde(default)]
538    pub injection: SecretInjection,
539
540    /// Action on a violation for this secret (overrides the config default).
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub on_violation: Option<ViolationAction>,
543
544    /// Require verified TLS identity before substituting (default: true).
545    ///
546    /// When true, the secret is only substituted if the connection uses TLS
547    /// interception (not bypass) and the SNI matches an allowed host.
548    #[serde(default = "default_true")]
549    pub require_tls_identity: bool,
550}
551
552/// Host pattern for a secret allowlist.
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
555#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
556#[serde(rename_all = "snake_case")]
557pub enum HostPattern {
558    /// Exact hostname match.
559    #[serde(alias = "Exact")]
560    Exact(String),
561    /// Wildcard match (e.g., `*.openai.com`).
562    #[serde(alias = "Wildcard")]
563    Wildcard(String),
564    /// Any host (dangerous — secret can be exfiltrated).
565    #[serde(alias = "Any")]
566    Any,
567}
568
569/// Where in the HTTP request a secret can be injected.
570#[derive(Debug, Clone, Serialize, Deserialize)]
571#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
572#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
573pub struct SecretInjection {
574    /// Substitute in HTTP headers (default: true).
575    #[serde(default = "default_true")]
576    pub headers: bool,
577
578    /// Substitute in HTTP Basic Auth (default: true).
579    #[serde(default = "default_true")]
580    pub basic_auth: bool,
581
582    /// Substitute in URL query parameters (default: false).
583    #[serde(default)]
584    pub query_params: bool,
585
586    /// Substitute in request body (default: false).
587    ///
588    /// Fixed-length HTTP/1 bodies up to 16 MiB update `Content-Length`;
589    /// larger fixed-length bodies are blocked. Chunked HTTP/1 bodies are
590    /// decoded and re-encoded with fresh chunk sizes. Encoded bodies pass
591    /// through unchanged. HTTP/2 DATA-frame body substitution is not
592    /// supported; matching body placeholders are blocked.
593    #[serde(default)]
594    pub body: bool,
595}
596
597/// Action when a secret placeholder is detected going to a disallowed host.
598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
599#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
600#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
601#[serde(rename_all = "snake_case")]
602pub enum ViolationAction {
603    /// Block the request silently.
604    #[serde(alias = "Block")]
605    Block,
606    /// Block and log (default).
607    #[default]
608    #[serde(alias = "BlockAndLog", alias = "block-and-log")]
609    BlockAndLog,
610    /// Block and terminate the sandbox.
611    #[serde(alias = "BlockAndTerminate", alias = "block-and-terminate")]
612    BlockAndTerminate,
613    /// Forward the request with the placeholder unchanged for matching hosts.
614    #[serde(alias = "Passthrough")]
615    Passthrough(Vec<HostPattern>),
616}
617
618/// Invalid secret configuration.
619#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
620pub enum SecretConfigError {
621    /// The environment variable name is empty.
622    #[error("secret #{secret_index}: env_var must not be empty")]
623    EmptyEnvVar {
624        /// Index of the invalid secret entry.
625        secret_index: usize,
626    },
627
628    /// The environment variable name contains `=`.
629    #[error("secret #{secret_index}: env_var must not contain `=`")]
630    EnvVarContainsEquals {
631        /// Index of the invalid secret entry.
632        secret_index: usize,
633    },
634
635    /// The environment variable name contains NUL.
636    #[error("secret #{secret_index}: env_var must not contain NUL")]
637    EnvVarContainsNul {
638        /// Index of the invalid secret entry.
639        secret_index: usize,
640    },
641
642    /// No allowed hosts were configured for a secret.
643    #[error("secret #{secret_index}: at least one allowed host is required")]
644    MissingAllowedHosts {
645        /// Index of the invalid secret entry.
646        secret_index: usize,
647    },
648
649    /// The placeholder is empty.
650    #[error("secret #{secret_index}: placeholder must not be empty")]
651    EmptyPlaceholder {
652        /// Index of the invalid secret entry.
653        secret_index: usize,
654    },
655
656    /// The placeholder exceeds the supported byte length.
657    #[error(
658        "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
659    )]
660    PlaceholderTooLong {
661        /// Index of the invalid secret entry.
662        secret_index: usize,
663        /// Actual placeholder length in bytes.
664        actual_bytes: usize,
665        /// Maximum supported placeholder length in bytes.
666        max_bytes: usize,
667    },
668
669    /// The placeholder contains NUL.
670    #[error("secret #{secret_index}: placeholder must not contain NUL")]
671    PlaceholderContainsNul {
672        /// Index of the invalid secret entry.
673        secret_index: usize,
674    },
675
676    /// The placeholder contains a line break.
677    #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
678    PlaceholderContainsLineBreak {
679        /// Index of the invalid secret entry.
680        secret_index: usize,
681    },
682}
683
684impl SecretsConfig {
685    /// Validate all configured secret entries.
686    pub fn validate(&self) -> Result<(), SecretConfigError> {
687        for (index, secret) in self.entries.iter().enumerate() {
688            secret.validate(index)?;
689        }
690        Ok(())
691    }
692}
693
694impl SecretEntry {
695    /// Validate this secret entry.
696    pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
697        validate_env_var(&self.env_var, secret_index)?;
698
699        if self.allowed_hosts.is_empty() {
700            return Err(SecretConfigError::MissingAllowedHosts { secret_index });
701        }
702
703        validate_placeholder(&self.placeholder, secret_index)
704    }
705}
706
707// The secret value must never reach a log line or an error message.
708impl fmt::Debug for SecretEntry {
709    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
710        f.debug_struct("SecretEntry")
711            .field("env_var", &self.env_var)
712            .field("value", &"[REDACTED]")
713            .field("source", &self.source)
714            .field("placeholder", &self.placeholder)
715            .field("allowed_hosts", &self.allowed_hosts)
716            .field("injection", &self.injection)
717            .field("on_violation", &self.on_violation)
718            .field("require_tls_identity", &self.require_tls_identity)
719            .finish()
720    }
721}
722
723impl HostPattern {
724    /// Parse a user-facing host string: `*` is any host, `*.`-prefixed
725    /// strings are wildcards, everything else matches exactly.
726    pub fn parse(host: &str) -> Self {
727        if host == "*" {
728            HostPattern::Any
729        } else if host.starts_with("*.") {
730            HostPattern::Wildcard(host.to_string())
731        } else {
732            HostPattern::Exact(host.to_string())
733        }
734    }
735
736    /// Check if a hostname matches this pattern.
737    ///
738    /// Uses ASCII case-insensitive comparison to avoid `to_lowercase()`
739    /// allocations (DNS hostnames are ASCII per RFC 4343).
740    pub fn matches(&self, hostname: &str) -> bool {
741        match self {
742            HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
743            HostPattern::Wildcard(pattern) => {
744                if let Some(suffix) = pattern.strip_prefix("*.") {
745                    hostname.eq_ignore_ascii_case(suffix)
746                        || (hostname.len() > suffix.len() + 1
747                            && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
748                            && hostname[hostname.len() - suffix.len()..]
749                                .eq_ignore_ascii_case(suffix))
750                } else {
751                    hostname.eq_ignore_ascii_case(pattern)
752                }
753            }
754            HostPattern::Any => true,
755        }
756    }
757}
758
759impl Default for SecretInjection {
760    fn default() -> Self {
761        Self {
762            headers: true,
763            basic_auth: true,
764            query_params: false,
765            body: false,
766        }
767    }
768}
769
770fn default_true() -> bool {
771    true
772}
773
774fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
775    if env_var.is_empty() {
776        return Err(SecretConfigError::EmptyEnvVar { secret_index });
777    }
778    if env_var.contains('=') {
779        return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
780    }
781    if env_var.contains('\0') {
782        return Err(SecretConfigError::EnvVarContainsNul { secret_index });
783    }
784    Ok(())
785}
786
787fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
788    if placeholder.is_empty() {
789        return Err(SecretConfigError::EmptyPlaceholder { secret_index });
790    }
791
792    let actual_bytes = placeholder.len();
793    if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
794        return Err(SecretConfigError::PlaceholderTooLong {
795            secret_index,
796            actual_bytes,
797            max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
798        });
799    }
800
801    if placeholder.contains('\0') {
802        return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
803    }
804    if placeholder.contains('\r') || placeholder.contains('\n') {
805        return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
806    }
807
808    Ok(())
809}
810
811//--------------------------------------------------------------------------------------------------
812// Types: TLS interception
813//--------------------------------------------------------------------------------------------------
814
815/// TLS interception configuration. Carried in [`NetworkSpec::tls`](NetworkSpec).
816///
817/// The local network engine terminates TCP at its in-process stack, so TLS MITM
818/// is handled by proxy tasks — these fields configure which ports/domains are
819/// intercepted and how the interception CA is sourced.
820#[derive(Debug, Clone, Serialize, Deserialize)]
821#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
822#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
823pub struct TlsConfig {
824    /// Whether TLS interception is enabled.
825    #[serde(default)]
826    pub enabled: bool,
827
828    /// TCP ports subject to TLS interception (default: `[443]`).
829    #[serde(default = "default_intercepted_ports")]
830    pub intercepted_ports: Vec<u16>,
831
832    /// Domains to bypass (no MITM). Supports exact match and `*.suffix` wildcards.
833    #[serde(default)]
834    pub bypass: Vec<String>,
835
836    /// Whether to verify the upstream server's TLS certificate.
837    #[serde(default = "default_true")]
838    pub verify_upstream: bool,
839
840    /// Drop UDP to intercepted ports when TLS interception is active, forcing
841    /// QUIC traffic to fall back to TCP/TLS.
842    #[serde(default = "default_true")]
843    pub block_quic_on_intercept: bool,
844
845    /// CA certificate PEM files to trust for upstream server verification.
846    #[serde(default)]
847    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
848    #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
849    pub upstream_ca_cert: Vec<PathBuf>,
850
851    /// Host-scoped CA certificate PEM files to trust for upstream server verification.
852    #[serde(default, alias = "scoped_upstream_ca_certs")]
853    pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
854
855    /// Host-scoped upstream verification overrides.
856    #[serde(default)]
857    pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
858
859    /// Interception CA configuration. The TLS proxy uses this CA to sign
860    /// per-domain certs it presents to the guest during interception.
861    #[serde(default, alias = "ca")]
862    pub intercept_ca: InterceptCaConfig,
863
864    /// Per-domain certificate cache configuration.
865    #[serde(default)]
866    pub cache: CertCacheConfig,
867}
868
869/// Certificate authority configuration for TLS interception.
870#[derive(Debug, Clone, Default, Serialize, Deserialize)]
871#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
872#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
873pub struct InterceptCaConfig {
874    /// Path to an existing CA certificate PEM file. If `None`, a CA is
875    /// auto-generated and persisted.
876    #[serde(default)]
877    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
878    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
879    pub cert_path: Option<PathBuf>,
880
881    /// Path to an existing CA private key PEM file. If `None`, a key is
882    /// auto-generated and persisted.
883    #[serde(default)]
884    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
885    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
886    pub key_path: Option<PathBuf>,
887}
888
889/// Per-domain certificate cache configuration.
890#[derive(Debug, Clone, Serialize, Deserialize)]
891#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
892#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
893pub struct CertCacheConfig {
894    /// Maximum number of cached certificates. Default: 1000.
895    #[serde(default = "default_cache_capacity")]
896    pub capacity: usize,
897
898    /// Certificate validity duration in hours. Default: 24.
899    #[serde(default = "default_cert_validity_hours")]
900    pub validity_hours: u64,
901}
902
903/// A CA certificate PEM file trusted only for matching upstream hosts.
904#[derive(Debug, Clone, Serialize, Deserialize)]
905#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
906#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
907pub struct ScopedUpstreamCaCert {
908    /// Host pattern this CA applies to. Supports exact hosts and `*.suffix` wildcards.
909    pub pattern: String,
910
911    /// Path to the CA certificate PEM file.
912    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
913    #[cfg_attr(feature = "ts", ts(type = "string"))]
914    pub path: PathBuf,
915}
916
917/// An upstream certificate verification override for matching hosts.
918#[derive(Debug, Clone, Serialize, Deserialize)]
919#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
920#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
921pub struct ScopedVerifyUpstream {
922    /// Host pattern this override applies to. Supports exact hosts and `*.suffix` wildcards.
923    pub pattern: String,
924
925    /// Whether to verify matching upstream server certificates.
926    pub verify: bool,
927}
928
929impl Default for TlsConfig {
930    fn default() -> Self {
931        Self {
932            enabled: false,
933            intercepted_ports: default_intercepted_ports(),
934            bypass: Vec::new(),
935            verify_upstream: true,
936            block_quic_on_intercept: true,
937            upstream_ca_cert: Vec::new(),
938            scoped_upstream_ca_cert: Vec::new(),
939            scoped_verify_upstream: Vec::new(),
940            intercept_ca: InterceptCaConfig::default(),
941            cache: CertCacheConfig::default(),
942        }
943    }
944}
945
946impl Default for CertCacheConfig {
947    fn default() -> Self {
948        Self {
949            capacity: default_cache_capacity(),
950            validity_hours: default_cert_validity_hours(),
951        }
952    }
953}
954
955fn default_intercepted_ports() -> Vec<u16> {
956    vec![443]
957}
958
959fn default_cache_capacity() -> usize {
960    1000
961}
962
963fn default_cert_validity_hours() -> u64 {
964    24
965}
966
967//--------------------------------------------------------------------------------------------------
968// Types: Networking — policy
969//--------------------------------------------------------------------------------------------------
970
971/// Action to take on traffic matched by a [`Rule`] (or a policy default).
972#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
973#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
974#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
975#[serde(rename_all = "kebab-case")]
976pub enum Action {
977    /// Allow the traffic.
978    Allow,
979    /// Silently drop the traffic.
980    Deny,
981}
982
983/// Direction a [`Rule`] applies to.
984#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
985#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
986#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
987#[serde(rename_all = "kebab-case")]
988pub enum Direction {
989    /// Outbound: guest → destination.
990    Egress,
991    /// Inbound: peer → guest.
992    Ingress,
993    /// Either direction.
994    Any,
995}
996
997/// Protocol filter for a [`Rule`].
998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
999#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1000#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1001#[serde(rename_all = "kebab-case")]
1002pub enum Protocol {
1003    /// TCP.
1004    Tcp,
1005    /// UDP.
1006    Udp,
1007    /// ICMPv4.
1008    Icmpv4,
1009    /// ICMPv6.
1010    Icmpv6,
1011}
1012
1013/// Pre-defined destination category for a [`Destination::Group`] match.
1014#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1015#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1016#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1017#[serde(rename_all = "snake_case")]
1018pub enum DestinationGroup {
1019    /// Public internet — any address not in another category.
1020    Public,
1021    /// Loopback addresses (`127.0.0.0/8`, `::1`).
1022    Loopback,
1023    /// Private ranges (RFC 1918 / RFC 4193 ULA / CGN).
1024    Private,
1025    /// Link-local addresses, excluding the metadata IP.
1026    #[serde(alias = "link-local")]
1027    LinkLocal,
1028    /// Cloud metadata endpoint (`169.254.169.254`).
1029    Metadata,
1030    /// Multicast addresses (`224.0.0.0/4`, `ff00::/8`).
1031    Multicast,
1032    /// The sandbox host, reachable via the gateway IP.
1033    Host,
1034}
1035
1036/// Traffic destination filter for a [`Rule`].
1037///
1038/// The `Cidr`, `Domain`, and `DomainSuffix` leaves carry their canonical
1039/// string form (e.g. `"10.0.0.0/8"`, `"example.com"`); the local network
1040/// engine re-parses and validates them into its richer internal types at
1041/// load time.
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1043#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1044#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1045#[serde(rename_all = "snake_case")]
1046pub enum Destination {
1047    /// Match any destination.
1048    Any,
1049    /// IP address or CIDR block (e.g. `"1.2.3.4"`, `"10.0.0.0/8"`).
1050    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1051    Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
1052    /// Exact domain name (e.g. `"example.com"`).
1053    Domain(String),
1054    /// Domain suffix — the apex and any subdomain of it.
1055    #[serde(alias = "domain-suffix")]
1056    DomainSuffix(String),
1057    /// A pre-defined destination group.
1058    Group(DestinationGroup),
1059}
1060
1061/// Inclusive guest-side port range for a [`Rule`] match.
1062#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1063#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1064#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1065pub struct PortRange {
1066    /// Start port (inclusive).
1067    pub start: u16,
1068    /// End port (inclusive).
1069    pub end: u16,
1070}
1071
1072/// A single egress/ingress policy rule. Evaluated first-match-wins per
1073/// direction.
1074#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1075#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1076#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1077pub struct Rule {
1078    /// Direction this rule applies to.
1079    pub direction: Direction,
1080    /// Destination filter (direction-dependent interpretation).
1081    pub destination: Destination,
1082    /// Protocol set; empty matches any protocol.
1083    #[serde(default)]
1084    pub protocols: Vec<Protocol>,
1085    /// Guest-side port-range set; empty matches any port.
1086    #[serde(default)]
1087    pub ports: Vec<PortRange>,
1088    /// Action to take on a match.
1089    pub action: Action,
1090}
1091
1092/// Egress/ingress network policy: an ordered [`Rule`] list plus a
1093/// per-direction default [`Action`]. Carried in [`NetworkSpec::policy`].
1094#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1095#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1096#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1097pub struct NetworkPolicy {
1098    /// Default action for egress traffic matching no rule. Default: `Deny`.
1099    #[serde(default = "action_deny")]
1100    pub default_egress: Action,
1101    /// Default action for ingress traffic matching no rule. Default: `Deny`.
1102    #[serde(default = "action_deny")]
1103    pub default_ingress: Action,
1104    /// Ordered rules, evaluated first-match-wins per direction.
1105    #[serde(default)]
1106    pub rules: Vec<Rule>,
1107}
1108
1109/// Default [`Action`] (`Deny`) for a policy's per-direction defaults, so a
1110/// partially-specified policy fails closed.
1111fn action_deny() -> Action {
1112    Action::Deny
1113}
1114
1115//--------------------------------------------------------------------------------------------------
1116// Types: Networking — DNS & interface
1117//--------------------------------------------------------------------------------------------------
1118
1119/// DNS interception and filtering settings. Carried in [`NetworkSpec::dns`].
1120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1121#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1122#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1123#[serde(default)]
1124pub struct DnsConfig {
1125    /// Whether DNS-rebinding protection is enabled. Default: true.
1126    pub rebind_protection: bool,
1127    /// Upstream nameservers as `IP`, `IP:PORT`, `HOST`, or `HOST:PORT`
1128    /// strings. Empty falls back to the host's `/etc/resolv.conf`.
1129    pub nameservers: Vec<String>,
1130    /// Per-query timeout in milliseconds. Default: 5000.
1131    pub query_timeout_ms: u64,
1132}
1133
1134impl Default for DnsConfig {
1135    fn default() -> Self {
1136        Self {
1137            rebind_protection: true,
1138            nameservers: Vec::new(),
1139            query_timeout_ms: 5000,
1140        }
1141    }
1142}
1143
1144/// Optional guest interface overrides. Unset fields are derived from the
1145/// sandbox slot by the local network engine. Carried in
1146/// [`NetworkSpec::interface`].
1147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1148#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1149#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1150#[serde(default)]
1151pub struct InterfaceOverrides {
1152    /// Guest MAC address as six octets. Default: derived from slot.
1153    #[serde(skip_serializing_if = "Option::is_none")]
1154    pub mac: Option<[u8; 6]>,
1155    /// Interface MTU. Default: 1500.
1156    #[serde(skip_serializing_if = "Option::is_none")]
1157    pub mtu: Option<u16>,
1158    /// Guest IPv4 address (e.g. `172.16.0.2`). Default: derived from slot.
1159    #[serde(skip_serializing_if = "Option::is_none")]
1160    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
1161    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
1162    pub ipv4_address: Option<Ipv4Addr>,
1163    /// Guest IPv4 pool CIDR (e.g. `"172.16.0.0/12"`). Default: derived from slot.
1164    #[serde(skip_serializing_if = "Option::is_none")]
1165    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
1166    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
1167    pub ipv4_pool: Option<Ipv4Network>,
1168    /// Guest IPv6 address. Default: derived from slot.
1169    #[serde(skip_serializing_if = "Option::is_none")]
1170    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
1171    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
1172    pub ipv6_address: Option<Ipv6Addr>,
1173    /// Guest IPv6 pool CIDR. Default: derived from slot.
1174    #[serde(skip_serializing_if = "Option::is_none")]
1175    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
1176    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
1177    pub ipv6_pool: Option<Ipv6Network>,
1178}
1179
1180//--------------------------------------------------------------------------------------------------
1181// Types: Networking — spec
1182//--------------------------------------------------------------------------------------------------
1183
1184/// Complete network specification for a sandbox.
1185///
1186/// All subdocuments are typed. The local-engine `policy`, `dns`, and
1187/// `interface` configs are mirrored here as wire types whose leaf values
1188/// (CIDRs, domain names, nameservers) are carried in their canonical string
1189/// form; the network engine re-parses these into its richer internal types at
1190/// load time, which keeps that engine's validation crates out of this shared
1191/// contract.
1192#[derive(Debug, Clone, Serialize, Deserialize)]
1193#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1194#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1195#[serde(default)]
1196pub struct NetworkSpec {
1197    /// Whether networking is enabled for this sandbox.
1198    pub enabled: bool,
1199
1200    /// Guest interface overrides for the local network engine.
1201    #[serde(skip_serializing_if = "Option::is_none")]
1202    pub interface: Option<InterfaceOverrides>,
1203
1204    /// Host-to-guest port mappings.
1205    pub ports: Vec<PublishedPortSpec>,
1206
1207    /// Egress and ingress policy.
1208    #[serde(skip_serializing_if = "Option::is_none")]
1209    pub policy: Option<NetworkPolicy>,
1210
1211    /// DNS interception and filtering.
1212    #[serde(skip_serializing_if = "Option::is_none")]
1213    pub dns: Option<DnsConfig>,
1214
1215    /// TLS-interception subdocument (see [`TlsConfig`]).
1216    #[serde(skip_serializing_if = "Option::is_none")]
1217    pub tls: Option<TlsConfig>,
1218
1219    /// Placeholder-based secret-injection subdocument (see [`SecretsConfig`]).
1220    #[serde(skip_serializing_if = "Option::is_none")]
1221    pub secrets: Option<SecretsConfig>,
1222
1223    /// Max concurrent guest connections.
1224    pub max_connections: Option<usize>,
1225
1226    /// Whether to copy trusted host CAs into the guest at boot.
1227    pub trust_host_cas: bool,
1228}
1229
1230/// A published port mapping between host and guest.
1231#[derive(Debug, Clone, Serialize, Deserialize)]
1232#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1233#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1234pub struct PublishedPortSpec {
1235    /// Host-side port to bind.
1236    pub host_port: u16,
1237
1238    /// Guest-side port to forward to.
1239    pub guest_port: u16,
1240
1241    /// Transport protocol.
1242    #[serde(default)]
1243    pub protocol: PortProtocol,
1244
1245    /// Host address to bind. Defaults to loopback.
1246    pub host_bind: String,
1247}
1248
1249/// Transport protocol for a published port.
1250#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1251#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1252#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1253pub enum PortProtocol {
1254    /// TCP.
1255    #[default]
1256    #[serde(rename = "tcp")]
1257    Tcp,
1258
1259    /// UDP.
1260    #[serde(rename = "udp")]
1261    Udp,
1262}
1263
1264//--------------------------------------------------------------------------------------------------
1265// Types: Init
1266//--------------------------------------------------------------------------------------------------
1267
1268/// Fully-assembled handoff-init specification.
1269#[derive(Debug, Clone, Serialize, Deserialize)]
1270#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1271#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1272pub struct HandoffInit {
1273    /// Init binary: absolute path inside the guest rootfs, or the literal `auto`.
1274    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1275    #[cfg_attr(feature = "ts", ts(type = "string"))]
1276    pub cmd: PathBuf,
1277
1278    /// Supplemental argv. `argv[0]` is implicitly `cmd`.
1279    #[serde(default)]
1280    pub args: Vec<String>,
1281
1282    /// Extra env vars merged on top of the inherited env.
1283    #[serde(default)]
1284    pub env: Vec<(String, String)>,
1285}
1286
1287//--------------------------------------------------------------------------------------------------
1288// Types: Lifecycle
1289//--------------------------------------------------------------------------------------------------
1290
1291/// Sandbox lifecycle policy.
1292#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1293#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1294#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1295pub struct SandboxPolicy {
1296    /// Whether the sandbox is ephemeral.
1297    ///
1298    /// Ephemeral sandboxes are one-off: the host runtime that owns the
1299    /// process removes the persisted DB row and on-disk state when the VM
1300    /// reaches a terminal status, and other host runtimes opportunistically
1301    /// clean up ephemeral leftovers from runtimes that died before they
1302    /// could self-clean. Defaults to `false` (persistent); named and created
1303    /// sandboxes stay inspectable and restartable after they stop.
1304    #[serde(default)]
1305    pub ephemeral: bool,
1306
1307    /// Hard cap on total sandbox lifetime in seconds. `None` = run forever.
1308    // typeshare rejects bare 64-bit ints (JS-unsafe); `U53` is its big-int
1309    // escape and the Go backend maps it to `uint64` — exact, since this crate's
1310    // typeshare output targets Go only.
1311    pub max_duration_secs: Option<u64>,
1312
1313    /// Idle timeout in seconds. `None` = no idle detection.
1314    pub idle_timeout_secs: Option<u64>,
1315}
1316
1317//--------------------------------------------------------------------------------------------------
1318// Types: Snapshots
1319//--------------------------------------------------------------------------------------------------
1320
1321/// Where to place a new snapshot artifact.
1322#[derive(Debug, Clone, Serialize, Deserialize)]
1323#[serde(rename_all = "lowercase")]
1324pub enum SnapshotDestination {
1325    /// Bare name resolved under the default snapshots directory.
1326    #[serde(alias = "Name")]
1327    Name(String),
1328
1329    /// Explicit absolute or relative path to the artifact directory.
1330    #[serde(alias = "Path")]
1331    Path(
1332        /// Destination path.
1333        PathBuf,
1334    ),
1335}
1336
1337/// Inputs to create a snapshot.
1338#[derive(Debug, Clone, Serialize, Deserialize)]
1339pub struct SnapshotSpec {
1340    /// Name of the source sandbox. Must be stopped.
1341    pub source_sandbox: String,
1342
1343    /// Where to write the artifact.
1344    pub destination: SnapshotDestination,
1345
1346    /// User-supplied labels.
1347    pub labels: Vec<(String, String)>,
1348
1349    /// Overwrite an existing artifact at the destination.
1350    pub force: bool,
1351
1352    /// Compute and record upper-layer content integrity at creation time.
1353    pub record_integrity: bool,
1354}
1355
1356//--------------------------------------------------------------------------------------------------
1357// Types: Sandbox Specs
1358//--------------------------------------------------------------------------------------------------
1359
1360/// Backend-neutral sandbox task description.
1361///
1362/// This is the durable contract for fields that are already shared across backends. Local-only execution state such as resolved manifest digests, snapshot upper-layer paths, registry credentials, replace flags, and backend dispatch stays outside this type.
1363#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1364#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1365#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1366#[serde(default)]
1367pub struct SandboxSpec {
1368    /// Unique sandbox name.
1369    pub name: String,
1370
1371    /// Root filesystem source.
1372    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
1373    pub image: RootfsSource,
1374
1375    /// CPU and memory resources.
1376    pub resources: SandboxResources,
1377
1378    /// Guest runtime options.
1379    pub runtime: SandboxRuntimeOptions,
1380
1381    /// Environment variables visible to commands in the sandbox.
1382    pub env: Vec<EnvVar>,
1383
1384    /// User-defined labels attached to the sandbox.
1385    pub labels: BTreeMap<String, String>,
1386
1387    /// Sandbox-wide resource limits inherited by guest processes.
1388    pub rlimits: Vec<Rlimit>,
1389
1390    /// Volume mounts.
1391    pub mounts: Vec<VolumeMount>,
1392
1393    /// Rootfs patches applied before VM start.
1394    pub patches: Vec<Patch>,
1395
1396    /// Network specification.
1397    pub network: NetworkSpec,
1398
1399    /// Hand off PID 1 to a guest init binary after agentd setup.
1400    pub init: Option<HandoffInit>,
1401
1402    /// Pull policy for OCI images.
1403    pub pull_policy: PullPolicy,
1404
1405    /// In-guest security profile.
1406    pub security_profile: SecurityProfile,
1407
1408    /// Sandbox lifecycle policy.
1409    pub lifecycle: SandboxPolicy,
1410}
1411
1412/// CPU and memory resources for a sandbox.
1413#[derive(Debug, Clone, Copy, Serialize)]
1414#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1415#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1416pub struct SandboxResources {
1417    /// Number of virtual CPUs currently presented to the guest at boot.
1418    pub vcpus: u8,
1419
1420    /// Guest memory currently presented to the guest at boot, in MiB.
1421    pub memory_mib: u32,
1422
1423    /// Maximum virtual CPUs the sandbox may expose after boot-time hotplug support lands.
1424    pub max_vcpus: u8,
1425
1426    /// Maximum guest memory the sandbox may expose after boot-time hotplug support lands, in MiB.
1427    pub max_memory_mib: u32,
1428}
1429
1430/// Guest runtime options for a sandbox.
1431#[derive(Debug, Clone, Serialize, Deserialize)]
1432#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1433#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1434#[serde(default)]
1435pub struct SandboxRuntimeOptions {
1436    /// Working directory inside the guest.
1437    pub workdir: Option<String>,
1438
1439    /// Default shell for scripts and interactive sessions.
1440    pub shell: Option<String>,
1441
1442    /// Named scripts available inside the guest.
1443    // typeshare doesn't recognize `BTreeMap`; serialize as a map for codegen
1444    // (identical JSON object shape) → Go `map[string]string`.
1445    pub scripts: BTreeMap<String, String>,
1446
1447    /// Image entrypoint override.
1448    pub entrypoint: Option<Vec<String>>,
1449
1450    /// Image command override.
1451    pub cmd: Option<Vec<String>>,
1452
1453    /// Guest hostname override.
1454    pub hostname: Option<String>,
1455
1456    /// Guest user identity override.
1457    pub user: Option<String>,
1458
1459    /// Runtime log verbosity.
1460    pub log_level: Option<SandboxLogLevel>,
1461
1462    /// Metrics sampling interval in milliseconds. `None` disables sampling.
1463    pub metrics_sample_interval_ms: Option<u64>,
1464
1465    /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`.
1466    pub disable_metrics_sample: bool,
1467}
1468
1469/// Environment variable entry.
1470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1471#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1472#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1473pub struct EnvVar {
1474    /// Environment variable name.
1475    pub key: String,
1476
1477    /// Environment variable value.
1478    pub value: String,
1479}
1480
1481/// Runtime log verbosity for sandbox specs.
1482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1483#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1484#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1485#[serde(rename_all = "lowercase")]
1486pub enum SandboxLogLevel {
1487    /// Emit only error logs.
1488    Error,
1489
1490    /// Emit warning and error logs.
1491    Warn,
1492
1493    /// Emit info, warning, and error logs.
1494    Info,
1495
1496    /// Emit debug and higher-severity logs.
1497    Debug,
1498
1499    /// Emit trace and higher-severity logs.
1500    Trace,
1501}
1502
1503//--------------------------------------------------------------------------------------------------
1504// Types: Exec
1505//--------------------------------------------------------------------------------------------------
1506
1507/// POSIX resource limit identifiers.
1508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1509#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1510#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1511#[serde(rename_all = "lowercase")]
1512pub enum RlimitResource {
1513    /// Max CPU time in seconds (`RLIMIT_CPU`).
1514    #[serde(alias = "Cpu")]
1515    Cpu,
1516    /// Max file size in bytes (`RLIMIT_FSIZE`).
1517    #[serde(alias = "Fsize")]
1518    Fsize,
1519    /// Max data segment size (`RLIMIT_DATA`).
1520    #[serde(alias = "Data")]
1521    Data,
1522    /// Max stack size (`RLIMIT_STACK`).
1523    #[serde(alias = "Stack")]
1524    Stack,
1525    /// Max core file size (`RLIMIT_CORE`).
1526    #[serde(alias = "Core")]
1527    Core,
1528    /// Max resident set size (`RLIMIT_RSS`).
1529    #[serde(alias = "Rss")]
1530    Rss,
1531    /// Max number of processes (`RLIMIT_NPROC`).
1532    #[serde(alias = "Nproc")]
1533    Nproc,
1534    /// Max open file descriptors (`RLIMIT_NOFILE`).
1535    #[serde(alias = "Nofile")]
1536    Nofile,
1537    /// Max locked memory (`RLIMIT_MEMLOCK`).
1538    #[serde(alias = "Memlock")]
1539    Memlock,
1540    /// Max address space size (`RLIMIT_AS`).
1541    #[serde(alias = "As")]
1542    As,
1543    /// Max file locks (`RLIMIT_LOCKS`).
1544    #[serde(alias = "Locks")]
1545    Locks,
1546    /// Max pending signals (`RLIMIT_SIGPENDING`).
1547    #[serde(alias = "Sigpending")]
1548    Sigpending,
1549    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
1550    #[serde(alias = "Msgqueue")]
1551    Msgqueue,
1552    /// Max nice priority (`RLIMIT_NICE`).
1553    #[serde(alias = "Nice")]
1554    Nice,
1555    /// Max real-time priority (`RLIMIT_RTPRIO`).
1556    #[serde(alias = "Rtprio")]
1557    Rtprio,
1558    /// Max real-time timeout (`RLIMIT_RTTIME`).
1559    #[serde(alias = "Rttime")]
1560    Rttime,
1561}
1562
1563/// A POSIX resource limit.
1564#[derive(Debug, Clone, Serialize, Deserialize)]
1565#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1566#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1567pub struct Rlimit {
1568    /// Resource type.
1569    pub resource: RlimitResource,
1570
1571    /// Soft limit (can be raised up to hard limit by the process).
1572    pub soft: u64,
1573
1574    /// Hard limit (ceiling, requires privileges to raise).
1575    pub hard: u64,
1576}
1577
1578//--------------------------------------------------------------------------------------------------
1579// Types: Logs
1580//--------------------------------------------------------------------------------------------------
1581
1582/// Source tag on a captured log entry.
1583#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1584#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1585#[serde(rename_all = "lowercase")]
1586pub enum LogSource {
1587    /// Captured from a session's stdout (pipe mode).
1588    Stdout,
1589
1590    /// Captured from a session's stderr (pipe mode).
1591    Stderr,
1592
1593    /// Captured from a session in pty mode (stdout + stderr merged at the kernel level inside the guest arrive as a single stream tagged `output`).
1594    Output,
1595
1596    /// Synthetic system entry: lifecycle markers, runtime diagnostics, kernel console output.
1597    System,
1598}
1599
1600//--------------------------------------------------------------------------------------------------
1601// Methods
1602//--------------------------------------------------------------------------------------------------
1603
1604impl DiskImageFormat {
1605    /// Returns the format as a CLI-safe lowercase string.
1606    pub fn as_str(&self) -> &'static str {
1607        match self {
1608            Self::Qcow2 => "qcow2",
1609            Self::Raw => "raw",
1610            Self::Vmdk => "vmdk",
1611        }
1612    }
1613
1614    /// Parse a disk image format from a file extension.
1615    ///
1616    /// Returns `None` if the extension is not a recognized disk image format.
1617    pub fn from_extension(ext: &str) -> Option<Self> {
1618        match ext {
1619            "qcow2" => Some(Self::Qcow2),
1620            "raw" => Some(Self::Raw),
1621            "vmdk" => Some(Self::Vmdk),
1622            _ => None,
1623        }
1624    }
1625}
1626
1627impl OciRootfsSource {
1628    /// Create a new OCI rootfs source.
1629    pub fn new(reference: impl Into<String>) -> Self {
1630        Self {
1631            reference: reference.into(),
1632            upper_size_mib: None,
1633        }
1634    }
1635}
1636
1637impl RootfsSource {
1638    /// Create an OCI rootfs source from an image reference.
1639    pub fn oci(reference: impl Into<String>) -> Self {
1640        Self::Oci(OciRootfsSource::new(reference))
1641    }
1642
1643    /// Return the OCI image reference if this is an OCI rootfs.
1644    pub fn oci_reference(&self) -> Option<&str> {
1645        match self {
1646            Self::Oci(oci) => Some(&oci.reference),
1647            _ => None,
1648        }
1649    }
1650
1651    /// Return the writable-overlay upper size in MiB for an OCI rootfs, if set.
1652    pub fn oci_upper_size_mib(&self) -> Option<u32> {
1653        match self {
1654            Self::Oci(oci) => oci.upper_size_mib,
1655            _ => None,
1656        }
1657    }
1658}
1659
1660impl EnvVar {
1661    /// Create an environment variable entry.
1662    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1663        Self {
1664            key: key.into(),
1665            value: value.into(),
1666        }
1667    }
1668
1669    /// Return this entry as key and value string slices.
1670    pub fn as_pair(&self) -> (&str, &str) {
1671        (&self.key, &self.value)
1672    }
1673}
1674
1675impl VolumeKind {
1676    /// Return the lowercase database and CLI representation.
1677    pub fn as_str(self) -> &'static str {
1678        match self {
1679            Self::Directory => "dir",
1680            Self::Disk => "disk",
1681        }
1682    }
1683
1684    /// Parse a persisted database value, defaulting to directory for unknown values.
1685    pub fn from_db_value(value: &str) -> Self {
1686        match value {
1687            "disk" => Self::Disk,
1688            _ => Self::Directory,
1689        }
1690    }
1691}
1692
1693impl VolumeSpec {
1694    /// Create a directory-backed volume spec with default options.
1695    pub fn new(name: impl Into<String>) -> Self {
1696        Self {
1697            name: name.into(),
1698            kind: VolumeKind::Directory,
1699            quota_mib: None,
1700            capacity_mib: None,
1701            labels: Vec::new(),
1702        }
1703    }
1704}
1705
1706impl NamedVolumeCreate {
1707    /// Creation behavior for this named volume mount.
1708    pub fn mode(&self) -> NamedVolumeMode {
1709        self.mode
1710    }
1711
1712    /// Volume name to create or ensure exists.
1713    pub fn name(&self) -> &str {
1714        &self.name
1715    }
1716
1717    /// Storage kind to create or ensure exists.
1718    pub fn kind(&self) -> VolumeKind {
1719        self.kind
1720    }
1721
1722    /// Directory quota in MiB, if configured.
1723    pub fn quota_mib(&self) -> Option<u32> {
1724        self.quota_mib
1725    }
1726
1727    /// Disk capacity in MiB, if configured.
1728    pub fn capacity_mib(&self) -> Option<u32> {
1729        self.capacity_mib
1730    }
1731
1732    /// Labels to attach to newly-created volumes.
1733    pub fn labels(&self) -> &[(String, String)] {
1734        &self.labels
1735    }
1736}
1737
1738impl VolumeMount {
1739    /// The absolute path where this mount appears inside the guest.
1740    pub fn guest(&self) -> &str {
1741        match self {
1742            Self::Bind { guest, .. }
1743            | Self::Named { guest, .. }
1744            | Self::Tmpfs { guest, .. }
1745            | Self::DiskImage { guest, .. } => guest,
1746        }
1747    }
1748
1749    /// Return named-volume creation metadata when this mount provisions a named volume.
1750    pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1751        match self {
1752            Self::Named { create, .. } => create.as_ref(),
1753            _ => None,
1754        }
1755    }
1756}
1757
1758impl RlimitResource {
1759    /// Returns the lowercase string representation used on the wire.
1760    pub fn as_str(&self) -> &'static str {
1761        match self {
1762            Self::Cpu => "cpu",
1763            Self::Fsize => "fsize",
1764            Self::Data => "data",
1765            Self::Stack => "stack",
1766            Self::Core => "core",
1767            Self::Rss => "rss",
1768            Self::Nproc => "nproc",
1769            Self::Nofile => "nofile",
1770            Self::Memlock => "memlock",
1771            Self::As => "as",
1772            Self::Locks => "locks",
1773            Self::Sigpending => "sigpending",
1774            Self::Msgqueue => "msgqueue",
1775            Self::Nice => "nice",
1776            Self::Rtprio => "rtprio",
1777            Self::Rttime => "rttime",
1778        }
1779    }
1780}
1781
1782impl LogSource {
1783    /// Apply the empty-means-default rule used by log readers.
1784    pub fn effective(requested: &[Self]) -> Vec<Self> {
1785        if requested.is_empty() {
1786            vec![Self::Stdout, Self::Stderr, Self::Output]
1787        } else {
1788            let mut sources = requested.to_vec();
1789            sources.sort_by_key(|src| match src {
1790                Self::Stdout => 0,
1791                Self::Stderr => 1,
1792                Self::Output => 2,
1793                Self::System => 3,
1794            });
1795            sources.dedup();
1796            sources
1797        }
1798    }
1799}
1800
1801impl SandboxLogLevel {
1802    /// Return the lowercase string representation for this level.
1803    pub const fn as_str(self) -> &'static str {
1804        match self {
1805            Self::Error => "error",
1806            Self::Warn => "warn",
1807            Self::Info => "info",
1808            Self::Debug => "debug",
1809            Self::Trace => "trace",
1810        }
1811    }
1812}
1813
1814//--------------------------------------------------------------------------------------------------
1815// Trait Implementations
1816//--------------------------------------------------------------------------------------------------
1817
1818impl std::fmt::Display for DiskImageFormat {
1819    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1820        f.write_str(self.as_str())
1821    }
1822}
1823
1824impl FromStr for DiskImageFormat {
1825    type Err = String;
1826
1827    fn from_str(s: &str) -> Result<Self, Self::Err> {
1828        match s {
1829            "qcow2" => Ok(Self::Qcow2),
1830            "raw" => Ok(Self::Raw),
1831            "vmdk" => Ok(Self::Vmdk),
1832            _ => Err(format!("unknown disk image format: {s}")),
1833        }
1834    }
1835}
1836
1837impl Default for RootfsSource {
1838    fn default() -> Self {
1839        Self::oci(String::new())
1840    }
1841}
1842
1843impl Default for SandboxResources {
1844    fn default() -> Self {
1845        Self {
1846            vcpus: DEFAULT_SANDBOX_VCPUS,
1847            memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1848            max_vcpus: DEFAULT_SANDBOX_VCPUS,
1849            max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1850        }
1851    }
1852}
1853
1854impl<'de> Deserialize<'de> for SandboxResources {
1855    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1856    where
1857        D: serde::Deserializer<'de>,
1858    {
1859        #[derive(Deserialize)]
1860        struct RawResources {
1861            #[serde(default = "default_sandbox_vcpus")]
1862            vcpus: u8,
1863            #[serde(default = "default_sandbox_memory_mib")]
1864            memory_mib: u32,
1865            max_vcpus: Option<u8>,
1866            max_memory_mib: Option<u32>,
1867        }
1868
1869        let raw = RawResources::deserialize(deserializer)?;
1870        Ok(Self {
1871            vcpus: raw.vcpus,
1872            memory_mib: raw.memory_mib,
1873            // Legacy configs predate boot-capacity fields. Treat their effective
1874            // resources as their maximum capacity so old sandboxes do not
1875            // deserialize into an impossible vcpus > max_vcpus state.
1876            max_vcpus: raw.max_vcpus.unwrap_or(raw.vcpus),
1877            max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1878        })
1879    }
1880}
1881
1882impl Default for SandboxRuntimeOptions {
1883    fn default() -> Self {
1884        Self {
1885            workdir: None,
1886            shell: None,
1887            scripts: BTreeMap::new(),
1888            entrypoint: None,
1889            cmd: None,
1890            hostname: None,
1891            user: None,
1892            log_level: None,
1893            metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1894            disable_metrics_sample: false,
1895        }
1896    }
1897}
1898
1899impl Default for NetworkSpec {
1900    fn default() -> Self {
1901        Self {
1902            enabled: true,
1903            interface: None,
1904            ports: Vec::new(),
1905            policy: None,
1906            dns: None,
1907            tls: None,
1908            secrets: None,
1909            max_connections: None,
1910            trust_host_cas: false,
1911        }
1912    }
1913}
1914
1915impl Default for PublishedPortSpec {
1916    fn default() -> Self {
1917        Self {
1918            host_port: 0,
1919            guest_port: 0,
1920            protocol: PortProtocol::Tcp,
1921            host_bind: "127.0.0.1".into(),
1922        }
1923    }
1924}
1925
1926impl From<(String, String)> for EnvVar {
1927    fn from((key, value): (String, String)) -> Self {
1928        Self { key, value }
1929    }
1930}
1931
1932impl From<EnvVar> for (String, String) {
1933    fn from(var: EnvVar) -> Self {
1934        (var.key, var.value)
1935    }
1936}
1937
1938impl FromStr for SandboxLogLevel {
1939    type Err = String;
1940
1941    fn from_str(s: &str) -> Result<Self, Self::Err> {
1942        match s {
1943            "error" => Ok(Self::Error),
1944            "warn" => Ok(Self::Warn),
1945            "info" => Ok(Self::Info),
1946            "debug" => Ok(Self::Debug),
1947            "trace" => Ok(Self::Trace),
1948            _ => Err(format!("unknown sandbox log level: {s}")),
1949        }
1950    }
1951}
1952
1953/// Case-insensitive string to [`RlimitResource`] conversion.
1954impl TryFrom<&str> for RlimitResource {
1955    type Error = String;
1956
1957    fn try_from(s: &str) -> Result<Self, Self::Error> {
1958        match s.to_ascii_lowercase().as_str() {
1959            "cpu" => Ok(Self::Cpu),
1960            "fsize" => Ok(Self::Fsize),
1961            "data" => Ok(Self::Data),
1962            "stack" => Ok(Self::Stack),
1963            "core" => Ok(Self::Core),
1964            "rss" => Ok(Self::Rss),
1965            "nproc" => Ok(Self::Nproc),
1966            "nofile" => Ok(Self::Nofile),
1967            "memlock" => Ok(Self::Memlock),
1968            "as" => Ok(Self::As),
1969            "locks" => Ok(Self::Locks),
1970            "sigpending" => Ok(Self::Sigpending),
1971            "msgqueue" => Ok(Self::Msgqueue),
1972            "nice" => Ok(Self::Nice),
1973            "rtprio" => Ok(Self::Rtprio),
1974            "rttime" => Ok(Self::Rttime),
1975            _ => Err(format!("unknown rlimit resource: {s}")),
1976        }
1977    }
1978}
1979
1980//--------------------------------------------------------------------------------------------------
1981// Functions
1982//--------------------------------------------------------------------------------------------------
1983
1984fn default_sandbox_vcpus() -> u8 {
1985    DEFAULT_SANDBOX_VCPUS
1986}
1987
1988fn default_sandbox_memory_mib() -> u32 {
1989    DEFAULT_SANDBOX_MEMORY_MIB
1990}
1991
1992fn empty_secret_value() -> Zeroizing<String> {
1993    Zeroizing::new(String::new())
1994}
1995
1996//--------------------------------------------------------------------------------------------------
1997// Tests
1998//--------------------------------------------------------------------------------------------------
1999
2000#[cfg(test)]
2001mod tests {
2002    use super::*;
2003
2004    #[test]
2005    fn casing_is_canonical_with_legacy_aliases() {
2006        // RlimitResource: canonical lowercase; legacy PascalCase still deserializes.
2007        assert_eq!(
2008            serde_json::to_string(&RlimitResource::Nofile).unwrap(),
2009            r#""nofile""#
2010        );
2011        assert_eq!(
2012            serde_json::from_str::<RlimitResource>(r#""Nofile""#).unwrap(),
2013            RlimitResource::Nofile
2014        );
2015
2016        // SnapshotDestination: canonical lowercase tag; legacy PascalCase accepted.
2017        assert_eq!(
2018            serde_json::to_string(&SnapshotDestination::Name("snap".into())).unwrap(),
2019            r#"{"name":"snap"}"#
2020        );
2021        assert!(matches!(
2022            serde_json::from_str::<SnapshotDestination>(r#"{"Name":"snap"}"#).unwrap(),
2023            SnapshotDestination::Name(_)
2024        ));
2025        assert!(matches!(
2026            serde_json::from_str::<SnapshotDestination>(r#"{"path":"/tmp/x"}"#).unwrap(),
2027            SnapshotDestination::Path(_)
2028        ));
2029    }
2030
2031    #[test]
2032    fn disk_image_format_from_extension() {
2033        assert_eq!(
2034            DiskImageFormat::from_extension("qcow2"),
2035            Some(DiskImageFormat::Qcow2)
2036        );
2037        assert_eq!(
2038            DiskImageFormat::from_extension("raw"),
2039            Some(DiskImageFormat::Raw)
2040        );
2041        assert_eq!(
2042            DiskImageFormat::from_extension("vmdk"),
2043            Some(DiskImageFormat::Vmdk)
2044        );
2045        assert_eq!(DiskImageFormat::from_extension("ext4"), None);
2046        assert_eq!(DiskImageFormat::from_extension(""), None);
2047    }
2048
2049    #[test]
2050    fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
2051        let resources: SandboxResources =
2052            serde_json::from_str(r#"{"vcpus":4,"memory_mib":2048}"#).unwrap();
2053
2054        assert_eq!(resources.vcpus, 4);
2055        assert_eq!(resources.max_vcpus, 4);
2056        assert_eq!(resources.memory_mib, 2048);
2057        assert_eq!(resources.max_memory_mib, 2048);
2058    }
2059
2060    #[test]
2061    fn disk_image_format_display_roundtrip() {
2062        for format in [
2063            DiskImageFormat::Qcow2,
2064            DiskImageFormat::Raw,
2065            DiskImageFormat::Vmdk,
2066        ] {
2067            let rendered = format.to_string();
2068            let parsed: DiskImageFormat = rendered.parse().unwrap();
2069            assert_eq!(parsed, format);
2070        }
2071    }
2072
2073    #[test]
2074    fn disk_image_format_from_str_unknown() {
2075        assert!("ext4".parse::<DiskImageFormat>().is_err());
2076    }
2077
2078    #[test]
2079    fn log_source_effective_uses_default_user_program_sources() {
2080        assert_eq!(
2081            LogSource::effective(&[]),
2082            vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
2083        );
2084    }
2085
2086    #[test]
2087    fn log_source_effective_sorts_and_deduplicates_requested_sources() {
2088        assert_eq!(
2089            LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
2090            vec![LogSource::Stdout, LogSource::System]
2091        );
2092    }
2093
2094    #[test]
2095    fn rlimit_resource_parses_case_insensitively() {
2096        assert_eq!(
2097            RlimitResource::try_from("NOFILE").unwrap(),
2098            RlimitResource::Nofile
2099        );
2100        assert!(RlimitResource::try_from("bogus").is_err());
2101    }
2102
2103    #[test]
2104    fn sandbox_policy_serde_roundtrip() {
2105        let policy = SandboxPolicy {
2106            ephemeral: true,
2107            max_duration_secs: Some(3600),
2108            idle_timeout_secs: Some(120),
2109        };
2110
2111        let json = serde_json::to_string(&policy).unwrap();
2112        let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
2113
2114        assert!(decoded.ephemeral);
2115        assert_eq!(decoded.max_duration_secs, Some(3600));
2116        assert_eq!(decoded.idle_timeout_secs, Some(120));
2117    }
2118
2119    #[test]
2120    fn sandbox_policy_defaults_to_persistent() {
2121        assert!(!SandboxPolicy::default().ephemeral);
2122    }
2123
2124    #[test]
2125    fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
2126        // `ephemeral` has a persistent default so partial policy payloads
2127        // deserialize to the conservative behavior.
2128        let decoded: SandboxPolicy =
2129            serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
2130        assert!(!decoded.ephemeral);
2131        assert_eq!(decoded.max_duration_secs, Some(60));
2132    }
2133
2134    #[test]
2135    fn sandbox_spec_default_uses_static_resource_defaults() {
2136        let spec = SandboxSpec::default();
2137
2138        assert_eq!(spec.resources.vcpus, DEFAULT_SANDBOX_VCPUS);
2139        assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
2140        assert_eq!(
2141            spec.runtime.metrics_sample_interval_ms,
2142            Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
2143        );
2144    }
2145
2146    #[test]
2147    fn sandbox_log_level_roundtrips_lowercase_values() {
2148        for (input, expected) in [
2149            ("error", SandboxLogLevel::Error),
2150            ("warn", SandboxLogLevel::Warn),
2151            ("info", SandboxLogLevel::Info),
2152            ("debug", SandboxLogLevel::Debug),
2153            ("trace", SandboxLogLevel::Trace),
2154        ] {
2155            let parsed: SandboxLogLevel = input.parse().unwrap();
2156            assert_eq!(parsed, expected);
2157            assert_eq!(parsed.as_str(), input);
2158        }
2159    }
2160}
2161
2162#[cfg(test)]
2163mod secret_tests {
2164    use super::*;
2165
2166    fn valid_secret() -> SecretEntry {
2167        SecretEntry {
2168            env_var: "API_KEY".into(),
2169            value: "secret".to_string().into(),
2170            source: None,
2171            placeholder: "$MSB_API_KEY".into(),
2172            allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
2173            injection: SecretInjection::default(),
2174            on_violation: None,
2175            require_tls_identity: true,
2176        }
2177    }
2178
2179    #[test]
2180    fn exact_host_match() {
2181        let p = HostPattern::Exact("api.openai.com".into());
2182        assert!(p.matches("api.openai.com"));
2183        assert!(p.matches("API.OpenAI.com"));
2184        assert!(!p.matches("evil.com"));
2185    }
2186
2187    #[test]
2188    fn wildcard_host_match() {
2189        let p = HostPattern::Wildcard("*.openai.com".into());
2190        assert!(p.matches("api.openai.com"));
2191        assert!(p.matches("openai.com"));
2192        assert!(!p.matches("evil.com"));
2193    }
2194
2195    #[test]
2196    fn any_host_match() {
2197        assert!(HostPattern::Any.matches("anything.com"));
2198    }
2199
2200    #[test]
2201    fn default_injection_scopes() {
2202        let inj = SecretInjection::default();
2203        assert!(inj.headers);
2204        assert!(inj.basic_auth);
2205        assert!(!inj.query_params);
2206        assert!(!inj.body);
2207    }
2208
2209    #[test]
2210    fn default_require_tls_identity_when_deserialized() {
2211        let entry: SecretEntry = serde_json::from_str(
2212            r#"{"env_var":"K","value":"v","placeholder":"$K","allowed_hosts":[{"exact":"h"}]}"#,
2213        )
2214        .unwrap();
2215        assert!(entry.require_tls_identity);
2216    }
2217
2218    #[test]
2219    fn secret_validation_accepts_linux_environment_name_shape() {
2220        let mut entry = valid_secret();
2221        entry.env_var = "1TOKEN.with-dashes".into();
2222        assert!(entry.validate(0).is_ok());
2223    }
2224
2225    #[test]
2226    fn secret_validation_rejects_invalid_env_var_names() {
2227        let cases = [
2228            ("", SecretConfigError::EmptyEnvVar { secret_index: 0 }),
2229            (
2230                "API=KEY",
2231                SecretConfigError::EnvVarContainsEquals { secret_index: 0 },
2232            ),
2233            (
2234                "API\0KEY",
2235                SecretConfigError::EnvVarContainsNul { secret_index: 0 },
2236            ),
2237        ];
2238        for (env_var, expected) in cases {
2239            let mut entry = valid_secret();
2240            entry.env_var = env_var.into();
2241            assert_eq!(entry.validate(0), Err(expected));
2242        }
2243    }
2244
2245    #[test]
2246    fn secret_validation_rejects_missing_allowed_hosts() {
2247        let mut entry = valid_secret();
2248        entry.allowed_hosts.clear();
2249        assert_eq!(
2250            entry.validate(0),
2251            Err(SecretConfigError::MissingAllowedHosts { secret_index: 0 })
2252        );
2253    }
2254
2255    #[test]
2256    fn secret_validation_rejects_invalid_placeholders() {
2257        let too_long = "x".repeat(MAX_SECRET_PLACEHOLDER_BYTES + 1);
2258        let cases = [
2259            ("", SecretConfigError::EmptyPlaceholder { secret_index: 0 }),
2260            (
2261                too_long.as_str(),
2262                SecretConfigError::PlaceholderTooLong {
2263                    secret_index: 0,
2264                    actual_bytes: MAX_SECRET_PLACEHOLDER_BYTES + 1,
2265                    max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2266                },
2267            ),
2268            (
2269                "abc\0def",
2270                SecretConfigError::PlaceholderContainsNul { secret_index: 0 },
2271            ),
2272            (
2273                "abc\rdef",
2274                SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
2275            ),
2276            (
2277                "abc\ndef",
2278                SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
2279            ),
2280        ];
2281        for (placeholder, expected) in cases {
2282            let mut entry = valid_secret();
2283            entry.placeholder = placeholder.into();
2284            assert_eq!(entry.validate(0), Err(expected));
2285        }
2286    }
2287
2288    #[test]
2289    fn violation_action_serializes_with_sdk_casing() {
2290        let action = ViolationAction::Passthrough(vec![
2291            HostPattern::Exact("api.anthropic.com".into()),
2292            HostPattern::Wildcard("*.anthropic.com".into()),
2293            HostPattern::Any,
2294        ]);
2295        assert_eq!(
2296            serde_json::to_string(&action).unwrap(),
2297            r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
2298        );
2299        assert_eq!(
2300            serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
2301            r#""block_and_log""#
2302        );
2303        assert_eq!(
2304            serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
2305            r#""block_and_terminate""#
2306        );
2307    }
2308
2309    #[test]
2310    fn violation_action_accepts_legacy_pascal_case() {
2311        let action: ViolationAction =
2312            serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
2313        assert_eq!(
2314            action,
2315            ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
2316        );
2317        assert_eq!(
2318            serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
2319            ViolationAction::BlockAndTerminate
2320        );
2321    }
2322
2323    #[test]
2324    fn secret_entry_debug_redacts_value() {
2325        let mut entry = valid_secret();
2326        entry.value = "uniq-sensitive-12345".to_string().into();
2327        let dbg = format!("{entry:?}");
2328        assert!(dbg.contains("[REDACTED]"));
2329        assert!(!dbg.contains("uniq-sensitive-12345"));
2330    }
2331}
2332
2333#[cfg(test)]
2334mod tls_tests {
2335    use super::*;
2336
2337    #[test]
2338    fn tls_config_defaults() {
2339        let t = TlsConfig::default();
2340        assert!(!t.enabled);
2341        assert_eq!(t.intercepted_ports, vec![443]);
2342        assert!(t.verify_upstream);
2343        assert!(t.block_quic_on_intercept);
2344        assert_eq!(t.cache.capacity, 1000);
2345        assert_eq!(t.cache.validity_hours, 24);
2346    }
2347
2348    #[test]
2349    fn tls_config_round_trips_and_accepts_ca_alias() {
2350        // `ca` is an accepted alias for `intercept_ca`.
2351        let cfg: TlsConfig = serde_json::from_str(
2352            r#"{"enabled":true,"bypass":["*.internal"],"ca":{"cert_path":"/etc/ca.pem"}}"#,
2353        )
2354        .unwrap();
2355        assert!(cfg.enabled);
2356        assert_eq!(cfg.bypass, vec!["*.internal".to_string()]);
2357        assert_eq!(
2358            cfg.intercept_ca.cert_path.as_deref(),
2359            Some(std::path::Path::new("/etc/ca.pem"))
2360        );
2361        let back: TlsConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
2362        assert_eq!(back.bypass, cfg.bypass);
2363    }
2364}