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    /// Friendly member name within a group; empty selects a generated name.
830    pub name: String,
831
832    /// Local snapshot group; defaults to the source sandbox's name.
833    #[serde(default)]
834    pub group: Option<String>,
835
836    /// Group-store root. `None` selects the default snapshots directory.
837    #[serde(default)]
838    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
839    pub dest_dir: Option<PathBuf>,
840
841    /// Source sandbox. Disk capture accepts running, paused, or stopped sources.
842    pub source_sandbox: String,
843
844    /// User-supplied labels.
845    pub labels: Vec<(String, String)>,
846
847    /// Overwrite a direct archive destination; installed members remain immutable.
848    pub force: bool,
849
850    /// Compute and record upper-layer content integrity at creation time.
851    pub record_integrity: bool,
852
853    /// Capture disk, memory, execution, and device state from a running sandbox.
854    #[serde(default)]
855    pub full: bool,
856}
857
858//--------------------------------------------------------------------------------------------------
859// Types: Sandbox Specs
860//--------------------------------------------------------------------------------------------------
861
862/// Backend-neutral sandbox task description.
863///
864/// 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.
865#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
866#[config_patch(name = SandboxConfigPatch)]
867#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
868#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
869#[serde(default)]
870pub struct SandboxSpec {
871    /// Unique sandbox name.
872    pub name: String,
873
874    /// Root filesystem source.
875    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
876    pub image: RootfsSource,
877
878    /// CPU and memory resources.
879    #[config_patch(nested)]
880    pub resources: SandboxResources,
881
882    /// Guest runtime options.
883    #[config_patch(nested)]
884    pub runtime: SandboxRuntimeOptions,
885
886    /// Environment variables visible to commands in the sandbox.
887    #[config_patch(merge_with = merge_env_vars)]
888    pub env: Vec<EnvVar>,
889
890    /// User-defined labels attached to the sandbox.
891    #[config_patch(merge)]
892    pub labels: BTreeMap<String, String>,
893
894    /// Sandbox-wide resource limits inherited by guest processes.
895    pub rlimits: Vec<Rlimit>,
896
897    /// Volume mounts.
898    pub mounts: Vec<VolumeMount>,
899
900    /// Rootfs patches applied before VM start.
901    pub patches: Vec<Patch>,
902
903    /// Network specification.
904    #[config_patch(nested)]
905    pub network: NetworkSpec,
906
907    /// Local host services exposed through virtio-vsock.
908    #[serde(default, skip_serializing_if = "VsockSpec::is_empty")]
909    #[config_patch(nested)]
910    pub vsock: VsockSpec,
911
912    /// Hand off PID 1 to a guest init binary after agentd setup.
913    pub init: Option<HandoffInit>,
914
915    /// Pull policy for OCI images.
916    pub pull_policy: PullPolicy,
917
918    /// In-guest security profile.
919    pub security_profile: SecurityProfile,
920
921    /// Host-runtime deployment profile.
922    ///
923    /// Local callers may request a profile, while a managed backend can
924    /// override it before launch. The cloud create wire intentionally omits
925    /// this field so tenant requests cannot select the platform profile.
926    pub deployment_profile: DeploymentProfile,
927
928    /// Sandbox lifecycle policy.
929    #[config_patch(nested)]
930    pub lifecycle: SandboxPolicy,
931}
932
933/// CPU and memory resources for a sandbox.
934#[derive(Debug, Clone, Serialize, ConfigPatch)]
935#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
936#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
937pub struct SandboxResources {
938    /// Number of virtual CPUs currently presented to the guest at boot.
939    pub cpus: u8,
940
941    /// Guest memory currently presented to the guest at boot, in MiB.
942    pub memory_mib: u32,
943
944    /// Maximum virtual CPUs the sandbox may expose after boot-time hotplug support lands.
945    pub max_cpus: u8,
946
947    /// Maximum guest memory the sandbox may expose after boot-time hotplug support lands, in MiB.
948    pub max_memory_mib: u32,
949
950    /// Host CPU placement requested for this sandbox.
951    #[serde(default, skip_serializing_if = "CpuPlacement::is_inherit")]
952    pub cpu_placement: CpuPlacement,
953
954    /// Host-defined placement profile selected for this sandbox.
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub placement_profile: Option<String>,
957
958    /// Guest transparent huge-page policy selected at boot.
959    #[serde(default, skip_serializing_if = "TransparentHugePagePolicy::is_madvise")]
960    pub thp: TransparentHugePagePolicy,
961}
962
963/// Controls how Microsandbox places vCPU threads on host processors.
964#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
965#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
966#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
967#[serde(rename_all = "lowercase")]
968pub enum CpuPlacement {
969    /// Preserve the invoking process's existing scheduler and affinity behavior.
970    #[default]
971    Inherit,
972
973    /// Spread across cores, then use SMT siblings, then share logical processors under pressure.
974    Auto,
975
976    /// Preserve the widest practical distribution, sharing logical processors when necessary.
977    Spread,
978
979    /// Prefer SMT siblings and fewer physical cores, then share balanced logical processors.
980    Compact,
981}
982
983/// Concrete host NUMA scope selected by a named placement profile.
984#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
985#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
986#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
987#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
988pub enum NumaPlacement {
989    /// Prefer one host NUMA node, falling back to inherited host placement when it cannot fit.
990    PreferSingle,
991    /// Require maximum CPU and memory capacity to fit one host NUMA node.
992    StrictSingle,
993    /// Preserve the operating system's ordinary NUMA behavior.
994    Inherit,
995}
996
997/// Host backing policy for guest memory selected by a named placement profile.
998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
999#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1000#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1001#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1002pub enum MemoryPlacement {
1003    /// Back guest RAM from the selected CPU node when enforceable, otherwise inherit host policy.
1004    FollowCpu,
1005    /// Preserve the operating system's ordinary memory policy.
1006    Inherit,
1007}
1008
1009/// Host-owned named placement profile resolved before a local VM starts.
1010#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1011#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1012#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1013#[serde(deny_unknown_fields)]
1014pub struct PlacementProfile {
1015    /// NUMA scope used while selecting host CPU capacity.
1016    pub numa: NumaPlacement,
1017    /// Host-memory behavior used for the resolved CPU nodes.
1018    pub memory: MemoryPlacement,
1019}
1020
1021/// Guest transparent huge-page policy applied through the kernel command line.
1022#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1023#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1024#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1025#[serde(rename_all = "lowercase")]
1026pub enum TransparentHugePagePolicy {
1027    /// Transparently use huge pages for eligible anonymous mappings.
1028    Always,
1029
1030    /// Use huge pages only for mappings that explicitly request them.
1031    #[default]
1032    Madvise,
1033
1034    /// Disable transparent huge pages for anonymous mappings.
1035    Never,
1036}
1037
1038/// Guest runtime options for a sandbox.
1039#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
1040#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1041#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1042#[serde(default)]
1043pub struct SandboxRuntimeOptions {
1044    /// Working directory inside the guest.
1045    pub workdir: Option<String>,
1046
1047    /// Default shell for scripts and interactive sessions.
1048    pub shell: Option<String>,
1049
1050    /// Named scripts available inside the guest.
1051    #[config_patch(merge)]
1052    pub scripts: BTreeMap<String, String>,
1053
1054    /// Image entrypoint override.
1055    pub entrypoint: Option<Vec<String>>,
1056
1057    /// Image command override.
1058    pub cmd: Option<Vec<String>>,
1059
1060    /// Guest hostname override.
1061    pub hostname: Option<String>,
1062
1063    /// Guest user identity override.
1064    pub user: Option<String>,
1065
1066    /// Runtime log verbosity.
1067    pub log_level: Option<SandboxLogLevel>,
1068
1069    /// Metrics sampling interval in milliseconds. `None` disables sampling.
1070    pub metrics_sample_interval_ms: Option<u64>,
1071
1072    /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`.
1073    pub disable_metrics_sample: bool,
1074}
1075
1076/// Environment variable entry.
1077#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1078#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1079#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1080pub struct EnvVar {
1081    /// Environment variable name.
1082    pub key: String,
1083
1084    /// Environment variable value.
1085    pub value: String,
1086}
1087
1088/// Runtime log verbosity for sandbox specs.
1089#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1090#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1091#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1092#[serde(rename_all = "lowercase")]
1093pub enum SandboxLogLevel {
1094    /// Emit only error logs.
1095    Error,
1096
1097    /// Emit warning and error logs.
1098    Warn,
1099
1100    /// Emit info, warning, and error logs.
1101    Info,
1102
1103    /// Emit debug and higher-severity logs.
1104    Debug,
1105
1106    /// Emit trace and higher-severity logs.
1107    Trace,
1108}
1109
1110//--------------------------------------------------------------------------------------------------
1111// Types: Exec
1112//--------------------------------------------------------------------------------------------------
1113
1114/// POSIX resource limit identifiers.
1115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1116#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1117#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1118pub enum RlimitResource {
1119    /// Max CPU time in seconds (`RLIMIT_CPU`).
1120    Cpu,
1121    /// Max file size in bytes (`RLIMIT_FSIZE`).
1122    Fsize,
1123    /// Max data segment size (`RLIMIT_DATA`).
1124    Data,
1125    /// Max stack size (`RLIMIT_STACK`).
1126    Stack,
1127    /// Max core file size (`RLIMIT_CORE`).
1128    Core,
1129    /// Max resident set size (`RLIMIT_RSS`).
1130    Rss,
1131    /// Max number of processes (`RLIMIT_NPROC`).
1132    Nproc,
1133    /// Max open file descriptors (`RLIMIT_NOFILE`).
1134    Nofile,
1135    /// Max locked memory (`RLIMIT_MEMLOCK`).
1136    Memlock,
1137    /// Max address space size (`RLIMIT_AS`).
1138    As,
1139    /// Max file locks (`RLIMIT_LOCKS`).
1140    Locks,
1141    /// Max pending signals (`RLIMIT_SIGPENDING`).
1142    Sigpending,
1143    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
1144    Msgqueue,
1145    /// Max nice priority (`RLIMIT_NICE`).
1146    Nice,
1147    /// Max real-time priority (`RLIMIT_RTPRIO`).
1148    Rtprio,
1149    /// Max real-time timeout (`RLIMIT_RTTIME`).
1150    Rttime,
1151}
1152
1153/// A POSIX resource limit.
1154#[derive(Debug, Clone, Serialize, Deserialize)]
1155#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1156#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1157pub struct Rlimit {
1158    /// Resource type.
1159    pub resource: RlimitResource,
1160
1161    /// Soft limit (can be raised up to hard limit by the process).
1162    pub soft: u64,
1163
1164    /// Hard limit (ceiling, requires privileges to raise).
1165    pub hard: u64,
1166}
1167
1168//--------------------------------------------------------------------------------------------------
1169// Types: Logs
1170//--------------------------------------------------------------------------------------------------
1171
1172/// Source tag on a captured log entry.
1173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1174#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1175#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1176#[serde(rename_all = "lowercase")]
1177pub enum LogSource {
1178    /// Captured from a session's stdout (pipe mode).
1179    Stdout,
1180
1181    /// Captured from a session's stderr (pipe mode).
1182    Stderr,
1183
1184    /// Captured from a session in pty mode (stdout + stderr merged at the kernel level inside the guest arrive as a single stream tagged `output`).
1185    Output,
1186
1187    /// Synthetic system entry: lifecycle markers, runtime diagnostics, kernel console output.
1188    System,
1189}
1190
1191//--------------------------------------------------------------------------------------------------
1192// Methods
1193//--------------------------------------------------------------------------------------------------
1194
1195impl SandboxResourcesPatch {
1196    /// Whether this patch explicitly sets the initial vCPU count, even to its default value.
1197    pub fn has_cpus(&self) -> bool {
1198        self.cpus.is_some()
1199    }
1200
1201    /// Whether this patch explicitly sets initial memory, even to its default value.
1202    pub fn has_memory_mib(&self) -> bool {
1203        self.memory_mib.is_some()
1204    }
1205
1206    /// Whether this patch explicitly sets the maximum vCPU count.
1207    pub fn has_max_cpus(&self) -> bool {
1208        self.max_cpus.is_some()
1209    }
1210
1211    /// Whether this patch explicitly sets maximum memory.
1212    pub fn has_max_memory_mib(&self) -> bool {
1213        self.max_memory_mib.is_some()
1214    }
1215}
1216
1217impl DiskImageFormat {
1218    /// Returns the format as a CLI-safe lowercase string.
1219    pub fn as_str(&self) -> &'static str {
1220        match self {
1221            Self::Qcow2 => "qcow2",
1222            Self::Raw => "raw",
1223            Self::Vmdk => "vmdk",
1224        }
1225    }
1226
1227    /// Parse a disk image format from a file extension.
1228    ///
1229    /// Returns `None` if the extension is not a recognized disk image format.
1230    pub fn from_extension(ext: &str) -> Option<Self> {
1231        match ext {
1232            "qcow2" => Some(Self::Qcow2),
1233            "raw" => Some(Self::Raw),
1234            "vmdk" => Some(Self::Vmdk),
1235            _ => None,
1236        }
1237    }
1238}
1239
1240impl OciRootfsSource {
1241    /// Create a new OCI rootfs source.
1242    pub fn new(reference: impl Into<String>) -> Self {
1243        Self {
1244            reference: reference.into(),
1245            root_disk: None,
1246        }
1247    }
1248}
1249
1250impl TransparentHugePagePolicy {
1251    /// Whether this is the density-conscious default policy.
1252    pub fn is_madvise(&self) -> bool {
1253        matches!(self, Self::Madvise)
1254    }
1255
1256    /// Return the lowercase kernel command-line representation.
1257    pub fn as_str(self) -> &'static str {
1258        match self {
1259            Self::Always => "always",
1260            Self::Madvise => "madvise",
1261            Self::Never => "never",
1262        }
1263    }
1264}
1265
1266impl RootDisk {
1267    /// Create a managed root disk with the given size in MiB.
1268    pub fn managed(size_mib: u32) -> Self {
1269        Self::Managed {
1270            size_mib: Some(size_mib),
1271        }
1272    }
1273
1274    /// Create a tmpfs root disk with the given size in MiB.
1275    pub fn tmpfs(size_mib: u32) -> Self {
1276        Self::Tmpfs {
1277            size_mib: Some(size_mib),
1278        }
1279    }
1280
1281    /// Create a flat root disk with the given final capacity in MiB.
1282    pub fn flat(size_mib: u32) -> Self {
1283        Self::Flat {
1284            size_mib: Some(size_mib),
1285            fstype: None,
1286            clone: FlatClone::Auto,
1287        }
1288    }
1289
1290    /// Return the configured size in MiB, if this kind carries one.
1291    pub fn size_mib(&self) -> Option<u32> {
1292        match self {
1293            Self::Managed { size_mib } | Self::Tmpfs { size_mib } | Self::Flat { size_mib, .. } => {
1294                *size_mib
1295            }
1296            Self::DiskImage { .. } => None,
1297        }
1298    }
1299
1300    /// Return the lowercase kind tag used on the wire, in the DB, and in CLI output.
1301    pub fn kind_str(&self) -> &'static str {
1302        match self {
1303            Self::Managed { .. } => "managed",
1304            Self::Tmpfs { .. } => "tmpfs",
1305            Self::DiskImage { .. } => "disk-image",
1306            Self::Flat { .. } => "flat",
1307        }
1308    }
1309
1310    /// Whether this is the managed (default) kind.
1311    pub fn is_managed(&self) -> bool {
1312        matches!(self, Self::Managed { .. })
1313    }
1314}
1315
1316impl FlatClone {
1317    /// Return the stable lowercase value used by CLI, SDK and persisted metadata surfaces.
1318    pub const fn as_str(self) -> &'static str {
1319        match self {
1320            Self::Auto => "auto",
1321            Self::Copy => "copy",
1322            Self::Reflink => "reflink",
1323        }
1324    }
1325
1326    /// Whether this is the default auto strategy.
1327    pub const fn is_auto(&self) -> bool {
1328        matches!(self, Self::Auto)
1329    }
1330}
1331
1332impl RootfsSource {
1333    /// Create an OCI rootfs source from an image reference.
1334    pub fn oci(reference: impl Into<String>) -> Self {
1335        Self::Oci(OciRootfsSource::new(reference))
1336    }
1337
1338    /// Return the OCI image reference if this is an OCI rootfs.
1339    pub fn oci_reference(&self) -> Option<&str> {
1340        match self {
1341            Self::Oci(oci) => Some(&oci.reference),
1342            _ => None,
1343        }
1344    }
1345
1346    /// Return the configured root disk if this is an OCI rootfs.
1347    pub fn oci_root_disk(&self) -> Option<&RootDisk> {
1348        match self {
1349            Self::Oci(oci) => oci.root_disk.as_ref(),
1350            _ => None,
1351        }
1352    }
1353
1354    /// Return the managed root disk size in MiB if this is an OCI rootfs with a managed
1355    /// (or unset, i.e. default-managed) root disk. Non-managed kinds return `None`.
1356    pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
1357        match self {
1358            Self::Oci(oci) => match &oci.root_disk {
1359                Some(RootDisk::Managed { size_mib }) => *size_mib,
1360                Some(_) => None,
1361                None => None,
1362            },
1363            _ => None,
1364        }
1365    }
1366}
1367
1368impl EnvVar {
1369    /// Create an environment variable entry.
1370    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1371        Self {
1372            key: key.into(),
1373            value: value.into(),
1374        }
1375    }
1376
1377    /// Return this entry as key and value string slices.
1378    pub fn as_pair(&self) -> (&str, &str) {
1379        (&self.key, &self.value)
1380    }
1381}
1382
1383impl VolumeKind {
1384    /// Return the lowercase database and CLI representation.
1385    pub fn as_str(self) -> &'static str {
1386        match self {
1387            Self::Directory => "dir",
1388            Self::Disk => "disk",
1389        }
1390    }
1391
1392    /// Parse a persisted database value, defaulting to directory for unknown values.
1393    pub fn from_db_value(value: &str) -> Self {
1394        match value {
1395            "disk" => Self::Disk,
1396            _ => Self::Directory,
1397        }
1398    }
1399}
1400
1401impl VolumeSpec {
1402    /// Create a directory-backed volume spec with default options.
1403    pub fn new(name: impl Into<String>) -> Self {
1404        Self {
1405            name: name.into(),
1406            kind: VolumeKind::Directory,
1407            quota_mib: None,
1408            capacity_mib: None,
1409            labels: Vec::new(),
1410        }
1411    }
1412}
1413
1414impl NamedVolumeCreate {
1415    /// Creation behavior for this named volume mount.
1416    pub fn mode(&self) -> NamedVolumeMode {
1417        self.mode
1418    }
1419
1420    /// Volume name to create or ensure exists.
1421    pub fn name(&self) -> &str {
1422        &self.name
1423    }
1424
1425    /// Storage kind to create or ensure exists.
1426    pub fn kind(&self) -> VolumeKind {
1427        self.kind
1428    }
1429
1430    /// Directory quota in MiB, if configured.
1431    pub fn quota_mib(&self) -> Option<u32> {
1432        self.quota_mib
1433    }
1434
1435    /// Disk capacity in MiB, if configured.
1436    pub fn capacity_mib(&self) -> Option<u32> {
1437        self.capacity_mib
1438    }
1439
1440    /// Labels to attach to newly-created volumes.
1441    pub fn labels(&self) -> &[(String, String)] {
1442        &self.labels
1443    }
1444}
1445
1446impl VolumeMount {
1447    /// The absolute path where this mount appears inside the guest.
1448    pub fn guest(&self) -> &str {
1449        match self {
1450            Self::Bind { guest, .. }
1451            | Self::Owned { guest, .. }
1452            | Self::Named { guest, .. }
1453            | Self::Tmpfs { guest, .. }
1454            | Self::DiskImage { guest, .. } => guest,
1455        }
1456    }
1457
1458    fn guest_mut(&mut self) -> &mut String {
1459        match self {
1460            Self::Bind { guest, .. }
1461            | Self::Owned { guest, .. }
1462            | Self::Named { guest, .. }
1463            | Self::Tmpfs { guest, .. }
1464            | Self::DiskImage { guest, .. } => guest,
1465        }
1466    }
1467
1468    /// Return named-volume creation metadata when this mount provisions a named volume.
1469    pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1470        match self {
1471            Self::Named { create, .. } => create.as_ref(),
1472            _ => None,
1473        }
1474    }
1475}
1476
1477//--------------------------------------------------------------------------------------------------
1478// Functions: Volume Mounts
1479//--------------------------------------------------------------------------------------------------
1480
1481/// Portable private-volume identity derived from an already canonical guest path.
1482/// The ASCII hint is diagnostic; the suffix keeps distinct paths distinct.
1483pub fn owned_volume_mount_id(guest: &str) -> String {
1484    use std::fmt::Write as _;
1485    let slug: String = guest
1486        .trim_start_matches('/')
1487        .chars()
1488        .take(11)
1489        .map(|character| {
1490            if character.is_ascii_alphanumeric() || character == '-' {
1491                character
1492            } else {
1493                '_'
1494            }
1495        })
1496        .collect();
1497    let mut id = if slug.is_empty() {
1498        String::new()
1499    } else {
1500        format!("{slug}_")
1501    };
1502    for byte in Sha256::digest(guest.as_bytes()).iter().take(4) {
1503        let _ = write!(id, "{byte:02x}");
1504    }
1505    id
1506}
1507
1508/// Canonicalizes guest paths and orders mounts from parent to child.
1509///
1510/// All SDKs and runtimes share this ordering contract so an enclosing mount
1511/// can never hide a nested mount merely because the caller used an unordered
1512/// collection. Paths at the same depth are ordered lexicographically to keep
1513/// serialized configurations deterministic.
1514pub fn canonicalize_volume_mounts(mounts: &mut [VolumeMount]) -> TypesResult<()> {
1515    for mount in mounts.iter_mut() {
1516        let canonical = canonical_guest_mount_path(mount.guest())?;
1517        *mount.guest_mut() = canonical;
1518    }
1519
1520    mounts.sort_by_cached_key(|mount| guest_mount_order_key(mount.guest()));
1521
1522    for pair in mounts.windows(2) {
1523        if pair[0].guest() == pair[1].guest() {
1524            return Err(TypesError::invalid_config(format!(
1525                "multiple volumes cannot mount the same guest path: {}",
1526                pair[0].guest()
1527            )));
1528        }
1529    }
1530
1531    Ok(())
1532}
1533
1534fn canonical_guest_mount_path(guest: &str) -> TypesResult<String> {
1535    let path = Utf8UnixPath::new(guest);
1536
1537    if !path.is_valid() {
1538        return Err(TypesError::invalid_config(format!(
1539            "guest mount path must be a valid Unix path: {guest}"
1540        )));
1541    }
1542    if !path.is_absolute() {
1543        return Err(TypesError::invalid_config(format!(
1544            "guest mount path must be absolute: {guest}"
1545        )));
1546    }
1547    if path
1548        .components()
1549        .any(|component| matches!(component, Utf8UnixComponent::ParentDir))
1550    {
1551        return Err(TypesError::invalid_config(format!(
1552            "guest mount path must not contain '..': {guest}"
1553        )));
1554    }
1555    if guest.contains(':') || guest.contains(';') || guest.contains(',') {
1556        return Err(TypesError::invalid_config(format!(
1557            "guest mount path must not contain ':', ';', or ',': {guest}"
1558        )));
1559    }
1560
1561    let canonical = path.normalize().to_string();
1562    if canonical == "/" {
1563        return Err(TypesError::invalid_config(
1564            "cannot mount a volume at guest root /",
1565        ));
1566    }
1567
1568    Ok(canonical)
1569}
1570
1571fn guest_mount_order_key(guest: &str) -> (usize, String) {
1572    let path = Utf8UnixPath::new(guest);
1573    let depth = path.components().filter(Utf8Component::is_normal).count();
1574    (depth, guest.to_owned())
1575}
1576
1577impl RlimitResource {
1578    /// Returns the lowercase string representation used on the wire.
1579    pub fn as_str(&self) -> &'static str {
1580        match self {
1581            Self::Cpu => "cpu",
1582            Self::Fsize => "fsize",
1583            Self::Data => "data",
1584            Self::Stack => "stack",
1585            Self::Core => "core",
1586            Self::Rss => "rss",
1587            Self::Nproc => "nproc",
1588            Self::Nofile => "nofile",
1589            Self::Memlock => "memlock",
1590            Self::As => "as",
1591            Self::Locks => "locks",
1592            Self::Sigpending => "sigpending",
1593            Self::Msgqueue => "msgqueue",
1594            Self::Nice => "nice",
1595            Self::Rtprio => "rtprio",
1596            Self::Rttime => "rttime",
1597        }
1598    }
1599}
1600
1601impl LogSource {
1602    /// Apply the empty-means-default rule used by log readers.
1603    pub fn effective(requested: &[Self]) -> Vec<Self> {
1604        if requested.is_empty() {
1605            vec![Self::Stdout, Self::Stderr, Self::Output]
1606        } else {
1607            let mut sources = requested.to_vec();
1608            sources.sort_by_key(|src| match src {
1609                Self::Stdout => 0,
1610                Self::Stderr => 1,
1611                Self::Output => 2,
1612                Self::System => 3,
1613            });
1614            sources.dedup();
1615            sources
1616        }
1617    }
1618}
1619
1620impl SandboxLogLevel {
1621    /// Return the lowercase string representation for this level.
1622    pub const fn as_str(self) -> &'static str {
1623        match self {
1624            Self::Error => "error",
1625            Self::Warn => "warn",
1626            Self::Info => "info",
1627            Self::Debug => "debug",
1628            Self::Trace => "trace",
1629        }
1630    }
1631}
1632
1633//--------------------------------------------------------------------------------------------------
1634// Trait Implementations
1635//--------------------------------------------------------------------------------------------------
1636
1637impl std::fmt::Display for DiskImageFormat {
1638    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1639        f.write_str(self.as_str())
1640    }
1641}
1642
1643impl FromStr for DiskImageFormat {
1644    type Err = String;
1645
1646    fn from_str(s: &str) -> Result<Self, Self::Err> {
1647        match s {
1648            "qcow2" => Ok(Self::Qcow2),
1649            "raw" => Ok(Self::Raw),
1650            "vmdk" => Ok(Self::Vmdk),
1651            _ => Err(format!("unknown disk image format: {s}")),
1652        }
1653    }
1654}
1655
1656impl fmt::Display for TransparentHugePagePolicy {
1657    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1658        f.write_str(self.as_str())
1659    }
1660}
1661
1662impl FromStr for TransparentHugePagePolicy {
1663    type Err = String;
1664
1665    fn from_str(value: &str) -> Result<Self, Self::Err> {
1666        match value {
1667            "always" => Ok(Self::Always),
1668            "madvise" => Ok(Self::Madvise),
1669            "never" => Ok(Self::Never),
1670            _ => Err(format!(
1671                "unknown transparent huge-page policy: {value}; expected always, madvise, or never"
1672            )),
1673        }
1674    }
1675}
1676
1677impl Default for RootfsSource {
1678    fn default() -> Self {
1679        Self::oci(String::new())
1680    }
1681}
1682
1683impl Default for SandboxResources {
1684    fn default() -> Self {
1685        Self {
1686            cpus: DEFAULT_SANDBOX_CPUS,
1687            memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1688            max_cpus: DEFAULT_SANDBOX_CPUS,
1689            max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1690            cpu_placement: CpuPlacement::Inherit,
1691            placement_profile: None,
1692            thp: TransparentHugePagePolicy::Madvise,
1693        }
1694    }
1695}
1696
1697impl<'de> Deserialize<'de> for SandboxResources {
1698    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1699    where
1700        D: serde::Deserializer<'de>,
1701    {
1702        #[derive(Deserialize)]
1703        struct RawResources {
1704            #[serde(default = "default_sandbox_cpus")]
1705            cpus: u8,
1706            #[serde(default = "default_sandbox_memory_mib")]
1707            memory_mib: u32,
1708            max_cpus: Option<u8>,
1709            max_memory_mib: Option<u32>,
1710            #[serde(default)]
1711            cpu_placement: CpuPlacement,
1712            #[serde(default)]
1713            placement_profile: Option<String>,
1714            #[serde(default)]
1715            thp: TransparentHugePagePolicy,
1716        }
1717
1718        let raw = RawResources::deserialize(deserializer)?;
1719        Ok(Self {
1720            cpus: raw.cpus,
1721            memory_mib: raw.memory_mib,
1722            // Legacy configs predate boot-capacity fields. Treat their effective
1723            // resources as their maximum capacity so old sandboxes do not
1724            // deserialize into an impossible cpus > max_cpus state.
1725            max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1726            max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1727            cpu_placement: raw.cpu_placement,
1728            placement_profile: raw.placement_profile,
1729            thp: raw.thp,
1730        })
1731    }
1732}
1733
1734impl CpuPlacement {
1735    /// Returns whether this policy preserves the inherited host placement.
1736    pub const fn is_inherit(&self) -> bool {
1737        matches!(self, Self::Inherit)
1738    }
1739}
1740
1741impl std::fmt::Display for CpuPlacement {
1742    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1743        f.write_str(match self {
1744            Self::Inherit => "inherit",
1745            Self::Auto => "auto",
1746            Self::Spread => "spread",
1747            Self::Compact => "compact",
1748        })
1749    }
1750}
1751
1752impl FromStr for CpuPlacement {
1753    type Err = String;
1754
1755    fn from_str(value: &str) -> Result<Self, Self::Err> {
1756        match value {
1757            "inherit" => Ok(Self::Inherit),
1758            "auto" => Ok(Self::Auto),
1759            "spread" => Ok(Self::Spread),
1760            "compact" => Ok(Self::Compact),
1761            _ => Err(format!(
1762                "unknown CPU placement: {value} (expected: inherit, auto, spread, compact)"
1763            )),
1764        }
1765    }
1766}
1767
1768impl Default for SandboxRuntimeOptions {
1769    fn default() -> Self {
1770        Self {
1771            workdir: None,
1772            shell: None,
1773            scripts: BTreeMap::new(),
1774            entrypoint: None,
1775            cmd: None,
1776            hostname: None,
1777            user: None,
1778            log_level: None,
1779            metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1780            disable_metrics_sample: false,
1781        }
1782    }
1783}
1784
1785impl Default for NetworkSpec {
1786    fn default() -> Self {
1787        Self {
1788            enabled: true,
1789            interface: None,
1790            ports: Vec::new(),
1791            policy: None,
1792            dns: None,
1793            tls: None,
1794            strict: false,
1795            secrets: None,
1796            max_tcp_connections: None,
1797            max_udp_connections: None,
1798            rate_limiter: None,
1799            trust_host_cas: false,
1800            outbound_proxy: None,
1801        }
1802    }
1803}
1804
1805impl Default for PublishedPortSpec {
1806    fn default() -> Self {
1807        Self {
1808            host_port: 0,
1809            guest_port: 0,
1810            protocol: PortProtocol::Tcp,
1811            host_bind: "127.0.0.1".into(),
1812        }
1813    }
1814}
1815
1816impl From<(String, String)> for EnvVar {
1817    fn from((key, value): (String, String)) -> Self {
1818        Self { key, value }
1819    }
1820}
1821
1822impl From<EnvVar> for (String, String) {
1823    fn from(var: EnvVar) -> Self {
1824        (var.key, var.value)
1825    }
1826}
1827
1828impl FromStr for SandboxLogLevel {
1829    type Err = String;
1830
1831    fn from_str(s: &str) -> Result<Self, Self::Err> {
1832        match s {
1833            "error" => Ok(Self::Error),
1834            "warn" => Ok(Self::Warn),
1835            "info" => Ok(Self::Info),
1836            "debug" => Ok(Self::Debug),
1837            "trace" => Ok(Self::Trace),
1838            _ => Err(format!("unknown sandbox log level: {s}")),
1839        }
1840    }
1841}
1842
1843impl std::fmt::Display for SandboxLogLevel {
1844    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1845        formatter.write_str(self.as_str())
1846    }
1847}
1848
1849impl Serialize for VolumeMount {
1850    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1851        use serde::ser::SerializeMap;
1852
1853        match self {
1854            Self::Owned {
1855                guest,
1856                storage,
1857                options,
1858                stat_virtualization,
1859                host_permissions,
1860            } => {
1861                // A distinct tag is intentional: older runtimes must reject ownership,
1862                // not reinterpret a private mount as an external or named volume.
1863                let mut map = serializer.serialize_map(Some(6))?;
1864                map.serialize_entry("type", "Owned")?;
1865                map.serialize_entry("guest", guest)?;
1866                map.serialize_entry("storage", storage)?;
1867                map.serialize_entry("options", options)?;
1868                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1869                map.serialize_entry("host_permissions", host_permissions)?;
1870                map.end()
1871            }
1872            Self::Bind {
1873                host,
1874                guest,
1875                options,
1876                stat_virtualization,
1877                host_permissions,
1878                follow_root_symlinks,
1879                quota_mib,
1880            } => {
1881                let mut map = serializer.serialize_map(Some(8))?;
1882                map.serialize_entry("type", "Bind")?;
1883                map.serialize_entry("host", host)?;
1884                map.serialize_entry("guest", guest)?;
1885                map.serialize_entry("options", options)?;
1886                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1887                map.serialize_entry("host_permissions", host_permissions)?;
1888                map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1889                map.serialize_entry("quota_mib", quota_mib)?;
1890                map.end()
1891            }
1892            Self::Named {
1893                name,
1894                guest,
1895                create: _,
1896                options,
1897                stat_virtualization,
1898                host_permissions,
1899                follow_root_symlinks,
1900            } => {
1901                let mut map = serializer.serialize_map(Some(7))?;
1902                map.serialize_entry("type", "Named")?;
1903                map.serialize_entry("name", name)?;
1904                map.serialize_entry("guest", guest)?;
1905                map.serialize_entry("options", options)?;
1906                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1907                map.serialize_entry("host_permissions", host_permissions)?;
1908                map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1909                map.end()
1910            }
1911            Self::Tmpfs {
1912                guest,
1913                size_mib,
1914                options,
1915            } => {
1916                let mut map = serializer.serialize_map(Some(4))?;
1917                map.serialize_entry("type", "Tmpfs")?;
1918                map.serialize_entry("guest", guest)?;
1919                map.serialize_entry("size_mib", size_mib)?;
1920                map.serialize_entry("options", options)?;
1921                map.end()
1922            }
1923            Self::DiskImage {
1924                host,
1925                guest,
1926                format,
1927                fstype,
1928                options,
1929            } => {
1930                let mut map = serializer.serialize_map(Some(6))?;
1931                map.serialize_entry("type", "DiskImage")?;
1932                map.serialize_entry("host", host)?;
1933                map.serialize_entry("guest", guest)?;
1934                map.serialize_entry("format", format)?;
1935                map.serialize_entry("fstype", fstype)?;
1936                map.serialize_entry("options", options)?;
1937                map.end()
1938            }
1939        }
1940    }
1941}
1942
1943impl<'de> Deserialize<'de> for VolumeMount {
1944    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1945        fn default_strict() -> StatVirtualization {
1946            StatVirtualization::Strict
1947        }
1948
1949        fn default_private() -> HostPermissions {
1950            HostPermissions::Private
1951        }
1952
1953        #[derive(Deserialize)]
1954        #[serde(tag = "type")]
1955        enum VolumeMountHelper {
1956            Owned {
1957                guest: String,
1958                storage: OwnedVolumeStorage,
1959                #[serde(default)]
1960                options: MountOptions,
1961                #[serde(default = "default_strict")]
1962                stat_virtualization: StatVirtualization,
1963                #[serde(default = "default_private")]
1964                host_permissions: HostPermissions,
1965            },
1966            Bind {
1967                host: PathBuf,
1968                guest: String,
1969                #[serde(default)]
1970                options: Option<MountOptions>,
1971                #[serde(default)]
1972                readonly: bool,
1973                #[serde(default = "default_strict")]
1974                stat_virtualization: StatVirtualization,
1975                #[serde(default = "default_private")]
1976                host_permissions: HostPermissions,
1977                #[serde(default)]
1978                follow_root_symlinks: bool,
1979                #[serde(default)]
1980                quota_mib: Option<u32>,
1981            },
1982            Named {
1983                name: String,
1984                guest: String,
1985                #[serde(default)]
1986                options: Option<MountOptions>,
1987                #[serde(default)]
1988                readonly: bool,
1989                #[serde(default = "default_strict")]
1990                stat_virtualization: StatVirtualization,
1991                #[serde(default = "default_private")]
1992                host_permissions: HostPermissions,
1993                #[serde(default)]
1994                follow_root_symlinks: bool,
1995            },
1996            Tmpfs {
1997                guest: String,
1998                #[serde(default)]
1999                size_mib: Option<u32>,
2000                #[serde(default)]
2001                options: Option<MountOptions>,
2002                #[serde(default)]
2003                readonly: bool,
2004            },
2005            DiskImage {
2006                host: PathBuf,
2007                guest: String,
2008                format: DiskImageFormat,
2009                #[serde(default)]
2010                fstype: Option<String>,
2011                #[serde(default)]
2012                options: Option<MountOptions>,
2013                #[serde(default)]
2014                readonly: bool,
2015            },
2016        }
2017
2018        let helper = VolumeMountHelper::deserialize(deserializer)?;
2019        Ok(match helper {
2020            VolumeMountHelper::Owned {
2021                guest,
2022                storage,
2023                options,
2024                stat_virtualization,
2025                host_permissions,
2026            } => Self::Owned {
2027                guest,
2028                storage,
2029                options,
2030                stat_virtualization,
2031                host_permissions,
2032            },
2033            VolumeMountHelper::Bind {
2034                host,
2035                guest,
2036                options,
2037                readonly,
2038                stat_virtualization,
2039                host_permissions,
2040                follow_root_symlinks,
2041                quota_mib,
2042            } => Self::Bind {
2043                host,
2044                guest,
2045                options: decode_mount_options(options, readonly),
2046                stat_virtualization,
2047                host_permissions,
2048                follow_root_symlinks,
2049                quota_mib,
2050            },
2051            VolumeMountHelper::Named {
2052                name,
2053                guest,
2054                options,
2055                readonly,
2056                stat_virtualization,
2057                host_permissions,
2058                follow_root_symlinks,
2059            } => Self::Named {
2060                name,
2061                guest,
2062                create: None,
2063                options: decode_mount_options(options, readonly),
2064                stat_virtualization,
2065                host_permissions,
2066                follow_root_symlinks,
2067            },
2068            VolumeMountHelper::Tmpfs {
2069                guest,
2070                size_mib,
2071                options,
2072                readonly,
2073            } => Self::Tmpfs {
2074                guest,
2075                size_mib,
2076                options: decode_mount_options(options, readonly),
2077            },
2078            VolumeMountHelper::DiskImage {
2079                host,
2080                guest,
2081                format,
2082                fstype,
2083                options,
2084                readonly,
2085            } => Self::DiskImage {
2086                host,
2087                guest,
2088                format,
2089                fstype,
2090                options: decode_mount_options(options, readonly),
2091            },
2092        })
2093    }
2094}
2095
2096impl fmt::Debug for VolumeMount {
2097    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2098        match self {
2099            Self::Owned {
2100                guest,
2101                storage,
2102                options,
2103                stat_virtualization,
2104                host_permissions,
2105            } => f
2106                .debug_struct("Owned")
2107                .field("guest", guest)
2108                .field("storage", storage)
2109                .field("options", options)
2110                .field("stat_virtualization", stat_virtualization)
2111                .field("host_permissions", host_permissions)
2112                .finish(),
2113            Self::Bind {
2114                host,
2115                guest,
2116                options,
2117                stat_virtualization,
2118                host_permissions,
2119                follow_root_symlinks,
2120                quota_mib,
2121            } => f
2122                .debug_struct("Bind")
2123                .field("host", host)
2124                .field("guest", guest)
2125                .field("options", options)
2126                .field("stat_virtualization", stat_virtualization)
2127                .field("host_permissions", host_permissions)
2128                .field("follow_root_symlinks", follow_root_symlinks)
2129                .field("quota_mib", quota_mib)
2130                .finish(),
2131            Self::Named {
2132                name,
2133                guest,
2134                create,
2135                options,
2136                stat_virtualization,
2137                host_permissions,
2138                follow_root_symlinks,
2139            } => f
2140                .debug_struct("Named")
2141                .field("name", name)
2142                .field("guest", guest)
2143                .field("create", create)
2144                .field("options", options)
2145                .field("stat_virtualization", stat_virtualization)
2146                .field("host_permissions", host_permissions)
2147                .field("follow_root_symlinks", follow_root_symlinks)
2148                .finish(),
2149            Self::Tmpfs {
2150                guest,
2151                size_mib,
2152                options,
2153            } => f
2154                .debug_struct("Tmpfs")
2155                .field("guest", guest)
2156                .field("size_mib", size_mib)
2157                .field("options", options)
2158                .finish(),
2159            Self::DiskImage {
2160                host,
2161                guest,
2162                format,
2163                fstype,
2164                options,
2165            } => f
2166                .debug_struct("DiskImage")
2167                .field("host", host)
2168                .field("guest", guest)
2169                .field("format", format)
2170                .field("fstype", fstype)
2171                .field("options", options)
2172                .finish(),
2173        }
2174    }
2175}
2176
2177/// Case-insensitive string to [`RlimitResource`] conversion.
2178impl TryFrom<&str> for RlimitResource {
2179    type Error = String;
2180
2181    fn try_from(s: &str) -> Result<Self, Self::Error> {
2182        match s.to_ascii_lowercase().as_str() {
2183            "cpu" => Ok(Self::Cpu),
2184            "fsize" => Ok(Self::Fsize),
2185            "data" => Ok(Self::Data),
2186            "stack" => Ok(Self::Stack),
2187            "core" => Ok(Self::Core),
2188            "rss" => Ok(Self::Rss),
2189            "nproc" => Ok(Self::Nproc),
2190            "nofile" => Ok(Self::Nofile),
2191            "memlock" => Ok(Self::Memlock),
2192            "as" => Ok(Self::As),
2193            "locks" => Ok(Self::Locks),
2194            "sigpending" => Ok(Self::Sigpending),
2195            "msgqueue" => Ok(Self::Msgqueue),
2196            "nice" => Ok(Self::Nice),
2197            "rtprio" => Ok(Self::Rtprio),
2198            "rttime" => Ok(Self::Rttime),
2199            _ => Err(format!("unknown rlimit resource: {s}")),
2200        }
2201    }
2202}
2203
2204//--------------------------------------------------------------------------------------------------
2205// Functions
2206//--------------------------------------------------------------------------------------------------
2207
2208fn default_sandbox_cpus() -> u8 {
2209    DEFAULT_SANDBOX_CPUS
2210}
2211
2212fn default_sandbox_memory_mib() -> u32 {
2213    DEFAULT_SANDBOX_MEMORY_MIB
2214}
2215
2216fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
2217    options.unwrap_or(MountOptions {
2218        readonly,
2219        ..MountOptions::default()
2220    })
2221}
2222
2223fn merge_env_vars(base: &mut Vec<EnvVar>, higher: Vec<EnvVar>) {
2224    for value in higher {
2225        match base.iter_mut().find(|current| current.key == value.key) {
2226            Some(current) => *current = value,
2227            None => base.push(value),
2228        }
2229    }
2230}
2231
2232fn merge_secret_entries(base: &mut Vec<SecretEntry>, higher: Vec<SecretEntry>) {
2233    for value in higher {
2234        match base
2235            .iter_mut()
2236            .find(|current| current.env_var == value.env_var)
2237        {
2238            Some(current) => *current = value,
2239            None => base.push(value),
2240        }
2241    }
2242}
2243
2244/// Default stat-virtualization policy (`Strict`) for a deserialized volume mount.
2245pub(crate) fn default_strict() -> StatVirtualization {
2246    StatVirtualization::Strict
2247}
2248
2249/// Default host-permission policy (`Private`) for a deserialized volume mount.
2250pub(crate) fn default_private() -> HostPermissions {
2251    HostPermissions::Private
2252}
2253
2254/// Maximum supported secret placeholder length in bytes.
2255pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
2256
2257/// Placeholder-based secret substitution for a sandbox's TLS-intercepted egress.
2258///
2259/// The sandbox only ever sees each secret's `placeholder`; the local network
2260/// engine substitutes the real `value` into outbound requests bound for an
2261/// allowed host (and blocks/forwards per [`SecretViolationAction`] otherwise). Carried
2262/// in [`NetworkSpec::secrets`](NetworkSpec).
2263#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigPatch)]
2264#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2265#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2266pub struct SecretsConfig {
2267    /// List of secrets to inject.
2268    #[serde(default)]
2269    #[config_patch(merge_with = merge_secret_entries)]
2270    pub secrets: Vec<SecretEntry>,
2271
2272    /// Default action when a placeholder leaks to a disallowed host.
2273    #[serde(default)]
2274    pub violation_action: SecretViolationAction,
2275}
2276
2277/// A single secret entry.
2278///
2279/// `value` is the sensitive material — it never enters the sandbox and is
2280/// redacted by the [`Debug`](fmt::Debug) impl.
2281#[derive(Clone, Serialize, Deserialize)]
2282#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2283#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2284pub struct SecretEntry {
2285    /// Environment variable name exposed to the sandbox (holds the placeholder).
2286    ///
2287    /// Must be non-empty and must not contain `=` or NUL. microsandbox does
2288    /// not require shell-identifier syntax because Linux environment entries
2289    /// only require a `NAME=value` shape.
2290    pub env_var: String,
2291
2292    /// The actual secret value (never enters the sandbox).
2293    ///
2294    /// Empty when the entry carries a [`source`](Self::source) reference
2295    /// instead: reference-model entries resolve the value host-side at spawn
2296    /// time so the durable sandbox config never stores raw secret material.
2297    ///
2298    /// Wrapped in [`Zeroizing`] so the owned plaintext copy is wiped when the
2299    /// entry drops.
2300    #[serde(default = "empty_secret_value")]
2301    #[cfg_attr(feature = "ts", ts(type = "string"))]
2302    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2303    pub value: Zeroizing<String>,
2304
2305    /// Host-side source reference resolved into [`value`](Self::value) at
2306    /// spawn time. `None` means `value` already carries the material (the
2307    /// inline model used by value-based secrets).
2308    #[serde(default, skip_serializing_if = "Option::is_none")]
2309    pub source: Option<SecretSource>,
2310
2311    /// Placeholder string the sandbox sees instead of the real value.
2312    ///
2313    /// Must be non-empty, no longer than [`MAX_SECRET_PLACEHOLDER_BYTES`], and
2314    /// must not contain NUL, CR, or LF.
2315    pub placeholder: String,
2316
2317    /// Hosts allowed to receive the substituted secret value.
2318    #[serde(default)]
2319    pub allowed_hosts: Vec<HostPattern>,
2320
2321    /// Request locations where the placeholder can be substituted.
2322    #[serde(default)]
2323    pub substitution: SecretSubstitution,
2324
2325    /// Hosts allowed to receive the placeholder unchanged.
2326    #[serde(default)]
2327    pub passthrough_hosts: Vec<HostPattern>,
2328
2329    /// Action on a violation for this secret (overrides the config default).
2330    #[serde(default, skip_serializing_if = "Option::is_none")]
2331    pub violation_action: Option<SecretViolationAction>,
2332
2333    /// Require verified TLS identity before substituting (default: true).
2334    ///
2335    /// When true, the secret is only substituted if the connection uses TLS
2336    /// interception (not bypass) and the SNI matches an allowed host.
2337    #[serde(default = "default_true")]
2338    pub require_tls_identity: bool,
2339}
2340
2341/// Host pattern for a secret allowlist.
2342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2343#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2344#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2345#[serde(rename_all = "kebab-case")]
2346pub enum HostPattern {
2347    /// Exact hostname match.
2348    #[serde(alias = "Exact")]
2349    Exact(String),
2350    /// Wildcard match (e.g., `*.openai.com`).
2351    #[serde(alias = "Wildcard")]
2352    Wildcard(String),
2353    /// Any host (dangerous — secret can be exfiltrated).
2354    #[serde(alias = "Any")]
2355    Any,
2356}
2357
2358/// Request locations where a placeholder can be substituted with its secret.
2359#[derive(Debug, Clone, Serialize, Deserialize)]
2360#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2361#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2362pub struct SecretSubstitution {
2363    /// Substitute in HTTP headers (default: true).
2364    #[serde(default = "default_true")]
2365    pub headers: bool,
2366
2367    /// Substitute in URL query parameters (default: false).
2368    #[serde(default)]
2369    pub query: bool,
2370
2371    /// Substitute in request body (default: false).
2372    ///
2373    /// Fixed-length HTTP/1 bodies up to 16 MiB update `Content-Length`;
2374    /// larger fixed-length bodies are blocked. Chunked HTTP/1 bodies are
2375    /// decoded and re-encoded with fresh chunk sizes. Encoded bodies pass
2376    /// through unchanged. HTTP/2 DATA-frame body substitution is not
2377    /// supported; matching body placeholders are blocked.
2378    #[serde(default)]
2379    pub body: bool,
2380}
2381
2382/// Action when a secret placeholder is not allowed to leave the sandbox.
2383#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2384#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2385#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2386#[serde(rename_all = "kebab-case")]
2387pub enum SecretViolationAction {
2388    /// Block the request silently.
2389    #[serde(alias = "Block")]
2390    Block,
2391    /// Block and log (default).
2392    #[default]
2393    #[serde(alias = "BlockAndLog", alias = "block_and_log")]
2394    BlockAndLog,
2395    /// Block and terminate the sandbox.
2396    #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
2397    BlockAndTerminate,
2398}
2399
2400/// Invalid secret configuration.
2401#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2402pub enum SecretConfigError {
2403    /// The environment variable name is empty.
2404    #[error("secret #{secret_index}: env_var must not be empty")]
2405    EmptyEnvVar {
2406        /// Index of the invalid secret entry.
2407        secret_index: usize,
2408    },
2409
2410    /// The environment variable name contains `=`.
2411    #[error("secret #{secret_index}: env_var must not contain `=`")]
2412    EnvVarContainsEquals {
2413        /// Index of the invalid secret entry.
2414        secret_index: usize,
2415    },
2416
2417    /// The environment variable name contains NUL.
2418    #[error("secret #{secret_index}: env_var must not contain NUL")]
2419    EnvVarContainsNul {
2420        /// Index of the invalid secret entry.
2421        secret_index: usize,
2422    },
2423
2424    /// No allowed hosts were configured for a secret.
2425    #[error("secret #{secret_index}: at least one allowed host is required")]
2426    MissingAllowedHosts {
2427        /// Index of the invalid secret entry.
2428        secret_index: usize,
2429    },
2430
2431    /// No request locations were enabled for substitution.
2432    #[error("secret #{secret_index}: at least one substitution location is required")]
2433    MissingSubstitutionLocation {
2434        /// Index of the invalid secret entry.
2435        secret_index: usize,
2436    },
2437
2438    /// The placeholder is empty.
2439    #[error("secret #{secret_index}: placeholder must not be empty")]
2440    EmptyPlaceholder {
2441        /// Index of the invalid secret entry.
2442        secret_index: usize,
2443    },
2444
2445    /// The placeholder exceeds the supported byte length.
2446    #[error(
2447        "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
2448    )]
2449    PlaceholderTooLong {
2450        /// Index of the invalid secret entry.
2451        secret_index: usize,
2452        /// Actual placeholder length in bytes.
2453        actual_bytes: usize,
2454        /// Maximum supported placeholder length in bytes.
2455        max_bytes: usize,
2456    },
2457
2458    /// The placeholder contains NUL.
2459    #[error("secret #{secret_index}: placeholder must not contain NUL")]
2460    PlaceholderContainsNul {
2461        /// Index of the invalid secret entry.
2462        secret_index: usize,
2463    },
2464
2465    /// The placeholder contains a line break.
2466    #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
2467    PlaceholderContainsLineBreak {
2468        /// Index of the invalid secret entry.
2469        secret_index: usize,
2470    },
2471}
2472
2473impl SecretsConfig {
2474    /// Validate all configured secret entries.
2475    pub fn validate(&self) -> Result<(), SecretConfigError> {
2476        for (index, secret) in self.secrets.iter().enumerate() {
2477            secret.validate(index)?;
2478        }
2479        Ok(())
2480    }
2481}
2482
2483impl SecretEntry {
2484    /// Validate this secret entry.
2485    pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
2486        validate_env_var(&self.env_var, secret_index)?;
2487
2488        if self.allowed_hosts.is_empty() {
2489            return Err(SecretConfigError::MissingAllowedHosts { secret_index });
2490        }
2491
2492        if !self.substitution.headers && !self.substitution.query && !self.substitution.body {
2493            return Err(SecretConfigError::MissingSubstitutionLocation { secret_index });
2494        }
2495
2496        validate_placeholder(&self.placeholder, secret_index)
2497    }
2498}
2499
2500// The secret value must never reach a log line or an error message.
2501impl fmt::Debug for SecretEntry {
2502    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2503        f.debug_struct("SecretEntry")
2504            .field("env_var", &self.env_var)
2505            .field("value", &"[REDACTED]")
2506            .field("source", &self.source)
2507            .field("placeholder", &self.placeholder)
2508            .field("allowed_hosts", &self.allowed_hosts)
2509            .field("substitution", &self.substitution)
2510            .field("passthrough_hosts", &self.passthrough_hosts)
2511            .field("violation_action", &self.violation_action)
2512            .field("require_tls_identity", &self.require_tls_identity)
2513            .finish()
2514    }
2515}
2516
2517impl HostPattern {
2518    /// Parse a user-facing host string: `*` is any host, `*.`-prefixed
2519    /// strings are wildcards, everything else matches exactly.
2520    pub fn parse(host: &str) -> Self {
2521        if host == "*" {
2522            HostPattern::Any
2523        } else if host.starts_with("*.") {
2524            HostPattern::Wildcard(host.to_string())
2525        } else {
2526            HostPattern::Exact(host.to_string())
2527        }
2528    }
2529
2530    /// Check if a hostname matches this pattern.
2531    ///
2532    /// Uses ASCII case-insensitive comparison to avoid `to_lowercase()`
2533    /// allocations (DNS hostnames are ASCII per RFC 4343).
2534    pub fn matches(&self, hostname: &str) -> bool {
2535        match self {
2536            HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
2537            HostPattern::Wildcard(pattern) => {
2538                if let Some(suffix) = pattern.strip_prefix("*.") {
2539                    hostname.eq_ignore_ascii_case(suffix)
2540                        || (hostname.len() > suffix.len() + 1
2541                            && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
2542                            && hostname[hostname.len() - suffix.len()..]
2543                                .eq_ignore_ascii_case(suffix))
2544                } else {
2545                    hostname.eq_ignore_ascii_case(pattern)
2546                }
2547            }
2548            HostPattern::Any => true,
2549        }
2550    }
2551}
2552
2553impl Default for SecretSubstitution {
2554    fn default() -> Self {
2555        Self {
2556            headers: true,
2557            query: false,
2558            body: false,
2559        }
2560    }
2561}
2562
2563fn default_true() -> bool {
2564    true
2565}
2566
2567fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2568    if env_var.is_empty() {
2569        return Err(SecretConfigError::EmptyEnvVar { secret_index });
2570    }
2571    if env_var.contains('=') {
2572        return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
2573    }
2574    if env_var.contains('\0') {
2575        return Err(SecretConfigError::EnvVarContainsNul { secret_index });
2576    }
2577    Ok(())
2578}
2579
2580fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2581    if placeholder.is_empty() {
2582        return Err(SecretConfigError::EmptyPlaceholder { secret_index });
2583    }
2584
2585    let actual_bytes = placeholder.len();
2586    if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
2587        return Err(SecretConfigError::PlaceholderTooLong {
2588            secret_index,
2589            actual_bytes,
2590            max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2591        });
2592    }
2593
2594    if placeholder.contains('\0') {
2595        return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
2596    }
2597    if placeholder.contains('\r') || placeholder.contains('\n') {
2598        return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
2599    }
2600
2601    Ok(())
2602}
2603
2604//--------------------------------------------------------------------------------------------------
2605// Types: TLS interception
2606//--------------------------------------------------------------------------------------------------
2607
2608/// TLS interception configuration. Carried in [`NetworkSpec::tls`](NetworkSpec).
2609///
2610/// The local network engine terminates TCP at its in-process stack, so TLS MITM
2611/// is handled by proxy tasks — these fields configure which ports/domains are
2612/// intercepted and how the interception CA is sourced.
2613#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
2614#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2615#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2616pub struct TlsConfig {
2617    /// Whether TLS interception is enabled.
2618    #[serde(default)]
2619    pub enabled: bool,
2620
2621    /// TCP ports subject to TLS interception (default: `[443]`).
2622    #[serde(default = "default_intercepted_ports")]
2623    pub intercepted_ports: Vec<u16>,
2624
2625    /// Domains to bypass (no MITM). Supports exact match and `*.suffix` wildcards.
2626    #[serde(default)]
2627    pub bypass: Vec<String>,
2628
2629    /// Whether to verify the upstream server's TLS certificate.
2630    #[serde(default = "default_true")]
2631    pub verify_upstream: bool,
2632
2633    /// Drop UDP to intercepted ports when TLS interception is active, forcing
2634    /// QUIC traffic to fall back to TCP/TLS.
2635    #[serde(default = "default_true")]
2636    pub block_quic_on_intercept: bool,
2637
2638    /// CA certificate PEM files to trust for upstream server verification.
2639    #[serde(default)]
2640    #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
2641    #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
2642    pub upstream_ca_cert: Vec<PathBuf>,
2643
2644    /// Host-scoped CA certificate PEM files to trust for upstream server verification.
2645    #[serde(default, alias = "scoped_upstream_ca_certs")]
2646    pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
2647
2648    /// Host-scoped upstream verification overrides.
2649    #[serde(default)]
2650    pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
2651
2652    /// Interception CA configuration. The TLS proxy uses this CA to sign
2653    /// per-domain certs it presents to the guest during interception.
2654    #[serde(default, alias = "ca")]
2655    pub intercept_ca: InterceptCaConfig,
2656
2657    /// Per-domain certificate cache configuration.
2658    #[serde(default)]
2659    pub cache: CertCacheConfig,
2660}
2661
2662/// Certificate authority configuration for TLS interception.
2663#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2664#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2665#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2666pub struct InterceptCaConfig {
2667    /// Path to an existing CA certificate PEM file. If `None`, a CA is
2668    /// auto-generated and persisted.
2669    #[serde(default)]
2670    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2671    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2672    pub cert_path: Option<PathBuf>,
2673
2674    /// Path to an existing CA private key PEM file. If `None`, a key is
2675    /// auto-generated and persisted.
2676    #[serde(default)]
2677    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2678    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2679    pub key_path: Option<PathBuf>,
2680}
2681
2682/// Per-domain certificate cache configuration.
2683#[derive(Debug, Clone, Serialize, Deserialize)]
2684#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2685#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2686pub struct CertCacheConfig {
2687    /// Maximum number of cached certificates. Default: 1000.
2688    #[serde(default = "default_cache_capacity")]
2689    pub capacity: usize,
2690
2691    /// Certificate validity duration in hours. Default: 24.
2692    #[serde(default = "default_cert_validity_hours")]
2693    pub validity_hours: u64,
2694}
2695
2696/// A CA certificate PEM file trusted only for matching upstream hosts.
2697#[derive(Debug, Clone, Serialize, Deserialize)]
2698#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2699#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2700pub struct ScopedUpstreamCaCert {
2701    /// Host pattern this CA applies to. Supports exact hosts and `*.suffix` wildcards.
2702    pub pattern: String,
2703
2704    /// Path to the CA certificate PEM file.
2705    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2706    #[cfg_attr(feature = "ts", ts(type = "string"))]
2707    pub path: PathBuf,
2708}
2709
2710/// An upstream certificate verification override for matching hosts.
2711#[derive(Debug, Clone, Serialize, Deserialize)]
2712#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2713#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2714pub struct ScopedVerifyUpstream {
2715    /// Host pattern this override applies to. Supports exact hosts and `*.suffix` wildcards.
2716    pub pattern: String,
2717
2718    /// Whether to verify matching upstream server certificates.
2719    pub verify: bool,
2720}
2721
2722impl Default for TlsConfig {
2723    fn default() -> Self {
2724        Self {
2725            enabled: false,
2726            intercepted_ports: default_intercepted_ports(),
2727            bypass: Vec::new(),
2728            verify_upstream: true,
2729            block_quic_on_intercept: true,
2730            upstream_ca_cert: Vec::new(),
2731            scoped_upstream_ca_cert: Vec::new(),
2732            scoped_verify_upstream: Vec::new(),
2733            intercept_ca: InterceptCaConfig::default(),
2734            cache: CertCacheConfig::default(),
2735        }
2736    }
2737}
2738
2739impl Default for CertCacheConfig {
2740    fn default() -> Self {
2741        Self {
2742            capacity: default_cache_capacity(),
2743            validity_hours: default_cert_validity_hours(),
2744        }
2745    }
2746}
2747
2748fn default_intercepted_ports() -> Vec<u16> {
2749    vec![443]
2750}
2751
2752fn default_cache_capacity() -> usize {
2753    1000
2754}
2755
2756fn default_cert_validity_hours() -> u64 {
2757    24
2758}
2759
2760//--------------------------------------------------------------------------------------------------
2761// Types: Networking — policy
2762//--------------------------------------------------------------------------------------------------
2763
2764/// Action to take on traffic matched by a [`Rule`] (or a policy default).
2765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2766#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2767#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2768#[serde(rename_all = "snake_case")]
2769pub enum Action {
2770    /// Allow the traffic.
2771    Allow,
2772    /// Silently drop the traffic.
2773    Deny,
2774}
2775
2776/// Direction a [`Rule`] applies to.
2777#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2778#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2779#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2780#[serde(rename_all = "snake_case")]
2781pub enum Direction {
2782    /// Outbound: guest → destination.
2783    Egress,
2784    /// Inbound: peer → guest.
2785    Ingress,
2786    /// Either direction.
2787    Any,
2788}
2789
2790/// Protocol filter for a [`Rule`].
2791#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2792#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2793#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2794#[serde(rename_all = "snake_case")]
2795pub enum Protocol {
2796    /// TCP.
2797    Tcp,
2798    /// UDP.
2799    Udp,
2800    /// ICMPv4.
2801    Icmpv4,
2802    /// ICMPv6.
2803    Icmpv6,
2804}
2805
2806/// Pre-defined destination category for a [`Destination::Group`] match.
2807#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2808#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2809#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2810#[serde(rename_all = "snake_case")]
2811pub enum DestinationGroup {
2812    /// Public internet — any address not in another category.
2813    Public,
2814    /// Loopback addresses (`127.0.0.0/8`, `::1`).
2815    Loopback,
2816    /// Private ranges (RFC 1918 / RFC 4193 ULA / CGN).
2817    Private,
2818    /// Link-local addresses, excluding the metadata IP.
2819    LinkLocal,
2820    /// Cloud metadata endpoint (`169.254.169.254`).
2821    Metadata,
2822    /// Multicast addresses (`224.0.0.0/4`, `ff00::/8`).
2823    Multicast,
2824    /// The sandbox host, reachable via the gateway IP.
2825    Host,
2826}
2827
2828/// Traffic destination filter for a [`Rule`].
2829///
2830/// The `Cidr`, `Domain`, and `DomainSuffix` leaves carry their canonical
2831/// string form (e.g. `"10.0.0.0/8"`, `"example.com"`); the local network
2832/// engine re-parses and validates them into its richer internal types at
2833/// load time.
2834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2835#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2836#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2837#[serde(rename_all = "snake_case")]
2838pub enum Destination {
2839    /// Match any destination.
2840    Any,
2841    /// IP address or CIDR block (e.g. `"1.2.3.4"`, `"10.0.0.0/8"`).
2842    #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2843    Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
2844    /// Exact domain name (e.g. `"example.com"`).
2845    Domain(String),
2846    /// Domain suffix — the apex and any subdomain of it.
2847    DomainSuffix(String),
2848    /// A pre-defined destination group.
2849    Group(DestinationGroup),
2850}
2851
2852/// Inclusive guest-side port range for a [`Rule`] match.
2853#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2854#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2855#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2856pub struct PortRange {
2857    /// Start port (inclusive).
2858    pub start: u16,
2859    /// End port (inclusive).
2860    pub end: u16,
2861}
2862
2863/// A single egress/ingress policy rule. Evaluated first-match-wins per
2864/// direction.
2865#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2866#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2867#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2868pub struct Rule {
2869    /// Direction this rule applies to.
2870    pub direction: Direction,
2871    /// Destination filter (direction-dependent interpretation).
2872    pub destination: Destination,
2873    /// Protocol set; empty matches any protocol.
2874    #[serde(default)]
2875    pub protocols: Vec<Protocol>,
2876    /// Guest-side port-range set; empty matches any port.
2877    #[serde(default)]
2878    pub ports: Vec<PortRange>,
2879    /// Action to take on a match.
2880    pub action: Action,
2881}
2882
2883/// Egress/ingress network policy: an ordered [`Rule`] list plus a
2884/// per-direction default [`Action`]. Carried in [`NetworkSpec::policy`].
2885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2886#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2887#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2888pub struct NetworkPolicy {
2889    /// Default action for egress traffic matching no rule. Default: `Deny`.
2890    #[serde(default = "action_deny")]
2891    pub default_egress: Action,
2892    /// Default action for ingress traffic matching no rule. Default: `Deny`.
2893    #[serde(default = "action_deny")]
2894    pub default_ingress: Action,
2895    /// Ordered rules, evaluated first-match-wins per direction.
2896    #[serde(default)]
2897    pub rules: Vec<Rule>,
2898}
2899
2900/// Default [`Action`] (`Deny`) for a policy's per-direction defaults, so a
2901/// partially-specified policy fails closed.
2902fn action_deny() -> Action {
2903    Action::Deny
2904}
2905
2906//--------------------------------------------------------------------------------------------------
2907// Types: Networking — DNS & interface
2908//--------------------------------------------------------------------------------------------------
2909
2910/// DNS interception and filtering settings. Carried in [`NetworkSpec::dns`].
2911#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
2912#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2913#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2914#[serde(default)]
2915pub struct DnsConfig {
2916    /// Whether DNS-rebinding protection is enabled. Default: true.
2917    pub rebind_protection: bool,
2918    /// Upstream nameservers as `IP`, `IP:PORT`, `HOST`, or `HOST:PORT`
2919    /// strings. Empty falls back to the host's `/etc/resolv.conf`.
2920    pub nameservers: Vec<String>,
2921    /// Per-query timeout in milliseconds. Default: 5000.
2922    pub query_timeout_ms: u64,
2923}
2924
2925impl Default for DnsConfig {
2926    fn default() -> Self {
2927        Self {
2928            rebind_protection: true,
2929            nameservers: Vec::new(),
2930            query_timeout_ms: 5000,
2931        }
2932    }
2933}
2934
2935/// Optional guest interface overrides. Unset fields are derived from the
2936/// sandbox slot by the local network engine. Carried in
2937/// [`NetworkSpec::interface`].
2938#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
2939#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2940#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2941#[serde(default)]
2942pub struct InterfaceOverrides {
2943    /// Guest MAC address as six octets. Default: derived from slot.
2944    #[serde(skip_serializing_if = "Option::is_none")]
2945    pub mac: Option<[u8; 6]>,
2946    /// Interface MTU. Default: 1500.
2947    #[serde(skip_serializing_if = "Option::is_none")]
2948    pub mtu: Option<u16>,
2949    /// Guest IPv4 address (e.g. `172.16.0.2`). Default: derived from slot.
2950    #[serde(skip_serializing_if = "Option::is_none")]
2951    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2952    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2953    pub ipv4_address: Option<Ipv4Addr>,
2954    /// Guest IPv4 pool CIDR (e.g. `"172.16.0.0/12"`). Default: derived from slot.
2955    #[serde(skip_serializing_if = "Option::is_none")]
2956    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2957    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2958    pub ipv4_pool: Option<Ipv4Network>,
2959    /// Guest IPv6 address. Default: derived from slot.
2960    #[serde(skip_serializing_if = "Option::is_none")]
2961    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2962    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2963    pub ipv6_address: Option<Ipv6Addr>,
2964    /// Guest IPv6 pool CIDR. Default: derived from slot.
2965    #[serde(skip_serializing_if = "Option::is_none")]
2966    #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2967    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2968    pub ipv6_pool: Option<Ipv6Network>,
2969}
2970
2971fn empty_secret_value() -> Zeroizing<String> {
2972    Zeroizing::new(String::new())
2973}
2974
2975//--------------------------------------------------------------------------------------------------
2976// Types: Networking — rate limits
2977//--------------------------------------------------------------------------------------------------
2978
2979/// Sandbox-relative direction governed by a network rate limiter.
2980#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2981pub enum NetworkRateLimitDirection {
2982    /// Traffic leaving the sandbox.
2983    Egress,
2984    /// Traffic entering the sandbox.
2985    Ingress,
2986}
2987
2988/// Egress and ingress rate limits for a local sandbox network.
2989#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
2990#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2991#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2992#[serde(default)]
2993pub struct NetworkRateLimiterConfig {
2994    /// Guest-to-runtime (egress) rate limiter. Missing means unlimited.
2995    #[serde(skip_serializing_if = "Option::is_none")]
2996    pub egress: Option<RateLimiterConfig>,
2997
2998    /// Runtime-to-guest (ingress) rate limiter. Missing means unlimited.
2999    #[serde(skip_serializing_if = "Option::is_none")]
3000    pub ingress: Option<RateLimiterConfig>,
3001}
3002
3003/// Token-bucket rate limiter for one traffic direction. Carried in
3004/// [`NetworkRateLimiterConfig::egress`] and [`NetworkRateLimiterConfig::ingress`].
3005///
3006/// A limiter caps bandwidth (bytes) and packet rate (operations)
3007/// independently; a missing bucket leaves that dimension unlimited.
3008#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
3009#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3010#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3011#[serde(default)]
3012pub struct RateLimiterConfig {
3013    /// Bandwidth bucket. One token is one byte of frame data.
3014    #[serde(skip_serializing_if = "Option::is_none")]
3015    pub bandwidth: Option<TokenBucketConfig>,
3016
3017    /// Operations bucket. One token is one network frame.
3018    #[serde(skip_serializing_if = "Option::is_none")]
3019    pub ops: Option<TokenBucketConfig>,
3020}
3021
3022/// One token bucket of a [`RateLimiterConfig`].
3023///
3024/// The bucket starts full and refills continuously at `size` tokens per
3025/// `refill_time_ms`. `one_time_burst` grants extra startup tokens that are
3026/// spent before the regular budget and never refill.
3027#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3028#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3029#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3030pub struct TokenBucketConfig {
3031    /// Bucket capacity in tokens. Must be greater than zero.
3032    pub size: u64,
3033
3034    /// Time to refill `size` tokens, in milliseconds. Must be greater than
3035    /// zero.
3036    pub refill_time_ms: u64,
3037
3038    /// Extra tokens granted once at startup. Default: 0.
3039    #[serde(default)]
3040    pub one_time_burst: u64,
3041}
3042
3043/// Invalid rate limiter configuration.
3044#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3045pub enum RateLimitConfigError {
3046    /// The limiter has neither a bandwidth nor an ops bucket.
3047    #[error("rate limiter must configure at least one of bandwidth or ops")]
3048    EmptyLimiter,
3049
3050    /// A bucket capacity is zero.
3051    #[error("{bucket} bucket: size must be greater than zero")]
3052    ZeroSize {
3053        /// Which bucket is invalid (`bandwidth` or `ops`).
3054        bucket: &'static str,
3055    },
3056
3057    /// A bucket refill interval is zero.
3058    #[error("{bucket} bucket: refill_time_ms must be greater than zero")]
3059    ZeroRefillTime {
3060        /// Which bucket is invalid (`bandwidth` or `ops`).
3061        bucket: &'static str,
3062    },
3063}
3064
3065impl RateLimiterConfig {
3066    /// Validate the limiter and each configured bucket.
3067    pub fn validate(&self) -> Result<(), RateLimitConfigError> {
3068        if self.bandwidth.is_none() && self.ops.is_none() {
3069            return Err(RateLimitConfigError::EmptyLimiter);
3070        }
3071        if let Some(bandwidth) = &self.bandwidth {
3072            bandwidth.validate("bandwidth")?;
3073        }
3074        if let Some(ops) = &self.ops {
3075            ops.validate("ops")?;
3076        }
3077        Ok(())
3078    }
3079}
3080
3081impl TokenBucketConfig {
3082    /// Validate this bucket. `bucket` names it in error messages.
3083    pub fn validate(&self, bucket: &'static str) -> Result<(), RateLimitConfigError> {
3084        if self.size == 0 {
3085            return Err(RateLimitConfigError::ZeroSize { bucket });
3086        }
3087        if self.refill_time_ms == 0 {
3088            return Err(RateLimitConfigError::ZeroRefillTime { bucket });
3089        }
3090        Ok(())
3091    }
3092}
3093
3094impl fmt::Display for NetworkRateLimitDirection {
3095    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3096        match self {
3097            Self::Egress => f.write_str("egress"),
3098            Self::Ingress => f.write_str("ingress"),
3099        }
3100    }
3101}
3102
3103//--------------------------------------------------------------------------------------------------
3104// Tests
3105//--------------------------------------------------------------------------------------------------
3106
3107#[cfg(test)]
3108mod tests {
3109    use super::*;
3110
3111    fn tmpfs_mount(guest: &str) -> VolumeMount {
3112        VolumeMount::Tmpfs {
3113            guest: guest.to_owned(),
3114            size_mib: None,
3115            options: MountOptions::default(),
3116        }
3117    }
3118
3119    #[test]
3120    fn mount_options_omit_unset_owner_but_accept_missing_fields() {
3121        let value = serde_json::to_value(MountOptions::default()).unwrap();
3122        assert!(value.get("override_uid").is_none());
3123        assert!(value.get("override_gid").is_none());
3124
3125        let decoded: MountOptions = serde_json::from_value(value).unwrap();
3126        assert_eq!(decoded.override_uid, None);
3127        assert_eq!(decoded.override_gid, None);
3128    }
3129
3130    #[test]
3131    fn volume_mounts_are_canonicalized_and_ordered_parent_first() {
3132        let mut mounts = vec![
3133            tmpfs_mount("/workspace//persist/./logs/"),
3134            tmpfs_mount("/alpha/z"),
3135            tmpfs_mount("/workspace"),
3136        ];
3137
3138        canonicalize_volume_mounts(&mut mounts).unwrap();
3139
3140        assert_eq!(
3141            mounts.iter().map(VolumeMount::guest).collect::<Vec<_>>(),
3142            vec!["/workspace", "/alpha/z", "/workspace/persist/logs"]
3143        );
3144    }
3145
3146    #[test]
3147    fn volume_mounts_reject_duplicate_canonical_paths() {
3148        let mut mounts = vec![tmpfs_mount("/data/cache"), tmpfs_mount("/data//./cache/")];
3149
3150        let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3151
3152        assert!(error.to_string().contains("same guest path: /data/cache"));
3153    }
3154
3155    #[test]
3156    fn volume_mounts_reject_parent_components_before_normalizing() {
3157        let mut mounts = vec![tmpfs_mount("/workspace/../secrets")];
3158
3159        let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3160
3161        assert!(error.to_string().contains("must not contain '..'"));
3162    }
3163
3164    #[test]
3165    fn disk_image_format_from_extension() {
3166        assert_eq!(
3167            DiskImageFormat::from_extension("qcow2"),
3168            Some(DiskImageFormat::Qcow2)
3169        );
3170        assert_eq!(
3171            DiskImageFormat::from_extension("raw"),
3172            Some(DiskImageFormat::Raw)
3173        );
3174        assert_eq!(
3175            DiskImageFormat::from_extension("vmdk"),
3176            Some(DiskImageFormat::Vmdk)
3177        );
3178        assert_eq!(DiskImageFormat::from_extension("ext4"), None);
3179        assert_eq!(DiskImageFormat::from_extension(""), None);
3180    }
3181
3182    #[test]
3183    fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
3184        let resources: SandboxResources =
3185            serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
3186
3187        assert_eq!(resources.cpus, 4);
3188        assert_eq!(resources.max_cpus, 4);
3189        assert_eq!(resources.memory_mib, 2048);
3190        assert_eq!(resources.max_memory_mib, 2048);
3191        assert_eq!(resources.cpu_placement, CpuPlacement::Inherit);
3192        assert_eq!(resources.thp, TransparentHugePagePolicy::Madvise);
3193        assert_eq!(
3194            serde_json::to_value(resources).unwrap(),
3195            serde_json::json!({
3196                "cpus": 4,
3197                "memory_mib": 2048,
3198                "max_cpus": 4,
3199                "max_memory_mib": 2048
3200            })
3201        );
3202    }
3203
3204    #[test]
3205    fn cpu_placement_omits_inherit_and_roundtrips_managed_policies() {
3206        let inherited = serde_json::to_value(SandboxResources::default()).unwrap();
3207        assert!(inherited.get("cpu_placement").is_none());
3208
3209        for policy in [
3210            CpuPlacement::Auto,
3211            CpuPlacement::Spread,
3212            CpuPlacement::Compact,
3213        ] {
3214            let resources = SandboxResources {
3215                cpu_placement: policy,
3216                ..Default::default()
3217            };
3218            let json = serde_json::to_string(&resources).unwrap();
3219            let decoded: SandboxResources = serde_json::from_str(&json).unwrap();
3220
3221            assert_eq!(decoded.cpu_placement, policy);
3222            assert_eq!(policy.to_string().parse::<CpuPlacement>().unwrap(), policy);
3223        }
3224    }
3225
3226    #[test]
3227    fn transparent_huge_page_policy_roundtrips_non_default() {
3228        let resources: SandboxResources = serde_json::from_str(
3229            r#"{"cpus":2,"memory_mib":8192,"max_cpus":2,"max_memory_mib":8192,"thp":"always"}"#,
3230        )
3231        .unwrap();
3232
3233        assert_eq!(resources.thp, TransparentHugePagePolicy::Always);
3234        assert_eq!(
3235            serde_json::to_value(resources).unwrap()["thp"],
3236            serde_json::json!("always")
3237        );
3238        assert_eq!(
3239            "never".parse::<TransparentHugePagePolicy>().unwrap(),
3240            TransparentHugePagePolicy::Never
3241        );
3242        assert!("auto".parse::<TransparentHugePagePolicy>().is_err());
3243    }
3244
3245    #[test]
3246    fn disk_image_format_display_roundtrip() {
3247        for format in [
3248            DiskImageFormat::Qcow2,
3249            DiskImageFormat::Raw,
3250            DiskImageFormat::Vmdk,
3251        ] {
3252            let rendered = format.to_string();
3253            let parsed: DiskImageFormat = rendered.parse().unwrap();
3254            assert_eq!(parsed, format);
3255        }
3256    }
3257
3258    #[test]
3259    fn disk_image_format_from_str_unknown() {
3260        assert!("ext4".parse::<DiskImageFormat>().is_err());
3261    }
3262
3263    #[test]
3264    fn log_source_effective_uses_default_user_program_sources() {
3265        assert_eq!(
3266            LogSource::effective(&[]),
3267            vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
3268        );
3269    }
3270
3271    #[test]
3272    fn log_source_effective_sorts_and_deduplicates_requested_sources() {
3273        assert_eq!(
3274            LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
3275            vec![LogSource::Stdout, LogSource::System]
3276        );
3277    }
3278
3279    #[test]
3280    fn rlimit_resource_parses_case_insensitively() {
3281        assert_eq!(
3282            RlimitResource::try_from("NOFILE").unwrap(),
3283            RlimitResource::Nofile
3284        );
3285        assert!(RlimitResource::try_from("bogus").is_err());
3286    }
3287
3288    #[test]
3289    fn sandbox_policy_serde_roundtrip() {
3290        let policy = SandboxPolicy {
3291            ephemeral: true,
3292            max_duration_secs: Some(3600),
3293            idle_timeout_secs: Some(120),
3294        };
3295
3296        let json = serde_json::to_string(&policy).unwrap();
3297        let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
3298
3299        assert!(decoded.ephemeral);
3300        assert_eq!(decoded.max_duration_secs, Some(3600));
3301        assert_eq!(decoded.idle_timeout_secs, Some(120));
3302    }
3303
3304    #[test]
3305    fn sandbox_policy_defaults_to_persistent() {
3306        assert!(!SandboxPolicy::default().ephemeral);
3307    }
3308
3309    #[test]
3310    fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
3311        // `ephemeral` has a persistent default so partial policy payloads
3312        // deserialize to the conservative behavior.
3313        let decoded: SandboxPolicy =
3314            serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
3315        assert!(!decoded.ephemeral);
3316        assert_eq!(decoded.max_duration_secs, Some(60));
3317    }
3318
3319    #[test]
3320    fn sandbox_spec_default_uses_static_resource_defaults() {
3321        let spec = SandboxSpec::default();
3322
3323        assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
3324        assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
3325        assert_eq!(
3326            spec.runtime.metrics_sample_interval_ms,
3327            Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
3328        );
3329        assert_eq!(spec.deployment_profile, DeploymentProfile::SingleTenant);
3330    }
3331
3332    #[test]
3333    fn deployment_profile_uses_stable_snake_case_wire_values() {
3334        assert_eq!(
3335            serde_json::to_string(&DeploymentProfile::MultiTenant).unwrap(),
3336            r#""multi_tenant""#
3337        );
3338        assert_eq!(
3339            serde_json::from_str::<DeploymentProfile>(r#""single_tenant""#).unwrap(),
3340            DeploymentProfile::SingleTenant
3341        );
3342    }
3343
3344    #[test]
3345    fn sandbox_log_level_roundtrips_lowercase_values() {
3346        for (input, expected) in [
3347            ("error", SandboxLogLevel::Error),
3348            ("warn", SandboxLogLevel::Warn),
3349            ("info", SandboxLogLevel::Info),
3350            ("debug", SandboxLogLevel::Debug),
3351            ("trace", SandboxLogLevel::Trace),
3352        ] {
3353            let parsed: SandboxLogLevel = input.parse().unwrap();
3354            assert_eq!(parsed, expected);
3355            assert_eq!(parsed.as_str(), input);
3356        }
3357    }
3358}