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