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