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