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 microsandbox_types_macros::ConfigPatch;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use typed_path::{Utf8Component, Utf8UnixComponent, Utf8UnixPath};
14use zeroize::Zeroizing;
15
16use crate::modify::SecretSource;
17use crate::{TypesError, TypesResult};
18
19//--------------------------------------------------------------------------------------------------
20// Constants
21//--------------------------------------------------------------------------------------------------
22
23/// Default number of virtual CPUs in a sandbox specification.
24pub const DEFAULT_SANDBOX_CPUS: u8 = 1;
25
26/// Default guest memory in MiB in a sandbox specification.
27pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
28
29/// Default metrics sampling interval in milliseconds.
30pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
31
32/// The well-known NAT64 prefix from RFC 6052.
33pub const WELL_KNOWN_NAT64_PREFIX: &str = "64:ff9b::/96";
34
35//--------------------------------------------------------------------------------------------------
36// Types: Root Filesystems
37//--------------------------------------------------------------------------------------------------
38
39/// Disk image format for virtio-blk root filesystems and volume mounts.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
42#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
43pub enum DiskImageFormat {
44    /// QEMU Copy-on-Write v2.
45    Qcow2,
46    /// Raw disk image.
47    Raw,
48    /// VMware Disk (FLAT/ZERO only, no delta links).
49    Vmdk,
50}
51
52/// Strategy used to create a sandbox-owned instance of a cached flat rootfs.
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
55#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
56#[serde(rename_all = "kebab-case")]
57pub enum FlatClone {
58    /// Use a native copy-on-write clone when supported, otherwise make a sparse copy.
59    #[default]
60    Auto,
61
62    /// Always create an independent sparse-aware copy.
63    Copy,
64
65    /// Require a native copy-on-write clone and fail when it is unavailable.
66    Reflink,
67}
68
69/// Root filesystem source for a sandbox.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
72pub enum RootfsSource {
73    /// Use a host directory directly as the root filesystem.
74    Bind {
75        /// Host path to bind mount.
76        #[cfg_attr(feature = "ts", ts(type = "string"))]
77        path: PathBuf,
78        /// Whether to follow symlinks when resolving the host rootfs path.
79        ///
80        /// Defaults to `false`: the path is resolved following no symlink in any
81        /// component, matching the `--mount` protection, so a symlink at or under
82        /// the rootfs path cannot redirect the mount. Set `true` to opt out when
83        /// the host rootfs path legitimately traverses a symlink.
84        #[serde(default)]
85        follow_root_symlinks: bool,
86    },
87
88    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
89    Oci(OciRootfsSource),
90
91    /// Use a disk image file as the root filesystem via virtio-blk.
92    DiskImage {
93        /// Path to the disk image file on the host.
94        #[cfg_attr(feature = "ts", ts(type = "string"))]
95        path: PathBuf,
96        /// Disk image format.
97        format: DiskImageFormat,
98        /// Inner filesystem type (optional; auto-detected if absent).
99        fstype: Option<String>,
100    },
101}
102
103/// OCI root filesystem source.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
106#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
107pub struct OciRootfsSource {
108    /// OCI image reference (e.g. `python`).
109    pub reference: String,
110
111    /// Writable rootfs layer backing. `None` resolves to a managed 4 GiB upper.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub root_disk: Option<RootDisk>,
114}
115
116/// Backing for the writable rootfs layer (overlay upper) of an OCI sandbox.
117///
118/// This lives only on [`OciRootfsSource`]: the root disk is a property of how an OCI image
119/// becomes a rootfs. Every user surface (CLI `--root-disk`, SDK builders) is sugar resolving
120/// into this type.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
123#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
124#[serde(tag = "kind", rename_all = "kebab-case")]
125pub enum RootDisk {
126    /// Sparse ext4 created and owned by microsandbox in the sandbox dir. Default. Persistent;
127    /// grow-only via modify; deleted with the sandbox.
128    Managed {
129        /// Virtual size in MiB. `None` resolves to 4096.
130        #[serde(default, skip_serializing_if = "Option::is_none")]
131        size_mib: Option<u32>,
132    },
133
134    /// RAM-backed upper. Ephemeral: the rootfs is pristine on every boot. Pages come from
135    /// guest memory, so the size must not exceed the sandbox memory.
136    Tmpfs {
137        /// Size in MiB. `None` resolves to half the sandbox memory.
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        size_mib: Option<u32>,
140    },
141
142    /// User-supplied disk image attached writable as the upper. User-owned lifecycle: never
143    /// created, resized, or deleted by microsandbox.
144    DiskImage {
145        /// Host path to the image file.
146        #[cfg_attr(feature = "ts", ts(type = "string"))]
147        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
148        path: PathBuf,
149        /// Disk image format. Never probed from file contents.
150        format: DiskImageFormat,
151        /// Inner filesystem type. `None` resolves to ext4.
152        #[serde(default, skip_serializing_if = "Option::is_none")]
153        fstype: Option<String>,
154    },
155
156    /// A complete OCI root filesystem materialized into one private writable filesystem.
157    ///
158    /// This is microsandbox-owned like [`RootDisk::Managed`], but it replaces the layered
159    /// EROFS-plus-OverlayFS topology rather than supplying only its writable upper.
160    Flat {
161        /// Final guest filesystem capacity in MiB. `None` resolves to the greater of 4096 MiB
162        /// and the materialized image's minimum size.
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        size_mib: Option<u32>,
165        /// Generated filesystem type. `None` resolves to ext4.
166        #[serde(default, skip_serializing_if = "Option::is_none")]
167        fstype: Option<String>,
168        /// Requested private-instance strategy.
169        #[serde(default, skip_serializing_if = "FlatClone::is_auto")]
170        clone: FlatClone,
171    },
172}
173
174/// Controls when an OCI registry is contacted for manifest freshness.
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
176#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
177#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
178pub enum PullPolicy {
179    /// Use cached layers if complete, pull otherwise.
180    #[default]
181    IfMissing,
182
183    /// Always fetch the manifest from the registry, reusing cached layers whose digests still match.
184    Always,
185
186    /// Never contact the registry. Error if the image is not fully cached locally.
187    Never,
188}
189
190//--------------------------------------------------------------------------------------------------
191// Types: Mounts
192//--------------------------------------------------------------------------------------------------
193
194/// Stat virtualization policy for a virtiofs-backed volume mount.
195///
196/// 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.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
199#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
200#[serde(rename_all = "lowercase")]
201pub enum StatVirtualization {
202    /// Fail-closed: probe the host backing path; require xattr support.
203    Strict,
204    /// Opportunistic: apply the overlay when present; tolerate missing xattr support.
205    Relaxed,
206    /// Literal host metadata: do not read or apply the override xattr.
207    Off,
208}
209
210/// Host permission propagation policy for a virtiofs-backed volume mount.
211///
212/// Serializes/deserializes as the lowercase variant name (`"private"`, `"mirror"`) to align with the CLI and NAPI spellings.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
215#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
216#[serde(rename_all = "lowercase")]
217pub enum HostPermissions {
218    /// Guest chmod stays in the metadata overlay only.
219    Private,
220    /// Mirror ordinary rwx bits for regular files and directories to the host inode.
221    Mirror,
222}
223
224/// Sandbox-level in-guest security profile.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
226#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
227#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
228#[serde(rename_all = "lowercase")]
229pub enum SecurityProfile {
230    /// Preserve normal guest-root semantics.
231    ///
232    /// 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.
233    #[default]
234    Default,
235
236    /// Harden guest exec sessions.
237    ///
238    /// 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.
239    Restricted,
240}
241
242/// Host-runtime isolation profile applied when a sandbox is deployed.
243///
244/// Unlike [`SecurityProfile`], which changes behavior inside the guest, this
245/// profile controls defenses enforced by host-side runtime backends. A hosting
246/// platform can override the requested value before launch.
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
248#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
249#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
250#[serde(rename_all = "snake_case")]
251pub enum DeploymentProfile {
252    /// The sandbox runs for one trusted tenant with the requested host-runtime configuration.
253    #[default]
254    SingleTenant,
255
256    /// The sandbox runs on shared infrastructure with platform-owned isolation floors.
257    MultiTenant,
258}
259
260/// Guest mount behavior shared by every volume mount kind.
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
262#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
263#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
264#[serde(default)]
265pub struct MountOptions {
266    /// Whether the mount is read-only.
267    ///
268    /// 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.
269    pub readonly: bool,
270
271    /// Whether direct execution from the mount is disabled.
272    ///
273    /// 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.
274    pub noexec: bool,
275
276    /// Whether setuid and setgid privilege elevation from files on the mount is ignored.
277    pub nosuid: bool,
278
279    /// Whether device files on the mount are ignored.
280    pub nodev: bool,
281
282    /// Guest uid presented for host files under this mount that carry no
283    /// per-file stat override.
284    ///
285    /// Host-created files (written outside the guest) have no override, so
286    /// without this they surface with the runtime's fallback owner. When set,
287    /// such files are presented as this uid instead. Must be set together with
288    /// [`override_gid`](Self::override_gid). `None` keeps the fallback.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub override_uid: Option<u32>,
291
292    /// Guest gid presented for host files under this mount that carry no
293    /// per-file stat override. See [`override_uid`](Self::override_uid); the two
294    /// must be set together.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub override_gid: Option<u32>,
297}
298
299/// Storage kind for a named volume.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
302#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
303pub enum VolumeKind {
304    /// Directory-backed named volume mounted through virtiofs.
305    Directory,
306
307    /// Raw ext4 disk-image named volume mounted through virtio-blk.
308    Disk,
309}
310
311/// Configuration for creating a named volume.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
314#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
315pub struct VolumeSpec {
316    /// Volume name.
317    pub name: String,
318
319    /// Storage kind.
320    pub kind: VolumeKind,
321
322    /// Size quota in MiB. `None` means unlimited.
323    pub quota_mib: Option<u32>,
324
325    /// Disk capacity in MiB. Required for disk volumes.
326    pub capacity_mib: Option<u32>,
327
328    /// Labels for organization.
329    pub labels: Vec<(String, String)>,
330}
331
332/// Sandbox-time behavior for a named volume mount.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
334#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
335#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
336pub enum NamedVolumeMode {
337    /// Require the named volume to already exist.
338    Existing,
339
340    /// Create the named volume and fail if it already exists.
341    Create,
342
343    /// Ensure the named volume exists, or reuse a compatible existing volume.
344    EnsureExists,
345}
346
347/// Creation metadata for sandbox-time named volume provisioning.
348#[derive(Debug, Clone, Serialize, Deserialize)]
349#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
350#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
351pub struct NamedVolumeCreate {
352    /// Creation behavior for this named volume mount.
353    pub mode: NamedVolumeMode,
354
355    /// Volume name to create or ensure exists.
356    pub name: String,
357
358    /// Storage kind to create or ensure exists.
359    pub kind: VolumeKind,
360
361    /// Directory quota in MiB, if configured.
362    pub quota_mib: Option<u32>,
363
364    /// Disk capacity in MiB, if configured.
365    pub capacity_mib: Option<u32>,
366
367    /// Labels to attach to newly-created volumes.
368    pub labels: Vec<(String, String)>,
369}
370
371/// Storage for a volume whose lifetime belongs exclusively to its sandbox.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
374#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
375#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
376pub enum OwnedVolumeStorage {
377    /// A private directory exposed through virtiofs.
378    Directory {
379        /// Guest-write budget in MiB; `None` uses the directory-mount default.
380        quota_mib: Option<u32>,
381    },
382    /// A private ext4 disk exposed through virtio-blk.
383    Disk {
384        /// Required, positive capacity in MiB.
385        capacity_mib: u32,
386    },
387}
388
389/// A volume mount specification for a sandbox.
390#[derive(Clone)]
391#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
392#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
393#[cfg_attr(feature = "ts", ts(tag = "type"))]
394pub enum VolumeMount {
395    /// An unnamed private volume removed with its owning sandbox.
396    Owned {
397        /// Guest mount path, also the stable identity within the sandbox.
398        guest: String,
399        /// Directory or ext4 disk storage.
400        storage: OwnedVolumeStorage,
401        /// Guest mount behavior.
402        options: MountOptions,
403        /// Guest-visible stat virtualization policy for directory storage.
404        stat_virtualization: StatVirtualization,
405        /// Host permission propagation policy for directory storage.
406        host_permissions: HostPermissions,
407    },
408    /// Bind mount a host directory into the guest.
409    Bind {
410        /// Host path to bind mount.
411        #[cfg_attr(feature = "ts", ts(type = "string"))]
412        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
413        host: PathBuf,
414        /// Guest mount path.
415        guest: String,
416        /// Guest mount behavior.
417        options: MountOptions,
418        /// Guest-visible stat virtualization policy.
419        stat_virtualization: StatVirtualization,
420        /// Host permission propagation policy.
421        host_permissions: HostPermissions,
422        /// Whether to follow symlinks when resolving the host mount root.
423        ///
424        /// Defaults to `false`: the host path is resolved following no symlink in
425        /// any component, so a symlink planted at (or under) the mount root cannot
426        /// redirect the mount out of its intended target. Set `true` to opt out
427        /// when the host path legitimately traverses a symlink.
428        follow_root_symlinks: bool,
429        /// Guest-write byte budget in MiB.
430        ///
431        /// Bounds how much the guest may add beyond the directory's existing
432        /// contents. `None` applies the protective default at spawn time; set a
433        /// value to override it.
434        quota_mib: Option<u32>,
435    },
436
437    /// Mount a named volume into the guest.
438    Named {
439        /// Volume name.
440        name: String,
441        /// Guest mount path.
442        guest: String,
443        /// Creation metadata for sandbox-time named volume provisioning.
444        ///
445        /// This is transient and intentionally skipped when sandbox configs are persisted; restarting a sandbox mounts the already-created volume.
446        create: Option<NamedVolumeCreate>,
447        /// Guest mount behavior.
448        options: MountOptions,
449        /// Guest-visible stat virtualization policy.
450        stat_virtualization: StatVirtualization,
451        /// Host permission propagation policy.
452        host_permissions: HostPermissions,
453        /// Whether to follow symlinks when resolving the host mount root.
454        ///
455        /// Defaults to `false` (resolve following no symlink). See
456        /// [`VolumeMount::Bind`] for details.
457        follow_root_symlinks: bool,
458    },
459
460    /// Temporary filesystem backed by guest memory.
461    Tmpfs {
462        /// Guest mount path.
463        guest: String,
464        /// Size limit in MiB.
465        size_mib: Option<u32>,
466        /// Guest mount behavior.
467        options: MountOptions,
468    },
469
470    /// Mount a disk image file as a virtio-blk device at a guest path.
471    DiskImage {
472        /// Host path to the disk image file.
473        #[cfg_attr(feature = "ts", ts(type = "string"))]
474        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
475        host: PathBuf,
476        /// Guest mount path.
477        guest: String,
478        /// Disk image format.
479        format: DiskImageFormat,
480        /// Inner filesystem type. When `None`, agentd probes `/proc/filesystems`.
481        fstype: Option<String>,
482        /// Guest mount behavior.
483        options: MountOptions,
484    },
485}
486
487/// Rootfs patch applied before VM startup.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
490#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
491pub enum Patch {
492    /// Write text content to a file.
493    Text {
494        /// Absolute guest path, such as `/etc/app.conf`.
495        path: String,
496        /// Text content to write.
497        content: String,
498        /// File permissions, such as `0o644`. `None` uses the default.
499        mode: Option<u32>,
500        /// Allow replacing a file that already exists in the rootfs.
501        replace: bool,
502    },
503
504    /// Write raw bytes to a file.
505    File {
506        /// Absolute guest path.
507        path: String,
508        /// Raw byte content to write.
509        content: Vec<u8>,
510        /// File permissions, such as `0o644`. `None` uses the default.
511        mode: Option<u32>,
512        /// Allow replacing a file that already exists in the rootfs.
513        replace: bool,
514    },
515
516    /// Copy a file from the host into the rootfs.
517    CopyFile {
518        /// Host path to copy from.
519        #[cfg_attr(feature = "ts", ts(type = "string"))]
520        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
521        src: PathBuf,
522        /// Absolute guest destination path.
523        dst: String,
524        /// File permissions. `None` preserves source permissions.
525        mode: Option<u32>,
526        /// Allow replacing a file that already exists in the rootfs.
527        replace: bool,
528    },
529
530    /// Copy a directory from the host into the rootfs.
531    CopyDir {
532        /// Host directory to copy from.
533        #[cfg_attr(feature = "ts", ts(type = "string"))]
534        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
535        src: PathBuf,
536        /// Absolute guest destination path.
537        dst: String,
538        /// Allow replacing files that already exist in the rootfs.
539        replace: bool,
540    },
541
542    /// Create a symlink.
543    Symlink {
544        /// Symlink target path.
545        target: String,
546        /// Absolute guest path where the symlink is created.
547        link: String,
548        /// Allow replacing a path that already exists in the rootfs.
549        replace: bool,
550    },
551
552    /// Create a directory.
553    Mkdir {
554        /// Absolute guest path.
555        path: String,
556        /// Directory permissions, such as `0o755`. `None` uses the default.
557        mode: Option<u32>,
558    },
559
560    /// Remove a file or directory.
561    Remove {
562        /// Absolute guest path to remove.
563        path: String,
564    },
565
566    /// Append content to an existing file.
567    Append {
568        /// Absolute guest path of the file to append to.
569        path: String,
570        /// Content to append.
571        content: String,
572    },
573}
574
575//--------------------------------------------------------------------------------------------------
576// Types: Networking
577//--------------------------------------------------------------------------------------------------
578
579/// Wire format of HTTP denial responses.
580#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
581#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
582#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
583#[serde(rename_all = "snake_case")]
584pub enum HttpDenyResponseFormat {
585    /// Legacy plain-text network denials with host substitution.
586    Text,
587
588    /// JSON network and secret denials with literal messages.
589    #[default]
590    Json,
591}
592
593/// HTTP responses returned for network and secret policy denials.
594#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
595#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
596#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
597#[serde(default)]
598pub struct HttpConfig {
599    /// Return readable HTTP 403 responses for supported denied requests. Default: true.
600    pub deny_response: bool,
601
602    /// Omission uses the runtime default: JSON.
603    pub deny_response_format: HttpDenyResponseFormat,
604
605    /// Legacy plain-text message with `{host}` substitution. Deprecated: use JSON mode
606    /// and `network_deny_message` for new integrations. Ignored in JSON mode.
607    #[serde(skip_serializing_if = "Option::is_none")]
608    pub deny_message: Option<String>,
609
610    /// Network-denial JSON `message`. Used as-is.
611    /// Used only when `deny_response` is enabled in JSON mode. Omission uses the default;
612    /// an empty string preserves the error code with an empty `message`.
613    #[serde(skip_serializing_if = "Option::is_none")]
614    pub network_deny_message: Option<String>,
615
616    /// Secret-denial JSON `message`. Used as-is when `deny_response` is enabled in JSON mode.
617    /// Omission uses the default; an empty string leaves `message` empty.
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub secret_deny_message: Option<String>,
620}
621
622/// Complete network specification for a sandbox.
623///
624/// Common, backend-visible fields are typed directly. Rich local-engine subdocuments such as policy, DNS, TLS, secrets, and interface overrides are carried as JSON so the shared contract can preserve them without depending on the local networking engine crate.
625#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
626#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
627#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
628#[serde(default)]
629pub struct NetworkSpec {
630    /// Whether networking is enabled for this sandbox.
631    pub enabled: bool,
632
633    /// Guest interface overrides for the local network engine.
634    #[serde(skip_serializing_if = "Option::is_none")]
635    #[config_patch(nested)]
636    pub interface: Option<InterfaceOverrides>,
637
638    /// Host-to-guest port mappings.
639    pub ports: Vec<PublishedPortSpec>,
640
641    /// Egress and ingress policy subdocument.
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub policy: Option<NetworkPolicy>,
644
645    /// DNS interception and filtering subdocument.
646    #[serde(skip_serializing_if = "Option::is_none")]
647    #[config_patch(nested)]
648    pub dns: Option<DnsConfig>,
649
650    /// TLS interception subdocument.
651    #[serde(skip_serializing_if = "Option::is_none")]
652    #[config_patch(nested)]
653    pub tls: Option<TlsConfig>,
654
655    /// Require hostname-based policy allows to use inspectable application authority.
656    pub strict: bool,
657
658    /// Secret substitution subdocument.
659    #[serde(skip_serializing_if = "Option::is_none")]
660    #[config_patch(nested)]
661    pub secrets: Option<SecretsConfig>,
662
663    /// TCP connection cap. `max_connections` is a deprecated configuration alias.
664    // Keep saved configurations readable by releases that predate the TCP-specific name.
665    #[serde(rename = "max_connections", alias = "max_tcp_connections")]
666    pub max_tcp_connections: Option<usize>,
667
668    /// Active inbound TCP connection cap across published ports. Omitted uses the runtime default; zero is unlimited.
669    #[serde(default, skip_serializing_if = "Option::is_none")]
670    pub max_inbound_tcp_connections: Option<usize>,
671
672    /// Max concurrent UDP relay sessions. Omitted is unlimited for single-tenant and 1024 for multi-tenant; zero means unlimited.
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub max_udp_connections: Option<usize>,
675
676    /// Accept-queue depth for published TCP port listeners, `1..=2147483647`. Omitted is 1024.
677    /// The host kernel clamps it to `net.core.somaxconn` (Linux) or `kern.ipc.somaxconn` (macOS).
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub tcp_accept_queue_size: Option<u32>,
680
681    /// Local network rate limits. Missing means unlimited in both directions.
682    #[serde(skip_serializing_if = "Option::is_none")]
683    #[config_patch(nested)]
684    pub rate_limiter: Option<NetworkRateLimiterConfig>,
685
686    /// NAT64 `/96` prefixes for policy classification.
687    #[serde(default = "default_nat64_prefixes")]
688    #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
689    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
690    pub nat64_prefixes: Vec<Ipv6Network>,
691
692    /// Whether to copy trusted host CAs into the guest at boot.
693    pub trust_host_cas: bool,
694
695    /// HTTP denial response settings.
696    #[config_patch(nested)]
697    #[serde(default)]
698    pub http: HttpConfig,
699
700    /// Proxy used for outbound sandbox connections and supported datagram flows.
701    ///
702    /// In Rust SDK creation from a concrete `SandboxConfig`, `None` inherits defaults; use a sparse patch to clear.
703    #[serde(skip_serializing_if = "Option::is_none")]
704    #[config_patch(nullable)]
705    pub outbound_proxy: Option<OutboundProxy>,
706}
707
708/// Proxy configuration for outbound sandbox connections.
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
711#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
712#[serde(tag = "protocol", rename_all = "lowercase")]
713#[non_exhaustive]
714pub enum OutboundProxy {
715    /// An HTTP proxy that opens TCP tunnels with CONNECT.
716    #[serde(rename = "http_connect")]
717    HttpConnect {
718        /// Proxy socket address.
719        address: String,
720        /// Optional username/password authentication configuration.
721        #[serde(default, skip_serializing_if = "Option::is_none")]
722        credentials: Option<HttpConnectCredentials>,
723    },
724
725    /// A SOCKS4 proxy at the given `IP:port` address.
726    Socks4 {
727        /// Proxy socket address.
728        address: String,
729        /// Optional user ID sent during the SOCKS4 handshake.
730        #[serde(default, skip_serializing_if = "Option::is_none")]
731        user_id: Option<String>,
732    },
733
734    /// A SOCKS5 proxy at the given `IP:port` address.
735    Socks5 {
736        /// Proxy socket address.
737        address: String,
738        /// Optional username/password authentication credentials.
739        #[serde(default, skip_serializing_if = "Option::is_none")]
740        credentials: Option<Socks5Credentials>,
741    },
742}
743
744/// Environment-backed username/password credentials for an HTTP CONNECT proxy.
745///
746/// This durable configuration contains only the host-side password source.
747/// The resolved password is carried by the private launch contract instead.
748#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
749#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
750#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
751pub struct HttpConnectCredentials {
752    /// HTTP CONNECT authentication username.
753    pub username: String,
754
755    /// Host-side source for the HTTP CONNECT authentication password.
756    pub password: SecretSource,
757}
758
759/// Environment-backed username/password credentials for a SOCKS5 proxy.
760///
761/// This durable configuration contains only the host-side password source.
762/// The resolved password is carried by the private launch contract instead.
763#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
764#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
765#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
766pub struct Socks5Credentials {
767    /// SOCKS5 authentication username.
768    pub username: String,
769
770    /// Host-side source for the SOCKS5 authentication password.
771    pub password: SecretSource,
772}
773
774/// A published port mapping between host and guest.
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
777#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
778pub struct PublishedPortSpec {
779    /// Host-side port to bind.
780    pub host_port: u16,
781
782    /// Guest-side port to forward to.
783    pub guest_port: u16,
784
785    /// Transport protocol.
786    #[serde(default)]
787    pub protocol: PortProtocol,
788
789    /// Host address to bind. Defaults to loopback.
790    pub host_bind: String,
791}
792
793/// Transport protocol for a published port.
794#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
795#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
796#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
797pub enum PortProtocol {
798    /// TCP.
799    #[default]
800    #[serde(rename = "tcp")]
801    Tcp,
802
803    /// UDP.
804    #[serde(rename = "udp")]
805    Udp,
806}
807
808//--------------------------------------------------------------------------------------------------
809// Types: Vsock
810//--------------------------------------------------------------------------------------------------
811
812/// Host services exposed to a sandbox through virtio-vsock.
813#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
814#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
815#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
816#[serde(default)]
817pub struct VsockSpec {
818    /// Guest-to-host routes registered before the VM starts.
819    pub routes: Vec<VsockRouteSpec>,
820}
821
822impl VsockSpec {
823    /// Return whether no host services are exposed through vsock.
824    pub fn is_empty(&self) -> bool {
825        self.routes.is_empty()
826    }
827}
828
829/// One host local-IPC endpoint exposed on a host-CID vsock port.
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
831#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
832#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
833pub struct VsockRouteSpec {
834    /// Existing Unix socket path or local Windows named-pipe path.
835    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
836    pub host_socket: PathBuf,
837
838    /// Port guests address on `VMADDR_CID_HOST` (CID 2).
839    pub port: u32,
840
841    /// Message semantics used by the guest and host endpoints.
842    #[serde(default)]
843    pub socket_type: VsockSocketType,
844}
845
846/// Socket semantics for a host-CID vsock route.
847#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
848#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
849#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
850#[serde(rename_all = "snake_case")]
851pub enum VsockSocketType {
852    /// Reliable, ordered byte stream.
853    #[default]
854    Stream,
855
856    /// Best-effort message transport preserving datagram boundaries.
857    Dgram,
858}
859
860//--------------------------------------------------------------------------------------------------
861// Types: Init
862//--------------------------------------------------------------------------------------------------
863
864/// Fully-assembled handoff-init specification.
865#[derive(Debug, Clone, Serialize, Deserialize)]
866#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
867#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
868pub struct HandoffInit {
869    /// Init binary: absolute path inside the guest rootfs, or the literal `auto`.
870    ///
871    /// Always a Linux-style `/`-separated path — never build it with host OS path APIs, whose semantics diverge on Windows (`\` separators, `/sbin/init` treated as relative).
872    pub cmd: String,
873
874    /// Supplemental argv. `argv[0]` is implicitly `cmd`.
875    #[serde(default)]
876    pub args: Vec<String>,
877
878    /// Extra env vars merged on top of the inherited env.
879    #[serde(default)]
880    pub env: Vec<(String, String)>,
881}
882
883//--------------------------------------------------------------------------------------------------
884// Types: Lifecycle
885//--------------------------------------------------------------------------------------------------
886
887/// Sandbox lifecycle policy.
888#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
889#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
890#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
891pub struct SandboxPolicy {
892    /// Whether the sandbox is ephemeral.
893    ///
894    /// Ephemeral sandboxes are one-off: the host runtime that owns the
895    /// process removes the persisted DB row and on-disk state when the VM
896    /// reaches a terminal status, and other host runtimes opportunistically
897    /// clean up ephemeral leftovers from runtimes that died before they
898    /// could self-clean. Defaults to `false` (persistent); named and created
899    /// sandboxes stay inspectable and restartable after they stop.
900    #[serde(default)]
901    pub ephemeral: bool,
902
903    /// Hard cap on total sandbox lifetime in seconds. `None` = run forever.
904    pub max_duration_secs: Option<u64>,
905
906    /// Idle timeout in seconds. `None` = no idle detection.
907    pub idle_timeout_secs: Option<u64>,
908}
909
910//--------------------------------------------------------------------------------------------------
911// Types: Snapshots
912//--------------------------------------------------------------------------------------------------
913
914/// Inputs to create a snapshot.
915///
916/// Installed artifacts live at `dest_dir/<group>/<snapshot_id>`. A friendly name
917/// is scoped to the group; it does not change the portable snapshot identity.
918/// Save/load moves artifacts between stores without starting a VM.
919#[derive(Debug, Clone, Serialize, Deserialize)]
920#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
921pub struct SnapshotSpec {
922    /// Optional guest writeback policy. Auto flushes live disk-only captures, not full RAM.
923    #[serde(default)]
924    pub guest_flush: crate::GuestFlush,
925    /// Friendly member name within a group; empty selects a generated name.
926    pub name: String,
927
928    /// Local snapshot group; defaults to the source sandbox's name.
929    #[serde(default)]
930    pub group: Option<String>,
931
932    /// Group-store root. `None` selects the default snapshots directory.
933    #[serde(default)]
934    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
935    pub dest_dir: Option<PathBuf>,
936
937    /// Source sandbox. Disk capture accepts running, paused, or stopped sources.
938    pub source_sandbox: String,
939
940    /// User-supplied labels.
941    pub labels: Vec<(String, String)>,
942
943    /// Overwrite a direct archive destination; installed members remain immutable.
944    pub force: bool,
945
946    /// Compute and record upper-layer content integrity at creation time.
947    pub record_integrity: bool,
948
949    /// Capture disk, memory, execution, and device state from a running sandbox.
950    #[serde(default)]
951    pub full: bool,
952}
953
954//--------------------------------------------------------------------------------------------------
955// Types: Sandbox Specs
956//--------------------------------------------------------------------------------------------------
957
958/// Backend-neutral sandbox task description.
959///
960/// 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.
961#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
962#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
963#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
964#[serde(default)]
965pub struct SandboxSpec {
966    /// Unique sandbox name.
967    pub name: String,
968
969    /// Root filesystem source.
970    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
971    pub image: RootfsSource,
972
973    /// CPU and memory resources.
974    #[config_patch(nested)]
975    pub resources: SandboxResources,
976
977    /// Guest runtime options.
978    #[config_patch(nested)]
979    pub runtime: SandboxRuntimeOptions,
980
981    /// Environment variables visible to commands in the sandbox.
982    #[config_patch(merge_with = merge_env_vars)]
983    pub env: Vec<EnvVar>,
984
985    /// User-defined labels attached to the sandbox.
986    #[config_patch(merge)]
987    pub labels: BTreeMap<String, String>,
988
989    /// Sandbox-wide resource limits inherited by guest processes.
990    pub rlimits: Vec<Rlimit>,
991
992    /// Volume mounts.
993    pub mounts: Vec<VolumeMount>,
994
995    /// Rootfs patches applied before VM start.
996    pub patches: Vec<Patch>,
997
998    /// Network specification.
999    #[config_patch(nested)]
1000    pub network: NetworkSpec,
1001
1002    /// Local host services exposed through virtio-vsock.
1003    #[serde(default, skip_serializing_if = "VsockSpec::is_empty")]
1004    #[config_patch(nested)]
1005    pub vsock: VsockSpec,
1006
1007    /// Hand off PID 1 to a guest init binary after agentd setup.
1008    pub init: Option<HandoffInit>,
1009
1010    /// Pull policy for OCI images.
1011    pub pull_policy: PullPolicy,
1012
1013    /// In-guest security profile.
1014    pub security_profile: SecurityProfile,
1015
1016    /// Host-runtime deployment profile.
1017    ///
1018    /// Local callers may request a profile, while a managed backend can
1019    /// override it before launch. The cloud create wire intentionally omits
1020    /// this field so tenant requests cannot select the platform profile.
1021    pub deployment_profile: DeploymentProfile,
1022
1023    /// Sandbox lifecycle policy.
1024    #[config_patch(nested)]
1025    pub lifecycle: SandboxPolicy,
1026}
1027
1028/// CPU and memory resources for a sandbox.
1029#[derive(Debug, Clone, Serialize, ConfigPatch)]
1030#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1031#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1032pub struct SandboxResources {
1033    /// Number of virtual CPUs currently presented to the guest at boot.
1034    pub cpus: u8,
1035
1036    /// Guest memory currently presented to the guest at boot, in MiB.
1037    pub memory_mib: u32,
1038
1039    /// Maximum virtual CPUs the sandbox may expose after boot-time hotplug support lands.
1040    pub max_cpus: u8,
1041
1042    /// Maximum guest memory the sandbox may expose after boot-time hotplug support lands, in MiB.
1043    pub max_memory_mib: u32,
1044
1045    /// Host CPU placement requested for this sandbox.
1046    #[serde(default, skip_serializing_if = "CpuPlacement::is_inherit")]
1047    pub cpu_placement: CpuPlacement,
1048
1049    /// Host-defined placement profile selected for this sandbox.
1050    /// In Rust SDK creation from a concrete `SandboxConfig`, `None` inherits defaults; use a sparse patch to clear.
1051    #[serde(default, skip_serializing_if = "Option::is_none")]
1052    #[config_patch(nullable)]
1053    pub placement_profile: Option<String>,
1054
1055    /// Guest transparent huge-page policy selected at boot.
1056    #[serde(default, skip_serializing_if = "TransparentHugePagePolicy::is_madvise")]
1057    pub thp: TransparentHugePagePolicy,
1058}
1059
1060/// Controls how Microsandbox places vCPU threads on host processors.
1061#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1063#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1064#[serde(rename_all = "lowercase")]
1065pub enum CpuPlacement {
1066    /// Preserve the invoking process's existing scheduler and affinity behavior.
1067    #[default]
1068    Inherit,
1069
1070    /// Spread across cores, then use SMT siblings, then share logical processors under pressure.
1071    Auto,
1072
1073    /// Preserve the widest practical distribution, sharing logical processors when necessary.
1074    Spread,
1075
1076    /// Prefer SMT siblings and fewer physical cores, then share balanced logical processors.
1077    Compact,
1078}
1079
1080/// Concrete host NUMA scope selected by a named placement profile.
1081#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1082#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1083#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1084#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1085pub enum NumaPlacement {
1086    /// Prefer one host NUMA node, falling back to inherited host placement when it cannot fit.
1087    PreferSingle,
1088    /// Require maximum CPU and memory capacity to fit one host NUMA node.
1089    StrictSingle,
1090    /// Preserve the operating system's ordinary NUMA behavior.
1091    Inherit,
1092}
1093
1094/// Host backing policy for guest memory selected by a named placement profile.
1095#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1096#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1097#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1098#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1099pub enum MemoryPlacement {
1100    /// Back guest RAM from the selected CPU node when enforceable, otherwise inherit host policy.
1101    FollowCpu,
1102    /// Preserve the operating system's ordinary memory policy.
1103    Inherit,
1104}
1105
1106/// Host-owned named placement profile resolved before a local VM starts.
1107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1108#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1109#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1110#[serde(deny_unknown_fields)]
1111pub struct PlacementProfile {
1112    /// NUMA scope used while selecting host CPU capacity.
1113    pub numa: NumaPlacement,
1114    /// Host-memory behavior used for the resolved CPU nodes.
1115    pub memory: MemoryPlacement,
1116}
1117
1118/// Guest transparent huge-page policy applied through the kernel command line.
1119#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1120#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1121#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1122#[serde(rename_all = "lowercase")]
1123pub enum TransparentHugePagePolicy {
1124    /// Transparently use huge pages for eligible anonymous mappings.
1125    Always,
1126
1127    /// Use huge pages only for mappings that explicitly request them.
1128    #[default]
1129    Madvise,
1130
1131    /// Disable transparent huge pages for anonymous mappings.
1132    Never,
1133}
1134
1135/// Host control over the guest wall clock (`CLOCK_REALTIME`).
1136///
1137/// Serializes as the lowercase variant name (`"sync"`, `"off"`) to match the CLI spelling.
1138/// The guest monotonic clock is never adjusted by either policy.
1139#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1140#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1141#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1142#[serde(rename_all = "lowercase")]
1143pub enum GuestClockPolicy {
1144    /// Keep the guest wall clock in step with the host.
1145    ///
1146    /// The runtime sends the host time at boot and about once a minute, and steps the
1147    /// guest clock to host time when a full snapshot is restored or a paused sandbox resumes.
1148    #[default]
1149    Sync,
1150
1151    /// Never set the guest wall clock after boot.
1152    ///
1153    /// The guest keeps the time it read at boot and advances it on its own. A restored full
1154    /// snapshot continues from the captured guest time instead of jumping to host time.
1155    Off,
1156}
1157
1158/// Guest runtime options for a sandbox.
1159#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
1160#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1161#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1162#[serde(default)]
1163pub struct SandboxRuntimeOptions {
1164    /// Working directory inside the guest.
1165    /// In Rust SDK creation from a concrete `SandboxConfig`, `None` inherits defaults; use a sparse patch to clear.
1166    #[config_patch(nullable)]
1167    pub workdir: Option<String>,
1168
1169    /// Default shell for scripts and interactive sessions.
1170    /// In Rust SDK creation from a concrete `SandboxConfig`, `None` explicitly clears lower-layer defaults; managed overrides still apply.
1171    #[config_patch(nullable)]
1172    pub shell: Option<String>,
1173
1174    /// Named scripts available inside the guest.
1175    #[config_patch(merge)]
1176    pub scripts: BTreeMap<String, String>,
1177
1178    /// Image entrypoint override.
1179    pub entrypoint: Option<Vec<String>>,
1180
1181    /// Image command override.
1182    pub cmd: Option<Vec<String>>,
1183
1184    /// Guest hostname override.
1185    pub hostname: Option<String>,
1186
1187    /// Guest user identity override.
1188    pub user: Option<String>,
1189
1190    /// Runtime log verbosity.
1191    /// In Rust SDK creation from a concrete `SandboxConfig`, `None` explicitly clears lower-layer defaults; managed overrides still apply.
1192    #[config_patch(nullable)]
1193    pub log_level: Option<SandboxLogLevel>,
1194
1195    /// Metrics sampling interval in milliseconds. `None` disables sampling.
1196    /// In Rust SDK creation from a concrete `SandboxConfig`, `None` explicitly clears lower-layer defaults; managed overrides still apply.
1197    #[config_patch(nullable)]
1198    pub metrics_sample_interval_ms: Option<u64>,
1199
1200    /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`.
1201    pub disable_metrics_sample: bool,
1202
1203    /// Host control over the guest wall clock. `None` selects [`GuestClockPolicy::Sync`];
1204    /// a full snapshot restore without an explicit value keeps the policy recorded in the snapshot.
1205    #[serde(default, skip_serializing_if = "Option::is_none")]
1206    pub guest_clock: Option<GuestClockPolicy>,
1207}
1208
1209/// Environment variable entry.
1210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1212#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1213pub struct EnvVar {
1214    /// Environment variable name.
1215    pub key: String,
1216
1217    /// Environment variable value.
1218    pub value: String,
1219}
1220
1221/// Runtime log verbosity for sandbox specs.
1222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1223#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1225#[serde(rename_all = "lowercase")]
1226pub enum SandboxLogLevel {
1227    /// Emit only error logs.
1228    Error,
1229
1230    /// Emit warning and error logs.
1231    Warn,
1232
1233    /// Emit info, warning, and error logs.
1234    Info,
1235
1236    /// Emit debug and higher-severity logs.
1237    Debug,
1238
1239    /// Emit trace and higher-severity logs.
1240    Trace,
1241}
1242
1243//--------------------------------------------------------------------------------------------------
1244// Types: Exec
1245//--------------------------------------------------------------------------------------------------
1246
1247/// POSIX resource limit identifiers.
1248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1249#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1250#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1251pub enum RlimitResource {
1252    /// Max CPU time in seconds (`RLIMIT_CPU`).
1253    Cpu,
1254    /// Max file size in bytes (`RLIMIT_FSIZE`).
1255    Fsize,
1256    /// Max data segment size (`RLIMIT_DATA`).
1257    Data,
1258    /// Max stack size (`RLIMIT_STACK`).
1259    Stack,
1260    /// Max core file size (`RLIMIT_CORE`).
1261    Core,
1262    /// Max resident set size (`RLIMIT_RSS`).
1263    Rss,
1264    /// Max number of processes (`RLIMIT_NPROC`).
1265    Nproc,
1266    /// Max open file descriptors (`RLIMIT_NOFILE`).
1267    Nofile,
1268    /// Max locked memory (`RLIMIT_MEMLOCK`).
1269    Memlock,
1270    /// Max address space size (`RLIMIT_AS`).
1271    As,
1272    /// Max file locks (`RLIMIT_LOCKS`).
1273    Locks,
1274    /// Max pending signals (`RLIMIT_SIGPENDING`).
1275    Sigpending,
1276    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
1277    Msgqueue,
1278    /// Max nice priority (`RLIMIT_NICE`).
1279    Nice,
1280    /// Max real-time priority (`RLIMIT_RTPRIO`).
1281    Rtprio,
1282    /// Max real-time timeout (`RLIMIT_RTTIME`).
1283    Rttime,
1284}
1285
1286/// A POSIX resource limit.
1287#[derive(Debug, Clone, Serialize, Deserialize)]
1288#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1289#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1290pub struct Rlimit {
1291    /// Resource type.
1292    pub resource: RlimitResource,
1293
1294    /// Soft limit (can be raised up to hard limit by the process).
1295    pub soft: u64,
1296
1297    /// Hard limit (ceiling, requires privileges to raise).
1298    pub hard: u64,
1299}
1300
1301//--------------------------------------------------------------------------------------------------
1302// Types: Logs
1303//--------------------------------------------------------------------------------------------------
1304
1305/// Source tag on a captured log entry.
1306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1307#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1308#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1309#[serde(rename_all = "lowercase")]
1310pub enum LogSource {
1311    /// Captured from a session's stdout (pipe mode).
1312    Stdout,
1313
1314    /// Captured from a session's stderr (pipe mode).
1315    Stderr,
1316
1317    /// Captured from a session in pty mode (stdout + stderr merged at the kernel level inside the guest arrive as a single stream tagged `output`).
1318    Output,
1319
1320    /// Synthetic system entry: lifecycle markers, runtime diagnostics, kernel console output.
1321    System,
1322}
1323
1324//--------------------------------------------------------------------------------------------------
1325// Methods
1326//--------------------------------------------------------------------------------------------------
1327
1328impl SandboxResourcesPatch {
1329    /// Whether this patch explicitly sets the initial vCPU count, even to its default value.
1330    pub fn has_cpus(&self) -> bool {
1331        self.cpus.is_some()
1332    }
1333
1334    /// Whether this patch explicitly sets initial memory, even to its default value.
1335    pub fn has_memory_mib(&self) -> bool {
1336        self.memory_mib.is_some()
1337    }
1338
1339    /// Whether this patch explicitly sets the maximum vCPU count.
1340    pub fn has_max_cpus(&self) -> bool {
1341        self.max_cpus.is_some()
1342    }
1343
1344    /// Whether this patch explicitly sets maximum memory.
1345    pub fn has_max_memory_mib(&self) -> bool {
1346        self.max_memory_mib.is_some()
1347    }
1348}
1349
1350impl DiskImageFormat {
1351    /// Returns the format as a CLI-safe lowercase string.
1352    pub fn as_str(&self) -> &'static str {
1353        match self {
1354            Self::Qcow2 => "qcow2",
1355            Self::Raw => "raw",
1356            Self::Vmdk => "vmdk",
1357        }
1358    }
1359
1360    /// Parse a disk image format from a file extension.
1361    ///
1362    /// Returns `None` if the extension is not a recognized disk image format.
1363    pub fn from_extension(ext: &str) -> Option<Self> {
1364        match ext {
1365            "qcow2" => Some(Self::Qcow2),
1366            "raw" => Some(Self::Raw),
1367            "vmdk" => Some(Self::Vmdk),
1368            _ => None,
1369        }
1370    }
1371}
1372
1373impl OciRootfsSource {
1374    /// Create a new OCI rootfs source.
1375    pub fn new(reference: impl Into<String>) -> Self {
1376        Self {
1377            reference: reference.into(),
1378            root_disk: None,
1379        }
1380    }
1381}
1382
1383impl TransparentHugePagePolicy {
1384    /// Whether this is the density-conscious default policy.
1385    pub fn is_madvise(&self) -> bool {
1386        matches!(self, Self::Madvise)
1387    }
1388
1389    /// Return the lowercase kernel command-line representation.
1390    pub fn as_str(self) -> &'static str {
1391        match self {
1392            Self::Always => "always",
1393            Self::Madvise => "madvise",
1394            Self::Never => "never",
1395        }
1396    }
1397}
1398
1399impl GuestClockPolicy {
1400    /// Whether the runtime keeps the guest wall clock in step with the host.
1401    pub fn is_sync(&self) -> bool {
1402        matches!(self, Self::Sync)
1403    }
1404
1405    /// Return the lowercase configuration spelling.
1406    pub fn as_str(self) -> &'static str {
1407        match self {
1408            Self::Sync => "sync",
1409            Self::Off => "off",
1410        }
1411    }
1412}
1413
1414impl RootDisk {
1415    /// Create a managed root disk with the given size in MiB.
1416    pub fn managed(size_mib: u32) -> Self {
1417        Self::Managed {
1418            size_mib: Some(size_mib),
1419        }
1420    }
1421
1422    /// Create a tmpfs root disk with the given size in MiB.
1423    pub fn tmpfs(size_mib: u32) -> Self {
1424        Self::Tmpfs {
1425            size_mib: Some(size_mib),
1426        }
1427    }
1428
1429    /// Create a flat root disk with the given final capacity in MiB.
1430    pub fn flat(size_mib: u32) -> Self {
1431        Self::Flat {
1432            size_mib: Some(size_mib),
1433            fstype: None,
1434            clone: FlatClone::Auto,
1435        }
1436    }
1437
1438    /// Return the configured size in MiB, if this kind carries one.
1439    pub fn size_mib(&self) -> Option<u32> {
1440        match self {
1441            Self::Managed { size_mib } | Self::Tmpfs { size_mib } | Self::Flat { size_mib, .. } => {
1442                *size_mib
1443            }
1444            Self::DiskImage { .. } => None,
1445        }
1446    }
1447
1448    /// Return the lowercase kind tag used on the wire, in the DB, and in CLI output.
1449    pub fn kind_str(&self) -> &'static str {
1450        match self {
1451            Self::Managed { .. } => "managed",
1452            Self::Tmpfs { .. } => "tmpfs",
1453            Self::DiskImage { .. } => "disk-image",
1454            Self::Flat { .. } => "flat",
1455        }
1456    }
1457
1458    /// Whether this is the managed (default) kind.
1459    pub fn is_managed(&self) -> bool {
1460        matches!(self, Self::Managed { .. })
1461    }
1462}
1463
1464impl FlatClone {
1465    /// Return the stable lowercase value used by CLI, SDK and persisted metadata surfaces.
1466    pub const fn as_str(self) -> &'static str {
1467        match self {
1468            Self::Auto => "auto",
1469            Self::Copy => "copy",
1470            Self::Reflink => "reflink",
1471        }
1472    }
1473
1474    /// Whether this is the default auto strategy.
1475    pub const fn is_auto(&self) -> bool {
1476        matches!(self, Self::Auto)
1477    }
1478}
1479
1480impl RootfsSource {
1481    /// Create an OCI rootfs source from an image reference.
1482    pub fn oci(reference: impl Into<String>) -> Self {
1483        Self::Oci(OciRootfsSource::new(reference))
1484    }
1485
1486    /// Return the OCI image reference if this is an OCI rootfs.
1487    pub fn oci_reference(&self) -> Option<&str> {
1488        match self {
1489            Self::Oci(oci) => Some(&oci.reference),
1490            _ => None,
1491        }
1492    }
1493
1494    /// Return the configured root disk if this is an OCI rootfs.
1495    pub fn oci_root_disk(&self) -> Option<&RootDisk> {
1496        match self {
1497            Self::Oci(oci) => oci.root_disk.as_ref(),
1498            _ => None,
1499        }
1500    }
1501
1502    /// Return the managed root disk size in MiB if this is an OCI rootfs with a managed
1503    /// (or unset, i.e. default-managed) root disk. Non-managed kinds return `None`.
1504    pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
1505        match self {
1506            Self::Oci(oci) => match &oci.root_disk {
1507                Some(RootDisk::Managed { size_mib }) => *size_mib,
1508                Some(_) => None,
1509                None => None,
1510            },
1511            _ => None,
1512        }
1513    }
1514}
1515
1516impl EnvVar {
1517    /// Create an environment variable entry.
1518    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1519        Self {
1520            key: key.into(),
1521            value: value.into(),
1522        }
1523    }
1524
1525    /// Return this entry as key and value string slices.
1526    pub fn as_pair(&self) -> (&str, &str) {
1527        (&self.key, &self.value)
1528    }
1529}
1530
1531impl VolumeKind {
1532    /// Return the lowercase database and CLI representation.
1533    pub fn as_str(self) -> &'static str {
1534        match self {
1535            Self::Directory => "dir",
1536            Self::Disk => "disk",
1537        }
1538    }
1539
1540    /// Parse a persisted database value, defaulting to directory for unknown values.
1541    pub fn from_db_value(value: &str) -> Self {
1542        match value {
1543            "disk" => Self::Disk,
1544            _ => Self::Directory,
1545        }
1546    }
1547}
1548
1549impl VolumeSpec {
1550    /// Create a directory-backed volume spec with default options.
1551    pub fn new(name: impl Into<String>) -> Self {
1552        Self {
1553            name: name.into(),
1554            kind: VolumeKind::Directory,
1555            quota_mib: None,
1556            capacity_mib: None,
1557            labels: Vec::new(),
1558        }
1559    }
1560}
1561
1562impl NamedVolumeCreate {
1563    /// Creation behavior for this named volume mount.
1564    pub fn mode(&self) -> NamedVolumeMode {
1565        self.mode
1566    }
1567
1568    /// Volume name to create or ensure exists.
1569    pub fn name(&self) -> &str {
1570        &self.name
1571    }
1572
1573    /// Storage kind to create or ensure exists.
1574    pub fn kind(&self) -> VolumeKind {
1575        self.kind
1576    }
1577
1578    /// Directory quota in MiB, if configured.
1579    pub fn quota_mib(&self) -> Option<u32> {
1580        self.quota_mib
1581    }
1582
1583    /// Disk capacity in MiB, if configured.
1584    pub fn capacity_mib(&self) -> Option<u32> {
1585        self.capacity_mib
1586    }
1587
1588    /// Labels to attach to newly-created volumes.
1589    pub fn labels(&self) -> &[(String, String)] {
1590        &self.labels
1591    }
1592}
1593
1594impl VolumeMount {
1595    /// The absolute path where this mount appears inside the guest.
1596    pub fn guest(&self) -> &str {
1597        match self {
1598            Self::Bind { guest, .. }
1599            | Self::Owned { guest, .. }
1600            | Self::Named { guest, .. }
1601            | Self::Tmpfs { guest, .. }
1602            | Self::DiskImage { guest, .. } => guest,
1603        }
1604    }
1605
1606    fn guest_mut(&mut self) -> &mut String {
1607        match self {
1608            Self::Bind { guest, .. }
1609            | Self::Owned { guest, .. }
1610            | Self::Named { guest, .. }
1611            | Self::Tmpfs { guest, .. }
1612            | Self::DiskImage { guest, .. } => guest,
1613        }
1614    }
1615
1616    /// Return named-volume creation metadata when this mount provisions a named volume.
1617    pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1618        match self {
1619            Self::Named { create, .. } => create.as_ref(),
1620            _ => None,
1621        }
1622    }
1623}
1624
1625//--------------------------------------------------------------------------------------------------
1626// Functions: Volume Mounts
1627//--------------------------------------------------------------------------------------------------
1628
1629/// Portable private-volume identity derived from an already canonical guest path.
1630/// The ASCII hint is diagnostic; the suffix keeps distinct paths distinct.
1631pub fn owned_volume_mount_id(guest: &str) -> String {
1632    use std::fmt::Write as _;
1633    let slug: String = guest
1634        .trim_start_matches('/')
1635        .chars()
1636        .take(11)
1637        .map(|character| {
1638            if character.is_ascii_alphanumeric() || character == '-' {
1639                character
1640            } else {
1641                '_'
1642            }
1643        })
1644        .collect();
1645    let mut id = if slug.is_empty() {
1646        String::new()
1647    } else {
1648        format!("{slug}_")
1649    };
1650    for byte in Sha256::digest(guest.as_bytes()).iter().take(4) {
1651        let _ = write!(id, "{byte:02x}");
1652    }
1653    id
1654}
1655
1656/// Canonicalizes guest paths and orders mounts from parent to child.
1657///
1658/// All SDKs and runtimes share this ordering contract so an enclosing mount
1659/// can never hide a nested mount merely because the caller used an unordered
1660/// collection. Paths at the same depth are ordered lexicographically to keep
1661/// serialized configurations deterministic.
1662pub fn canonicalize_volume_mounts(mounts: &mut [VolumeMount]) -> TypesResult<()> {
1663    for mount in mounts.iter_mut() {
1664        let canonical = canonical_guest_mount_path(mount.guest())?;
1665        *mount.guest_mut() = canonical;
1666    }
1667
1668    mounts.sort_by_cached_key(|mount| guest_mount_order_key(mount.guest()));
1669
1670    for pair in mounts.windows(2) {
1671        if pair[0].guest() == pair[1].guest() {
1672            return Err(TypesError::invalid_config(format!(
1673                "multiple volumes cannot mount the same guest path: {}",
1674                pair[0].guest()
1675            )));
1676        }
1677    }
1678
1679    Ok(())
1680}
1681
1682fn canonical_guest_mount_path(guest: &str) -> TypesResult<String> {
1683    let path = Utf8UnixPath::new(guest);
1684
1685    if !path.is_valid() {
1686        return Err(TypesError::invalid_config(format!(
1687            "guest mount path must be a valid Unix path: {guest}"
1688        )));
1689    }
1690    if !path.is_absolute() {
1691        return Err(TypesError::invalid_config(format!(
1692            "guest mount path must be absolute: {guest}"
1693        )));
1694    }
1695    if path
1696        .components()
1697        .any(|component| matches!(component, Utf8UnixComponent::ParentDir))
1698    {
1699        return Err(TypesError::invalid_config(format!(
1700            "guest mount path must not contain '..': {guest}"
1701        )));
1702    }
1703    if guest.contains(':') || guest.contains(';') || guest.contains(',') {
1704        return Err(TypesError::invalid_config(format!(
1705            "guest mount path must not contain ':', ';', or ',': {guest}"
1706        )));
1707    }
1708
1709    let canonical = path.normalize().to_string();
1710    if canonical == "/" {
1711        return Err(TypesError::invalid_config(
1712            "cannot mount a volume at guest root /",
1713        ));
1714    }
1715
1716    Ok(canonical)
1717}
1718
1719fn guest_mount_order_key(guest: &str) -> (usize, String) {
1720    let path = Utf8UnixPath::new(guest);
1721    let depth = path.components().filter(Utf8Component::is_normal).count();
1722    (depth, guest.to_owned())
1723}
1724
1725impl RlimitResource {
1726    /// Returns the lowercase string representation used on the wire.
1727    pub fn as_str(&self) -> &'static str {
1728        match self {
1729            Self::Cpu => "cpu",
1730            Self::Fsize => "fsize",
1731            Self::Data => "data",
1732            Self::Stack => "stack",
1733            Self::Core => "core",
1734            Self::Rss => "rss",
1735            Self::Nproc => "nproc",
1736            Self::Nofile => "nofile",
1737            Self::Memlock => "memlock",
1738            Self::As => "as",
1739            Self::Locks => "locks",
1740            Self::Sigpending => "sigpending",
1741            Self::Msgqueue => "msgqueue",
1742            Self::Nice => "nice",
1743            Self::Rtprio => "rtprio",
1744            Self::Rttime => "rttime",
1745        }
1746    }
1747}
1748
1749impl LogSource {
1750    /// Apply the empty-means-default rule used by log readers.
1751    pub fn effective(requested: &[Self]) -> Vec<Self> {
1752        if requested.is_empty() {
1753            vec![Self::Stdout, Self::Stderr, Self::Output]
1754        } else {
1755            let mut sources = requested.to_vec();
1756            sources.sort_by_key(|src| match src {
1757                Self::Stdout => 0,
1758                Self::Stderr => 1,
1759                Self::Output => 2,
1760                Self::System => 3,
1761            });
1762            sources.dedup();
1763            sources
1764        }
1765    }
1766}
1767
1768impl SandboxLogLevel {
1769    /// Return the lowercase string representation for this level.
1770    pub const fn as_str(self) -> &'static str {
1771        match self {
1772            Self::Error => "error",
1773            Self::Warn => "warn",
1774            Self::Info => "info",
1775            Self::Debug => "debug",
1776            Self::Trace => "trace",
1777        }
1778    }
1779}
1780
1781//--------------------------------------------------------------------------------------------------
1782// Trait Implementations
1783//--------------------------------------------------------------------------------------------------
1784
1785impl std::fmt::Display for DiskImageFormat {
1786    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1787        f.write_str(self.as_str())
1788    }
1789}
1790
1791impl FromStr for DiskImageFormat {
1792    type Err = String;
1793
1794    fn from_str(s: &str) -> Result<Self, Self::Err> {
1795        match s {
1796            "qcow2" => Ok(Self::Qcow2),
1797            "raw" => Ok(Self::Raw),
1798            "vmdk" => Ok(Self::Vmdk),
1799            _ => Err(format!("unknown disk image format: {s}")),
1800        }
1801    }
1802}
1803
1804impl fmt::Display for TransparentHugePagePolicy {
1805    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1806        f.write_str(self.as_str())
1807    }
1808}
1809
1810impl FromStr for TransparentHugePagePolicy {
1811    type Err = String;
1812
1813    fn from_str(value: &str) -> Result<Self, Self::Err> {
1814        match value {
1815            "always" => Ok(Self::Always),
1816            "madvise" => Ok(Self::Madvise),
1817            "never" => Ok(Self::Never),
1818            _ => Err(format!(
1819                "unknown transparent huge-page policy: {value}; expected always, madvise, or never"
1820            )),
1821        }
1822    }
1823}
1824
1825impl fmt::Display for GuestClockPolicy {
1826    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1827        f.write_str(self.as_str())
1828    }
1829}
1830
1831impl FromStr for GuestClockPolicy {
1832    type Err = String;
1833
1834    fn from_str(value: &str) -> Result<Self, Self::Err> {
1835        match value {
1836            "sync" => Ok(Self::Sync),
1837            "off" => Ok(Self::Off),
1838            _ => Err(format!(
1839                "unknown guest clock policy: {value}; expected sync or off"
1840            )),
1841        }
1842    }
1843}
1844
1845impl Default for RootfsSource {
1846    fn default() -> Self {
1847        Self::oci(String::new())
1848    }
1849}
1850
1851impl Default for SandboxResources {
1852    fn default() -> Self {
1853        Self {
1854            cpus: DEFAULT_SANDBOX_CPUS,
1855            memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1856            max_cpus: DEFAULT_SANDBOX_CPUS,
1857            max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1858            cpu_placement: CpuPlacement::Inherit,
1859            placement_profile: None,
1860            thp: TransparentHugePagePolicy::Madvise,
1861        }
1862    }
1863}
1864
1865impl<'de> Deserialize<'de> for SandboxResources {
1866    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1867    where
1868        D: serde::Deserializer<'de>,
1869    {
1870        #[derive(Deserialize)]
1871        struct RawResources {
1872            #[serde(default = "default_sandbox_cpus")]
1873            cpus: u8,
1874            #[serde(default = "default_sandbox_memory_mib")]
1875            memory_mib: u32,
1876            max_cpus: Option<u8>,
1877            max_memory_mib: Option<u32>,
1878            #[serde(default)]
1879            cpu_placement: CpuPlacement,
1880            #[serde(default)]
1881            placement_profile: Option<String>,
1882            #[serde(default)]
1883            thp: TransparentHugePagePolicy,
1884        }
1885
1886        let raw = RawResources::deserialize(deserializer)?;
1887        Ok(Self {
1888            cpus: raw.cpus,
1889            memory_mib: raw.memory_mib,
1890            // Legacy configs predate boot-capacity fields. Treat their effective
1891            // resources as their maximum capacity so old sandboxes do not
1892            // deserialize into an impossible cpus > max_cpus state.
1893            max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1894            max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1895            cpu_placement: raw.cpu_placement,
1896            placement_profile: raw.placement_profile,
1897            thp: raw.thp,
1898        })
1899    }
1900}
1901
1902impl CpuPlacement {
1903    /// Returns whether this policy preserves the inherited host placement.
1904    pub const fn is_inherit(&self) -> bool {
1905        matches!(self, Self::Inherit)
1906    }
1907}
1908
1909impl std::fmt::Display for CpuPlacement {
1910    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1911        f.write_str(match self {
1912            Self::Inherit => "inherit",
1913            Self::Auto => "auto",
1914            Self::Spread => "spread",
1915            Self::Compact => "compact",
1916        })
1917    }
1918}
1919
1920impl FromStr for CpuPlacement {
1921    type Err = String;
1922
1923    fn from_str(value: &str) -> Result<Self, Self::Err> {
1924        match value {
1925            "inherit" => Ok(Self::Inherit),
1926            "auto" => Ok(Self::Auto),
1927            "spread" => Ok(Self::Spread),
1928            "compact" => Ok(Self::Compact),
1929            _ => Err(format!(
1930                "unknown CPU placement: {value} (expected: inherit, auto, spread, compact)"
1931            )),
1932        }
1933    }
1934}
1935
1936impl Default for SandboxRuntimeOptions {
1937    fn default() -> Self {
1938        Self {
1939            workdir: None,
1940            shell: None,
1941            scripts: BTreeMap::new(),
1942            entrypoint: None,
1943            cmd: None,
1944            hostname: None,
1945            user: None,
1946            log_level: None,
1947            metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1948            disable_metrics_sample: false,
1949            guest_clock: None,
1950        }
1951    }
1952}
1953
1954impl Default for HttpConfig {
1955    fn default() -> Self {
1956        Self {
1957            deny_response: true,
1958            deny_response_format: HttpDenyResponseFormat::Json,
1959            deny_message: None,
1960            network_deny_message: None,
1961            secret_deny_message: None,
1962        }
1963    }
1964}
1965
1966impl Default for NetworkSpec {
1967    fn default() -> Self {
1968        Self {
1969            enabled: true,
1970            interface: None,
1971            ports: Vec::new(),
1972            policy: None,
1973            dns: None,
1974            tls: None,
1975            strict: true,
1976            secrets: None,
1977            max_tcp_connections: None,
1978            max_udp_connections: None,
1979            tcp_accept_queue_size: None,
1980            max_inbound_tcp_connections: None,
1981            rate_limiter: None,
1982            nat64_prefixes: default_nat64_prefixes(),
1983            trust_host_cas: false,
1984            outbound_proxy: None,
1985            http: HttpConfig::default(),
1986        }
1987    }
1988}
1989
1990pub(crate) fn default_nat64_prefixes() -> Vec<Ipv6Network> {
1991    vec![
1992        WELL_KNOWN_NAT64_PREFIX
1993            .parse()
1994            .expect("well-known NAT64 prefix must be valid"),
1995    ]
1996}
1997
1998impl Default for PublishedPortSpec {
1999    fn default() -> Self {
2000        Self {
2001            host_port: 0,
2002            guest_port: 0,
2003            protocol: PortProtocol::Tcp,
2004            host_bind: "127.0.0.1".into(),
2005        }
2006    }
2007}
2008
2009impl From<(String, String)> for EnvVar {
2010    fn from((key, value): (String, String)) -> Self {
2011        Self { key, value }
2012    }
2013}
2014
2015impl From<EnvVar> for (String, String) {
2016    fn from(var: EnvVar) -> Self {
2017        (var.key, var.value)
2018    }
2019}
2020
2021impl FromStr for SandboxLogLevel {
2022    type Err = String;
2023
2024    fn from_str(s: &str) -> Result<Self, Self::Err> {
2025        match s {
2026            "error" => Ok(Self::Error),
2027            "warn" => Ok(Self::Warn),
2028            "info" => Ok(Self::Info),
2029            "debug" => Ok(Self::Debug),
2030            "trace" => Ok(Self::Trace),
2031            _ => Err(format!("unknown sandbox log level: {s}")),
2032        }
2033    }
2034}
2035
2036impl std::fmt::Display for SandboxLogLevel {
2037    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2038        formatter.write_str(self.as_str())
2039    }
2040}
2041
2042impl Serialize for VolumeMount {
2043    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2044        use serde::ser::SerializeMap;
2045
2046        match self {
2047            Self::Owned {
2048                guest,
2049                storage,
2050                options,
2051                stat_virtualization,
2052                host_permissions,
2053            } => {
2054                // A distinct tag is intentional: older runtimes must reject ownership,
2055                // not reinterpret a private mount as an external or named volume.
2056                let mut map = serializer.serialize_map(Some(6))?;
2057                map.serialize_entry("type", "Owned")?;
2058                map.serialize_entry("guest", guest)?;
2059                map.serialize_entry("storage", storage)?;
2060                map.serialize_entry("options", options)?;
2061                map.serialize_entry("stat_virtualization", stat_virtualization)?;
2062                map.serialize_entry("host_permissions", host_permissions)?;
2063                map.end()
2064            }
2065            Self::Bind {
2066                host,
2067                guest,
2068                options,
2069                stat_virtualization,
2070                host_permissions,
2071                follow_root_symlinks,
2072                quota_mib,
2073            } => {
2074                let mut map = serializer.serialize_map(Some(8))?;
2075                map.serialize_entry("type", "Bind")?;
2076                map.serialize_entry("host", host)?;
2077                map.serialize_entry("guest", guest)?;
2078                map.serialize_entry("options", options)?;
2079                map.serialize_entry("stat_virtualization", stat_virtualization)?;
2080                map.serialize_entry("host_permissions", host_permissions)?;
2081                map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2082                map.serialize_entry("quota_mib", quota_mib)?;
2083                map.end()
2084            }
2085            Self::Named {
2086                name,
2087                guest,
2088                create: _,
2089                options,
2090                stat_virtualization,
2091                host_permissions,
2092                follow_root_symlinks,
2093            } => {
2094                let mut map = serializer.serialize_map(Some(7))?;
2095                map.serialize_entry("type", "Named")?;
2096                map.serialize_entry("name", name)?;
2097                map.serialize_entry("guest", guest)?;
2098                map.serialize_entry("options", options)?;
2099                map.serialize_entry("stat_virtualization", stat_virtualization)?;
2100                map.serialize_entry("host_permissions", host_permissions)?;
2101                map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2102                map.end()
2103            }
2104            Self::Tmpfs {
2105                guest,
2106                size_mib,
2107                options,
2108            } => {
2109                let mut map = serializer.serialize_map(Some(4))?;
2110                map.serialize_entry("type", "Tmpfs")?;
2111                map.serialize_entry("guest", guest)?;
2112                map.serialize_entry("size_mib", size_mib)?;
2113                map.serialize_entry("options", options)?;
2114                map.end()
2115            }
2116            Self::DiskImage {
2117                host,
2118                guest,
2119                format,
2120                fstype,
2121                options,
2122            } => {
2123                let mut map = serializer.serialize_map(Some(6))?;
2124                map.serialize_entry("type", "DiskImage")?;
2125                map.serialize_entry("host", host)?;
2126                map.serialize_entry("guest", guest)?;
2127                map.serialize_entry("format", format)?;
2128                map.serialize_entry("fstype", fstype)?;
2129                map.serialize_entry("options", options)?;
2130                map.end()
2131            }
2132        }
2133    }
2134}
2135
2136impl<'de> Deserialize<'de> for VolumeMount {
2137    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2138        fn default_strict() -> StatVirtualization {
2139            StatVirtualization::Strict
2140        }
2141
2142        fn default_private() -> HostPermissions {
2143            HostPermissions::Private
2144        }
2145
2146        #[derive(Deserialize)]
2147        #[serde(tag = "type")]
2148        enum VolumeMountHelper {
2149            Owned {
2150                guest: String,
2151                storage: OwnedVolumeStorage,
2152                #[serde(default)]
2153                options: MountOptions,
2154                #[serde(default = "default_strict")]
2155                stat_virtualization: StatVirtualization,
2156                #[serde(default = "default_private")]
2157                host_permissions: HostPermissions,
2158            },
2159            Bind {
2160                host: PathBuf,
2161                guest: String,
2162                #[serde(default)]
2163                options: Option<MountOptions>,
2164                #[serde(default)]
2165                readonly: bool,
2166                #[serde(default = "default_strict")]
2167                stat_virtualization: StatVirtualization,
2168                #[serde(default = "default_private")]
2169                host_permissions: HostPermissions,
2170                #[serde(default)]
2171                follow_root_symlinks: bool,
2172                #[serde(default)]
2173                quota_mib: Option<u32>,
2174            },
2175            Named {
2176                name: String,
2177                guest: String,
2178                #[serde(default)]
2179                options: Option<MountOptions>,
2180                #[serde(default)]
2181                readonly: bool,
2182                #[serde(default = "default_strict")]
2183                stat_virtualization: StatVirtualization,
2184                #[serde(default = "default_private")]
2185                host_permissions: HostPermissions,
2186                #[serde(default)]
2187                follow_root_symlinks: bool,
2188            },
2189            Tmpfs {
2190                guest: String,
2191                #[serde(default)]
2192                size_mib: Option<u32>,
2193                #[serde(default)]
2194                options: Option<MountOptions>,
2195                #[serde(default)]
2196                readonly: bool,
2197            },
2198            DiskImage {
2199                host: PathBuf,
2200                guest: String,
2201                format: DiskImageFormat,
2202                #[serde(default)]
2203                fstype: Option<String>,
2204                #[serde(default)]
2205                options: Option<MountOptions>,
2206                #[serde(default)]
2207                readonly: bool,
2208            },
2209        }
2210
2211        let helper = VolumeMountHelper::deserialize(deserializer)?;
2212        Ok(match helper {
2213            VolumeMountHelper::Owned {
2214                guest,
2215                storage,
2216                options,
2217                stat_virtualization,
2218                host_permissions,
2219            } => Self::Owned {
2220                guest,
2221                storage,
2222                options,
2223                stat_virtualization,
2224                host_permissions,
2225            },
2226            VolumeMountHelper::Bind {
2227                host,
2228                guest,
2229                options,
2230                readonly,
2231                stat_virtualization,
2232                host_permissions,
2233                follow_root_symlinks,
2234                quota_mib,
2235            } => Self::Bind {
2236                host,
2237                guest,
2238                options: decode_mount_options(options, readonly),
2239                stat_virtualization,
2240                host_permissions,
2241                follow_root_symlinks,
2242                quota_mib,
2243            },
2244            VolumeMountHelper::Named {
2245                name,
2246                guest,
2247                options,
2248                readonly,
2249                stat_virtualization,
2250                host_permissions,
2251                follow_root_symlinks,
2252            } => Self::Named {
2253                name,
2254                guest,
2255                create: None,
2256                options: decode_mount_options(options, readonly),
2257                stat_virtualization,
2258                host_permissions,
2259                follow_root_symlinks,
2260            },
2261            VolumeMountHelper::Tmpfs {
2262                guest,
2263                size_mib,
2264                options,
2265                readonly,
2266            } => Self::Tmpfs {
2267                guest,
2268                size_mib,
2269                options: decode_mount_options(options, readonly),
2270            },
2271            VolumeMountHelper::DiskImage {
2272                host,
2273                guest,
2274                format,
2275                fstype,
2276                options,
2277                readonly,
2278            } => Self::DiskImage {
2279                host,
2280                guest,
2281                format,
2282                fstype,
2283                options: decode_mount_options(options, readonly),
2284            },
2285        })
2286    }
2287}
2288
2289impl fmt::Debug for VolumeMount {
2290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2291        match self {
2292            Self::Owned {
2293                guest,
2294                storage,
2295                options,
2296                stat_virtualization,
2297                host_permissions,
2298            } => f
2299                .debug_struct("Owned")
2300                .field("guest", guest)
2301                .field("storage", storage)
2302                .field("options", options)
2303                .field("stat_virtualization", stat_virtualization)
2304                .field("host_permissions", host_permissions)
2305                .finish(),
2306            Self::Bind {
2307                host,
2308                guest,
2309                options,
2310                stat_virtualization,
2311                host_permissions,
2312                follow_root_symlinks,
2313                quota_mib,
2314            } => f
2315                .debug_struct("Bind")
2316                .field("host", host)
2317                .field("guest", guest)
2318                .field("options", options)
2319                .field("stat_virtualization", stat_virtualization)
2320                .field("host_permissions", host_permissions)
2321                .field("follow_root_symlinks", follow_root_symlinks)
2322                .field("quota_mib", quota_mib)
2323                .finish(),
2324            Self::Named {
2325                name,
2326                guest,
2327                create,
2328                options,
2329                stat_virtualization,
2330                host_permissions,
2331                follow_root_symlinks,
2332            } => f
2333                .debug_struct("Named")
2334                .field("name", name)
2335                .field("guest", guest)
2336                .field("create", create)
2337                .field("options", options)
2338                .field("stat_virtualization", stat_virtualization)
2339                .field("host_permissions", host_permissions)
2340                .field("follow_root_symlinks", follow_root_symlinks)
2341                .finish(),
2342            Self::Tmpfs {
2343                guest,
2344                size_mib,
2345                options,
2346            } => f
2347                .debug_struct("Tmpfs")
2348                .field("guest", guest)
2349                .field("size_mib", size_mib)
2350                .field("options", options)
2351                .finish(),
2352            Self::DiskImage {
2353                host,
2354                guest,
2355                format,
2356                fstype,
2357                options,
2358            } => f
2359                .debug_struct("DiskImage")
2360                .field("host", host)
2361                .field("guest", guest)
2362                .field("format", format)
2363                .field("fstype", fstype)
2364                .field("options", options)
2365                .finish(),
2366        }
2367    }
2368}
2369
2370/// Case-insensitive string to [`RlimitResource`] conversion.
2371impl TryFrom<&str> for RlimitResource {
2372    type Error = String;
2373
2374    fn try_from(s: &str) -> Result<Self, Self::Error> {
2375        match s.to_ascii_lowercase().as_str() {
2376            "cpu" => Ok(Self::Cpu),
2377            "fsize" => Ok(Self::Fsize),
2378            "data" => Ok(Self::Data),
2379            "stack" => Ok(Self::Stack),
2380            "core" => Ok(Self::Core),
2381            "rss" => Ok(Self::Rss),
2382            "nproc" => Ok(Self::Nproc),
2383            "nofile" => Ok(Self::Nofile),
2384            "memlock" => Ok(Self::Memlock),
2385            "as" => Ok(Self::As),
2386            "locks" => Ok(Self::Locks),
2387            "sigpending" => Ok(Self::Sigpending),
2388            "msgqueue" => Ok(Self::Msgqueue),
2389            "nice" => Ok(Self::Nice),
2390            "rtprio" => Ok(Self::Rtprio),
2391            "rttime" => Ok(Self::Rttime),
2392            _ => Err(format!("unknown rlimit resource: {s}")),
2393        }
2394    }
2395}
2396
2397//--------------------------------------------------------------------------------------------------
2398// Functions
2399//--------------------------------------------------------------------------------------------------
2400
2401fn default_sandbox_cpus() -> u8 {
2402    DEFAULT_SANDBOX_CPUS
2403}
2404
2405fn default_sandbox_memory_mib() -> u32 {
2406    DEFAULT_SANDBOX_MEMORY_MIB
2407}
2408
2409fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
2410    options.unwrap_or(MountOptions {
2411        readonly,
2412        ..MountOptions::default()
2413    })
2414}
2415
2416fn merge_env_vars(base: &mut Vec<EnvVar>, higher: Vec<EnvVar>) {
2417    for value in higher {
2418        match base.iter_mut().find(|current| current.key == value.key) {
2419            Some(current) => *current = value,
2420            None => base.push(value),
2421        }
2422    }
2423}
2424
2425fn merge_secret_entries(base: &mut Vec<SecretEntry>, higher: Vec<SecretEntry>) {
2426    for value in higher {
2427        match base
2428            .iter_mut()
2429            .find(|current| current.env_var == value.env_var)
2430        {
2431            Some(current) => *current = value,
2432            None => base.push(value),
2433        }
2434    }
2435}
2436
2437/// Default stat-virtualization policy (`Strict`) for a deserialized volume mount.
2438pub(crate) fn default_strict() -> StatVirtualization {
2439    StatVirtualization::Strict
2440}
2441
2442/// Default host-permission policy (`Private`) for a deserialized volume mount.
2443pub(crate) fn default_private() -> HostPermissions {
2444    HostPermissions::Private
2445}
2446
2447/// Maximum supported secret placeholder length in bytes.
2448pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
2449
2450/// Placeholder-based secret substitution for a sandbox's TLS-intercepted egress.
2451///
2452/// The sandbox only ever sees each secret's `placeholder`; the local network
2453/// engine substitutes the real `value` into outbound requests bound for an
2454/// allowed host (and blocks/forwards per [`SecretViolationAction`] otherwise). Carried
2455/// in [`NetworkSpec::secrets`](NetworkSpec).
2456///
2457/// When constructing directly, use `..Default::default()` for unspecified fields.
2458/// The global `passthrough_hosts` field preserves historical defaults; its addition
2459/// requires updating older exhaustive struct literals and patterns.
2460#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigPatch)]
2461#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2462#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2463pub struct SecretsConfig {
2464    /// Default hosts allowed to receive placeholders unchanged.
2465    /// A per-secret violation action overrides this default.
2466    #[doc(hidden)]
2467    #[serde(default, skip_serializing_if = "Option::is_none")]
2468    #[cfg_attr(feature = "ts", ts(skip))]
2469    #[cfg_attr(feature = "utoipa", schema(ignore))]
2470    pub passthrough_hosts: Option<Vec<HostPattern>>,
2471
2472    /// List of secrets to inject.
2473    #[serde(default)]
2474    #[config_patch(merge_with = merge_secret_entries)]
2475    pub secrets: Vec<SecretEntry>,
2476
2477    /// Default action when a placeholder leaks to a disallowed host.
2478    #[serde(default)]
2479    pub violation_action: SecretViolationAction,
2480}
2481
2482/// A single secret entry.
2483///
2484/// `value` is the sensitive material — it never enters the sandbox and is
2485/// redacted by the [`Debug`](fmt::Debug) impl.
2486#[derive(Clone, Serialize, Deserialize)]
2487#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2488#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2489pub struct SecretEntry {
2490    /// Environment variable name exposed to the sandbox (holds the placeholder).
2491    ///
2492    /// Must be non-empty and must not contain `=` or NUL. microsandbox does
2493    /// not require shell-identifier syntax because Linux environment entries
2494    /// only require a `NAME=value` shape.
2495    pub env_var: String,
2496
2497    /// The actual secret value (never enters the sandbox).
2498    ///
2499    /// Empty when the entry carries a [`source`](Self::source) reference
2500    /// instead: reference-model entries resolve the value host-side at spawn
2501    /// time so the durable sandbox config never stores raw secret material.
2502    ///
2503    /// Wrapped in [`Zeroizing`] so the owned plaintext copy is wiped when the
2504    /// entry drops.
2505    #[serde(default = "empty_secret_value")]
2506    #[cfg_attr(feature = "ts", ts(type = "string"))]
2507    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2508    pub value: Zeroizing<String>,
2509
2510    /// Host-side source reference resolved into [`value`](Self::value) at
2511    /// spawn time. `None` means `value` already carries the material (the
2512    /// inline model used by value-based secrets).
2513    #[serde(default, skip_serializing_if = "Option::is_none")]
2514    pub source: Option<SecretSource>,
2515
2516    /// Placeholder string the sandbox sees instead of the real value.
2517    ///
2518    /// Must be non-empty, no longer than [`MAX_SECRET_PLACEHOLDER_BYTES`], and
2519    /// must not contain NUL, CR, or LF.
2520    pub placeholder: String,
2521
2522    /// Hosts allowed to receive the substituted secret value.
2523    #[serde(default)]
2524    pub allowed_hosts: Vec<HostPattern>,
2525
2526    /// Request locations where the placeholder can be substituted.
2527    #[serde(default)]
2528    pub substitution: SecretSubstitution,
2529
2530    /// Hosts allowed to receive the placeholder unchanged.
2531    #[serde(default)]
2532    pub passthrough_hosts: Vec<HostPattern>,
2533
2534    /// Action on a violation for this secret (overrides the config default).
2535    #[serde(default, skip_serializing_if = "Option::is_none")]
2536    pub violation_action: Option<SecretViolationAction>,
2537
2538    /// Require verified TLS identity before substituting (default: true).
2539    ///
2540    /// When true, the secret is only substituted if the connection uses TLS
2541    /// interception (not bypass) and the SNI matches an allowed host.
2542    #[serde(default = "default_true")]
2543    pub require_tls_identity: bool,
2544}
2545
2546/// Host pattern for a secret allowlist.
2547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2548#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2549#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2550#[serde(rename_all = "kebab-case")]
2551pub enum HostPattern {
2552    /// Exact hostname match.
2553    #[serde(alias = "Exact")]
2554    Exact(String),
2555    /// Wildcard match (e.g., `*.openai.com`).
2556    #[serde(alias = "Wildcard")]
2557    Wildcard(String),
2558    /// Any host (dangerous — secret can be exfiltrated).
2559    #[serde(alias = "Any")]
2560    Any,
2561}
2562
2563/// Request locations where a placeholder can be substituted with its secret.
2564#[derive(Debug, Clone, Serialize, Deserialize)]
2565#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2566#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2567pub struct SecretSubstitution {
2568    /// Substitute in HTTP headers (default: true).
2569    #[serde(default = "default_true")]
2570    pub headers: bool,
2571
2572    /// Restrict header substitution to these header field names.
2573    ///
2574    /// Requires [`headers`](Self::headers) to be true; a non-empty list with
2575    /// header substitution disabled is rejected as contradictory. An empty list
2576    /// (the default) allows every header field. When non-empty, the
2577    /// placeholder is substituted only in the named fields (matched ASCII
2578    /// case-insensitively); a placeholder found in any other header field is
2579    /// treated as a disabled location: on a verified allowed destination it is
2580    /// forwarded unchanged, while other destinations follow the violation
2581    /// policy.
2582    ///
2583    /// Prefer an allowlist containing only the intended credential header
2584    /// (typically `Authorization`). Substituting in every header lets an
2585    /// untrusted guest place the placeholder in a header the upstream host
2586    /// reflects back in its response (or otherwise exposes), which would let
2587    /// the guest read the real secret out of that response.
2588    ///
2589    /// Names must be valid HTTP field names (RFC 9110 `token`).
2590    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2591    pub header_fields: Vec<String>,
2592
2593    /// Substitute in URL query parameters (default: false).
2594    #[serde(default)]
2595    pub query: bool,
2596
2597    /// Substitute in request body (default: false).
2598    ///
2599    /// Fixed-length HTTP/1 bodies up to 16 MiB update `Content-Length`;
2600    /// larger fixed-length bodies are blocked. Chunked HTTP/1 bodies are
2601    /// decoded and re-encoded with fresh chunk sizes. Encoded bodies pass
2602    /// through unchanged. HTTP/2 DATA-frame body substitution is not
2603    /// supported; matching body placeholders are blocked.
2604    #[serde(default)]
2605    pub body: bool,
2606}
2607
2608/// Action when a secret placeholder is not allowed to leave the sandbox.
2609#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2610#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2611#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2612#[serde(rename_all = "kebab-case")]
2613pub enum SecretViolationAction {
2614    /// Block the request silently.
2615    #[serde(alias = "Block")]
2616    Block,
2617    /// Block and log (default).
2618    #[default]
2619    #[serde(alias = "BlockAndLog", alias = "block_and_log")]
2620    BlockAndLog,
2621    /// Block and terminate the sandbox.
2622    #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
2623    BlockAndTerminate,
2624}
2625
2626/// Invalid secret configuration.
2627#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2628pub enum SecretConfigError {
2629    /// The environment variable name is empty.
2630    #[error("secret #{secret_index}: env_var must not be empty")]
2631    EmptyEnvVar {
2632        /// Index of the invalid secret entry.
2633        secret_index: usize,
2634    },
2635
2636    /// The environment variable name contains `=`.
2637    #[error("secret #{secret_index}: env_var must not contain `=`")]
2638    EnvVarContainsEquals {
2639        /// Index of the invalid secret entry.
2640        secret_index: usize,
2641    },
2642
2643    /// The environment variable name contains NUL.
2644    #[error("secret #{secret_index}: env_var must not contain NUL")]
2645    EnvVarContainsNul {
2646        /// Index of the invalid secret entry.
2647        secret_index: usize,
2648    },
2649
2650    /// No allowed hosts were configured for a secret.
2651    #[error("secret #{secret_index}: at least one allowed host is required")]
2652    MissingAllowedHosts {
2653        /// Index of the invalid secret entry.
2654        secret_index: usize,
2655    },
2656
2657    /// No request locations were enabled for substitution.
2658    #[error("secret #{secret_index}: at least one substitution location is required")]
2659    MissingSubstitutionLocation {
2660        /// Index of the invalid secret entry.
2661        secret_index: usize,
2662    },
2663
2664    /// Header field restrictions were configured while header substitution is disabled.
2665    #[error(
2666        "secret #{secret_index}: header field substitutions require header substitution to be enabled"
2667    )]
2668    HeaderFieldsRequireHeaders {
2669        /// Index of the invalid secret entry.
2670        secret_index: usize,
2671    },
2672
2673    /// A configured header field name is not a valid HTTP token.
2674    #[error("secret #{secret_index}: invalid header field name {field:?}")]
2675    InvalidHeaderFieldName {
2676        /// Index of the invalid secret entry.
2677        secret_index: usize,
2678        /// The rejected header field name.
2679        field: String,
2680    },
2681
2682    /// The placeholder is empty.
2683    #[error("secret #{secret_index}: placeholder must not be empty")]
2684    EmptyPlaceholder {
2685        /// Index of the invalid secret entry.
2686        secret_index: usize,
2687    },
2688
2689    /// The placeholder exceeds the supported byte length.
2690    #[error(
2691        "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
2692    )]
2693    PlaceholderTooLong {
2694        /// Index of the invalid secret entry.
2695        secret_index: usize,
2696        /// Actual placeholder length in bytes.
2697        actual_bytes: usize,
2698        /// Maximum supported placeholder length in bytes.
2699        max_bytes: usize,
2700    },
2701
2702    /// The placeholder contains NUL.
2703    #[error("secret #{secret_index}: placeholder must not contain NUL")]
2704    PlaceholderContainsNul {
2705        /// Index of the invalid secret entry.
2706        secret_index: usize,
2707    },
2708
2709    /// The placeholder contains a line break.
2710    #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
2711    PlaceholderContainsLineBreak {
2712        /// Index of the invalid secret entry.
2713        secret_index: usize,
2714    },
2715}
2716
2717impl SecretsConfig {
2718    /// Whether any configured secret requires verified TLS identity.
2719    pub fn has_tls_identity_secrets(&self) -> bool {
2720        self.secrets
2721            .iter()
2722            .any(|secret| secret.require_tls_identity)
2723    }
2724
2725    /// Whether a secret is configured for the given environment variable.
2726    pub fn contains_env_var(&self, env_var: &str) -> bool {
2727        self.secrets.iter().any(|secret| secret.env_var == env_var)
2728    }
2729
2730    /// Validate all configured secret entries.
2731    pub fn validate(&self) -> Result<(), SecretConfigError> {
2732        for (index, secret) in self.secrets.iter().enumerate() {
2733            secret.validate(index)?;
2734        }
2735        Ok(())
2736    }
2737}
2738
2739impl SecretEntry {
2740    /// Validate this secret entry.
2741    pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
2742        validate_env_var(&self.env_var, secret_index)?;
2743
2744        if self.allowed_hosts.is_empty() {
2745            return Err(SecretConfigError::MissingAllowedHosts { secret_index });
2746        }
2747
2748        if !self.substitution.headers && !self.substitution.query && !self.substitution.body {
2749            return Err(SecretConfigError::MissingSubstitutionLocation { secret_index });
2750        }
2751
2752        if !self.substitution.headers && !self.substitution.header_fields.is_empty() {
2753            return Err(SecretConfigError::HeaderFieldsRequireHeaders { secret_index });
2754        }
2755
2756        for field in &self.substitution.header_fields {
2757            validate_header_field_name(field, secret_index)?;
2758        }
2759
2760        validate_placeholder(&self.placeholder, secret_index)
2761    }
2762}
2763
2764// The secret value must never reach a log line or an error message.
2765impl fmt::Debug for SecretEntry {
2766    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2767        f.debug_struct("SecretEntry")
2768            .field("env_var", &self.env_var)
2769            .field("value", &"[REDACTED]")
2770            .field("source", &self.source)
2771            .field("placeholder", &self.placeholder)
2772            .field("allowed_hosts", &self.allowed_hosts)
2773            .field("substitution", &self.substitution)
2774            .field("passthrough_hosts", &self.passthrough_hosts)
2775            .field("violation_action", &self.violation_action)
2776            .field("require_tls_identity", &self.require_tls_identity)
2777            .finish()
2778    }
2779}
2780
2781impl HostPattern {
2782    /// Parse a user-facing host string: `*` is any host, `*.`-prefixed
2783    /// strings are wildcards, everything else matches exactly.
2784    pub fn parse(host: &str) -> Self {
2785        if host == "*" {
2786            HostPattern::Any
2787        } else if host.starts_with("*.") {
2788            HostPattern::Wildcard(host.to_string())
2789        } else {
2790            HostPattern::Exact(host.to_string())
2791        }
2792    }
2793
2794    /// Check if a hostname matches this pattern.
2795    ///
2796    /// Uses ASCII case-insensitive comparison to avoid `to_lowercase()`
2797    /// allocations (DNS hostnames are ASCII per RFC 4343).
2798    pub fn matches(&self, hostname: &str) -> bool {
2799        match self {
2800            HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
2801            HostPattern::Wildcard(pattern) => {
2802                if let Some(suffix) = pattern.strip_prefix("*.") {
2803                    hostname.eq_ignore_ascii_case(suffix)
2804                        || (hostname.len() > suffix.len() + 1
2805                            && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
2806                            && hostname[hostname.len() - suffix.len()..]
2807                                .eq_ignore_ascii_case(suffix))
2808                } else {
2809                    hostname.eq_ignore_ascii_case(pattern)
2810                }
2811            }
2812            HostPattern::Any => true,
2813        }
2814    }
2815}
2816
2817impl Default for SecretSubstitution {
2818    fn default() -> Self {
2819        Self {
2820            headers: true,
2821            header_fields: Vec::new(),
2822            query: false,
2823            body: false,
2824        }
2825    }
2826}
2827
2828fn default_true() -> bool {
2829    true
2830}
2831
2832/// Validate an HTTP header field name (RFC 9110 `token`).
2833fn validate_header_field_name(field: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2834    if field.is_empty() || !field.bytes().all(is_http_tchar) {
2835        return Err(SecretConfigError::InvalidHeaderFieldName {
2836            secret_index,
2837            field: field.to_string(),
2838        });
2839    }
2840    Ok(())
2841}
2842
2843/// `tchar` from RFC 9110, the character set allowed in a header field name.
2844fn is_http_tchar(byte: u8) -> bool {
2845    byte.is_ascii_alphanumeric()
2846        || matches!(
2847            byte,
2848            b'!' | b'#'
2849                | b'$'
2850                | b'%'
2851                | b'&'
2852                | b'\''
2853                | b'*'
2854                | b'+'
2855                | b'-'
2856                | b'.'
2857                | b'^'
2858                | b'_'
2859                | b'`'
2860                | b'|'
2861                | b'~'
2862        )
2863}
2864
2865fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2866    if env_var.is_empty() {
2867        return Err(SecretConfigError::EmptyEnvVar { secret_index });
2868    }
2869    if env_var.contains('=') {
2870        return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
2871    }
2872    if env_var.contains('\0') {
2873        return Err(SecretConfigError::EnvVarContainsNul { secret_index });
2874    }
2875    Ok(())
2876}
2877
2878fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2879    if placeholder.is_empty() {
2880        return Err(SecretConfigError::EmptyPlaceholder { secret_index });
2881    }
2882
2883    let actual_bytes = placeholder.len();
2884    if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
2885        return Err(SecretConfigError::PlaceholderTooLong {
2886            secret_index,
2887            actual_bytes,
2888            max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2889        });
2890    }
2891
2892    if placeholder.contains('\0') {
2893        return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
2894    }
2895    if placeholder.contains('\r') || placeholder.contains('\n') {
2896        return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
2897    }
2898
2899    Ok(())
2900}
2901
2902//--------------------------------------------------------------------------------------------------
2903// Types: TLS interception
2904//--------------------------------------------------------------------------------------------------
2905
2906/// TLS interception configuration. Carried in [`NetworkSpec::tls`](NetworkSpec).
2907///
2908/// The local network engine terminates TCP at its in-process stack, so TLS MITM
2909/// is handled by proxy tasks — these fields configure which ports/domains are
2910/// intercepted and how the interception CA is sourced.
2911#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
2912#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2913#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2914pub struct TlsConfig {
2915    /// Whether TLS interception is enabled.
2916    #[serde(default)]
2917    pub enabled: bool,
2918
2919    /// TCP ports subject to TLS interception (default: `[443]`).
2920    #[serde(default = "default_intercepted_ports")]
2921    pub intercepted_ports: Vec<u16>,
2922
2923    /// Domains to bypass (no MITM). Supports exact match and `*.suffix` wildcards.
2924    #[serde(default)]
2925    pub bypass: Vec<String>,
2926
2927    /// Whether to verify the upstream server's TLS certificate.
2928    #[serde(default = "default_true")]
2929    pub verify_upstream: bool,
2930
2931    /// Drop UDP to intercepted ports when TLS interception is active, forcing
2932    /// QUIC traffic to fall back to TCP/TLS.
2933    #[serde(default = "default_true")]
2934    pub block_quic_on_intercept: bool,
2935
2936    /// CA certificate PEM files to trust for upstream server verification.
2937    #[serde(default)]
2938    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
2939    #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
2940    pub upstream_ca_cert: Vec<PathBuf>,
2941
2942    /// Host-scoped CA certificate PEM files to trust for upstream server verification.
2943    #[serde(default, alias = "scoped_upstream_ca_certs")]
2944    pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
2945
2946    /// Host-scoped upstream verification overrides.
2947    #[serde(default)]
2948    pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
2949
2950    /// Interception CA configuration. The TLS proxy uses this CA to sign
2951    /// per-domain certs it presents to the guest during interception.
2952    #[serde(default, alias = "ca")]
2953    pub intercept_ca: InterceptCaConfig,
2954
2955    /// Per-domain certificate cache configuration.
2956    #[serde(default)]
2957    pub cache: CertCacheConfig,
2958}
2959
2960/// Certificate authority configuration for TLS interception.
2961#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2962#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2963#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2964pub struct InterceptCaConfig {
2965    /// Path to an existing CA certificate PEM file. If `None`, a CA is
2966    /// auto-generated and persisted.
2967    #[serde(default)]
2968    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2969    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2970    pub cert_path: Option<PathBuf>,
2971
2972    /// Path to an existing CA private key PEM file. If `None`, a key is
2973    /// auto-generated and persisted.
2974    #[serde(default)]
2975    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2976    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2977    pub key_path: Option<PathBuf>,
2978}
2979
2980/// Per-domain certificate cache configuration.
2981#[derive(Debug, Clone, Serialize, Deserialize)]
2982#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2983#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2984pub struct CertCacheConfig {
2985    /// Maximum number of cached certificates. Default: 1000.
2986    #[serde(default = "default_cache_capacity")]
2987    pub capacity: usize,
2988
2989    /// Certificate validity duration in hours. Default: 24.
2990    #[serde(default = "default_cert_validity_hours")]
2991    pub validity_hours: u64,
2992}
2993
2994/// A CA certificate PEM file trusted only for matching upstream hosts.
2995#[derive(Debug, Clone, Serialize, Deserialize)]
2996#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2997#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2998pub struct ScopedUpstreamCaCert {
2999    /// Host pattern this CA applies to. Supports exact hosts and `*.suffix` wildcards.
3000    pub pattern: String,
3001
3002    /// Path to the CA certificate PEM file.
3003    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
3004    #[cfg_attr(feature = "ts", ts(type = "string"))]
3005    pub path: PathBuf,
3006}
3007
3008/// An upstream certificate verification override for matching hosts.
3009#[derive(Debug, Clone, Serialize, Deserialize)]
3010#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3011#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3012pub struct ScopedVerifyUpstream {
3013    /// Host pattern this override applies to. Supports exact hosts and `*.suffix` wildcards.
3014    pub pattern: String,
3015
3016    /// Whether to verify matching upstream server certificates.
3017    pub verify: bool,
3018}
3019
3020impl Default for TlsConfig {
3021    fn default() -> Self {
3022        Self {
3023            enabled: false,
3024            intercepted_ports: default_intercepted_ports(),
3025            bypass: Vec::new(),
3026            verify_upstream: true,
3027            block_quic_on_intercept: true,
3028            upstream_ca_cert: Vec::new(),
3029            scoped_upstream_ca_cert: Vec::new(),
3030            scoped_verify_upstream: Vec::new(),
3031            intercept_ca: InterceptCaConfig::default(),
3032            cache: CertCacheConfig::default(),
3033        }
3034    }
3035}
3036
3037impl Default for CertCacheConfig {
3038    fn default() -> Self {
3039        Self {
3040            capacity: default_cache_capacity(),
3041            validity_hours: default_cert_validity_hours(),
3042        }
3043    }
3044}
3045
3046fn default_intercepted_ports() -> Vec<u16> {
3047    vec![443]
3048}
3049
3050fn default_cache_capacity() -> usize {
3051    1000
3052}
3053
3054fn default_cert_validity_hours() -> u64 {
3055    24
3056}
3057
3058//--------------------------------------------------------------------------------------------------
3059// Types: Networking — policy
3060//--------------------------------------------------------------------------------------------------
3061
3062/// Action to take on traffic matched by a [`Rule`] (or a policy default).
3063#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3064#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3065#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3066#[serde(rename_all = "snake_case")]
3067pub enum Action {
3068    /// Allow the traffic.
3069    Allow,
3070    /// Silently drop the traffic.
3071    Deny,
3072}
3073
3074/// Direction a [`Rule`] applies to.
3075#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3076#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3077#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3078#[serde(rename_all = "snake_case")]
3079pub enum Direction {
3080    /// Outbound: guest → destination.
3081    Egress,
3082    /// Inbound: peer → guest.
3083    Ingress,
3084    /// Either direction.
3085    Any,
3086}
3087
3088/// Protocol filter for a [`Rule`].
3089#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3090#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3091#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3092#[serde(rename_all = "snake_case")]
3093pub enum Protocol {
3094    /// TCP.
3095    Tcp,
3096    /// UDP.
3097    Udp,
3098    /// ICMPv4.
3099    Icmpv4,
3100    /// ICMPv6.
3101    Icmpv6,
3102}
3103
3104/// Pre-defined destination category for a [`Destination::Group`] match.
3105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3106#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3107#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3108#[serde(rename_all = "snake_case")]
3109pub enum DestinationGroup {
3110    /// Public internet — any address not in another category.
3111    Public,
3112    /// Loopback addresses (`127.0.0.0/8`, `::1`).
3113    Loopback,
3114    /// Private ranges (RFC 1918 / RFC 4193 ULA / CGN).
3115    Private,
3116    /// Link-local addresses, excluding the metadata IP.
3117    LinkLocal,
3118    /// Cloud metadata endpoint (`169.254.169.254`).
3119    Metadata,
3120    /// Multicast addresses (`224.0.0.0/4`, `ff00::/8`).
3121    Multicast,
3122    /// The sandbox host, reachable via the gateway IP.
3123    Host,
3124}
3125
3126/// Traffic destination filter for a [`Rule`].
3127///
3128/// The `Cidr`, `Domain`, and `DomainSuffix` leaves carry their canonical
3129/// string form (e.g. `"10.0.0.0/8"`, `"example.com"`); the local network
3130/// engine re-parses and validates them into its richer internal types at
3131/// load time.
3132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3133#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3134#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3135#[serde(rename_all = "snake_case")]
3136pub enum Destination {
3137    /// Match any destination.
3138    Any,
3139    /// IP address or CIDR block (e.g. `"1.2.3.4"`, `"10.0.0.0/8"`).
3140    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
3141    Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
3142    /// Exact domain name (e.g. `"example.com"`).
3143    Domain(String),
3144    /// Domain suffix — the apex and any subdomain of it.
3145    DomainSuffix(String),
3146    /// A pre-defined destination group.
3147    Group(DestinationGroup),
3148}
3149
3150/// Inclusive guest-side port range for a [`Rule`] match.
3151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3152#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3153#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3154pub struct PortRange {
3155    /// Start port (inclusive).
3156    pub start: u16,
3157    /// End port (inclusive).
3158    pub end: u16,
3159}
3160
3161/// A single egress/ingress policy rule. Evaluated first-match-wins per
3162/// direction.
3163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3164#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3165#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3166pub struct Rule {
3167    /// Direction this rule applies to.
3168    pub direction: Direction,
3169    /// Destination filter (direction-dependent interpretation).
3170    pub destination: Destination,
3171    /// Protocol set; empty matches any protocol.
3172    #[serde(default)]
3173    pub protocols: Vec<Protocol>,
3174    /// Guest-side port-range set; empty matches any port.
3175    #[serde(default)]
3176    pub ports: Vec<PortRange>,
3177    /// Action to take on a match.
3178    pub action: Action,
3179}
3180
3181/// Egress/ingress network policy: an ordered [`Rule`] list plus a
3182/// per-direction default [`Action`]. Carried in [`NetworkSpec::policy`].
3183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3184#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3185#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3186pub struct NetworkPolicy {
3187    /// Default action for egress traffic matching no rule. Default: `Deny`.
3188    #[serde(default = "action_deny")]
3189    pub default_egress: Action,
3190    /// Default action for ingress traffic matching no rule. Default: `Deny`.
3191    #[serde(default = "action_deny")]
3192    pub default_ingress: Action,
3193    /// Ordered rules, evaluated first-match-wins per direction.
3194    #[serde(default)]
3195    pub rules: Vec<Rule>,
3196}
3197
3198/// Default [`Action`] (`Deny`) for a policy's per-direction defaults, so a
3199/// partially-specified policy fails closed.
3200fn action_deny() -> Action {
3201    Action::Deny
3202}
3203
3204//--------------------------------------------------------------------------------------------------
3205// Types: Networking — DNS & interface
3206//--------------------------------------------------------------------------------------------------
3207
3208/// DNS interception and filtering settings. Carried in [`NetworkSpec::dns`].
3209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3210#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3211#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3212#[serde(default)]
3213pub struct DnsConfig {
3214    /// Whether DNS-rebinding protection is enabled. Default: true.
3215    pub rebind_protection: bool,
3216    /// Upstream nameservers as `IP`, `IP:PORT`, `HOST`, or `HOST:PORT`
3217    /// strings. Empty falls back to the host's `/etc/resolv.conf`.
3218    pub nameservers: Vec<String>,
3219    /// Per-query timeout in milliseconds. Default: 5000.
3220    pub query_timeout_ms: u64,
3221}
3222
3223impl Default for DnsConfig {
3224    fn default() -> Self {
3225        Self {
3226            rebind_protection: true,
3227            nameservers: Vec::new(),
3228            query_timeout_ms: 5000,
3229        }
3230    }
3231}
3232
3233/// Optional guest interface overrides. Unset fields are derived from the
3234/// sandbox slot by the local network engine. Carried in
3235/// [`NetworkSpec::interface`].
3236#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3237#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3238#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3239#[serde(default)]
3240pub struct InterfaceOverrides {
3241    /// Guest MAC address as six octets. Default: derived from slot.
3242    #[serde(skip_serializing_if = "Option::is_none")]
3243    pub mac: Option<[u8; 6]>,
3244    /// Interface MTU. Default: 1500.
3245    #[serde(skip_serializing_if = "Option::is_none")]
3246    pub mtu: Option<u16>,
3247    /// Guest IPv4 address (e.g. `172.16.0.2`). Default: derived from slot.
3248    #[serde(skip_serializing_if = "Option::is_none")]
3249    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3250    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3251    pub ipv4_address: Option<Ipv4Addr>,
3252    /// Guest IPv4 pool CIDR (e.g. `"172.16.0.0/12"`). Default: derived from slot.
3253    #[serde(skip_serializing_if = "Option::is_none")]
3254    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3255    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3256    pub ipv4_pool: Option<Ipv4Network>,
3257    /// Guest IPv6 address. Default: derived from slot.
3258    #[serde(skip_serializing_if = "Option::is_none")]
3259    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3260    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3261    pub ipv6_address: Option<Ipv6Addr>,
3262    /// Guest IPv6 pool CIDR. Default: derived from slot.
3263    #[serde(skip_serializing_if = "Option::is_none")]
3264    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3265    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3266    pub ipv6_pool: Option<Ipv6Network>,
3267}
3268
3269fn empty_secret_value() -> Zeroizing<String> {
3270    Zeroizing::new(String::new())
3271}
3272
3273//--------------------------------------------------------------------------------------------------
3274// Types: Networking — rate limits
3275//--------------------------------------------------------------------------------------------------
3276
3277/// Sandbox-relative direction governed by a network rate limiter.
3278#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3279pub enum NetworkRateLimitDirection {
3280    /// Traffic leaving the sandbox.
3281    Egress,
3282    /// Traffic entering the sandbox.
3283    Ingress,
3284}
3285
3286/// Egress and ingress rate limits for a local sandbox network.
3287#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3288#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3289#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3290#[serde(default)]
3291pub struct NetworkRateLimiterConfig {
3292    /// Guest-to-runtime (egress) rate limiter. Missing means unlimited.
3293    #[serde(skip_serializing_if = "Option::is_none")]
3294    pub egress: Option<RateLimiterConfig>,
3295
3296    /// Runtime-to-guest (ingress) rate limiter. Missing means unlimited.
3297    #[serde(skip_serializing_if = "Option::is_none")]
3298    pub ingress: Option<RateLimiterConfig>,
3299}
3300
3301/// Token-bucket rate limiter for one traffic direction. Carried in
3302/// [`NetworkRateLimiterConfig::egress`] and [`NetworkRateLimiterConfig::ingress`].
3303///
3304/// A limiter caps bandwidth (bytes) and packet rate (operations)
3305/// independently; a missing bucket leaves that dimension unlimited.
3306#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
3307#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3308#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3309#[serde(default)]
3310pub struct RateLimiterConfig {
3311    /// Bandwidth bucket. One token is one byte of frame data.
3312    #[serde(skip_serializing_if = "Option::is_none")]
3313    pub bandwidth: Option<TokenBucketConfig>,
3314
3315    /// Operations bucket. One token is one network frame.
3316    #[serde(skip_serializing_if = "Option::is_none")]
3317    pub ops: Option<TokenBucketConfig>,
3318}
3319
3320/// One token bucket of a [`RateLimiterConfig`].
3321///
3322/// The bucket starts full and refills continuously at `size` tokens per
3323/// `refill_time_ms`. `one_time_burst` grants extra startup tokens that are
3324/// spent before the regular budget and never refill.
3325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3326#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3327#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3328pub struct TokenBucketConfig {
3329    /// Bucket capacity in tokens. Must be greater than zero.
3330    pub size: u64,
3331
3332    /// Time to refill `size` tokens, in milliseconds. Must be greater than
3333    /// zero.
3334    pub refill_time_ms: u64,
3335
3336    /// Extra tokens granted once at startup. Default: 0.
3337    #[serde(default)]
3338    pub one_time_burst: u64,
3339}
3340
3341/// Invalid rate limiter configuration.
3342#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3343pub enum RateLimitConfigError {
3344    /// The limiter has neither a bandwidth nor an ops bucket.
3345    #[error("rate limiter must configure at least one of bandwidth or ops")]
3346    EmptyLimiter,
3347
3348    /// A bucket capacity is zero.
3349    #[error("{bucket} bucket: size must be greater than zero")]
3350    ZeroSize {
3351        /// Which bucket is invalid (`bandwidth` or `ops`).
3352        bucket: &'static str,
3353    },
3354
3355    /// A bucket refill interval is zero.
3356    #[error("{bucket} bucket: refill_time_ms must be greater than zero")]
3357    ZeroRefillTime {
3358        /// Which bucket is invalid (`bandwidth` or `ops`).
3359        bucket: &'static str,
3360    },
3361}
3362
3363impl RateLimiterConfig {
3364    /// Validate the limiter and each configured bucket.
3365    pub fn validate(&self) -> Result<(), RateLimitConfigError> {
3366        if self.bandwidth.is_none() && self.ops.is_none() {
3367            return Err(RateLimitConfigError::EmptyLimiter);
3368        }
3369        if let Some(bandwidth) = &self.bandwidth {
3370            bandwidth.validate("bandwidth")?;
3371        }
3372        if let Some(ops) = &self.ops {
3373            ops.validate("ops")?;
3374        }
3375        Ok(())
3376    }
3377}
3378
3379impl TokenBucketConfig {
3380    /// Validate this bucket. `bucket` names it in error messages.
3381    pub fn validate(&self, bucket: &'static str) -> Result<(), RateLimitConfigError> {
3382        if self.size == 0 {
3383            return Err(RateLimitConfigError::ZeroSize { bucket });
3384        }
3385        if self.refill_time_ms == 0 {
3386            return Err(RateLimitConfigError::ZeroRefillTime { bucket });
3387        }
3388        Ok(())
3389    }
3390}
3391
3392impl fmt::Display for NetworkRateLimitDirection {
3393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3394        match self {
3395            Self::Egress => f.write_str("egress"),
3396            Self::Ingress => f.write_str("ingress"),
3397        }
3398    }
3399}
3400
3401//--------------------------------------------------------------------------------------------------
3402// Tests
3403//--------------------------------------------------------------------------------------------------
3404
3405#[cfg(test)]
3406mod tests {
3407    use super::*;
3408
3409    fn secret_entry(env_var: &str, require_tls_identity: bool) -> SecretEntry {
3410        SecretEntry {
3411            env_var: env_var.to_owned(),
3412            value: Zeroizing::new("secret".to_owned()),
3413            source: None,
3414            placeholder: format!("$MSB_{env_var}"),
3415            allowed_hosts: vec![HostPattern::Any],
3416            substitution: SecretSubstitution::default(),
3417            passthrough_hosts: Vec::new(),
3418            violation_action: None,
3419            require_tls_identity,
3420        }
3421    }
3422
3423    fn tmpfs_mount(guest: &str) -> VolumeMount {
3424        VolumeMount::Tmpfs {
3425            guest: guest.to_owned(),
3426            size_mib: None,
3427            options: MountOptions::default(),
3428        }
3429    }
3430
3431    #[test]
3432    fn mount_options_omit_unset_owner_but_accept_missing_fields() {
3433        let value = serde_json::to_value(MountOptions::default()).unwrap();
3434        assert!(value.get("override_uid").is_none());
3435        assert!(value.get("override_gid").is_none());
3436
3437        let decoded: MountOptions = serde_json::from_value(value).unwrap();
3438        assert_eq!(decoded.override_uid, None);
3439        assert_eq!(decoded.override_gid, None);
3440    }
3441
3442    #[test]
3443    fn volume_mounts_are_canonicalized_and_ordered_parent_first() {
3444        let mut mounts = vec![
3445            tmpfs_mount("/workspace//persist/./logs/"),
3446            tmpfs_mount("/alpha/z"),
3447            tmpfs_mount("/workspace"),
3448        ];
3449
3450        canonicalize_volume_mounts(&mut mounts).unwrap();
3451
3452        assert_eq!(
3453            mounts.iter().map(VolumeMount::guest).collect::<Vec<_>>(),
3454            vec!["/workspace", "/alpha/z", "/workspace/persist/logs"]
3455        );
3456    }
3457
3458    #[test]
3459    fn secrets_config_queries_entries() {
3460        let mut config = SecretsConfig {
3461            secrets: vec![secret_entry("HTTP_TOKEN", false)],
3462            ..Default::default()
3463        };
3464
3465        assert!(!config.has_tls_identity_secrets());
3466        assert!(config.contains_env_var("HTTP_TOKEN"));
3467        assert!(!config.contains_env_var("MISSING"));
3468
3469        config.secrets.push(secret_entry("API_KEY", true));
3470        assert!(config.has_tls_identity_secrets());
3471    }
3472
3473    #[test]
3474    fn volume_mounts_reject_duplicate_canonical_paths() {
3475        let mut mounts = vec![tmpfs_mount("/data/cache"), tmpfs_mount("/data//./cache/")];
3476
3477        let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3478
3479        assert!(error.to_string().contains("same guest path: /data/cache"));
3480    }
3481
3482    #[test]
3483    fn volume_mounts_reject_parent_components_before_normalizing() {
3484        let mut mounts = vec![tmpfs_mount("/workspace/../secrets")];
3485
3486        let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3487
3488        assert!(error.to_string().contains("must not contain '..'"));
3489    }
3490
3491    #[test]
3492    fn disk_image_format_from_extension() {
3493        assert_eq!(
3494            DiskImageFormat::from_extension("qcow2"),
3495            Some(DiskImageFormat::Qcow2)
3496        );
3497        assert_eq!(
3498            DiskImageFormat::from_extension("raw"),
3499            Some(DiskImageFormat::Raw)
3500        );
3501        assert_eq!(
3502            DiskImageFormat::from_extension("vmdk"),
3503            Some(DiskImageFormat::Vmdk)
3504        );
3505        assert_eq!(DiskImageFormat::from_extension("ext4"), None);
3506        assert_eq!(DiskImageFormat::from_extension(""), None);
3507    }
3508
3509    fn secret_with_header_fields(fields: Vec<&str>) -> SecretEntry {
3510        SecretEntry {
3511            env_var: "API_KEY".into(),
3512            value: Zeroizing::new("secret".into()),
3513            source: None,
3514            placeholder: "$MSB_API_KEY".into(),
3515            allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
3516            substitution: SecretSubstitution {
3517                headers: true,
3518                header_fields: fields.into_iter().map(ToString::to_string).collect(),
3519                query: false,
3520                body: false,
3521            },
3522            passthrough_hosts: Vec::new(),
3523            violation_action: None,
3524            require_tls_identity: true,
3525        }
3526    }
3527
3528    #[test]
3529    fn secret_substitution_header_fields_round_trip_and_default() {
3530        let entry = secret_with_header_fields(vec!["Authorization", "X-Api-Key"]);
3531        entry.validate(0).expect("valid header fields");
3532        let json = serde_json::to_value(&entry).unwrap();
3533        assert_eq!(
3534            json["substitution"]["header_fields"],
3535            serde_json::json!(["Authorization", "X-Api-Key"])
3536        );
3537
3538        let omitted: SecretSubstitution =
3539            serde_json::from_value(serde_json::json!({ "headers": true })).unwrap();
3540        assert!(omitted.header_fields.is_empty());
3541
3542        // An empty allowlist means "all headers" and is omitted on the wire.
3543        let default = SecretSubstitution::default();
3544        assert!(
3545            serde_json::to_value(&default)
3546                .unwrap()
3547                .get("header_fields")
3548                .is_none()
3549        );
3550    }
3551
3552    #[test]
3553    fn secret_validation_rejects_invalid_header_fields() {
3554        for field in ["", "bad header", "bad:name", "bad\0name"] {
3555            let entry = secret_with_header_fields(vec![field]);
3556            let error = entry.validate(0).unwrap_err();
3557            assert!(
3558                matches!(error, SecretConfigError::InvalidHeaderFieldName { .. }),
3559                "{field:?} should be rejected, got {error}"
3560            );
3561        }
3562    }
3563
3564    #[test]
3565    fn secret_validation_rejects_header_fields_when_headers_disabled() {
3566        // The allowlist is inert without header substitution, so requesting both
3567        // is contradictory rather than an implicit "all headers".
3568        let mut entry = secret_with_header_fields(vec!["authorization"]);
3569        entry.substitution.headers = false;
3570        entry.substitution.query = true;
3571        let error = entry.validate(0).unwrap_err();
3572        assert!(
3573            matches!(error, SecretConfigError::HeaderFieldsRequireHeaders { .. }),
3574            "{error}"
3575        );
3576
3577        // With query substitution enabled and no allowlist the entry stays valid.
3578        entry.substitution.header_fields.clear();
3579        entry
3580            .validate(0)
3581            .expect("disabled headers without fields is valid");
3582    }
3583
3584    #[test]
3585    fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
3586        let resources: SandboxResources =
3587            serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
3588
3589        assert_eq!(resources.cpus, 4);
3590        assert_eq!(resources.max_cpus, 4);
3591        assert_eq!(resources.memory_mib, 2048);
3592        assert_eq!(resources.max_memory_mib, 2048);
3593        assert_eq!(resources.cpu_placement, CpuPlacement::Inherit);
3594        assert_eq!(resources.thp, TransparentHugePagePolicy::Madvise);
3595        assert_eq!(
3596            serde_json::to_value(resources).unwrap(),
3597            serde_json::json!({
3598                "cpus": 4,
3599                "memory_mib": 2048,
3600                "max_cpus": 4,
3601                "max_memory_mib": 2048
3602            })
3603        );
3604    }
3605
3606    #[test]
3607    fn cpu_placement_omits_inherit_and_roundtrips_managed_policies() {
3608        let inherited = serde_json::to_value(SandboxResources::default()).unwrap();
3609        assert!(inherited.get("cpu_placement").is_none());
3610
3611        for policy in [
3612            CpuPlacement::Auto,
3613            CpuPlacement::Spread,
3614            CpuPlacement::Compact,
3615        ] {
3616            let resources = SandboxResources {
3617                cpu_placement: policy,
3618                ..Default::default()
3619            };
3620            let json = serde_json::to_string(&resources).unwrap();
3621            let decoded: SandboxResources = serde_json::from_str(&json).unwrap();
3622
3623            assert_eq!(decoded.cpu_placement, policy);
3624            assert_eq!(policy.to_string().parse::<CpuPlacement>().unwrap(), policy);
3625        }
3626    }
3627
3628    #[test]
3629    fn guest_clock_policy_is_omitted_until_set_and_roundtrips() {
3630        let defaults = serde_json::to_value(SandboxRuntimeOptions::default()).unwrap();
3631        assert!(defaults.get("guest_clock").is_none());
3632
3633        let legacy: SandboxRuntimeOptions = serde_json::from_str(r#"{"workdir":"/app"}"#).unwrap();
3634        assert_eq!(legacy.guest_clock, None);
3635
3636        for policy in [GuestClockPolicy::Sync, GuestClockPolicy::Off] {
3637            let runtime = SandboxRuntimeOptions {
3638                guest_clock: Some(policy),
3639                ..Default::default()
3640            };
3641            let json = serde_json::to_value(&runtime).unwrap();
3642            assert_eq!(json["guest_clock"], serde_json::json!(policy.as_str()));
3643            let decoded: SandboxRuntimeOptions = serde_json::from_value(json).unwrap();
3644            assert_eq!(decoded.guest_clock, Some(policy));
3645            assert_eq!(
3646                policy.to_string().parse::<GuestClockPolicy>().unwrap(),
3647                policy
3648            );
3649        }
3650
3651        assert_eq!(GuestClockPolicy::default(), GuestClockPolicy::Sync);
3652        assert!("host_sync".parse::<GuestClockPolicy>().is_err());
3653    }
3654
3655    #[test]
3656    fn transparent_huge_page_policy_roundtrips_non_default() {
3657        let resources: SandboxResources = serde_json::from_str(
3658            r#"{"cpus":2,"memory_mib":8192,"max_cpus":2,"max_memory_mib":8192,"thp":"always"}"#,
3659        )
3660        .unwrap();
3661
3662        assert_eq!(resources.thp, TransparentHugePagePolicy::Always);
3663        assert_eq!(
3664            serde_json::to_value(resources).unwrap()["thp"],
3665            serde_json::json!("always")
3666        );
3667        assert_eq!(
3668            "never".parse::<TransparentHugePagePolicy>().unwrap(),
3669            TransparentHugePagePolicy::Never
3670        );
3671        assert!("auto".parse::<TransparentHugePagePolicy>().is_err());
3672    }
3673
3674    #[test]
3675    fn disk_image_format_display_roundtrip() {
3676        for format in [
3677            DiskImageFormat::Qcow2,
3678            DiskImageFormat::Raw,
3679            DiskImageFormat::Vmdk,
3680        ] {
3681            let rendered = format.to_string();
3682            let parsed: DiskImageFormat = rendered.parse().unwrap();
3683            assert_eq!(parsed, format);
3684        }
3685    }
3686
3687    #[test]
3688    fn disk_image_format_from_str_unknown() {
3689        assert!("ext4".parse::<DiskImageFormat>().is_err());
3690    }
3691
3692    #[test]
3693    fn log_source_effective_uses_default_user_program_sources() {
3694        assert_eq!(
3695            LogSource::effective(&[]),
3696            vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
3697        );
3698    }
3699
3700    #[test]
3701    fn log_source_effective_sorts_and_deduplicates_requested_sources() {
3702        assert_eq!(
3703            LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
3704            vec![LogSource::Stdout, LogSource::System]
3705        );
3706    }
3707
3708    #[test]
3709    fn rlimit_resource_parses_case_insensitively() {
3710        assert_eq!(
3711            RlimitResource::try_from("NOFILE").unwrap(),
3712            RlimitResource::Nofile
3713        );
3714        assert!(RlimitResource::try_from("bogus").is_err());
3715    }
3716
3717    #[test]
3718    fn sandbox_policy_serde_roundtrip() {
3719        let policy = SandboxPolicy {
3720            ephemeral: true,
3721            max_duration_secs: Some(3600),
3722            idle_timeout_secs: Some(120),
3723        };
3724
3725        let json = serde_json::to_string(&policy).unwrap();
3726        let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
3727
3728        assert!(decoded.ephemeral);
3729        assert_eq!(decoded.max_duration_secs, Some(3600));
3730        assert_eq!(decoded.idle_timeout_secs, Some(120));
3731    }
3732
3733    #[test]
3734    fn sandbox_policy_defaults_to_persistent() {
3735        assert!(!SandboxPolicy::default().ephemeral);
3736    }
3737
3738    #[test]
3739    fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
3740        // `ephemeral` has a persistent default so partial policy payloads
3741        // deserialize to the conservative behavior.
3742        let decoded: SandboxPolicy =
3743            serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
3744        assert!(!decoded.ephemeral);
3745        assert_eq!(decoded.max_duration_secs, Some(60));
3746    }
3747
3748    #[test]
3749    fn sandbox_spec_default_uses_static_resource_defaults() {
3750        let spec = SandboxSpec::default();
3751
3752        assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
3753        assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
3754        assert_eq!(
3755            spec.runtime.metrics_sample_interval_ms,
3756            Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
3757        );
3758        assert_eq!(spec.deployment_profile, DeploymentProfile::SingleTenant);
3759    }
3760
3761    #[test]
3762    fn deployment_profile_uses_stable_snake_case_wire_values() {
3763        assert_eq!(
3764            serde_json::to_string(&DeploymentProfile::MultiTenant).unwrap(),
3765            r#""multi_tenant""#
3766        );
3767        assert_eq!(
3768            serde_json::from_str::<DeploymentProfile>(r#""single_tenant""#).unwrap(),
3769            DeploymentProfile::SingleTenant
3770        );
3771    }
3772
3773    #[test]
3774    fn sandbox_log_level_roundtrips_lowercase_values() {
3775        for (input, expected) in [
3776            ("error", SandboxLogLevel::Error),
3777            ("warn", SandboxLogLevel::Warn),
3778            ("info", SandboxLogLevel::Info),
3779            ("debug", SandboxLogLevel::Debug),
3780            ("trace", SandboxLogLevel::Trace),
3781        ] {
3782            let parsed: SandboxLogLevel = input.parse().unwrap();
3783            assert_eq!(parsed, expected);
3784            assert_eq!(parsed.as_str(), input);
3785        }
3786    }
3787}