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