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)]
605#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
606pub struct SandboxResources {
607    /// Number of virtual CPUs currently presented to the guest at boot.
608    pub cpus: u8,
609
610    /// Guest memory currently presented to the guest at boot, in MiB.
611    pub memory_mib: u32,
612
613    /// Maximum virtual CPUs the sandbox may expose after boot-time hotplug support lands.
614    pub max_cpus: u8,
615
616    /// Maximum guest memory the sandbox may expose after boot-time hotplug support lands, in MiB.
617    pub max_memory_mib: u32,
618}
619
620/// Guest runtime options for a sandbox.
621#[derive(Debug, Clone, Serialize, Deserialize)]
622#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
623#[serde(default)]
624pub struct SandboxRuntimeOptions {
625    /// Working directory inside the guest.
626    pub workdir: Option<String>,
627
628    /// Default shell for scripts and interactive sessions.
629    pub shell: Option<String>,
630
631    /// Named scripts available inside the guest.
632    pub scripts: BTreeMap<String, String>,
633
634    /// Image entrypoint override.
635    pub entrypoint: Option<Vec<String>>,
636
637    /// Image command override.
638    pub cmd: Option<Vec<String>>,
639
640    /// Guest hostname override.
641    pub hostname: Option<String>,
642
643    /// Guest user identity override.
644    pub user: Option<String>,
645
646    /// Runtime log verbosity.
647    pub log_level: Option<SandboxLogLevel>,
648
649    /// Metrics sampling interval in milliseconds. `None` disables sampling.
650    pub metrics_sample_interval_ms: Option<u64>,
651
652    /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`.
653    pub disable_metrics_sample: bool,
654}
655
656/// Environment variable entry.
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
659pub struct EnvVar {
660    /// Environment variable name.
661    pub key: String,
662
663    /// Environment variable value.
664    pub value: String,
665}
666
667/// Runtime log verbosity for sandbox specs.
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
669#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
670#[serde(rename_all = "lowercase")]
671pub enum SandboxLogLevel {
672    /// Emit only error logs.
673    Error,
674
675    /// Emit warning and error logs.
676    Warn,
677
678    /// Emit info, warning, and error logs.
679    Info,
680
681    /// Emit debug and higher-severity logs.
682    Debug,
683
684    /// Emit trace and higher-severity logs.
685    Trace,
686}
687
688//--------------------------------------------------------------------------------------------------
689// Types: Exec
690//--------------------------------------------------------------------------------------------------
691
692/// POSIX resource limit identifiers.
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
694#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
695pub enum RlimitResource {
696    /// Max CPU time in seconds (`RLIMIT_CPU`).
697    Cpu,
698    /// Max file size in bytes (`RLIMIT_FSIZE`).
699    Fsize,
700    /// Max data segment size (`RLIMIT_DATA`).
701    Data,
702    /// Max stack size (`RLIMIT_STACK`).
703    Stack,
704    /// Max core file size (`RLIMIT_CORE`).
705    Core,
706    /// Max resident set size (`RLIMIT_RSS`).
707    Rss,
708    /// Max number of processes (`RLIMIT_NPROC`).
709    Nproc,
710    /// Max open file descriptors (`RLIMIT_NOFILE`).
711    Nofile,
712    /// Max locked memory (`RLIMIT_MEMLOCK`).
713    Memlock,
714    /// Max address space size (`RLIMIT_AS`).
715    As,
716    /// Max file locks (`RLIMIT_LOCKS`).
717    Locks,
718    /// Max pending signals (`RLIMIT_SIGPENDING`).
719    Sigpending,
720    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
721    Msgqueue,
722    /// Max nice priority (`RLIMIT_NICE`).
723    Nice,
724    /// Max real-time priority (`RLIMIT_RTPRIO`).
725    Rtprio,
726    /// Max real-time timeout (`RLIMIT_RTTIME`).
727    Rttime,
728}
729
730/// A POSIX resource limit.
731#[derive(Debug, Clone, Serialize, Deserialize)]
732#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
733pub struct Rlimit {
734    /// Resource type.
735    pub resource: RlimitResource,
736
737    /// Soft limit (can be raised up to hard limit by the process).
738    pub soft: u64,
739
740    /// Hard limit (ceiling, requires privileges to raise).
741    pub hard: u64,
742}
743
744//--------------------------------------------------------------------------------------------------
745// Types: Logs
746//--------------------------------------------------------------------------------------------------
747
748/// Source tag on a captured log entry.
749#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
750#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
751#[serde(rename_all = "lowercase")]
752pub enum LogSource {
753    /// Captured from a session's stdout (pipe mode).
754    Stdout,
755
756    /// Captured from a session's stderr (pipe mode).
757    Stderr,
758
759    /// Captured from a session in pty mode (stdout + stderr merged at the kernel level inside the guest arrive as a single stream tagged `output`).
760    Output,
761
762    /// Synthetic system entry: lifecycle markers, runtime diagnostics, kernel console output.
763    System,
764}
765
766//--------------------------------------------------------------------------------------------------
767// Methods
768//--------------------------------------------------------------------------------------------------
769
770impl DiskImageFormat {
771    /// Returns the format as a CLI-safe lowercase string.
772    pub fn as_str(&self) -> &'static str {
773        match self {
774            Self::Qcow2 => "qcow2",
775            Self::Raw => "raw",
776            Self::Vmdk => "vmdk",
777        }
778    }
779
780    /// Parse a disk image format from a file extension.
781    ///
782    /// Returns `None` if the extension is not a recognized disk image format.
783    pub fn from_extension(ext: &str) -> Option<Self> {
784        match ext {
785            "qcow2" => Some(Self::Qcow2),
786            "raw" => Some(Self::Raw),
787            "vmdk" => Some(Self::Vmdk),
788            _ => None,
789        }
790    }
791}
792
793impl OciRootfsSource {
794    /// Create a new OCI rootfs source.
795    pub fn new(reference: impl Into<String>) -> Self {
796        Self {
797            reference: reference.into(),
798            upper_size_mib: None,
799        }
800    }
801}
802
803impl RootfsSource {
804    /// Create an OCI rootfs source from an image reference.
805    pub fn oci(reference: impl Into<String>) -> Self {
806        Self::Oci(OciRootfsSource::new(reference))
807    }
808
809    /// Return the OCI image reference if this is an OCI rootfs.
810    pub fn oci_reference(&self) -> Option<&str> {
811        match self {
812            Self::Oci(oci) => Some(&oci.reference),
813            _ => None,
814        }
815    }
816
817    /// Return the configured OCI upper size in MiB if this is an OCI rootfs.
818    pub fn oci_upper_size_mib(&self) -> Option<u32> {
819        match self {
820            Self::Oci(oci) => oci.upper_size_mib,
821            _ => None,
822        }
823    }
824}
825
826impl EnvVar {
827    /// Create an environment variable entry.
828    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
829        Self {
830            key: key.into(),
831            value: value.into(),
832        }
833    }
834
835    /// Return this entry as key and value string slices.
836    pub fn as_pair(&self) -> (&str, &str) {
837        (&self.key, &self.value)
838    }
839}
840
841impl VolumeKind {
842    /// Return the lowercase database and CLI representation.
843    pub fn as_str(self) -> &'static str {
844        match self {
845            Self::Directory => "dir",
846            Self::Disk => "disk",
847        }
848    }
849
850    /// Parse a persisted database value, defaulting to directory for unknown values.
851    pub fn from_db_value(value: &str) -> Self {
852        match value {
853            "disk" => Self::Disk,
854            _ => Self::Directory,
855        }
856    }
857}
858
859impl VolumeSpec {
860    /// Create a directory-backed volume spec with default options.
861    pub fn new(name: impl Into<String>) -> Self {
862        Self {
863            name: name.into(),
864            kind: VolumeKind::Directory,
865            quota_mib: None,
866            capacity_mib: None,
867            labels: Vec::new(),
868        }
869    }
870}
871
872impl NamedVolumeCreate {
873    /// Creation behavior for this named volume mount.
874    pub fn mode(&self) -> NamedVolumeMode {
875        self.mode
876    }
877
878    /// Volume name to create or ensure exists.
879    pub fn name(&self) -> &str {
880        &self.name
881    }
882
883    /// Storage kind to create or ensure exists.
884    pub fn kind(&self) -> VolumeKind {
885        self.kind
886    }
887
888    /// Directory quota in MiB, if configured.
889    pub fn quota_mib(&self) -> Option<u32> {
890        self.quota_mib
891    }
892
893    /// Disk capacity in MiB, if configured.
894    pub fn capacity_mib(&self) -> Option<u32> {
895        self.capacity_mib
896    }
897
898    /// Labels to attach to newly-created volumes.
899    pub fn labels(&self) -> &[(String, String)] {
900        &self.labels
901    }
902}
903
904impl VolumeMount {
905    /// The absolute path where this mount appears inside the guest.
906    pub fn guest(&self) -> &str {
907        match self {
908            Self::Bind { guest, .. }
909            | Self::Named { guest, .. }
910            | Self::Tmpfs { guest, .. }
911            | Self::DiskImage { guest, .. } => guest,
912        }
913    }
914
915    /// Return named-volume creation metadata when this mount provisions a named volume.
916    pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
917        match self {
918            Self::Named { create, .. } => create.as_ref(),
919            _ => None,
920        }
921    }
922}
923
924impl RlimitResource {
925    /// Returns the lowercase string representation used on the wire.
926    pub fn as_str(&self) -> &'static str {
927        match self {
928            Self::Cpu => "cpu",
929            Self::Fsize => "fsize",
930            Self::Data => "data",
931            Self::Stack => "stack",
932            Self::Core => "core",
933            Self::Rss => "rss",
934            Self::Nproc => "nproc",
935            Self::Nofile => "nofile",
936            Self::Memlock => "memlock",
937            Self::As => "as",
938            Self::Locks => "locks",
939            Self::Sigpending => "sigpending",
940            Self::Msgqueue => "msgqueue",
941            Self::Nice => "nice",
942            Self::Rtprio => "rtprio",
943            Self::Rttime => "rttime",
944        }
945    }
946}
947
948impl LogSource {
949    /// Apply the empty-means-default rule used by log readers.
950    pub fn effective(requested: &[Self]) -> Vec<Self> {
951        if requested.is_empty() {
952            vec![Self::Stdout, Self::Stderr, Self::Output]
953        } else {
954            let mut sources = requested.to_vec();
955            sources.sort_by_key(|src| match src {
956                Self::Stdout => 0,
957                Self::Stderr => 1,
958                Self::Output => 2,
959                Self::System => 3,
960            });
961            sources.dedup();
962            sources
963        }
964    }
965}
966
967impl SandboxLogLevel {
968    /// Return the lowercase string representation for this level.
969    pub const fn as_str(self) -> &'static str {
970        match self {
971            Self::Error => "error",
972            Self::Warn => "warn",
973            Self::Info => "info",
974            Self::Debug => "debug",
975            Self::Trace => "trace",
976        }
977    }
978}
979
980//--------------------------------------------------------------------------------------------------
981// Trait Implementations
982//--------------------------------------------------------------------------------------------------
983
984impl std::fmt::Display for DiskImageFormat {
985    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
986        f.write_str(self.as_str())
987    }
988}
989
990impl FromStr for DiskImageFormat {
991    type Err = String;
992
993    fn from_str(s: &str) -> Result<Self, Self::Err> {
994        match s {
995            "qcow2" => Ok(Self::Qcow2),
996            "raw" => Ok(Self::Raw),
997            "vmdk" => Ok(Self::Vmdk),
998            _ => Err(format!("unknown disk image format: {s}")),
999        }
1000    }
1001}
1002
1003impl Default for RootfsSource {
1004    fn default() -> Self {
1005        Self::oci(String::new())
1006    }
1007}
1008
1009impl Default for SandboxResources {
1010    fn default() -> Self {
1011        Self {
1012            cpus: DEFAULT_SANDBOX_CPUS,
1013            memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1014            max_cpus: DEFAULT_SANDBOX_CPUS,
1015            max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1016        }
1017    }
1018}
1019
1020impl<'de> Deserialize<'de> for SandboxResources {
1021    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1022    where
1023        D: serde::Deserializer<'de>,
1024    {
1025        #[derive(Deserialize)]
1026        struct RawResources {
1027            #[serde(default = "default_sandbox_cpus")]
1028            cpus: u8,
1029            #[serde(default = "default_sandbox_memory_mib")]
1030            memory_mib: u32,
1031            max_cpus: Option<u8>,
1032            max_memory_mib: Option<u32>,
1033        }
1034
1035        let raw = RawResources::deserialize(deserializer)?;
1036        Ok(Self {
1037            cpus: raw.cpus,
1038            memory_mib: raw.memory_mib,
1039            // Legacy configs predate boot-capacity fields. Treat their effective
1040            // resources as their maximum capacity so old sandboxes do not
1041            // deserialize into an impossible cpus > max_cpus state.
1042            max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1043            max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1044        })
1045    }
1046}
1047
1048impl Default for SandboxRuntimeOptions {
1049    fn default() -> Self {
1050        Self {
1051            workdir: None,
1052            shell: None,
1053            scripts: BTreeMap::new(),
1054            entrypoint: None,
1055            cmd: None,
1056            hostname: None,
1057            user: None,
1058            log_level: None,
1059            metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1060            disable_metrics_sample: false,
1061        }
1062    }
1063}
1064
1065impl Default for NetworkSpec {
1066    fn default() -> Self {
1067        Self {
1068            enabled: true,
1069            interface: None,
1070            ports: Vec::new(),
1071            policy: None,
1072            dns: None,
1073            tls: None,
1074            secrets: None,
1075            max_connections: None,
1076            trust_host_cas: false,
1077        }
1078    }
1079}
1080
1081impl Default for PublishedPortSpec {
1082    fn default() -> Self {
1083        Self {
1084            host_port: 0,
1085            guest_port: 0,
1086            protocol: PortProtocol::Tcp,
1087            host_bind: "127.0.0.1".into(),
1088        }
1089    }
1090}
1091
1092impl From<(String, String)> for EnvVar {
1093    fn from((key, value): (String, String)) -> Self {
1094        Self { key, value }
1095    }
1096}
1097
1098impl From<EnvVar> for (String, String) {
1099    fn from(var: EnvVar) -> Self {
1100        (var.key, var.value)
1101    }
1102}
1103
1104impl FromStr for SandboxLogLevel {
1105    type Err = String;
1106
1107    fn from_str(s: &str) -> Result<Self, Self::Err> {
1108        match s {
1109            "error" => Ok(Self::Error),
1110            "warn" => Ok(Self::Warn),
1111            "info" => Ok(Self::Info),
1112            "debug" => Ok(Self::Debug),
1113            "trace" => Ok(Self::Trace),
1114            _ => Err(format!("unknown sandbox log level: {s}")),
1115        }
1116    }
1117}
1118
1119impl Serialize for VolumeMount {
1120    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1121        use serde::ser::SerializeMap;
1122
1123        match self {
1124            Self::Bind {
1125                host,
1126                guest,
1127                options,
1128                stat_virtualization,
1129                host_permissions,
1130                quota_mib,
1131            } => {
1132                let mut map = serializer.serialize_map(Some(7))?;
1133                map.serialize_entry("type", "Bind")?;
1134                map.serialize_entry("host", host)?;
1135                map.serialize_entry("guest", guest)?;
1136                map.serialize_entry("options", options)?;
1137                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1138                map.serialize_entry("host_permissions", host_permissions)?;
1139                map.serialize_entry("quota_mib", quota_mib)?;
1140                map.end()
1141            }
1142            Self::Named {
1143                name,
1144                guest,
1145                create: _,
1146                options,
1147                stat_virtualization,
1148                host_permissions,
1149            } => {
1150                let mut map = serializer.serialize_map(Some(6))?;
1151                map.serialize_entry("type", "Named")?;
1152                map.serialize_entry("name", name)?;
1153                map.serialize_entry("guest", guest)?;
1154                map.serialize_entry("options", options)?;
1155                map.serialize_entry("stat_virtualization", stat_virtualization)?;
1156                map.serialize_entry("host_permissions", host_permissions)?;
1157                map.end()
1158            }
1159            Self::Tmpfs {
1160                guest,
1161                size_mib,
1162                options,
1163            } => {
1164                let mut map = serializer.serialize_map(Some(4))?;
1165                map.serialize_entry("type", "Tmpfs")?;
1166                map.serialize_entry("guest", guest)?;
1167                map.serialize_entry("size_mib", size_mib)?;
1168                map.serialize_entry("options", options)?;
1169                map.end()
1170            }
1171            Self::DiskImage {
1172                host,
1173                guest,
1174                format,
1175                fstype,
1176                options,
1177            } => {
1178                let mut map = serializer.serialize_map(Some(6))?;
1179                map.serialize_entry("type", "DiskImage")?;
1180                map.serialize_entry("host", host)?;
1181                map.serialize_entry("guest", guest)?;
1182                map.serialize_entry("format", format)?;
1183                map.serialize_entry("fstype", fstype)?;
1184                map.serialize_entry("options", options)?;
1185                map.end()
1186            }
1187        }
1188    }
1189}
1190
1191impl<'de> Deserialize<'de> for VolumeMount {
1192    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1193        fn default_strict() -> StatVirtualization {
1194            StatVirtualization::Strict
1195        }
1196
1197        fn default_private() -> HostPermissions {
1198            HostPermissions::Private
1199        }
1200
1201        #[derive(Deserialize)]
1202        #[serde(tag = "type")]
1203        enum VolumeMountHelper {
1204            Bind {
1205                host: PathBuf,
1206                guest: String,
1207                #[serde(default)]
1208                options: Option<MountOptions>,
1209                #[serde(default)]
1210                readonly: bool,
1211                #[serde(default = "default_strict")]
1212                stat_virtualization: StatVirtualization,
1213                #[serde(default = "default_private")]
1214                host_permissions: HostPermissions,
1215                #[serde(default)]
1216                quota_mib: Option<u32>,
1217            },
1218            Named {
1219                name: String,
1220                guest: String,
1221                #[serde(default)]
1222                options: Option<MountOptions>,
1223                #[serde(default)]
1224                readonly: bool,
1225                #[serde(default = "default_strict")]
1226                stat_virtualization: StatVirtualization,
1227                #[serde(default = "default_private")]
1228                host_permissions: HostPermissions,
1229            },
1230            Tmpfs {
1231                guest: String,
1232                #[serde(default)]
1233                size_mib: Option<u32>,
1234                #[serde(default)]
1235                options: Option<MountOptions>,
1236                #[serde(default)]
1237                readonly: bool,
1238            },
1239            DiskImage {
1240                host: PathBuf,
1241                guest: String,
1242                format: DiskImageFormat,
1243                #[serde(default)]
1244                fstype: Option<String>,
1245                #[serde(default)]
1246                options: Option<MountOptions>,
1247                #[serde(default)]
1248                readonly: bool,
1249            },
1250        }
1251
1252        let helper = VolumeMountHelper::deserialize(deserializer)?;
1253        Ok(match helper {
1254            VolumeMountHelper::Bind {
1255                host,
1256                guest,
1257                options,
1258                readonly,
1259                stat_virtualization,
1260                host_permissions,
1261                quota_mib,
1262            } => Self::Bind {
1263                host,
1264                guest,
1265                options: decode_mount_options(options, readonly),
1266                stat_virtualization,
1267                host_permissions,
1268                quota_mib,
1269            },
1270            VolumeMountHelper::Named {
1271                name,
1272                guest,
1273                options,
1274                readonly,
1275                stat_virtualization,
1276                host_permissions,
1277            } => Self::Named {
1278                name,
1279                guest,
1280                create: None,
1281                options: decode_mount_options(options, readonly),
1282                stat_virtualization,
1283                host_permissions,
1284            },
1285            VolumeMountHelper::Tmpfs {
1286                guest,
1287                size_mib,
1288                options,
1289                readonly,
1290            } => Self::Tmpfs {
1291                guest,
1292                size_mib,
1293                options: decode_mount_options(options, readonly),
1294            },
1295            VolumeMountHelper::DiskImage {
1296                host,
1297                guest,
1298                format,
1299                fstype,
1300                options,
1301                readonly,
1302            } => Self::DiskImage {
1303                host,
1304                guest,
1305                format,
1306                fstype,
1307                options: decode_mount_options(options, readonly),
1308            },
1309        })
1310    }
1311}
1312
1313impl fmt::Debug for VolumeMount {
1314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1315        match self {
1316            Self::Bind {
1317                host,
1318                guest,
1319                options,
1320                stat_virtualization,
1321                host_permissions,
1322                quota_mib,
1323            } => f
1324                .debug_struct("Bind")
1325                .field("host", host)
1326                .field("guest", guest)
1327                .field("options", options)
1328                .field("stat_virtualization", stat_virtualization)
1329                .field("host_permissions", host_permissions)
1330                .field("quota_mib", quota_mib)
1331                .finish(),
1332            Self::Named {
1333                name,
1334                guest,
1335                create,
1336                options,
1337                stat_virtualization,
1338                host_permissions,
1339            } => f
1340                .debug_struct("Named")
1341                .field("name", name)
1342                .field("guest", guest)
1343                .field("create", create)
1344                .field("options", options)
1345                .field("stat_virtualization", stat_virtualization)
1346                .field("host_permissions", host_permissions)
1347                .finish(),
1348            Self::Tmpfs {
1349                guest,
1350                size_mib,
1351                options,
1352            } => f
1353                .debug_struct("Tmpfs")
1354                .field("guest", guest)
1355                .field("size_mib", size_mib)
1356                .field("options", options)
1357                .finish(),
1358            Self::DiskImage {
1359                host,
1360                guest,
1361                format,
1362                fstype,
1363                options,
1364            } => f
1365                .debug_struct("DiskImage")
1366                .field("host", host)
1367                .field("guest", guest)
1368                .field("format", format)
1369                .field("fstype", fstype)
1370                .field("options", options)
1371                .finish(),
1372        }
1373    }
1374}
1375
1376/// Case-insensitive string to [`RlimitResource`] conversion.
1377impl TryFrom<&str> for RlimitResource {
1378    type Error = String;
1379
1380    fn try_from(s: &str) -> Result<Self, Self::Error> {
1381        match s.to_ascii_lowercase().as_str() {
1382            "cpu" => Ok(Self::Cpu),
1383            "fsize" => Ok(Self::Fsize),
1384            "data" => Ok(Self::Data),
1385            "stack" => Ok(Self::Stack),
1386            "core" => Ok(Self::Core),
1387            "rss" => Ok(Self::Rss),
1388            "nproc" => Ok(Self::Nproc),
1389            "nofile" => Ok(Self::Nofile),
1390            "memlock" => Ok(Self::Memlock),
1391            "as" => Ok(Self::As),
1392            "locks" => Ok(Self::Locks),
1393            "sigpending" => Ok(Self::Sigpending),
1394            "msgqueue" => Ok(Self::Msgqueue),
1395            "nice" => Ok(Self::Nice),
1396            "rtprio" => Ok(Self::Rtprio),
1397            "rttime" => Ok(Self::Rttime),
1398            _ => Err(format!("unknown rlimit resource: {s}")),
1399        }
1400    }
1401}
1402
1403//--------------------------------------------------------------------------------------------------
1404// Functions
1405//--------------------------------------------------------------------------------------------------
1406
1407fn default_sandbox_cpus() -> u8 {
1408    DEFAULT_SANDBOX_CPUS
1409}
1410
1411fn default_sandbox_memory_mib() -> u32 {
1412    DEFAULT_SANDBOX_MEMORY_MIB
1413}
1414
1415fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
1416    options.unwrap_or(MountOptions {
1417        readonly,
1418        ..MountOptions::default()
1419    })
1420}
1421
1422//--------------------------------------------------------------------------------------------------
1423// Tests
1424//--------------------------------------------------------------------------------------------------
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429
1430    #[test]
1431    fn disk_image_format_from_extension() {
1432        assert_eq!(
1433            DiskImageFormat::from_extension("qcow2"),
1434            Some(DiskImageFormat::Qcow2)
1435        );
1436        assert_eq!(
1437            DiskImageFormat::from_extension("raw"),
1438            Some(DiskImageFormat::Raw)
1439        );
1440        assert_eq!(
1441            DiskImageFormat::from_extension("vmdk"),
1442            Some(DiskImageFormat::Vmdk)
1443        );
1444        assert_eq!(DiskImageFormat::from_extension("ext4"), None);
1445        assert_eq!(DiskImageFormat::from_extension(""), None);
1446    }
1447
1448    #[test]
1449    fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
1450        let resources: SandboxResources =
1451            serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
1452
1453        assert_eq!(resources.cpus, 4);
1454        assert_eq!(resources.max_cpus, 4);
1455        assert_eq!(resources.memory_mib, 2048);
1456        assert_eq!(resources.max_memory_mib, 2048);
1457    }
1458
1459    #[test]
1460    fn disk_image_format_display_roundtrip() {
1461        for format in [
1462            DiskImageFormat::Qcow2,
1463            DiskImageFormat::Raw,
1464            DiskImageFormat::Vmdk,
1465        ] {
1466            let rendered = format.to_string();
1467            let parsed: DiskImageFormat = rendered.parse().unwrap();
1468            assert_eq!(parsed, format);
1469        }
1470    }
1471
1472    #[test]
1473    fn disk_image_format_from_str_unknown() {
1474        assert!("ext4".parse::<DiskImageFormat>().is_err());
1475    }
1476
1477    #[test]
1478    fn log_source_effective_uses_default_user_program_sources() {
1479        assert_eq!(
1480            LogSource::effective(&[]),
1481            vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
1482        );
1483    }
1484
1485    #[test]
1486    fn log_source_effective_sorts_and_deduplicates_requested_sources() {
1487        assert_eq!(
1488            LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
1489            vec![LogSource::Stdout, LogSource::System]
1490        );
1491    }
1492
1493    #[test]
1494    fn rlimit_resource_parses_case_insensitively() {
1495        assert_eq!(
1496            RlimitResource::try_from("NOFILE").unwrap(),
1497            RlimitResource::Nofile
1498        );
1499        assert!(RlimitResource::try_from("bogus").is_err());
1500    }
1501
1502    #[test]
1503    fn sandbox_policy_serde_roundtrip() {
1504        let policy = SandboxPolicy {
1505            ephemeral: true,
1506            max_duration_secs: Some(3600),
1507            idle_timeout_secs: Some(120),
1508        };
1509
1510        let json = serde_json::to_string(&policy).unwrap();
1511        let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
1512
1513        assert!(decoded.ephemeral);
1514        assert_eq!(decoded.max_duration_secs, Some(3600));
1515        assert_eq!(decoded.idle_timeout_secs, Some(120));
1516    }
1517
1518    #[test]
1519    fn sandbox_policy_defaults_to_persistent() {
1520        assert!(!SandboxPolicy::default().ephemeral);
1521    }
1522
1523    #[test]
1524    fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
1525        // `ephemeral` has a persistent default so partial policy payloads
1526        // deserialize to the conservative behavior.
1527        let decoded: SandboxPolicy =
1528            serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
1529        assert!(!decoded.ephemeral);
1530        assert_eq!(decoded.max_duration_secs, Some(60));
1531    }
1532
1533    #[test]
1534    fn sandbox_spec_default_uses_static_resource_defaults() {
1535        let spec = SandboxSpec::default();
1536
1537        assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
1538        assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1539        assert_eq!(
1540            spec.runtime.metrics_sample_interval_ms,
1541            Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
1542        );
1543    }
1544
1545    #[test]
1546    fn sandbox_log_level_roundtrips_lowercase_values() {
1547        for (input, expected) in [
1548            ("error", SandboxLogLevel::Error),
1549            ("warn", SandboxLogLevel::Warn),
1550            ("info", SandboxLogLevel::Info),
1551            ("debug", SandboxLogLevel::Debug),
1552            ("trace", SandboxLogLevel::Trace),
1553        ] {
1554            let parsed: SandboxLogLevel = input.parse().unwrap();
1555            assert_eq!(parsed, expected);
1556            assert_eq!(parsed.as_str(), input);
1557        }
1558    }
1559}