Skip to main content

microsandbox_types/
domain.rs

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