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