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