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