Skip to main content

microsandbox_types/
domain.rs

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