Skip to main content

microsandbox_types/
domain.rs

1//! Shared sandbox domain types.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11//--------------------------------------------------------------------------------------------------
12// Constants
13//--------------------------------------------------------------------------------------------------
14
15/// Default number of virtual CPUs in a sandbox specification.
16pub const DEFAULT_SANDBOX_CPUS: u8 = 1;
17
18/// Default guest memory in MiB in a sandbox specification.
19pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
20
21/// Default metrics sampling interval in milliseconds.
22pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
23
24//--------------------------------------------------------------------------------------------------
25// Types: Root Filesystems
26//--------------------------------------------------------------------------------------------------
27
28/// Disk image format for virtio-blk root filesystems and volume mounts.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
31pub enum DiskImageFormat {
32    /// QEMU Copy-on-Write v2.
33    Qcow2,
34    /// Raw disk image.
35    Raw,
36    /// VMware Disk (FLAT/ZERO only, no delta links).
37    Vmdk,
38}
39
40/// Root filesystem source for a sandbox.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
43pub enum RootfsSource {
44    /// Use a host directory directly as the root filesystem.
45    Bind(
46        /// Host path to bind mount.
47        #[cfg_attr(feature = "ts", ts(type = "string"))]
48        PathBuf,
49    ),
50
51    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
52    Oci(OciRootfsSource),
53
54    /// Use a disk image file as the root filesystem via virtio-blk.
55    DiskImage {
56        /// Path to the disk image file on the host.
57        #[cfg_attr(feature = "ts", ts(type = "string"))]
58        path: PathBuf,
59        /// Disk image format.
60        format: DiskImageFormat,
61        /// Inner filesystem type (optional; auto-detected if absent).
62        fstype: Option<String>,
63    },
64}
65
66/// OCI root filesystem source.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
69pub struct OciRootfsSource {
70    /// OCI image reference (e.g. `python`).
71    pub reference: String,
72
73    /// Writable overlay upper size in MiB.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub upper_size_mib: Option<u32>,
76}
77
78/// Controls when an OCI registry is contacted for manifest freshness.
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
81pub enum PullPolicy {
82    /// Use cached layers if complete, pull otherwise.
83    #[default]
84    IfMissing,
85
86    /// Always fetch the manifest from the registry, reusing cached layers whose digests still match.
87    Always,
88
89    /// Never contact the registry. Error if the image is not fully cached locally.
90    Never,
91}
92
93//--------------------------------------------------------------------------------------------------
94// Types: Mounts
95//--------------------------------------------------------------------------------------------------
96
97/// Stat virtualization policy for a virtiofs-backed volume mount.
98///
99/// 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.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
102#[serde(rename_all = "lowercase")]
103pub enum StatVirtualization {
104    /// Fail-closed: probe the host backing path; require xattr support.
105    Strict,
106    /// Opportunistic: apply the overlay when present; tolerate missing xattr support.
107    Relaxed,
108    /// Literal host metadata: do not read or apply the override xattr.
109    Off,
110}
111
112/// Host permission propagation policy for a virtiofs-backed volume mount.
113///
114/// Serializes/deserializes as the lowercase variant name (`"private"`, `"mirror"`) to align with the CLI and NAPI spellings.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
117#[serde(rename_all = "lowercase")]
118pub enum HostPermissions {
119    /// Guest chmod stays in the metadata overlay only.
120    Private,
121    /// Mirror ordinary rwx bits for regular files and directories to the host inode.
122    Mirror,
123}
124
125/// Sandbox-level in-guest security profile.
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
128#[serde(rename_all = "lowercase")]
129pub enum SecurityProfile {
130    /// Preserve normal guest-root semantics.
131    ///
132    /// 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.
133    #[default]
134    Default,
135
136    /// Harden guest exec sessions.
137    ///
138    /// 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.
139    Restricted,
140}
141
142/// Guest mount behavior shared by every volume mount kind.
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
145#[serde(default)]
146pub struct MountOptions {
147    /// Whether the mount is read-only.
148    ///
149    /// 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.
150    pub readonly: bool,
151
152    /// Whether direct execution from the mount is disabled.
153    ///
154    /// 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.
155    pub noexec: bool,
156
157    /// Whether setuid and setgid privilege elevation from files on the mount is ignored.
158    pub nosuid: bool,
159
160    /// Whether device files on the mount are ignored.
161    pub nodev: bool,
162}
163
164/// Storage kind for a named volume.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
167pub enum VolumeKind {
168    /// Directory-backed named volume mounted through virtiofs.
169    Directory,
170
171    /// Raw ext4 disk-image named volume mounted through virtio-blk.
172    Disk,
173}
174
175/// Configuration for creating a named volume.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
178pub struct VolumeSpec {
179    /// Volume name.
180    pub name: String,
181
182    /// Storage kind.
183    pub kind: VolumeKind,
184
185    /// Size quota in MiB. `None` means unlimited.
186    pub quota_mib: Option<u32>,
187
188    /// Disk capacity in MiB. Required for disk volumes.
189    pub capacity_mib: Option<u32>,
190
191    /// Labels for organization.
192    pub labels: Vec<(String, String)>,
193}
194
195/// Sandbox-time behavior for a named volume mount.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
198pub enum NamedVolumeMode {
199    /// Require the named volume to already exist.
200    Existing,
201
202    /// Create the named volume and fail if it already exists.
203    Create,
204
205    /// Ensure the named volume exists, or reuse a compatible existing volume.
206    EnsureExists,
207}
208
209/// Creation metadata for sandbox-time named volume provisioning.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
212pub struct NamedVolumeCreate {
213    /// Creation behavior for this named volume mount.
214    pub mode: NamedVolumeMode,
215
216    /// Volume name to create or ensure exists.
217    pub name: String,
218
219    /// Storage kind to create or ensure exists.
220    pub kind: VolumeKind,
221
222    /// Directory quota in MiB, if configured.
223    pub quota_mib: Option<u32>,
224
225    /// Disk capacity in MiB, if configured.
226    pub capacity_mib: Option<u32>,
227
228    /// Labels to attach to newly-created volumes.
229    pub labels: Vec<(String, String)>,
230}
231
232/// A volume mount specification for a sandbox.
233#[derive(Clone)]
234#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
235#[cfg_attr(feature = "ts", ts(tag = "type"))]
236pub enum VolumeMount {
237    /// Bind mount a host directory into the guest.
238    Bind {
239        /// Host path to bind mount.
240        #[cfg_attr(feature = "ts", ts(type = "string"))]
241        host: PathBuf,
242        /// Guest mount path.
243        guest: String,
244        /// Guest mount behavior.
245        options: MountOptions,
246        /// Guest-visible stat virtualization policy.
247        stat_virtualization: StatVirtualization,
248        /// Host permission propagation policy.
249        host_permissions: HostPermissions,
250    },
251
252    /// Mount a named volume into the guest.
253    Named {
254        /// Volume name.
255        name: String,
256        /// Guest mount path.
257        guest: String,
258        /// Creation metadata for sandbox-time named volume provisioning.
259        ///
260        /// This is transient and intentionally skipped when sandbox configs are persisted; restarting a sandbox mounts the already-created volume.
261        create: Option<NamedVolumeCreate>,
262        /// Guest mount behavior.
263        options: MountOptions,
264        /// Guest-visible stat virtualization policy.
265        stat_virtualization: StatVirtualization,
266        /// Host permission propagation policy.
267        host_permissions: HostPermissions,
268    },
269
270    /// Temporary filesystem backed by guest memory.
271    Tmpfs {
272        /// Guest mount path.
273        guest: String,
274        /// Size limit in MiB.
275        size_mib: Option<u32>,
276        /// Guest mount behavior.
277        options: MountOptions,
278    },
279
280    /// Mount a disk image file as a virtio-blk device at a guest path.
281    DiskImage {
282        /// Host path to the disk image file.
283        #[cfg_attr(feature = "ts", ts(type = "string"))]
284        host: PathBuf,
285        /// Guest mount path.
286        guest: String,
287        /// Disk image format.
288        format: DiskImageFormat,
289        /// Inner filesystem type. When `None`, agentd probes `/proc/filesystems`.
290        fstype: Option<String>,
291        /// Guest mount behavior.
292        options: MountOptions,
293    },
294}
295
296/// Rootfs patch applied before VM startup.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
299pub enum Patch {
300    /// Write text content to a file.
301    Text {
302        /// Absolute guest path, such as `/etc/app.conf`.
303        path: String,
304        /// Text content to write.
305        content: String,
306        /// File permissions, such as `0o644`. `None` uses the default.
307        mode: Option<u32>,
308        /// Allow replacing a file that already exists in the rootfs.
309        replace: bool,
310    },
311
312    /// Write raw bytes to a file.
313    File {
314        /// Absolute guest path.
315        path: String,
316        /// Raw byte content to write.
317        content: Vec<u8>,
318        /// File permissions, such as `0o644`. `None` uses the default.
319        mode: Option<u32>,
320        /// Allow replacing a file that already exists in the rootfs.
321        replace: bool,
322    },
323
324    /// Copy a file from the host into the rootfs.
325    CopyFile {
326        /// Host path to copy from.
327        #[cfg_attr(feature = "ts", ts(type = "string"))]
328        src: PathBuf,
329        /// Absolute guest destination path.
330        dst: String,
331        /// File permissions. `None` preserves source permissions.
332        mode: Option<u32>,
333        /// Allow replacing a file that already exists in the rootfs.
334        replace: bool,
335    },
336
337    /// Copy a directory from the host into the rootfs.
338    CopyDir {
339        /// Host directory to copy from.
340        #[cfg_attr(feature = "ts", ts(type = "string"))]
341        src: PathBuf,
342        /// Absolute guest destination path.
343        dst: String,
344        /// Allow replacing files that already exist in the rootfs.
345        replace: bool,
346    },
347
348    /// Create a symlink.
349    Symlink {
350        /// Symlink target path.
351        target: String,
352        /// Absolute guest path where the symlink is created.
353        link: String,
354        /// Allow replacing a path that already exists in the rootfs.
355        replace: bool,
356    },
357
358    /// Create a directory.
359    Mkdir {
360        /// Absolute guest path.
361        path: String,
362        /// Directory permissions, such as `0o755`. `None` uses the default.
363        mode: Option<u32>,
364    },
365
366    /// Remove a file or directory.
367    Remove {
368        /// Absolute guest path to remove.
369        path: String,
370    },
371
372    /// Append content to an existing file.
373    Append {
374        /// Absolute guest path of the file to append to.
375        path: String,
376        /// Content to append.
377        content: String,
378    },
379}
380
381//--------------------------------------------------------------------------------------------------
382// Types: Networking
383//--------------------------------------------------------------------------------------------------
384
385/// Complete network specification for a sandbox.
386///
387/// 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.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
390#[serde(default)]
391pub struct NetworkSpec {
392    /// Whether networking is enabled for this sandbox.
393    pub enabled: bool,
394
395    /// Guest interface overrides for the local network engine.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub interface: Option<Value>,
398
399    /// Host-to-guest port mappings.
400    pub ports: Vec<PublishedPortSpec>,
401
402    /// Egress and ingress policy subdocument.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub policy: Option<Value>,
405
406    /// DNS interception and filtering subdocument.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub dns: Option<Value>,
409
410    /// TLS interception subdocument.
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub tls: Option<Value>,
413
414    /// Secret injection subdocument.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub secrets: Option<Value>,
417
418    /// Max concurrent guest connections.
419    pub max_connections: Option<usize>,
420
421    /// Whether to copy trusted host CAs into the guest at boot.
422    pub trust_host_cas: bool,
423}
424
425/// A published port mapping between host and guest.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
428pub struct PublishedPortSpec {
429    /// Host-side port to bind.
430    pub host_port: u16,
431
432    /// Guest-side port to forward to.
433    pub guest_port: u16,
434
435    /// Transport protocol.
436    #[serde(default)]
437    pub protocol: PortProtocol,
438
439    /// Host address to bind. Defaults to loopback.
440    pub host_bind: String,
441}
442
443/// Transport protocol for a published port.
444#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
445#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
446pub enum PortProtocol {
447    /// TCP.
448    #[default]
449    #[serde(rename = "tcp")]
450    Tcp,
451
452    /// UDP.
453    #[serde(rename = "udp")]
454    Udp,
455}
456
457//--------------------------------------------------------------------------------------------------
458// Types: Init
459//--------------------------------------------------------------------------------------------------
460
461/// Fully-assembled handoff-init specification.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
464pub struct HandoffInit {
465    /// Init binary: absolute path inside the guest rootfs, or the literal `auto`.
466    #[cfg_attr(feature = "ts", ts(type = "string"))]
467    pub cmd: PathBuf,
468
469    /// Supplemental argv. `argv[0]` is implicitly `cmd`.
470    #[serde(default)]
471    pub args: Vec<String>,
472
473    /// Extra env vars merged on top of the inherited env.
474    #[serde(default)]
475    pub env: Vec<(String, String)>,
476}
477
478//--------------------------------------------------------------------------------------------------
479// Types: Lifecycle
480//--------------------------------------------------------------------------------------------------
481
482/// Sandbox lifecycle policy.
483#[derive(Debug, Default, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
485pub struct SandboxPolicy {
486    /// Whether the sandbox is ephemeral.
487    ///
488    /// Ephemeral sandboxes are one-off: the host runtime that owns the
489    /// process removes the persisted DB row and on-disk state when the VM
490    /// reaches a terminal status, and other host runtimes opportunistically
491    /// clean up ephemeral leftovers from runtimes that died before they
492    /// could self-clean. Defaults to `false` (persistent); named and created
493    /// sandboxes stay inspectable and restartable after they stop.
494    #[serde(default)]
495    pub ephemeral: bool,
496
497    /// Hard cap on total sandbox lifetime in seconds. `None` = run forever.
498    pub max_duration_secs: Option<u64>,
499
500    /// Idle timeout in seconds. `None` = no idle detection.
501    pub idle_timeout_secs: Option<u64>,
502}
503
504//--------------------------------------------------------------------------------------------------
505// Types: Snapshots
506//--------------------------------------------------------------------------------------------------
507
508/// Where to place a new snapshot artifact.
509#[derive(Debug, Clone, Serialize, Deserialize)]
510#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
511pub enum SnapshotDestination {
512    /// Bare name resolved under the default snapshots directory.
513    Name(String),
514
515    /// Explicit absolute or relative path to the artifact directory.
516    Path(
517        /// Destination path.
518        #[cfg_attr(feature = "ts", ts(type = "string"))]
519        PathBuf,
520    ),
521}
522
523/// Inputs to create a snapshot.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
526pub struct SnapshotSpec {
527    /// Name of the source sandbox. Must be stopped.
528    pub source_sandbox: String,
529
530    /// Where to write the artifact.
531    pub destination: SnapshotDestination,
532
533    /// User-supplied labels.
534    pub labels: Vec<(String, String)>,
535
536    /// Overwrite an existing artifact at the destination.
537    pub force: bool,
538
539    /// Compute and record upper-layer content integrity at creation time.
540    pub record_integrity: bool,
541}
542
543//--------------------------------------------------------------------------------------------------
544// Types: Sandbox Specs
545//--------------------------------------------------------------------------------------------------
546
547/// Backend-neutral sandbox task description.
548///
549/// 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.
550#[derive(Debug, Default, Clone, Serialize, Deserialize)]
551#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
552#[serde(default)]
553pub struct SandboxSpec {
554    /// Unique sandbox name.
555    pub name: String,
556
557    /// Root filesystem source.
558    pub image: RootfsSource,
559
560    /// CPU and memory resources.
561    pub resources: SandboxResources,
562
563    /// Guest runtime options.
564    pub runtime: SandboxRuntimeOptions,
565
566    /// Environment variables visible to commands in the sandbox.
567    pub env: Vec<EnvVar>,
568
569    /// User-defined labels attached to the sandbox.
570    pub labels: BTreeMap<String, String>,
571
572    /// Sandbox-wide resource limits inherited by guest processes.
573    pub rlimits: Vec<Rlimit>,
574
575    /// Volume mounts.
576    pub mounts: Vec<VolumeMount>,
577
578    /// Rootfs patches applied before VM start.
579    pub patches: Vec<Patch>,
580
581    /// Network specification.
582    pub network: NetworkSpec,
583
584    /// Hand off PID 1 to a guest init binary after agentd setup.
585    pub init: Option<HandoffInit>,
586
587    /// Pull policy for OCI images.
588    pub pull_policy: PullPolicy,
589
590    /// In-guest security profile.
591    pub security_profile: SecurityProfile,
592
593    /// Sandbox lifecycle policy.
594    pub lifecycle: SandboxPolicy,
595}
596
597/// CPU and memory resources for a sandbox.
598#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
599#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
600#[serde(default)]
601pub struct SandboxResources {
602    /// Number of virtual CPUs.
603    pub cpus: u8,
604
605    /// Guest memory in MiB.
606    pub memory_mib: u32,
607}
608
609/// Guest runtime options for a sandbox.
610#[derive(Debug, Clone, Serialize, Deserialize)]
611#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
612#[serde(default)]
613pub struct SandboxRuntimeOptions {
614    /// Working directory inside the guest.
615    pub workdir: Option<String>,
616
617    /// Default shell for scripts and interactive sessions.
618    pub shell: Option<String>,
619
620    /// Named scripts available inside the guest.
621    pub scripts: BTreeMap<String, String>,
622
623    /// Image entrypoint override.
624    pub entrypoint: Option<Vec<String>>,
625
626    /// Image command override.
627    pub cmd: Option<Vec<String>>,
628
629    /// Guest hostname override.
630    pub hostname: Option<String>,
631
632    /// Guest user identity override.
633    pub user: Option<String>,
634
635    /// Runtime log verbosity.
636    pub log_level: Option<SandboxLogLevel>,
637
638    /// Metrics sampling interval in milliseconds. `None` disables sampling.
639    pub metrics_sample_interval_ms: Option<u64>,
640
641    /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`.
642    pub disable_metrics_sample: bool,
643}
644
645/// Environment variable entry.
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
648pub struct EnvVar {
649    /// Environment variable name.
650    pub key: String,
651
652    /// Environment variable value.
653    pub value: String,
654}
655
656/// Runtime log verbosity for sandbox specs.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
658#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
659#[serde(rename_all = "lowercase")]
660pub enum SandboxLogLevel {
661    /// Emit only error logs.
662    Error,
663
664    /// Emit warning and error logs.
665    Warn,
666
667    /// Emit info, warning, and error logs.
668    Info,
669
670    /// Emit debug and higher-severity logs.
671    Debug,
672
673    /// Emit trace and higher-severity logs.
674    Trace,
675}
676
677//--------------------------------------------------------------------------------------------------
678// Types: Exec
679//--------------------------------------------------------------------------------------------------
680
681/// POSIX resource limit identifiers.
682#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
683#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
684pub enum RlimitResource {
685    /// Max CPU time in seconds (`RLIMIT_CPU`).
686    Cpu,
687    /// Max file size in bytes (`RLIMIT_FSIZE`).
688    Fsize,
689    /// Max data segment size (`RLIMIT_DATA`).
690    Data,
691    /// Max stack size (`RLIMIT_STACK`).
692    Stack,
693    /// Max core file size (`RLIMIT_CORE`).
694    Core,
695    /// Max resident set size (`RLIMIT_RSS`).
696    Rss,
697    /// Max number of processes (`RLIMIT_NPROC`).
698    Nproc,
699    /// Max open file descriptors (`RLIMIT_NOFILE`).
700    Nofile,
701    /// Max locked memory (`RLIMIT_MEMLOCK`).
702    Memlock,
703    /// Max address space size (`RLIMIT_AS`).
704    As,
705    /// Max file locks (`RLIMIT_LOCKS`).
706    Locks,
707    /// Max pending signals (`RLIMIT_SIGPENDING`).
708    Sigpending,
709    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
710    Msgqueue,
711    /// Max nice priority (`RLIMIT_NICE`).
712    Nice,
713    /// Max real-time priority (`RLIMIT_RTPRIO`).
714    Rtprio,
715    /// Max real-time timeout (`RLIMIT_RTTIME`).
716    Rttime,
717}
718
719/// A POSIX resource limit.
720#[derive(Debug, Clone, Serialize, Deserialize)]
721#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
722pub struct Rlimit {
723    /// Resource type.
724    pub resource: RlimitResource,
725
726    /// Soft limit (can be raised up to hard limit by the process).
727    pub soft: u64,
728
729    /// Hard limit (ceiling, requires privileges to raise).
730    pub hard: u64,
731}
732
733//--------------------------------------------------------------------------------------------------
734// Types: Logs
735//--------------------------------------------------------------------------------------------------
736
737/// Source tag on a captured log entry.
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
739#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
740#[serde(rename_all = "lowercase")]
741pub enum LogSource {
742    /// Captured from a session's stdout (pipe mode).
743    Stdout,
744
745    /// Captured from a session's stderr (pipe mode).
746    Stderr,
747
748    /// Captured from a session in pty mode (stdout + stderr merged at the kernel level inside the guest arrive as a single stream tagged `output`).
749    Output,
750
751    /// Synthetic system entry: lifecycle markers, runtime diagnostics, kernel console output.
752    System,
753}
754
755//--------------------------------------------------------------------------------------------------
756// Methods
757//--------------------------------------------------------------------------------------------------
758
759impl DiskImageFormat {
760    /// Returns the format as a CLI-safe lowercase string.
761    pub fn as_str(&self) -> &'static str {
762        match self {
763            Self::Qcow2 => "qcow2",
764            Self::Raw => "raw",
765            Self::Vmdk => "vmdk",
766        }
767    }
768
769    /// Parse a disk image format from a file extension.
770    ///
771    /// Returns `None` if the extension is not a recognized disk image format.
772    pub fn from_extension(ext: &str) -> Option<Self> {
773        match ext {
774            "qcow2" => Some(Self::Qcow2),
775            "raw" => Some(Self::Raw),
776            "vmdk" => Some(Self::Vmdk),
777            _ => None,
778        }
779    }
780}
781
782impl OciRootfsSource {
783    /// Create a new OCI rootfs source.
784    pub fn new(reference: impl Into<String>) -> Self {
785        Self {
786            reference: reference.into(),
787            upper_size_mib: None,
788        }
789    }
790}
791
792impl RootfsSource {
793    /// Create an OCI rootfs source from an image reference.
794    pub fn oci(reference: impl Into<String>) -> Self {
795        Self::Oci(OciRootfsSource::new(reference))
796    }
797
798    /// Return the OCI image reference if this is an OCI rootfs.
799    pub fn oci_reference(&self) -> Option<&str> {
800        match self {
801            Self::Oci(oci) => Some(&oci.reference),
802            _ => None,
803        }
804    }
805
806    /// Return the configured OCI upper size in MiB if this is an OCI rootfs.
807    pub fn oci_upper_size_mib(&self) -> Option<u32> {
808        match self {
809            Self::Oci(oci) => oci.upper_size_mib,
810            _ => None,
811        }
812    }
813}
814
815impl EnvVar {
816    /// Create an environment variable entry.
817    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
818        Self {
819            key: key.into(),
820            value: value.into(),
821        }
822    }
823
824    /// Return this entry as key and value string slices.
825    pub fn as_pair(&self) -> (&str, &str) {
826        (&self.key, &self.value)
827    }
828}
829
830impl VolumeKind {
831    /// Return the lowercase database and CLI representation.
832    pub fn as_str(self) -> &'static str {
833        match self {
834            Self::Directory => "dir",
835            Self::Disk => "disk",
836        }
837    }
838
839    /// Parse a persisted database value, defaulting to directory for unknown values.
840    pub fn from_db_value(value: &str) -> Self {
841        match value {
842            "disk" => Self::Disk,
843            _ => Self::Directory,
844        }
845    }
846}
847
848impl VolumeSpec {
849    /// Create a directory-backed volume spec with default options.
850    pub fn new(name: impl Into<String>) -> Self {
851        Self {
852            name: name.into(),
853            kind: VolumeKind::Directory,
854            quota_mib: None,
855            capacity_mib: None,
856            labels: Vec::new(),
857        }
858    }
859}
860
861impl NamedVolumeCreate {
862    /// Creation behavior for this named volume mount.
863    pub fn mode(&self) -> NamedVolumeMode {
864        self.mode
865    }
866
867    /// Volume name to create or ensure exists.
868    pub fn name(&self) -> &str {
869        &self.name
870    }
871
872    /// Storage kind to create or ensure exists.
873    pub fn kind(&self) -> VolumeKind {
874        self.kind
875    }
876
877    /// Directory quota in MiB, if configured.
878    pub fn quota_mib(&self) -> Option<u32> {
879        self.quota_mib
880    }
881
882    /// Disk capacity in MiB, if configured.
883    pub fn capacity_mib(&self) -> Option<u32> {
884        self.capacity_mib
885    }
886
887    /// Labels to attach to newly-created volumes.
888    pub fn labels(&self) -> &[(String, String)] {
889        &self.labels
890    }
891}
892
893impl VolumeMount {
894    /// The absolute path where this mount appears inside the guest.
895    pub fn guest(&self) -> &str {
896        match self {
897            Self::Bind { guest, .. }
898            | Self::Named { guest, .. }
899            | Self::Tmpfs { guest, .. }
900            | Self::DiskImage { guest, .. } => guest,
901        }
902    }
903
904    /// Return named-volume creation metadata when this mount provisions a named volume.
905    pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
906        match self {
907            Self::Named { create, .. } => create.as_ref(),
908            _ => None,
909        }
910    }
911}
912
913impl RlimitResource {
914    /// Returns the lowercase string representation used on the wire.
915    pub fn as_str(&self) -> &'static str {
916        match self {
917            Self::Cpu => "cpu",
918            Self::Fsize => "fsize",
919            Self::Data => "data",
920            Self::Stack => "stack",
921            Self::Core => "core",
922            Self::Rss => "rss",
923            Self::Nproc => "nproc",
924            Self::Nofile => "nofile",
925            Self::Memlock => "memlock",
926            Self::As => "as",
927            Self::Locks => "locks",
928            Self::Sigpending => "sigpending",
929            Self::Msgqueue => "msgqueue",
930            Self::Nice => "nice",
931            Self::Rtprio => "rtprio",
932            Self::Rttime => "rttime",
933        }
934    }
935}
936
937impl LogSource {
938    /// Apply the empty-means-default rule used by log readers.
939    pub fn effective(requested: &[Self]) -> Vec<Self> {
940        if requested.is_empty() {
941            vec![Self::Stdout, Self::Stderr, Self::Output]
942        } else {
943            let mut sources = requested.to_vec();
944            sources.sort_by_key(|src| match src {
945                Self::Stdout => 0,
946                Self::Stderr => 1,
947                Self::Output => 2,
948                Self::System => 3,
949            });
950            sources.dedup();
951            sources
952        }
953    }
954}
955
956impl SandboxLogLevel {
957    /// Return the lowercase string representation for this level.
958    pub const fn as_str(self) -> &'static str {
959        match self {
960            Self::Error => "error",
961            Self::Warn => "warn",
962            Self::Info => "info",
963            Self::Debug => "debug",
964            Self::Trace => "trace",
965        }
966    }
967}
968
969//--------------------------------------------------------------------------------------------------
970// Trait Implementations
971//--------------------------------------------------------------------------------------------------
972
973impl std::fmt::Display for DiskImageFormat {
974    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
975        f.write_str(self.as_str())
976    }
977}
978
979impl FromStr for DiskImageFormat {
980    type Err = String;
981
982    fn from_str(s: &str) -> Result<Self, Self::Err> {
983        match s {
984            "qcow2" => Ok(Self::Qcow2),
985            "raw" => Ok(Self::Raw),
986            "vmdk" => Ok(Self::Vmdk),
987            _ => Err(format!("unknown disk image format: {s}")),
988        }
989    }
990}
991
992impl Default for RootfsSource {
993    fn default() -> Self {
994        Self::oci(String::new())
995    }
996}
997
998impl Default for SandboxResources {
999    fn default() -> Self {
1000        Self {
1001            cpus: DEFAULT_SANDBOX_CPUS,
1002            memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1003        }
1004    }
1005}
1006
1007impl Default for SandboxRuntimeOptions {
1008    fn default() -> Self {
1009        Self {
1010            workdir: None,
1011            shell: None,
1012            scripts: BTreeMap::new(),
1013            entrypoint: None,
1014            cmd: None,
1015            hostname: None,
1016            user: None,
1017            log_level: None,
1018            metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1019            disable_metrics_sample: false,
1020        }
1021    }
1022}
1023
1024impl Default for NetworkSpec {
1025    fn default() -> Self {
1026        Self {
1027            enabled: true,
1028            interface: None,
1029            ports: Vec::new(),
1030            policy: None,
1031            dns: None,
1032            tls: None,
1033            secrets: None,
1034            max_connections: None,
1035            trust_host_cas: false,
1036        }
1037    }
1038}
1039
1040impl Default for PublishedPortSpec {
1041    fn default() -> Self {
1042        Self {
1043            host_port: 0,
1044            guest_port: 0,
1045            protocol: PortProtocol::Tcp,
1046            host_bind: "127.0.0.1".into(),
1047        }
1048    }
1049}
1050
1051impl From<(String, String)> for EnvVar {
1052    fn from((key, value): (String, String)) -> Self {
1053        Self { key, value }
1054    }
1055}
1056
1057impl From<EnvVar> for (String, String) {
1058    fn from(var: EnvVar) -> Self {
1059        (var.key, var.value)
1060    }
1061}
1062
1063impl FromStr for SandboxLogLevel {
1064    type Err = String;
1065
1066    fn from_str(s: &str) -> Result<Self, Self::Err> {
1067        match s {
1068            "error" => Ok(Self::Error),
1069            "warn" => Ok(Self::Warn),
1070            "info" => Ok(Self::Info),
1071            "debug" => Ok(Self::Debug),
1072            "trace" => Ok(Self::Trace),
1073            _ => Err(format!("unknown sandbox log level: {s}")),
1074        }
1075    }
1076}
1077
1078impl Serialize for VolumeMount {
1079    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1080        use serde::ser::SerializeMap;
1081
1082        match self {
1083            Self::Bind {
1084                host,
1085                guest,
1086                options,
1087                stat_virtualization,
1088                host_permissions,
1089            } => {
1090                let mut map = serializer.serialize_map(Some(6))?;
1091                map.serialize_entry("type", "Bind")?;
1092                map.serialize_entry("host", host)?;
1093                map.serialize_entry("guest", guest)?;
1094                map.serialize_entry("options", options)?;
1095                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1096                map.serialize_entry("host_permissions", host_permissions)?;
1097                map.end()
1098            }
1099            Self::Named {
1100                name,
1101                guest,
1102                create: _,
1103                options,
1104                stat_virtualization,
1105                host_permissions,
1106            } => {
1107                let mut map = serializer.serialize_map(Some(6))?;
1108                map.serialize_entry("type", "Named")?;
1109                map.serialize_entry("name", name)?;
1110                map.serialize_entry("guest", guest)?;
1111                map.serialize_entry("options", options)?;
1112                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1113                map.serialize_entry("host_permissions", host_permissions)?;
1114                map.end()
1115            }
1116            Self::Tmpfs {
1117                guest,
1118                size_mib,
1119                options,
1120            } => {
1121                let mut map = serializer.serialize_map(Some(4))?;
1122                map.serialize_entry("type", "Tmpfs")?;
1123                map.serialize_entry("guest", guest)?;
1124                map.serialize_entry("size_mib", size_mib)?;
1125                map.serialize_entry("options", options)?;
1126                map.end()
1127            }
1128            Self::DiskImage {
1129                host,
1130                guest,
1131                format,
1132                fstype,
1133                options,
1134            } => {
1135                let mut map = serializer.serialize_map(Some(6))?;
1136                map.serialize_entry("type", "DiskImage")?;
1137                map.serialize_entry("host", host)?;
1138                map.serialize_entry("guest", guest)?;
1139                map.serialize_entry("format", format)?;
1140                map.serialize_entry("fstype", fstype)?;
1141                map.serialize_entry("options", options)?;
1142                map.end()
1143            }
1144        }
1145    }
1146}
1147
1148impl<'de> Deserialize<'de> for VolumeMount {
1149    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1150        fn default_strict() -> StatVirtualization {
1151            StatVirtualization::Strict
1152        }
1153
1154        fn default_private() -> HostPermissions {
1155            HostPermissions::Private
1156        }
1157
1158        #[derive(Deserialize)]
1159        #[serde(tag = "type")]
1160        enum VolumeMountHelper {
1161            Bind {
1162                host: PathBuf,
1163                guest: String,
1164                #[serde(default)]
1165                options: Option<MountOptions>,
1166                #[serde(default)]
1167                readonly: bool,
1168                #[serde(default = "default_strict")]
1169                stat_virtualization: StatVirtualization,
1170                #[serde(default = "default_private")]
1171                host_permissions: HostPermissions,
1172            },
1173            Named {
1174                name: String,
1175                guest: String,
1176                #[serde(default)]
1177                options: Option<MountOptions>,
1178                #[serde(default)]
1179                readonly: bool,
1180                #[serde(default = "default_strict")]
1181                stat_virtualization: StatVirtualization,
1182                #[serde(default = "default_private")]
1183                host_permissions: HostPermissions,
1184            },
1185            Tmpfs {
1186                guest: String,
1187                #[serde(default)]
1188                size_mib: Option<u32>,
1189                #[serde(default)]
1190                options: Option<MountOptions>,
1191                #[serde(default)]
1192                readonly: bool,
1193            },
1194            DiskImage {
1195                host: PathBuf,
1196                guest: String,
1197                format: DiskImageFormat,
1198                #[serde(default)]
1199                fstype: Option<String>,
1200                #[serde(default)]
1201                options: Option<MountOptions>,
1202                #[serde(default)]
1203                readonly: bool,
1204            },
1205        }
1206
1207        let helper = VolumeMountHelper::deserialize(deserializer)?;
1208        Ok(match helper {
1209            VolumeMountHelper::Bind {
1210                host,
1211                guest,
1212                options,
1213                readonly,
1214                stat_virtualization,
1215                host_permissions,
1216            } => Self::Bind {
1217                host,
1218                guest,
1219                options: decode_mount_options(options, readonly),
1220                stat_virtualization,
1221                host_permissions,
1222            },
1223            VolumeMountHelper::Named {
1224                name,
1225                guest,
1226                options,
1227                readonly,
1228                stat_virtualization,
1229                host_permissions,
1230            } => Self::Named {
1231                name,
1232                guest,
1233                create: None,
1234                options: decode_mount_options(options, readonly),
1235                stat_virtualization,
1236                host_permissions,
1237            },
1238            VolumeMountHelper::Tmpfs {
1239                guest,
1240                size_mib,
1241                options,
1242                readonly,
1243            } => Self::Tmpfs {
1244                guest,
1245                size_mib,
1246                options: decode_mount_options(options, readonly),
1247            },
1248            VolumeMountHelper::DiskImage {
1249                host,
1250                guest,
1251                format,
1252                fstype,
1253                options,
1254                readonly,
1255            } => Self::DiskImage {
1256                host,
1257                guest,
1258                format,
1259                fstype,
1260                options: decode_mount_options(options, readonly),
1261            },
1262        })
1263    }
1264}
1265
1266impl fmt::Debug for VolumeMount {
1267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1268        match self {
1269            Self::Bind {
1270                host,
1271                guest,
1272                options,
1273                stat_virtualization,
1274                host_permissions,
1275            } => f
1276                .debug_struct("Bind")
1277                .field("host", host)
1278                .field("guest", guest)
1279                .field("options", options)
1280                .field("stat_virtualization", stat_virtualization)
1281                .field("host_permissions", host_permissions)
1282                .finish(),
1283            Self::Named {
1284                name,
1285                guest,
1286                create,
1287                options,
1288                stat_virtualization,
1289                host_permissions,
1290            } => f
1291                .debug_struct("Named")
1292                .field("name", name)
1293                .field("guest", guest)
1294                .field("create", create)
1295                .field("options", options)
1296                .field("stat_virtualization", stat_virtualization)
1297                .field("host_permissions", host_permissions)
1298                .finish(),
1299            Self::Tmpfs {
1300                guest,
1301                size_mib,
1302                options,
1303            } => f
1304                .debug_struct("Tmpfs")
1305                .field("guest", guest)
1306                .field("size_mib", size_mib)
1307                .field("options", options)
1308                .finish(),
1309            Self::DiskImage {
1310                host,
1311                guest,
1312                format,
1313                fstype,
1314                options,
1315            } => f
1316                .debug_struct("DiskImage")
1317                .field("host", host)
1318                .field("guest", guest)
1319                .field("format", format)
1320                .field("fstype", fstype)
1321                .field("options", options)
1322                .finish(),
1323        }
1324    }
1325}
1326
1327/// Case-insensitive string to [`RlimitResource`] conversion.
1328impl TryFrom<&str> for RlimitResource {
1329    type Error = String;
1330
1331    fn try_from(s: &str) -> Result<Self, Self::Error> {
1332        match s.to_ascii_lowercase().as_str() {
1333            "cpu" => Ok(Self::Cpu),
1334            "fsize" => Ok(Self::Fsize),
1335            "data" => Ok(Self::Data),
1336            "stack" => Ok(Self::Stack),
1337            "core" => Ok(Self::Core),
1338            "rss" => Ok(Self::Rss),
1339            "nproc" => Ok(Self::Nproc),
1340            "nofile" => Ok(Self::Nofile),
1341            "memlock" => Ok(Self::Memlock),
1342            "as" => Ok(Self::As),
1343            "locks" => Ok(Self::Locks),
1344            "sigpending" => Ok(Self::Sigpending),
1345            "msgqueue" => Ok(Self::Msgqueue),
1346            "nice" => Ok(Self::Nice),
1347            "rtprio" => Ok(Self::Rtprio),
1348            "rttime" => Ok(Self::Rttime),
1349            _ => Err(format!("unknown rlimit resource: {s}")),
1350        }
1351    }
1352}
1353
1354//--------------------------------------------------------------------------------------------------
1355// Functions
1356//--------------------------------------------------------------------------------------------------
1357
1358fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
1359    options.unwrap_or(MountOptions {
1360        readonly,
1361        ..MountOptions::default()
1362    })
1363}
1364
1365//--------------------------------------------------------------------------------------------------
1366// Tests
1367//--------------------------------------------------------------------------------------------------
1368
1369#[cfg(test)]
1370mod tests {
1371    use super::*;
1372
1373    #[test]
1374    fn disk_image_format_from_extension() {
1375        assert_eq!(
1376            DiskImageFormat::from_extension("qcow2"),
1377            Some(DiskImageFormat::Qcow2)
1378        );
1379        assert_eq!(
1380            DiskImageFormat::from_extension("raw"),
1381            Some(DiskImageFormat::Raw)
1382        );
1383        assert_eq!(
1384            DiskImageFormat::from_extension("vmdk"),
1385            Some(DiskImageFormat::Vmdk)
1386        );
1387        assert_eq!(DiskImageFormat::from_extension("ext4"), None);
1388        assert_eq!(DiskImageFormat::from_extension(""), None);
1389    }
1390
1391    #[test]
1392    fn disk_image_format_display_roundtrip() {
1393        for format in [
1394            DiskImageFormat::Qcow2,
1395            DiskImageFormat::Raw,
1396            DiskImageFormat::Vmdk,
1397        ] {
1398            let rendered = format.to_string();
1399            let parsed: DiskImageFormat = rendered.parse().unwrap();
1400            assert_eq!(parsed, format);
1401        }
1402    }
1403
1404    #[test]
1405    fn disk_image_format_from_str_unknown() {
1406        assert!("ext4".parse::<DiskImageFormat>().is_err());
1407    }
1408
1409    #[test]
1410    fn log_source_effective_uses_default_user_program_sources() {
1411        assert_eq!(
1412            LogSource::effective(&[]),
1413            vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
1414        );
1415    }
1416
1417    #[test]
1418    fn log_source_effective_sorts_and_deduplicates_requested_sources() {
1419        assert_eq!(
1420            LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
1421            vec![LogSource::Stdout, LogSource::System]
1422        );
1423    }
1424
1425    #[test]
1426    fn rlimit_resource_parses_case_insensitively() {
1427        assert_eq!(
1428            RlimitResource::try_from("NOFILE").unwrap(),
1429            RlimitResource::Nofile
1430        );
1431        assert!(RlimitResource::try_from("bogus").is_err());
1432    }
1433
1434    #[test]
1435    fn sandbox_policy_serde_roundtrip() {
1436        let policy = SandboxPolicy {
1437            ephemeral: true,
1438            max_duration_secs: Some(3600),
1439            idle_timeout_secs: Some(120),
1440        };
1441
1442        let json = serde_json::to_string(&policy).unwrap();
1443        let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
1444
1445        assert!(decoded.ephemeral);
1446        assert_eq!(decoded.max_duration_secs, Some(3600));
1447        assert_eq!(decoded.idle_timeout_secs, Some(120));
1448    }
1449
1450    #[test]
1451    fn sandbox_policy_defaults_to_persistent() {
1452        assert!(!SandboxPolicy::default().ephemeral);
1453    }
1454
1455    #[test]
1456    fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
1457        // `ephemeral` has a persistent default so partial policy payloads
1458        // deserialize to the conservative behavior.
1459        let decoded: SandboxPolicy =
1460            serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
1461        assert!(!decoded.ephemeral);
1462        assert_eq!(decoded.max_duration_secs, Some(60));
1463    }
1464
1465    #[test]
1466    fn sandbox_spec_default_uses_static_resource_defaults() {
1467        let spec = SandboxSpec::default();
1468
1469        assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
1470        assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1471        assert_eq!(
1472            spec.runtime.metrics_sample_interval_ms,
1473            Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
1474        );
1475    }
1476
1477    #[test]
1478    fn sandbox_log_level_roundtrips_lowercase_values() {
1479        for (input, expected) in [
1480            ("error", SandboxLogLevel::Error),
1481            ("warn", SandboxLogLevel::Warn),
1482            ("info", SandboxLogLevel::Info),
1483            ("debug", SandboxLogLevel::Debug),
1484            ("trace", SandboxLogLevel::Trace),
1485        ] {
1486            let parsed: SandboxLogLevel = input.parse().unwrap();
1487            assert_eq!(parsed, expected);
1488            assert_eq!(parsed.as_str(), input);
1489        }
1490    }
1491}