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 zeroize::Zeroizing;
12
13use crate::modify::SecretSource;
14
15//--------------------------------------------------------------------------------------------------
16// Constants
17//--------------------------------------------------------------------------------------------------
18
19/// Default number of virtual CPUs in a sandbox specification.
20pub const DEFAULT_SANDBOX_CPUS: u8 = 1;
21
22/// Default guest memory in MiB in a sandbox specification.
23pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
24
25/// Default metrics sampling interval in milliseconds.
26pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
27
28//--------------------------------------------------------------------------------------------------
29// Types: Root Filesystems
30//--------------------------------------------------------------------------------------------------
31
32/// Disk image format for virtio-blk root filesystems and volume mounts.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
35#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
36pub enum DiskImageFormat {
37    /// QEMU Copy-on-Write v2.
38    Qcow2,
39    /// Raw disk image.
40    Raw,
41    /// VMware Disk (FLAT/ZERO only, no delta links).
42    Vmdk,
43}
44
45/// Root filesystem source for a sandbox.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
48pub enum RootfsSource {
49    /// Use a host directory directly as the root filesystem.
50    Bind {
51        /// Host path to bind mount.
52        #[cfg_attr(feature = "ts", ts(type = "string"))]
53        path: PathBuf,
54        /// Whether to follow symlinks when resolving the host rootfs path.
55        ///
56        /// Defaults to `false`: the path is resolved following no symlink in any
57        /// component, matching the `--mount` protection, so a symlink at or under
58        /// the rootfs path cannot redirect the mount. Set `true` to opt out when
59        /// the host rootfs path legitimately traverses a symlink.
60        #[serde(default)]
61        follow_root_symlinks: bool,
62    },
63
64    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
65    Oci(OciRootfsSource),
66
67    /// Use a disk image file as the root filesystem via virtio-blk.
68    DiskImage {
69        /// Path to the disk image file on the host.
70        #[cfg_attr(feature = "ts", ts(type = "string"))]
71        path: PathBuf,
72        /// Disk image format.
73        format: DiskImageFormat,
74        /// Inner filesystem type (optional; auto-detected if absent).
75        fstype: Option<String>,
76    },
77}
78
79/// OCI root filesystem source.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
82#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
83pub struct OciRootfsSource {
84    /// OCI image reference (e.g. `python`).
85    pub reference: String,
86
87    /// Writable rootfs layer backing. `None` resolves to a managed 4 GiB upper.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub root_disk: Option<RootDisk>,
90}
91
92/// Backing for the writable rootfs layer (overlay upper) of an OCI sandbox.
93///
94/// This lives only on [`OciRootfsSource`]: the root disk is a property of how an OCI image
95/// becomes a rootfs. Every user surface (CLI `--root-disk`, SDK builders) is sugar resolving
96/// into this type.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
99#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
100#[serde(tag = "kind", rename_all = "kebab-case")]
101pub enum RootDisk {
102    /// Sparse ext4 created and owned by microsandbox in the sandbox dir. Default. Persistent;
103    /// grow-only via modify; deleted with the sandbox.
104    Managed {
105        /// Virtual size in MiB. `None` resolves to 4096.
106        #[serde(default, skip_serializing_if = "Option::is_none")]
107        size_mib: Option<u32>,
108    },
109
110    /// RAM-backed upper. Ephemeral: the rootfs is pristine on every boot. Pages come from
111    /// guest memory, so the size must not exceed the sandbox memory.
112    Tmpfs {
113        /// Size in MiB. `None` resolves to half the sandbox memory.
114        #[serde(default, skip_serializing_if = "Option::is_none")]
115        size_mib: Option<u32>,
116    },
117
118    /// User-supplied disk image attached writable as the upper. User-owned lifecycle: never
119    /// created, resized, or deleted by microsandbox.
120    DiskImage {
121        /// Host path to the image file.
122        #[cfg_attr(feature = "ts", ts(type = "string"))]
123        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
124        path: PathBuf,
125        /// Disk image format. Never probed from file contents.
126        format: DiskImageFormat,
127        /// Inner filesystem type. `None` resolves to ext4.
128        #[serde(default, skip_serializing_if = "Option::is_none")]
129        fstype: Option<String>,
130    },
131}
132
133/// Controls when an OCI registry is contacted for manifest freshness.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
136#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
137pub enum PullPolicy {
138    /// Use cached layers if complete, pull otherwise.
139    #[default]
140    IfMissing,
141
142    /// Always fetch the manifest from the registry, reusing cached layers whose digests still match.
143    Always,
144
145    /// Never contact the registry. Error if the image is not fully cached locally.
146    Never,
147}
148
149//--------------------------------------------------------------------------------------------------
150// Types: Mounts
151//--------------------------------------------------------------------------------------------------
152
153/// Stat virtualization policy for a virtiofs-backed volume mount.
154///
155/// 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.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
158#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
159#[serde(rename_all = "lowercase")]
160pub enum StatVirtualization {
161    /// Fail-closed: probe the host backing path; require xattr support.
162    Strict,
163    /// Opportunistic: apply the overlay when present; tolerate missing xattr support.
164    Relaxed,
165    /// Literal host metadata: do not read or apply the override xattr.
166    Off,
167}
168
169/// Host permission propagation policy for a virtiofs-backed volume mount.
170///
171/// Serializes/deserializes as the lowercase variant name (`"private"`, `"mirror"`) to align with the CLI and NAPI spellings.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
174#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
175#[serde(rename_all = "lowercase")]
176pub enum HostPermissions {
177    /// Guest chmod stays in the metadata overlay only.
178    Private,
179    /// Mirror ordinary rwx bits for regular files and directories to the host inode.
180    Mirror,
181}
182
183/// Sandbox-level in-guest security profile.
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
185#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
186#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
187#[serde(rename_all = "lowercase")]
188pub enum SecurityProfile {
189    /// Preserve normal guest-root semantics.
190    ///
191    /// 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.
192    #[default]
193    Default,
194
195    /// Harden guest exec sessions.
196    ///
197    /// 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.
198    Restricted,
199}
200
201/// Guest mount behavior shared by every volume mount kind.
202#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
204#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
205#[serde(default)]
206pub struct MountOptions {
207    /// Whether the mount is read-only.
208    ///
209    /// 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.
210    pub readonly: bool,
211
212    /// Whether direct execution from the mount is disabled.
213    ///
214    /// 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.
215    pub noexec: bool,
216
217    /// Whether setuid and setgid privilege elevation from files on the mount is ignored.
218    pub nosuid: bool,
219
220    /// Whether device files on the mount are ignored.
221    pub nodev: bool,
222}
223
224/// Storage kind for a named volume.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
227#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
228pub enum VolumeKind {
229    /// Directory-backed named volume mounted through virtiofs.
230    Directory,
231
232    /// Raw ext4 disk-image named volume mounted through virtio-blk.
233    Disk,
234}
235
236/// Configuration for creating a named volume.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
239#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
240pub struct VolumeSpec {
241    /// Volume name.
242    pub name: String,
243
244    /// Storage kind.
245    pub kind: VolumeKind,
246
247    /// Size quota in MiB. `None` means unlimited.
248    pub quota_mib: Option<u32>,
249
250    /// Disk capacity in MiB. Required for disk volumes.
251    pub capacity_mib: Option<u32>,
252
253    /// Labels for organization.
254    pub labels: Vec<(String, String)>,
255}
256
257/// Sandbox-time behavior for a named volume mount.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
260#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
261pub enum NamedVolumeMode {
262    /// Require the named volume to already exist.
263    Existing,
264
265    /// Create the named volume and fail if it already exists.
266    Create,
267
268    /// Ensure the named volume exists, or reuse a compatible existing volume.
269    EnsureExists,
270}
271
272/// Creation metadata for sandbox-time named volume provisioning.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
275#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
276pub struct NamedVolumeCreate {
277    /// Creation behavior for this named volume mount.
278    pub mode: NamedVolumeMode,
279
280    /// Volume name to create or ensure exists.
281    pub name: String,
282
283    /// Storage kind to create or ensure exists.
284    pub kind: VolumeKind,
285
286    /// Directory quota in MiB, if configured.
287    pub quota_mib: Option<u32>,
288
289    /// Disk capacity in MiB, if configured.
290    pub capacity_mib: Option<u32>,
291
292    /// Labels to attach to newly-created volumes.
293    pub labels: Vec<(String, String)>,
294}
295
296/// A volume mount specification for a sandbox.
297#[derive(Clone)]
298#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
299#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
300#[cfg_attr(feature = "ts", ts(tag = "type"))]
301pub enum VolumeMount {
302    /// Bind mount a host directory into the guest.
303    Bind {
304        /// Host path to bind mount.
305        #[cfg_attr(feature = "ts", ts(type = "string"))]
306        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
307        host: PathBuf,
308        /// Guest mount path.
309        guest: String,
310        /// Guest mount behavior.
311        options: MountOptions,
312        /// Guest-visible stat virtualization policy.
313        stat_virtualization: StatVirtualization,
314        /// Host permission propagation policy.
315        host_permissions: HostPermissions,
316        /// Whether to follow symlinks when resolving the host mount root.
317        ///
318        /// Defaults to `false`: the host path is resolved following no symlink in
319        /// any component, so a symlink planted at (or under) the mount root cannot
320        /// redirect the mount out of its intended target. Set `true` to opt out
321        /// when the host path legitimately traverses a symlink.
322        follow_root_symlinks: bool,
323        /// Guest-write byte budget in MiB.
324        ///
325        /// Bounds how much the guest may add beyond the directory's existing
326        /// contents. `None` applies the protective default at spawn time; set a
327        /// value to override it.
328        quota_mib: Option<u32>,
329    },
330
331    /// Mount a named volume into the guest.
332    Named {
333        /// Volume name.
334        name: String,
335        /// Guest mount path.
336        guest: String,
337        /// Creation metadata for sandbox-time named volume provisioning.
338        ///
339        /// This is transient and intentionally skipped when sandbox configs are persisted; restarting a sandbox mounts the already-created volume.
340        create: Option<NamedVolumeCreate>,
341        /// Guest mount behavior.
342        options: MountOptions,
343        /// Guest-visible stat virtualization policy.
344        stat_virtualization: StatVirtualization,
345        /// Host permission propagation policy.
346        host_permissions: HostPermissions,
347        /// Whether to follow symlinks when resolving the host mount root.
348        ///
349        /// Defaults to `false` (resolve following no symlink). See
350        /// [`VolumeMount::Bind`] for details.
351        follow_root_symlinks: bool,
352    },
353
354    /// Temporary filesystem backed by guest memory.
355    Tmpfs {
356        /// Guest mount path.
357        guest: String,
358        /// Size limit in MiB.
359        size_mib: Option<u32>,
360        /// Guest mount behavior.
361        options: MountOptions,
362    },
363
364    /// Mount a disk image file as a virtio-blk device at a guest path.
365    DiskImage {
366        /// Host path to the disk image file.
367        #[cfg_attr(feature = "ts", ts(type = "string"))]
368        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
369        host: PathBuf,
370        /// Guest mount path.
371        guest: String,
372        /// Disk image format.
373        format: DiskImageFormat,
374        /// Inner filesystem type. When `None`, agentd probes `/proc/filesystems`.
375        fstype: Option<String>,
376        /// Guest mount behavior.
377        options: MountOptions,
378    },
379}
380
381/// Rootfs patch applied before VM startup.
382#[derive(Debug, Clone, Serialize, Deserialize)]
383#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
384#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
385pub enum Patch {
386    /// Write text content to a file.
387    Text {
388        /// Absolute guest path, such as `/etc/app.conf`.
389        path: String,
390        /// Text content to write.
391        content: String,
392        /// File permissions, such as `0o644`. `None` uses the default.
393        mode: Option<u32>,
394        /// Allow replacing a file that already exists in the rootfs.
395        replace: bool,
396    },
397
398    /// Write raw bytes to a file.
399    File {
400        /// Absolute guest path.
401        path: String,
402        /// Raw byte content to write.
403        content: Vec<u8>,
404        /// File permissions, such as `0o644`. `None` uses the default.
405        mode: Option<u32>,
406        /// Allow replacing a file that already exists in the rootfs.
407        replace: bool,
408    },
409
410    /// Copy a file from the host into the rootfs.
411    CopyFile {
412        /// Host path to copy from.
413        #[cfg_attr(feature = "ts", ts(type = "string"))]
414        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
415        src: PathBuf,
416        /// Absolute guest destination path.
417        dst: String,
418        /// File permissions. `None` preserves source permissions.
419        mode: Option<u32>,
420        /// Allow replacing a file that already exists in the rootfs.
421        replace: bool,
422    },
423
424    /// Copy a directory from the host into the rootfs.
425    CopyDir {
426        /// Host directory to copy from.
427        #[cfg_attr(feature = "ts", ts(type = "string"))]
428        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
429        src: PathBuf,
430        /// Absolute guest destination path.
431        dst: String,
432        /// Allow replacing files that already exist in the rootfs.
433        replace: bool,
434    },
435
436    /// Create a symlink.
437    Symlink {
438        /// Symlink target path.
439        target: String,
440        /// Absolute guest path where the symlink is created.
441        link: String,
442        /// Allow replacing a path that already exists in the rootfs.
443        replace: bool,
444    },
445
446    /// Create a directory.
447    Mkdir {
448        /// Absolute guest path.
449        path: String,
450        /// Directory permissions, such as `0o755`. `None` uses the default.
451        mode: Option<u32>,
452    },
453
454    /// Remove a file or directory.
455    Remove {
456        /// Absolute guest path to remove.
457        path: String,
458    },
459
460    /// Append content to an existing file.
461    Append {
462        /// Absolute guest path of the file to append to.
463        path: String,
464        /// Content to append.
465        content: String,
466    },
467}
468
469//--------------------------------------------------------------------------------------------------
470// Types: Networking
471//--------------------------------------------------------------------------------------------------
472
473/// Complete network specification for a sandbox.
474///
475/// 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.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
478#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
479#[serde(default)]
480pub struct NetworkSpec {
481    /// Whether networking is enabled for this sandbox.
482    pub enabled: bool,
483
484    /// Guest interface overrides for the local network engine.
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub interface: Option<InterfaceOverrides>,
487
488    /// Host-to-guest port mappings.
489    pub ports: Vec<PublishedPortSpec>,
490
491    /// Egress and ingress policy subdocument.
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub policy: Option<NetworkPolicy>,
494
495    /// DNS interception and filtering subdocument.
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub dns: Option<DnsConfig>,
498
499    /// TLS interception subdocument.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub tls: Option<TlsConfig>,
502
503    /// Secret injection subdocument.
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub secrets: Option<SecretsConfig>,
506
507    /// Max concurrent guest connections.
508    pub max_connections: Option<usize>,
509
510    /// Whether to copy trusted host CAs into the guest at boot.
511    pub trust_host_cas: bool,
512}
513
514/// A published port mapping between host and guest.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
517#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
518pub struct PublishedPortSpec {
519    /// Host-side port to bind.
520    pub host_port: u16,
521
522    /// Guest-side port to forward to.
523    pub guest_port: u16,
524
525    /// Transport protocol.
526    #[serde(default)]
527    pub protocol: PortProtocol,
528
529    /// Host address to bind. Defaults to loopback.
530    pub host_bind: String,
531}
532
533/// Transport protocol for a published port.
534#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
535#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
536#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
537pub enum PortProtocol {
538    /// TCP.
539    #[default]
540    #[serde(rename = "tcp")]
541    Tcp,
542
543    /// UDP.
544    #[serde(rename = "udp")]
545    Udp,
546}
547
548//--------------------------------------------------------------------------------------------------
549// Types: Init
550//--------------------------------------------------------------------------------------------------
551
552/// Fully-assembled handoff-init specification.
553#[derive(Debug, Clone, Serialize, Deserialize)]
554#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
555#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
556pub struct HandoffInit {
557    /// Init binary: absolute path inside the guest rootfs, or the literal `auto`.
558    ///
559    /// 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).
560    pub cmd: String,
561
562    /// Supplemental argv. `argv[0]` is implicitly `cmd`.
563    #[serde(default)]
564    pub args: Vec<String>,
565
566    /// Extra env vars merged on top of the inherited env.
567    #[serde(default)]
568    pub env: Vec<(String, String)>,
569}
570
571//--------------------------------------------------------------------------------------------------
572// Types: Lifecycle
573//--------------------------------------------------------------------------------------------------
574
575/// Sandbox lifecycle policy.
576#[derive(Debug, Default, Clone, Serialize, Deserialize)]
577#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
578#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
579pub struct SandboxPolicy {
580    /// Whether the sandbox is ephemeral.
581    ///
582    /// Ephemeral sandboxes are one-off: the host runtime that owns the
583    /// process removes the persisted DB row and on-disk state when the VM
584    /// reaches a terminal status, and other host runtimes opportunistically
585    /// clean up ephemeral leftovers from runtimes that died before they
586    /// could self-clean. Defaults to `false` (persistent); named and created
587    /// sandboxes stay inspectable and restartable after they stop.
588    #[serde(default)]
589    pub ephemeral: bool,
590
591    /// Hard cap on total sandbox lifetime in seconds. `None` = run forever.
592    pub max_duration_secs: Option<u64>,
593
594    /// Idle timeout in seconds. `None` = no idle detection.
595    pub idle_timeout_secs: Option<u64>,
596}
597
598//--------------------------------------------------------------------------------------------------
599// Types: Snapshots
600//--------------------------------------------------------------------------------------------------
601
602/// Inputs to create a snapshot.
603///
604/// The snapshot's name is its identity; the artifact directory is
605/// `dest_dir.join(name)`, with `dest_dir` defaulting to the snapshots
606/// store. Archive movement happens through save/load (the artifact
607/// directory is also self-contained and safe to move directly).
608#[derive(Debug, Clone, Serialize, Deserialize)]
609#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
610pub struct SnapshotSpec {
611    /// Snapshot name. Always the artifact directory's basename.
612    pub name: String,
613
614    /// Parent directory to create the artifact in. `None` = the default
615    /// snapshots directory.
616    #[serde(default)]
617    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
618    pub dest_dir: Option<PathBuf>,
619
620    /// Name of the source sandbox. Must be stopped.
621    pub source_sandbox: String,
622
623    /// User-supplied labels.
624    pub labels: Vec<(String, String)>,
625
626    /// Overwrite an existing artifact at the destination.
627    pub force: bool,
628
629    /// Compute and record upper-layer content integrity at creation time.
630    pub record_integrity: bool,
631
632    /// Request a future resumable snapshot that includes memory/device state.
633    ///
634    /// This is part of the public contract now so callers can validate shape
635    /// early. The local runtime returns an unsupported-feature error until VM
636    /// pause/resume capture lands.
637    #[serde(default)]
638    pub resumable: bool,
639}
640
641//--------------------------------------------------------------------------------------------------
642// Types: Sandbox Specs
643//--------------------------------------------------------------------------------------------------
644
645/// Backend-neutral sandbox task description.
646///
647/// 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.
648#[derive(Debug, Default, Clone, Serialize, Deserialize)]
649#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
650#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
651#[serde(default)]
652pub struct SandboxSpec {
653    /// Unique sandbox name.
654    pub name: String,
655
656    /// Root filesystem source.
657    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
658    pub image: RootfsSource,
659
660    /// CPU and memory resources.
661    pub resources: SandboxResources,
662
663    /// Guest runtime options.
664    pub runtime: SandboxRuntimeOptions,
665
666    /// Environment variables visible to commands in the sandbox.
667    pub env: Vec<EnvVar>,
668
669    /// User-defined labels attached to the sandbox.
670    pub labels: BTreeMap<String, String>,
671
672    /// Sandbox-wide resource limits inherited by guest processes.
673    pub rlimits: Vec<Rlimit>,
674
675    /// Volume mounts.
676    pub mounts: Vec<VolumeMount>,
677
678    /// Rootfs patches applied before VM start.
679    pub patches: Vec<Patch>,
680
681    /// Network specification.
682    pub network: NetworkSpec,
683
684    /// Hand off PID 1 to a guest init binary after agentd setup.
685    pub init: Option<HandoffInit>,
686
687    /// Pull policy for OCI images.
688    pub pull_policy: PullPolicy,
689
690    /// In-guest security profile.
691    pub security_profile: SecurityProfile,
692
693    /// Sandbox lifecycle policy.
694    pub lifecycle: SandboxPolicy,
695}
696
697/// CPU and memory resources for a sandbox.
698#[derive(Debug, Clone, Copy, Serialize)]
699#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
700#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
701pub struct SandboxResources {
702    /// Number of virtual CPUs currently presented to the guest at boot.
703    pub cpus: u8,
704
705    /// Guest memory currently presented to the guest at boot, in MiB.
706    pub memory_mib: u32,
707
708    /// Maximum virtual CPUs the sandbox may expose after boot-time hotplug support lands.
709    pub max_cpus: u8,
710
711    /// Maximum guest memory the sandbox may expose after boot-time hotplug support lands, in MiB.
712    pub max_memory_mib: u32,
713}
714
715/// Guest runtime options for a sandbox.
716#[derive(Debug, Clone, Serialize, Deserialize)]
717#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
718#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
719#[serde(default)]
720pub struct SandboxRuntimeOptions {
721    /// Working directory inside the guest.
722    pub workdir: Option<String>,
723
724    /// Default shell for scripts and interactive sessions.
725    pub shell: Option<String>,
726
727    /// Named scripts available inside the guest.
728    pub scripts: BTreeMap<String, String>,
729
730    /// Image entrypoint override.
731    pub entrypoint: Option<Vec<String>>,
732
733    /// Image command override.
734    pub cmd: Option<Vec<String>>,
735
736    /// Guest hostname override.
737    pub hostname: Option<String>,
738
739    /// Guest user identity override.
740    pub user: Option<String>,
741
742    /// Runtime log verbosity.
743    pub log_level: Option<SandboxLogLevel>,
744
745    /// Metrics sampling interval in milliseconds. `None` disables sampling.
746    pub metrics_sample_interval_ms: Option<u64>,
747
748    /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`.
749    pub disable_metrics_sample: bool,
750}
751
752/// Environment variable entry.
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
755#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
756pub struct EnvVar {
757    /// Environment variable name.
758    pub key: String,
759
760    /// Environment variable value.
761    pub value: String,
762}
763
764/// Runtime log verbosity for sandbox specs.
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
766#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
767#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
768#[serde(rename_all = "lowercase")]
769pub enum SandboxLogLevel {
770    /// Emit only error logs.
771    Error,
772
773    /// Emit warning and error logs.
774    Warn,
775
776    /// Emit info, warning, and error logs.
777    Info,
778
779    /// Emit debug and higher-severity logs.
780    Debug,
781
782    /// Emit trace and higher-severity logs.
783    Trace,
784}
785
786//--------------------------------------------------------------------------------------------------
787// Types: Exec
788//--------------------------------------------------------------------------------------------------
789
790/// POSIX resource limit identifiers.
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
792#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
793#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
794pub enum RlimitResource {
795    /// Max CPU time in seconds (`RLIMIT_CPU`).
796    Cpu,
797    /// Max file size in bytes (`RLIMIT_FSIZE`).
798    Fsize,
799    /// Max data segment size (`RLIMIT_DATA`).
800    Data,
801    /// Max stack size (`RLIMIT_STACK`).
802    Stack,
803    /// Max core file size (`RLIMIT_CORE`).
804    Core,
805    /// Max resident set size (`RLIMIT_RSS`).
806    Rss,
807    /// Max number of processes (`RLIMIT_NPROC`).
808    Nproc,
809    /// Max open file descriptors (`RLIMIT_NOFILE`).
810    Nofile,
811    /// Max locked memory (`RLIMIT_MEMLOCK`).
812    Memlock,
813    /// Max address space size (`RLIMIT_AS`).
814    As,
815    /// Max file locks (`RLIMIT_LOCKS`).
816    Locks,
817    /// Max pending signals (`RLIMIT_SIGPENDING`).
818    Sigpending,
819    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
820    Msgqueue,
821    /// Max nice priority (`RLIMIT_NICE`).
822    Nice,
823    /// Max real-time priority (`RLIMIT_RTPRIO`).
824    Rtprio,
825    /// Max real-time timeout (`RLIMIT_RTTIME`).
826    Rttime,
827}
828
829/// A POSIX resource limit.
830#[derive(Debug, Clone, Serialize, Deserialize)]
831#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
832#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
833pub struct Rlimit {
834    /// Resource type.
835    pub resource: RlimitResource,
836
837    /// Soft limit (can be raised up to hard limit by the process).
838    pub soft: u64,
839
840    /// Hard limit (ceiling, requires privileges to raise).
841    pub hard: u64,
842}
843
844//--------------------------------------------------------------------------------------------------
845// Types: Logs
846//--------------------------------------------------------------------------------------------------
847
848/// Source tag on a captured log entry.
849#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
850#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
851#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
852#[serde(rename_all = "lowercase")]
853pub enum LogSource {
854    /// Captured from a session's stdout (pipe mode).
855    Stdout,
856
857    /// Captured from a session's stderr (pipe mode).
858    Stderr,
859
860    /// Captured from a session in pty mode (stdout + stderr merged at the kernel level inside the guest arrive as a single stream tagged `output`).
861    Output,
862
863    /// Synthetic system entry: lifecycle markers, runtime diagnostics, kernel console output.
864    System,
865}
866
867//--------------------------------------------------------------------------------------------------
868// Methods
869//--------------------------------------------------------------------------------------------------
870
871impl DiskImageFormat {
872    /// Returns the format as a CLI-safe lowercase string.
873    pub fn as_str(&self) -> &'static str {
874        match self {
875            Self::Qcow2 => "qcow2",
876            Self::Raw => "raw",
877            Self::Vmdk => "vmdk",
878        }
879    }
880
881    /// Parse a disk image format from a file extension.
882    ///
883    /// Returns `None` if the extension is not a recognized disk image format.
884    pub fn from_extension(ext: &str) -> Option<Self> {
885        match ext {
886            "qcow2" => Some(Self::Qcow2),
887            "raw" => Some(Self::Raw),
888            "vmdk" => Some(Self::Vmdk),
889            _ => None,
890        }
891    }
892}
893
894impl OciRootfsSource {
895    /// Create a new OCI rootfs source.
896    pub fn new(reference: impl Into<String>) -> Self {
897        Self {
898            reference: reference.into(),
899            root_disk: None,
900        }
901    }
902}
903
904impl RootDisk {
905    /// Create a managed root disk with the given size in MiB.
906    pub fn managed(size_mib: u32) -> Self {
907        Self::Managed {
908            size_mib: Some(size_mib),
909        }
910    }
911
912    /// Create a tmpfs root disk with the given size in MiB.
913    pub fn tmpfs(size_mib: u32) -> Self {
914        Self::Tmpfs {
915            size_mib: Some(size_mib),
916        }
917    }
918
919    /// Return the configured size in MiB, if this kind carries one.
920    pub fn size_mib(&self) -> Option<u32> {
921        match self {
922            Self::Managed { size_mib } | Self::Tmpfs { size_mib } => *size_mib,
923            Self::DiskImage { .. } => None,
924        }
925    }
926
927    /// Return the lowercase kind tag used on the wire, in the DB, and in CLI output.
928    pub fn kind_str(&self) -> &'static str {
929        match self {
930            Self::Managed { .. } => "managed",
931            Self::Tmpfs { .. } => "tmpfs",
932            Self::DiskImage { .. } => "disk-image",
933        }
934    }
935
936    /// Whether this is the managed (default) kind.
937    pub fn is_managed(&self) -> bool {
938        matches!(self, Self::Managed { .. })
939    }
940}
941
942impl RootfsSource {
943    /// Create an OCI rootfs source from an image reference.
944    pub fn oci(reference: impl Into<String>) -> Self {
945        Self::Oci(OciRootfsSource::new(reference))
946    }
947
948    /// Return the OCI image reference if this is an OCI rootfs.
949    pub fn oci_reference(&self) -> Option<&str> {
950        match self {
951            Self::Oci(oci) => Some(&oci.reference),
952            _ => None,
953        }
954    }
955
956    /// Return the configured root disk if this is an OCI rootfs.
957    pub fn oci_root_disk(&self) -> Option<&RootDisk> {
958        match self {
959            Self::Oci(oci) => oci.root_disk.as_ref(),
960            _ => None,
961        }
962    }
963
964    /// Return the managed root disk size in MiB if this is an OCI rootfs with a managed
965    /// (or unset, i.e. default-managed) root disk. Non-managed kinds return `None`.
966    pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
967        match self {
968            Self::Oci(oci) => match &oci.root_disk {
969                Some(RootDisk::Managed { size_mib }) => *size_mib,
970                Some(_) => None,
971                None => None,
972            },
973            _ => None,
974        }
975    }
976}
977
978impl EnvVar {
979    /// Create an environment variable entry.
980    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
981        Self {
982            key: key.into(),
983            value: value.into(),
984        }
985    }
986
987    /// Return this entry as key and value string slices.
988    pub fn as_pair(&self) -> (&str, &str) {
989        (&self.key, &self.value)
990    }
991}
992
993impl VolumeKind {
994    /// Return the lowercase database and CLI representation.
995    pub fn as_str(self) -> &'static str {
996        match self {
997            Self::Directory => "dir",
998            Self::Disk => "disk",
999        }
1000    }
1001
1002    /// Parse a persisted database value, defaulting to directory for unknown values.
1003    pub fn from_db_value(value: &str) -> Self {
1004        match value {
1005            "disk" => Self::Disk,
1006            _ => Self::Directory,
1007        }
1008    }
1009}
1010
1011impl VolumeSpec {
1012    /// Create a directory-backed volume spec with default options.
1013    pub fn new(name: impl Into<String>) -> Self {
1014        Self {
1015            name: name.into(),
1016            kind: VolumeKind::Directory,
1017            quota_mib: None,
1018            capacity_mib: None,
1019            labels: Vec::new(),
1020        }
1021    }
1022}
1023
1024impl NamedVolumeCreate {
1025    /// Creation behavior for this named volume mount.
1026    pub fn mode(&self) -> NamedVolumeMode {
1027        self.mode
1028    }
1029
1030    /// Volume name to create or ensure exists.
1031    pub fn name(&self) -> &str {
1032        &self.name
1033    }
1034
1035    /// Storage kind to create or ensure exists.
1036    pub fn kind(&self) -> VolumeKind {
1037        self.kind
1038    }
1039
1040    /// Directory quota in MiB, if configured.
1041    pub fn quota_mib(&self) -> Option<u32> {
1042        self.quota_mib
1043    }
1044
1045    /// Disk capacity in MiB, if configured.
1046    pub fn capacity_mib(&self) -> Option<u32> {
1047        self.capacity_mib
1048    }
1049
1050    /// Labels to attach to newly-created volumes.
1051    pub fn labels(&self) -> &[(String, String)] {
1052        &self.labels
1053    }
1054}
1055
1056impl VolumeMount {
1057    /// The absolute path where this mount appears inside the guest.
1058    pub fn guest(&self) -> &str {
1059        match self {
1060            Self::Bind { guest, .. }
1061            | Self::Named { guest, .. }
1062            | Self::Tmpfs { guest, .. }
1063            | Self::DiskImage { guest, .. } => guest,
1064        }
1065    }
1066
1067    /// Return named-volume creation metadata when this mount provisions a named volume.
1068    pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1069        match self {
1070            Self::Named { create, .. } => create.as_ref(),
1071            _ => None,
1072        }
1073    }
1074}
1075
1076impl RlimitResource {
1077    /// Returns the lowercase string representation used on the wire.
1078    pub fn as_str(&self) -> &'static str {
1079        match self {
1080            Self::Cpu => "cpu",
1081            Self::Fsize => "fsize",
1082            Self::Data => "data",
1083            Self::Stack => "stack",
1084            Self::Core => "core",
1085            Self::Rss => "rss",
1086            Self::Nproc => "nproc",
1087            Self::Nofile => "nofile",
1088            Self::Memlock => "memlock",
1089            Self::As => "as",
1090            Self::Locks => "locks",
1091            Self::Sigpending => "sigpending",
1092            Self::Msgqueue => "msgqueue",
1093            Self::Nice => "nice",
1094            Self::Rtprio => "rtprio",
1095            Self::Rttime => "rttime",
1096        }
1097    }
1098}
1099
1100impl LogSource {
1101    /// Apply the empty-means-default rule used by log readers.
1102    pub fn effective(requested: &[Self]) -> Vec<Self> {
1103        if requested.is_empty() {
1104            vec![Self::Stdout, Self::Stderr, Self::Output]
1105        } else {
1106            let mut sources = requested.to_vec();
1107            sources.sort_by_key(|src| match src {
1108                Self::Stdout => 0,
1109                Self::Stderr => 1,
1110                Self::Output => 2,
1111                Self::System => 3,
1112            });
1113            sources.dedup();
1114            sources
1115        }
1116    }
1117}
1118
1119impl SandboxLogLevel {
1120    /// Return the lowercase string representation for this level.
1121    pub const fn as_str(self) -> &'static str {
1122        match self {
1123            Self::Error => "error",
1124            Self::Warn => "warn",
1125            Self::Info => "info",
1126            Self::Debug => "debug",
1127            Self::Trace => "trace",
1128        }
1129    }
1130}
1131
1132//--------------------------------------------------------------------------------------------------
1133// Trait Implementations
1134//--------------------------------------------------------------------------------------------------
1135
1136impl std::fmt::Display for DiskImageFormat {
1137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1138        f.write_str(self.as_str())
1139    }
1140}
1141
1142impl FromStr for DiskImageFormat {
1143    type Err = String;
1144
1145    fn from_str(s: &str) -> Result<Self, Self::Err> {
1146        match s {
1147            "qcow2" => Ok(Self::Qcow2),
1148            "raw" => Ok(Self::Raw),
1149            "vmdk" => Ok(Self::Vmdk),
1150            _ => Err(format!("unknown disk image format: {s}")),
1151        }
1152    }
1153}
1154
1155impl Default for RootfsSource {
1156    fn default() -> Self {
1157        Self::oci(String::new())
1158    }
1159}
1160
1161impl Default for SandboxResources {
1162    fn default() -> Self {
1163        Self {
1164            cpus: DEFAULT_SANDBOX_CPUS,
1165            memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1166            max_cpus: DEFAULT_SANDBOX_CPUS,
1167            max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1168        }
1169    }
1170}
1171
1172impl<'de> Deserialize<'de> for SandboxResources {
1173    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1174    where
1175        D: serde::Deserializer<'de>,
1176    {
1177        #[derive(Deserialize)]
1178        struct RawResources {
1179            #[serde(default = "default_sandbox_cpus")]
1180            cpus: u8,
1181            #[serde(default = "default_sandbox_memory_mib")]
1182            memory_mib: u32,
1183            max_cpus: Option<u8>,
1184            max_memory_mib: Option<u32>,
1185        }
1186
1187        let raw = RawResources::deserialize(deserializer)?;
1188        Ok(Self {
1189            cpus: raw.cpus,
1190            memory_mib: raw.memory_mib,
1191            // Legacy configs predate boot-capacity fields. Treat their effective
1192            // resources as their maximum capacity so old sandboxes do not
1193            // deserialize into an impossible cpus > max_cpus state.
1194            max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1195            max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1196        })
1197    }
1198}
1199
1200impl Default for SandboxRuntimeOptions {
1201    fn default() -> Self {
1202        Self {
1203            workdir: None,
1204            shell: None,
1205            scripts: BTreeMap::new(),
1206            entrypoint: None,
1207            cmd: None,
1208            hostname: None,
1209            user: None,
1210            log_level: None,
1211            metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1212            disable_metrics_sample: false,
1213        }
1214    }
1215}
1216
1217impl Default for NetworkSpec {
1218    fn default() -> Self {
1219        Self {
1220            enabled: true,
1221            interface: None,
1222            ports: Vec::new(),
1223            policy: None,
1224            dns: None,
1225            tls: None,
1226            secrets: None,
1227            max_connections: None,
1228            trust_host_cas: false,
1229        }
1230    }
1231}
1232
1233impl Default for PublishedPortSpec {
1234    fn default() -> Self {
1235        Self {
1236            host_port: 0,
1237            guest_port: 0,
1238            protocol: PortProtocol::Tcp,
1239            host_bind: "127.0.0.1".into(),
1240        }
1241    }
1242}
1243
1244impl From<(String, String)> for EnvVar {
1245    fn from((key, value): (String, String)) -> Self {
1246        Self { key, value }
1247    }
1248}
1249
1250impl From<EnvVar> for (String, String) {
1251    fn from(var: EnvVar) -> Self {
1252        (var.key, var.value)
1253    }
1254}
1255
1256impl FromStr for SandboxLogLevel {
1257    type Err = String;
1258
1259    fn from_str(s: &str) -> Result<Self, Self::Err> {
1260        match s {
1261            "error" => Ok(Self::Error),
1262            "warn" => Ok(Self::Warn),
1263            "info" => Ok(Self::Info),
1264            "debug" => Ok(Self::Debug),
1265            "trace" => Ok(Self::Trace),
1266            _ => Err(format!("unknown sandbox log level: {s}")),
1267        }
1268    }
1269}
1270
1271impl Serialize for VolumeMount {
1272    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1273        use serde::ser::SerializeMap;
1274
1275        match self {
1276            Self::Bind {
1277                host,
1278                guest,
1279                options,
1280                stat_virtualization,
1281                host_permissions,
1282                follow_root_symlinks,
1283                quota_mib,
1284            } => {
1285                let mut map = serializer.serialize_map(Some(8))?;
1286                map.serialize_entry("type", "Bind")?;
1287                map.serialize_entry("host", host)?;
1288                map.serialize_entry("guest", guest)?;
1289                map.serialize_entry("options", options)?;
1290                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1291                map.serialize_entry("host_permissions", host_permissions)?;
1292                map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1293                map.serialize_entry("quota_mib", quota_mib)?;
1294                map.end()
1295            }
1296            Self::Named {
1297                name,
1298                guest,
1299                create: _,
1300                options,
1301                stat_virtualization,
1302                host_permissions,
1303                follow_root_symlinks,
1304            } => {
1305                let mut map = serializer.serialize_map(Some(7))?;
1306                map.serialize_entry("type", "Named")?;
1307                map.serialize_entry("name", name)?;
1308                map.serialize_entry("guest", guest)?;
1309                map.serialize_entry("options", options)?;
1310                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1311                map.serialize_entry("host_permissions", host_permissions)?;
1312                map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1313                map.end()
1314            }
1315            Self::Tmpfs {
1316                guest,
1317                size_mib,
1318                options,
1319            } => {
1320                let mut map = serializer.serialize_map(Some(4))?;
1321                map.serialize_entry("type", "Tmpfs")?;
1322                map.serialize_entry("guest", guest)?;
1323                map.serialize_entry("size_mib", size_mib)?;
1324                map.serialize_entry("options", options)?;
1325                map.end()
1326            }
1327            Self::DiskImage {
1328                host,
1329                guest,
1330                format,
1331                fstype,
1332                options,
1333            } => {
1334                let mut map = serializer.serialize_map(Some(6))?;
1335                map.serialize_entry("type", "DiskImage")?;
1336                map.serialize_entry("host", host)?;
1337                map.serialize_entry("guest", guest)?;
1338                map.serialize_entry("format", format)?;
1339                map.serialize_entry("fstype", fstype)?;
1340                map.serialize_entry("options", options)?;
1341                map.end()
1342            }
1343        }
1344    }
1345}
1346
1347impl<'de> Deserialize<'de> for VolumeMount {
1348    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1349        fn default_strict() -> StatVirtualization {
1350            StatVirtualization::Strict
1351        }
1352
1353        fn default_private() -> HostPermissions {
1354            HostPermissions::Private
1355        }
1356
1357        #[derive(Deserialize)]
1358        #[serde(tag = "type")]
1359        enum VolumeMountHelper {
1360            Bind {
1361                host: PathBuf,
1362                guest: String,
1363                #[serde(default)]
1364                options: Option<MountOptions>,
1365                #[serde(default)]
1366                readonly: bool,
1367                #[serde(default = "default_strict")]
1368                stat_virtualization: StatVirtualization,
1369                #[serde(default = "default_private")]
1370                host_permissions: HostPermissions,
1371                #[serde(default)]
1372                follow_root_symlinks: bool,
1373                #[serde(default)]
1374                quota_mib: Option<u32>,
1375            },
1376            Named {
1377                name: String,
1378                guest: String,
1379                #[serde(default)]
1380                options: Option<MountOptions>,
1381                #[serde(default)]
1382                readonly: bool,
1383                #[serde(default = "default_strict")]
1384                stat_virtualization: StatVirtualization,
1385                #[serde(default = "default_private")]
1386                host_permissions: HostPermissions,
1387                #[serde(default)]
1388                follow_root_symlinks: bool,
1389            },
1390            Tmpfs {
1391                guest: String,
1392                #[serde(default)]
1393                size_mib: Option<u32>,
1394                #[serde(default)]
1395                options: Option<MountOptions>,
1396                #[serde(default)]
1397                readonly: bool,
1398            },
1399            DiskImage {
1400                host: PathBuf,
1401                guest: String,
1402                format: DiskImageFormat,
1403                #[serde(default)]
1404                fstype: Option<String>,
1405                #[serde(default)]
1406                options: Option<MountOptions>,
1407                #[serde(default)]
1408                readonly: bool,
1409            },
1410        }
1411
1412        let helper = VolumeMountHelper::deserialize(deserializer)?;
1413        Ok(match helper {
1414            VolumeMountHelper::Bind {
1415                host,
1416                guest,
1417                options,
1418                readonly,
1419                stat_virtualization,
1420                host_permissions,
1421                follow_root_symlinks,
1422                quota_mib,
1423            } => Self::Bind {
1424                host,
1425                guest,
1426                options: decode_mount_options(options, readonly),
1427                stat_virtualization,
1428                host_permissions,
1429                follow_root_symlinks,
1430                quota_mib,
1431            },
1432            VolumeMountHelper::Named {
1433                name,
1434                guest,
1435                options,
1436                readonly,
1437                stat_virtualization,
1438                host_permissions,
1439                follow_root_symlinks,
1440            } => Self::Named {
1441                name,
1442                guest,
1443                create: None,
1444                options: decode_mount_options(options, readonly),
1445                stat_virtualization,
1446                host_permissions,
1447                follow_root_symlinks,
1448            },
1449            VolumeMountHelper::Tmpfs {
1450                guest,
1451                size_mib,
1452                options,
1453                readonly,
1454            } => Self::Tmpfs {
1455                guest,
1456                size_mib,
1457                options: decode_mount_options(options, readonly),
1458            },
1459            VolumeMountHelper::DiskImage {
1460                host,
1461                guest,
1462                format,
1463                fstype,
1464                options,
1465                readonly,
1466            } => Self::DiskImage {
1467                host,
1468                guest,
1469                format,
1470                fstype,
1471                options: decode_mount_options(options, readonly),
1472            },
1473        })
1474    }
1475}
1476
1477impl fmt::Debug for VolumeMount {
1478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1479        match self {
1480            Self::Bind {
1481                host,
1482                guest,
1483                options,
1484                stat_virtualization,
1485                host_permissions,
1486                follow_root_symlinks,
1487                quota_mib,
1488            } => f
1489                .debug_struct("Bind")
1490                .field("host", host)
1491                .field("guest", guest)
1492                .field("options", options)
1493                .field("stat_virtualization", stat_virtualization)
1494                .field("host_permissions", host_permissions)
1495                .field("follow_root_symlinks", follow_root_symlinks)
1496                .field("quota_mib", quota_mib)
1497                .finish(),
1498            Self::Named {
1499                name,
1500                guest,
1501                create,
1502                options,
1503                stat_virtualization,
1504                host_permissions,
1505                follow_root_symlinks,
1506            } => f
1507                .debug_struct("Named")
1508                .field("name", name)
1509                .field("guest", guest)
1510                .field("create", create)
1511                .field("options", options)
1512                .field("stat_virtualization", stat_virtualization)
1513                .field("host_permissions", host_permissions)
1514                .field("follow_root_symlinks", follow_root_symlinks)
1515                .finish(),
1516            Self::Tmpfs {
1517                guest,
1518                size_mib,
1519                options,
1520            } => f
1521                .debug_struct("Tmpfs")
1522                .field("guest", guest)
1523                .field("size_mib", size_mib)
1524                .field("options", options)
1525                .finish(),
1526            Self::DiskImage {
1527                host,
1528                guest,
1529                format,
1530                fstype,
1531                options,
1532            } => f
1533                .debug_struct("DiskImage")
1534                .field("host", host)
1535                .field("guest", guest)
1536                .field("format", format)
1537                .field("fstype", fstype)
1538                .field("options", options)
1539                .finish(),
1540        }
1541    }
1542}
1543
1544/// Case-insensitive string to [`RlimitResource`] conversion.
1545impl TryFrom<&str> for RlimitResource {
1546    type Error = String;
1547
1548    fn try_from(s: &str) -> Result<Self, Self::Error> {
1549        match s.to_ascii_lowercase().as_str() {
1550            "cpu" => Ok(Self::Cpu),
1551            "fsize" => Ok(Self::Fsize),
1552            "data" => Ok(Self::Data),
1553            "stack" => Ok(Self::Stack),
1554            "core" => Ok(Self::Core),
1555            "rss" => Ok(Self::Rss),
1556            "nproc" => Ok(Self::Nproc),
1557            "nofile" => Ok(Self::Nofile),
1558            "memlock" => Ok(Self::Memlock),
1559            "as" => Ok(Self::As),
1560            "locks" => Ok(Self::Locks),
1561            "sigpending" => Ok(Self::Sigpending),
1562            "msgqueue" => Ok(Self::Msgqueue),
1563            "nice" => Ok(Self::Nice),
1564            "rtprio" => Ok(Self::Rtprio),
1565            "rttime" => Ok(Self::Rttime),
1566            _ => Err(format!("unknown rlimit resource: {s}")),
1567        }
1568    }
1569}
1570
1571//--------------------------------------------------------------------------------------------------
1572// Functions
1573//--------------------------------------------------------------------------------------------------
1574
1575fn default_sandbox_cpus() -> u8 {
1576    DEFAULT_SANDBOX_CPUS
1577}
1578
1579fn default_sandbox_memory_mib() -> u32 {
1580    DEFAULT_SANDBOX_MEMORY_MIB
1581}
1582
1583fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
1584    options.unwrap_or(MountOptions {
1585        readonly,
1586        ..MountOptions::default()
1587    })
1588}
1589
1590/// Default stat-virtualization policy (`Strict`) for a deserialized volume mount.
1591pub(crate) fn default_strict() -> StatVirtualization {
1592    StatVirtualization::Strict
1593}
1594
1595/// Default host-permission policy (`Private`) for a deserialized volume mount.
1596pub(crate) fn default_private() -> HostPermissions {
1597    HostPermissions::Private
1598}
1599
1600/// Maximum supported secret placeholder length in bytes.
1601pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
1602
1603/// Placeholder-based secret injection for a sandbox's TLS-intercepted egress.
1604///
1605/// The sandbox only ever sees each secret's `placeholder`; the local network
1606/// engine substitutes the real `value` into outbound requests bound for an
1607/// allowed host (and blocks/forwards per [`ViolationAction`] otherwise). Carried
1608/// in [`NetworkSpec::secrets`](NetworkSpec).
1609#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1610#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1611#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1612pub struct SecretsConfig {
1613    /// List of secrets to inject.
1614    #[serde(default)]
1615    pub secrets: Vec<SecretEntry>,
1616
1617    /// Default action when a placeholder leaks to a disallowed host.
1618    #[serde(default)]
1619    pub on_violation: ViolationAction,
1620}
1621
1622/// A single secret entry.
1623///
1624/// `value` is the sensitive material — it never enters the sandbox and is
1625/// redacted by the [`Debug`](fmt::Debug) impl.
1626#[derive(Clone, Serialize, Deserialize)]
1627#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1628#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1629pub struct SecretEntry {
1630    /// Environment variable name exposed to the sandbox (holds the placeholder).
1631    ///
1632    /// Must be non-empty and must not contain `=` or NUL. microsandbox does
1633    /// not require shell-identifier syntax because Linux environment entries
1634    /// only require a `NAME=value` shape.
1635    pub env_var: String,
1636
1637    /// The actual secret value (never enters the sandbox).
1638    ///
1639    /// Empty when the entry carries a [`source`](Self::source) reference
1640    /// instead: reference-model entries resolve the value host-side at spawn
1641    /// time so the durable sandbox config never stores raw secret material.
1642    ///
1643    /// Wrapped in [`Zeroizing`] so the owned plaintext copy is wiped when the
1644    /// entry drops.
1645    #[serde(default = "empty_secret_value")]
1646    #[cfg_attr(feature = "ts", ts(type = "string"))]
1647    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1648    pub value: Zeroizing<String>,
1649
1650    /// Host-side source reference resolved into [`value`](Self::value) at
1651    /// spawn time. `None` means `value` already carries the material (the
1652    /// inline model used by value-based secrets).
1653    #[serde(default, skip_serializing_if = "Option::is_none")]
1654    pub source: Option<SecretSource>,
1655
1656    /// Placeholder string the sandbox sees instead of the real value.
1657    ///
1658    /// Must be non-empty, no longer than [`MAX_SECRET_PLACEHOLDER_BYTES`], and
1659    /// must not contain NUL, CR, or LF.
1660    pub placeholder: String,
1661
1662    /// Hosts allowed to receive this secret.
1663    #[serde(default)]
1664    pub allowed_hosts: Vec<HostPattern>,
1665
1666    /// Where the secret can be injected.
1667    #[serde(default)]
1668    pub injection: SecretInjection,
1669
1670    /// Action on a violation for this secret (overrides the config default).
1671    #[serde(default, skip_serializing_if = "Option::is_none")]
1672    pub on_violation: Option<ViolationAction>,
1673
1674    /// Require verified TLS identity before substituting (default: true).
1675    ///
1676    /// When true, the secret is only substituted if the connection uses TLS
1677    /// interception (not bypass) and the SNI matches an allowed host.
1678    #[serde(default = "default_true")]
1679    pub require_tls_identity: bool,
1680}
1681
1682/// Host pattern for a secret allowlist.
1683#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1684#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1685#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1686#[serde(rename_all = "kebab-case")]
1687pub enum HostPattern {
1688    /// Exact hostname match.
1689    #[serde(alias = "Exact")]
1690    Exact(String),
1691    /// Wildcard match (e.g., `*.openai.com`).
1692    #[serde(alias = "Wildcard")]
1693    Wildcard(String),
1694    /// Any host (dangerous — secret can be exfiltrated).
1695    #[serde(alias = "Any")]
1696    Any,
1697}
1698
1699/// Where in the HTTP request a secret can be injected.
1700#[derive(Debug, Clone, Serialize, Deserialize)]
1701#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1702#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1703pub struct SecretInjection {
1704    /// Substitute in HTTP headers (default: true).
1705    #[serde(default = "default_true")]
1706    pub headers: bool,
1707
1708    /// Substitute in HTTP Basic Auth (default: true).
1709    #[serde(default = "default_true")]
1710    pub basic_auth: bool,
1711
1712    /// Substitute in URL query parameters (default: false).
1713    #[serde(default)]
1714    pub query_params: bool,
1715
1716    /// Substitute in request body (default: false).
1717    ///
1718    /// Fixed-length HTTP/1 bodies up to 16 MiB update `Content-Length`;
1719    /// larger fixed-length bodies are blocked. Chunked HTTP/1 bodies are
1720    /// decoded and re-encoded with fresh chunk sizes. Encoded bodies pass
1721    /// through unchanged. HTTP/2 DATA-frame body substitution is not
1722    /// supported; matching body placeholders are blocked.
1723    #[serde(default)]
1724    pub body: bool,
1725}
1726
1727/// Action when a secret placeholder is detected going to a disallowed host.
1728#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1729#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1730#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1731#[serde(rename_all = "kebab-case")]
1732pub enum ViolationAction {
1733    /// Block the request silently.
1734    #[serde(alias = "Block")]
1735    Block,
1736    /// Block and log (default).
1737    #[default]
1738    #[serde(alias = "BlockAndLog", alias = "block_and_log")]
1739    BlockAndLog,
1740    /// Block and terminate the sandbox.
1741    #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
1742    BlockAndTerminate,
1743    /// Forward the request with the placeholder unchanged for matching hosts.
1744    #[serde(alias = "Passthrough")]
1745    Passthrough(Vec<HostPattern>),
1746}
1747
1748/// Invalid secret configuration.
1749#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1750pub enum SecretConfigError {
1751    /// The environment variable name is empty.
1752    #[error("secret #{secret_index}: env_var must not be empty")]
1753    EmptyEnvVar {
1754        /// Index of the invalid secret entry.
1755        secret_index: usize,
1756    },
1757
1758    /// The environment variable name contains `=`.
1759    #[error("secret #{secret_index}: env_var must not contain `=`")]
1760    EnvVarContainsEquals {
1761        /// Index of the invalid secret entry.
1762        secret_index: usize,
1763    },
1764
1765    /// The environment variable name contains NUL.
1766    #[error("secret #{secret_index}: env_var must not contain NUL")]
1767    EnvVarContainsNul {
1768        /// Index of the invalid secret entry.
1769        secret_index: usize,
1770    },
1771
1772    /// No allowed hosts were configured for a secret.
1773    #[error("secret #{secret_index}: at least one allowed host is required")]
1774    MissingAllowedHosts {
1775        /// Index of the invalid secret entry.
1776        secret_index: usize,
1777    },
1778
1779    /// The placeholder is empty.
1780    #[error("secret #{secret_index}: placeholder must not be empty")]
1781    EmptyPlaceholder {
1782        /// Index of the invalid secret entry.
1783        secret_index: usize,
1784    },
1785
1786    /// The placeholder exceeds the supported byte length.
1787    #[error(
1788        "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
1789    )]
1790    PlaceholderTooLong {
1791        /// Index of the invalid secret entry.
1792        secret_index: usize,
1793        /// Actual placeholder length in bytes.
1794        actual_bytes: usize,
1795        /// Maximum supported placeholder length in bytes.
1796        max_bytes: usize,
1797    },
1798
1799    /// The placeholder contains NUL.
1800    #[error("secret #{secret_index}: placeholder must not contain NUL")]
1801    PlaceholderContainsNul {
1802        /// Index of the invalid secret entry.
1803        secret_index: usize,
1804    },
1805
1806    /// The placeholder contains a line break.
1807    #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
1808    PlaceholderContainsLineBreak {
1809        /// Index of the invalid secret entry.
1810        secret_index: usize,
1811    },
1812}
1813
1814impl SecretsConfig {
1815    /// Validate all configured secret entries.
1816    pub fn validate(&self) -> Result<(), SecretConfigError> {
1817        for (index, secret) in self.secrets.iter().enumerate() {
1818            secret.validate(index)?;
1819        }
1820        Ok(())
1821    }
1822}
1823
1824impl SecretEntry {
1825    /// Validate this secret entry.
1826    pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
1827        validate_env_var(&self.env_var, secret_index)?;
1828
1829        if self.allowed_hosts.is_empty() {
1830            return Err(SecretConfigError::MissingAllowedHosts { secret_index });
1831        }
1832
1833        validate_placeholder(&self.placeholder, secret_index)
1834    }
1835}
1836
1837// The secret value must never reach a log line or an error message.
1838impl fmt::Debug for SecretEntry {
1839    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1840        f.debug_struct("SecretEntry")
1841            .field("env_var", &self.env_var)
1842            .field("value", &"[REDACTED]")
1843            .field("source", &self.source)
1844            .field("placeholder", &self.placeholder)
1845            .field("allowed_hosts", &self.allowed_hosts)
1846            .field("injection", &self.injection)
1847            .field("on_violation", &self.on_violation)
1848            .field("require_tls_identity", &self.require_tls_identity)
1849            .finish()
1850    }
1851}
1852
1853impl HostPattern {
1854    /// Parse a user-facing host string: `*` is any host, `*.`-prefixed
1855    /// strings are wildcards, everything else matches exactly.
1856    pub fn parse(host: &str) -> Self {
1857        if host == "*" {
1858            HostPattern::Any
1859        } else if host.starts_with("*.") {
1860            HostPattern::Wildcard(host.to_string())
1861        } else {
1862            HostPattern::Exact(host.to_string())
1863        }
1864    }
1865
1866    /// Check if a hostname matches this pattern.
1867    ///
1868    /// Uses ASCII case-insensitive comparison to avoid `to_lowercase()`
1869    /// allocations (DNS hostnames are ASCII per RFC 4343).
1870    pub fn matches(&self, hostname: &str) -> bool {
1871        match self {
1872            HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
1873            HostPattern::Wildcard(pattern) => {
1874                if let Some(suffix) = pattern.strip_prefix("*.") {
1875                    hostname.eq_ignore_ascii_case(suffix)
1876                        || (hostname.len() > suffix.len() + 1
1877                            && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
1878                            && hostname[hostname.len() - suffix.len()..]
1879                                .eq_ignore_ascii_case(suffix))
1880                } else {
1881                    hostname.eq_ignore_ascii_case(pattern)
1882                }
1883            }
1884            HostPattern::Any => true,
1885        }
1886    }
1887}
1888
1889impl Default for SecretInjection {
1890    fn default() -> Self {
1891        Self {
1892            headers: true,
1893            basic_auth: true,
1894            query_params: false,
1895            body: false,
1896        }
1897    }
1898}
1899
1900fn default_true() -> bool {
1901    true
1902}
1903
1904fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
1905    if env_var.is_empty() {
1906        return Err(SecretConfigError::EmptyEnvVar { secret_index });
1907    }
1908    if env_var.contains('=') {
1909        return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
1910    }
1911    if env_var.contains('\0') {
1912        return Err(SecretConfigError::EnvVarContainsNul { secret_index });
1913    }
1914    Ok(())
1915}
1916
1917fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
1918    if placeholder.is_empty() {
1919        return Err(SecretConfigError::EmptyPlaceholder { secret_index });
1920    }
1921
1922    let actual_bytes = placeholder.len();
1923    if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
1924        return Err(SecretConfigError::PlaceholderTooLong {
1925            secret_index,
1926            actual_bytes,
1927            max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
1928        });
1929    }
1930
1931    if placeholder.contains('\0') {
1932        return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
1933    }
1934    if placeholder.contains('\r') || placeholder.contains('\n') {
1935        return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
1936    }
1937
1938    Ok(())
1939}
1940
1941//--------------------------------------------------------------------------------------------------
1942// Types: TLS interception
1943//--------------------------------------------------------------------------------------------------
1944
1945/// TLS interception configuration. Carried in [`NetworkSpec::tls`](NetworkSpec).
1946///
1947/// The local network engine terminates TCP at its in-process stack, so TLS MITM
1948/// is handled by proxy tasks — these fields configure which ports/domains are
1949/// intercepted and how the interception CA is sourced.
1950#[derive(Debug, Clone, Serialize, Deserialize)]
1951#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1952#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1953pub struct TlsConfig {
1954    /// Whether TLS interception is enabled.
1955    #[serde(default)]
1956    pub enabled: bool,
1957
1958    /// TCP ports subject to TLS interception (default: `[443]`).
1959    #[serde(default = "default_intercepted_ports")]
1960    pub intercepted_ports: Vec<u16>,
1961
1962    /// Domains to bypass (no MITM). Supports exact match and `*.suffix` wildcards.
1963    #[serde(default)]
1964    pub bypass: Vec<String>,
1965
1966    /// Whether to verify the upstream server's TLS certificate.
1967    #[serde(default = "default_true")]
1968    pub verify_upstream: bool,
1969
1970    /// Drop UDP to intercepted ports when TLS interception is active, forcing
1971    /// QUIC traffic to fall back to TCP/TLS.
1972    #[serde(default = "default_true")]
1973    pub block_quic_on_intercept: bool,
1974
1975    /// CA certificate PEM files to trust for upstream server verification.
1976    #[serde(default)]
1977    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
1978    #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
1979    pub upstream_ca_cert: Vec<PathBuf>,
1980
1981    /// Host-scoped CA certificate PEM files to trust for upstream server verification.
1982    #[serde(default, alias = "scoped_upstream_ca_certs")]
1983    pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
1984
1985    /// Host-scoped upstream verification overrides.
1986    #[serde(default)]
1987    pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
1988
1989    /// Interception CA configuration. The TLS proxy uses this CA to sign
1990    /// per-domain certs it presents to the guest during interception.
1991    #[serde(default, alias = "ca")]
1992    pub intercept_ca: InterceptCaConfig,
1993
1994    /// Per-domain certificate cache configuration.
1995    #[serde(default)]
1996    pub cache: CertCacheConfig,
1997}
1998
1999/// Certificate authority configuration for TLS interception.
2000#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2001#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2002#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2003pub struct InterceptCaConfig {
2004    /// Path to an existing CA certificate PEM file. If `None`, a CA is
2005    /// auto-generated and persisted.
2006    #[serde(default)]
2007    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2008    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2009    pub cert_path: Option<PathBuf>,
2010
2011    /// Path to an existing CA private key PEM file. If `None`, a key is
2012    /// auto-generated and persisted.
2013    #[serde(default)]
2014    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2015    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2016    pub key_path: Option<PathBuf>,
2017}
2018
2019/// Per-domain certificate cache configuration.
2020#[derive(Debug, Clone, Serialize, Deserialize)]
2021#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2022#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2023pub struct CertCacheConfig {
2024    /// Maximum number of cached certificates. Default: 1000.
2025    #[serde(default = "default_cache_capacity")]
2026    pub capacity: usize,
2027
2028    /// Certificate validity duration in hours. Default: 24.
2029    #[serde(default = "default_cert_validity_hours")]
2030    pub validity_hours: u64,
2031}
2032
2033/// A CA certificate PEM file trusted only for matching upstream hosts.
2034#[derive(Debug, Clone, Serialize, Deserialize)]
2035#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2036#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2037pub struct ScopedUpstreamCaCert {
2038    /// Host pattern this CA applies to. Supports exact hosts and `*.suffix` wildcards.
2039    pub pattern: String,
2040
2041    /// Path to the CA certificate PEM file.
2042    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2043    #[cfg_attr(feature = "ts", ts(type = "string"))]
2044    pub path: PathBuf,
2045}
2046
2047/// An upstream certificate verification override for matching hosts.
2048#[derive(Debug, Clone, Serialize, Deserialize)]
2049#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2050#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2051pub struct ScopedVerifyUpstream {
2052    /// Host pattern this override applies to. Supports exact hosts and `*.suffix` wildcards.
2053    pub pattern: String,
2054
2055    /// Whether to verify matching upstream server certificates.
2056    pub verify: bool,
2057}
2058
2059impl Default for TlsConfig {
2060    fn default() -> Self {
2061        Self {
2062            enabled: false,
2063            intercepted_ports: default_intercepted_ports(),
2064            bypass: Vec::new(),
2065            verify_upstream: true,
2066            block_quic_on_intercept: true,
2067            upstream_ca_cert: Vec::new(),
2068            scoped_upstream_ca_cert: Vec::new(),
2069            scoped_verify_upstream: Vec::new(),
2070            intercept_ca: InterceptCaConfig::default(),
2071            cache: CertCacheConfig::default(),
2072        }
2073    }
2074}
2075
2076impl Default for CertCacheConfig {
2077    fn default() -> Self {
2078        Self {
2079            capacity: default_cache_capacity(),
2080            validity_hours: default_cert_validity_hours(),
2081        }
2082    }
2083}
2084
2085fn default_intercepted_ports() -> Vec<u16> {
2086    vec![443]
2087}
2088
2089fn default_cache_capacity() -> usize {
2090    1000
2091}
2092
2093fn default_cert_validity_hours() -> u64 {
2094    24
2095}
2096
2097//--------------------------------------------------------------------------------------------------
2098// Types: Networking — policy
2099//--------------------------------------------------------------------------------------------------
2100
2101/// Action to take on traffic matched by a [`Rule`] (or a policy default).
2102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2103#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2104#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2105#[serde(rename_all = "snake_case")]
2106pub enum Action {
2107    /// Allow the traffic.
2108    Allow,
2109    /// Silently drop the traffic.
2110    Deny,
2111}
2112
2113/// Direction a [`Rule`] applies to.
2114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2115#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2116#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2117#[serde(rename_all = "snake_case")]
2118pub enum Direction {
2119    /// Outbound: guest → destination.
2120    Egress,
2121    /// Inbound: peer → guest.
2122    Ingress,
2123    /// Either direction.
2124    Any,
2125}
2126
2127/// Protocol filter for a [`Rule`].
2128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2129#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2130#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2131#[serde(rename_all = "snake_case")]
2132pub enum Protocol {
2133    /// TCP.
2134    Tcp,
2135    /// UDP.
2136    Udp,
2137    /// ICMPv4.
2138    Icmpv4,
2139    /// ICMPv6.
2140    Icmpv6,
2141}
2142
2143/// Pre-defined destination category for a [`Destination::Group`] match.
2144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2145#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2146#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2147#[serde(rename_all = "snake_case")]
2148pub enum DestinationGroup {
2149    /// Public internet — any address not in another category.
2150    Public,
2151    /// Loopback addresses (`127.0.0.0/8`, `::1`).
2152    Loopback,
2153    /// Private ranges (RFC 1918 / RFC 4193 ULA / CGN).
2154    Private,
2155    /// Link-local addresses, excluding the metadata IP.
2156    LinkLocal,
2157    /// Cloud metadata endpoint (`169.254.169.254`).
2158    Metadata,
2159    /// Multicast addresses (`224.0.0.0/4`, `ff00::/8`).
2160    Multicast,
2161    /// The sandbox host, reachable via the gateway IP.
2162    Host,
2163}
2164
2165/// Traffic destination filter for a [`Rule`].
2166///
2167/// The `Cidr`, `Domain`, and `DomainSuffix` leaves carry their canonical
2168/// string form (e.g. `"10.0.0.0/8"`, `"example.com"`); the local network
2169/// engine re-parses and validates them into its richer internal types at
2170/// load time.
2171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2172#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2173#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2174#[serde(rename_all = "snake_case")]
2175pub enum Destination {
2176    /// Match any destination.
2177    Any,
2178    /// IP address or CIDR block (e.g. `"1.2.3.4"`, `"10.0.0.0/8"`).
2179    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2180    Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
2181    /// Exact domain name (e.g. `"example.com"`).
2182    Domain(String),
2183    /// Domain suffix — the apex and any subdomain of it.
2184    DomainSuffix(String),
2185    /// A pre-defined destination group.
2186    Group(DestinationGroup),
2187}
2188
2189/// Inclusive guest-side port range for a [`Rule`] match.
2190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2191#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2192#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2193pub struct PortRange {
2194    /// Start port (inclusive).
2195    pub start: u16,
2196    /// End port (inclusive).
2197    pub end: u16,
2198}
2199
2200/// A single egress/ingress policy rule. Evaluated first-match-wins per
2201/// direction.
2202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2203#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2204#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2205pub struct Rule {
2206    /// Direction this rule applies to.
2207    pub direction: Direction,
2208    /// Destination filter (direction-dependent interpretation).
2209    pub destination: Destination,
2210    /// Protocol set; empty matches any protocol.
2211    #[serde(default)]
2212    pub protocols: Vec<Protocol>,
2213    /// Guest-side port-range set; empty matches any port.
2214    #[serde(default)]
2215    pub ports: Vec<PortRange>,
2216    /// Action to take on a match.
2217    pub action: Action,
2218}
2219
2220/// Egress/ingress network policy: an ordered [`Rule`] list plus a
2221/// per-direction default [`Action`]. Carried in [`NetworkSpec::policy`].
2222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2223#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2225pub struct NetworkPolicy {
2226    /// Default action for egress traffic matching no rule. Default: `Deny`.
2227    #[serde(default = "action_deny")]
2228    pub default_egress: Action,
2229    /// Default action for ingress traffic matching no rule. Default: `Deny`.
2230    #[serde(default = "action_deny")]
2231    pub default_ingress: Action,
2232    /// Ordered rules, evaluated first-match-wins per direction.
2233    #[serde(default)]
2234    pub rules: Vec<Rule>,
2235}
2236
2237/// Default [`Action`] (`Deny`) for a policy's per-direction defaults, so a
2238/// partially-specified policy fails closed.
2239fn action_deny() -> Action {
2240    Action::Deny
2241}
2242
2243//--------------------------------------------------------------------------------------------------
2244// Types: Networking — DNS & interface
2245//--------------------------------------------------------------------------------------------------
2246
2247/// DNS interception and filtering settings. Carried in [`NetworkSpec::dns`].
2248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2249#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2250#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2251#[serde(default)]
2252pub struct DnsConfig {
2253    /// Whether DNS-rebinding protection is enabled. Default: true.
2254    pub rebind_protection: bool,
2255    /// Upstream nameservers as `IP`, `IP:PORT`, `HOST`, or `HOST:PORT`
2256    /// strings. Empty falls back to the host's `/etc/resolv.conf`.
2257    pub nameservers: Vec<String>,
2258    /// Per-query timeout in milliseconds. Default: 5000.
2259    pub query_timeout_ms: u64,
2260}
2261
2262impl Default for DnsConfig {
2263    fn default() -> Self {
2264        Self {
2265            rebind_protection: true,
2266            nameservers: Vec::new(),
2267            query_timeout_ms: 5000,
2268        }
2269    }
2270}
2271
2272/// Optional guest interface overrides. Unset fields are derived from the
2273/// sandbox slot by the local network engine. Carried in
2274/// [`NetworkSpec::interface`].
2275#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2276#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2277#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2278#[serde(default)]
2279pub struct InterfaceOverrides {
2280    /// Guest MAC address as six octets. Default: derived from slot.
2281    #[serde(skip_serializing_if = "Option::is_none")]
2282    pub mac: Option<[u8; 6]>,
2283    /// Interface MTU. Default: 1500.
2284    #[serde(skip_serializing_if = "Option::is_none")]
2285    pub mtu: Option<u16>,
2286    /// Guest IPv4 address (e.g. `172.16.0.2`). Default: derived from slot.
2287    #[serde(skip_serializing_if = "Option::is_none")]
2288    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2289    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2290    pub ipv4_address: Option<Ipv4Addr>,
2291    /// Guest IPv4 pool CIDR (e.g. `"172.16.0.0/12"`). Default: derived from slot.
2292    #[serde(skip_serializing_if = "Option::is_none")]
2293    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2294    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2295    pub ipv4_pool: Option<Ipv4Network>,
2296    /// Guest IPv6 address. Default: derived from slot.
2297    #[serde(skip_serializing_if = "Option::is_none")]
2298    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2299    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2300    pub ipv6_address: Option<Ipv6Addr>,
2301    /// Guest IPv6 pool CIDR. Default: derived from slot.
2302    #[serde(skip_serializing_if = "Option::is_none")]
2303    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2304    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2305    pub ipv6_pool: Option<Ipv6Network>,
2306}
2307
2308fn empty_secret_value() -> Zeroizing<String> {
2309    Zeroizing::new(String::new())
2310}
2311
2312//--------------------------------------------------------------------------------------------------
2313// Tests
2314//--------------------------------------------------------------------------------------------------
2315
2316#[cfg(test)]
2317mod tests {
2318    use super::*;
2319
2320    #[test]
2321    fn disk_image_format_from_extension() {
2322        assert_eq!(
2323            DiskImageFormat::from_extension("qcow2"),
2324            Some(DiskImageFormat::Qcow2)
2325        );
2326        assert_eq!(
2327            DiskImageFormat::from_extension("raw"),
2328            Some(DiskImageFormat::Raw)
2329        );
2330        assert_eq!(
2331            DiskImageFormat::from_extension("vmdk"),
2332            Some(DiskImageFormat::Vmdk)
2333        );
2334        assert_eq!(DiskImageFormat::from_extension("ext4"), None);
2335        assert_eq!(DiskImageFormat::from_extension(""), None);
2336    }
2337
2338    #[test]
2339    fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
2340        let resources: SandboxResources =
2341            serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
2342
2343        assert_eq!(resources.cpus, 4);
2344        assert_eq!(resources.max_cpus, 4);
2345        assert_eq!(resources.memory_mib, 2048);
2346        assert_eq!(resources.max_memory_mib, 2048);
2347    }
2348
2349    #[test]
2350    fn disk_image_format_display_roundtrip() {
2351        for format in [
2352            DiskImageFormat::Qcow2,
2353            DiskImageFormat::Raw,
2354            DiskImageFormat::Vmdk,
2355        ] {
2356            let rendered = format.to_string();
2357            let parsed: DiskImageFormat = rendered.parse().unwrap();
2358            assert_eq!(parsed, format);
2359        }
2360    }
2361
2362    #[test]
2363    fn disk_image_format_from_str_unknown() {
2364        assert!("ext4".parse::<DiskImageFormat>().is_err());
2365    }
2366
2367    #[test]
2368    fn log_source_effective_uses_default_user_program_sources() {
2369        assert_eq!(
2370            LogSource::effective(&[]),
2371            vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
2372        );
2373    }
2374
2375    #[test]
2376    fn log_source_effective_sorts_and_deduplicates_requested_sources() {
2377        assert_eq!(
2378            LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
2379            vec![LogSource::Stdout, LogSource::System]
2380        );
2381    }
2382
2383    #[test]
2384    fn rlimit_resource_parses_case_insensitively() {
2385        assert_eq!(
2386            RlimitResource::try_from("NOFILE").unwrap(),
2387            RlimitResource::Nofile
2388        );
2389        assert!(RlimitResource::try_from("bogus").is_err());
2390    }
2391
2392    #[test]
2393    fn sandbox_policy_serde_roundtrip() {
2394        let policy = SandboxPolicy {
2395            ephemeral: true,
2396            max_duration_secs: Some(3600),
2397            idle_timeout_secs: Some(120),
2398        };
2399
2400        let json = serde_json::to_string(&policy).unwrap();
2401        let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
2402
2403        assert!(decoded.ephemeral);
2404        assert_eq!(decoded.max_duration_secs, Some(3600));
2405        assert_eq!(decoded.idle_timeout_secs, Some(120));
2406    }
2407
2408    #[test]
2409    fn sandbox_policy_defaults_to_persistent() {
2410        assert!(!SandboxPolicy::default().ephemeral);
2411    }
2412
2413    #[test]
2414    fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
2415        // `ephemeral` has a persistent default so partial policy payloads
2416        // deserialize to the conservative behavior.
2417        let decoded: SandboxPolicy =
2418            serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
2419        assert!(!decoded.ephemeral);
2420        assert_eq!(decoded.max_duration_secs, Some(60));
2421    }
2422
2423    #[test]
2424    fn sandbox_spec_default_uses_static_resource_defaults() {
2425        let spec = SandboxSpec::default();
2426
2427        assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
2428        assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
2429        assert_eq!(
2430            spec.runtime.metrics_sample_interval_ms,
2431            Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
2432        );
2433    }
2434
2435    #[test]
2436    fn sandbox_log_level_roundtrips_lowercase_values() {
2437        for (input, expected) in [
2438            ("error", SandboxLogLevel::Error),
2439            ("warn", SandboxLogLevel::Warn),
2440            ("info", SandboxLogLevel::Info),
2441            ("debug", SandboxLogLevel::Debug),
2442            ("trace", SandboxLogLevel::Trace),
2443        ] {
2444            let parsed: SandboxLogLevel = input.parse().unwrap();
2445            assert_eq!(parsed, expected);
2446            assert_eq!(parsed.as_str(), input);
2447        }
2448    }
2449}