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