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