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