1use std::collections::BTreeMap;
4use std::fmt;
5use std::net::{Ipv4Addr, Ipv6Addr};
6use std::path::PathBuf;
7use std::str::FromStr;
8
9use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
10use serde::{Deserialize, Serialize};
11use zeroize::Zeroizing;
12
13use crate::modify::SecretSource;
14
15pub const DEFAULT_SANDBOX_CPUS: u8 = 1;
21
22pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
24
25pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
35#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
36pub enum DiskImageFormat {
37 Qcow2,
39 Raw,
41 Vmdk,
43}
44
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
48#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
49#[serde(rename_all = "kebab-case")]
50pub enum FlatClone {
51 #[default]
53 Auto,
54
55 Copy,
57
58 Reflink,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
65pub enum RootfsSource {
66 Bind {
68 #[cfg_attr(feature = "ts", ts(type = "string"))]
70 path: PathBuf,
71 #[serde(default)]
78 follow_root_symlinks: bool,
79 },
80
81 Oci(OciRootfsSource),
83
84 DiskImage {
86 #[cfg_attr(feature = "ts", ts(type = "string"))]
88 path: PathBuf,
89 format: DiskImageFormat,
91 fstype: Option<String>,
93 },
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
99#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
100pub struct OciRootfsSource {
101 pub reference: String,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub root_disk: Option<RootDisk>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
116#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
117#[serde(tag = "kind", rename_all = "kebab-case")]
118pub enum RootDisk {
119 Managed {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 size_mib: Option<u32>,
125 },
126
127 Tmpfs {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 size_mib: Option<u32>,
133 },
134
135 DiskImage {
138 #[cfg_attr(feature = "ts", ts(type = "string"))]
140 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
141 path: PathBuf,
142 format: DiskImageFormat,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 fstype: Option<String>,
147 },
148
149 Flat {
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 size_mib: Option<u32>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 fstype: Option<String>,
161 #[serde(default, skip_serializing_if = "FlatClone::is_auto")]
163 clone: FlatClone,
164 },
165}
166
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
170#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
171pub enum PullPolicy {
172 #[default]
174 IfMissing,
175
176 Always,
178
179 Never,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
192#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
193#[serde(rename_all = "lowercase")]
194pub enum StatVirtualization {
195 Strict,
197 Relaxed,
199 Off,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
208#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
209#[serde(rename_all = "lowercase")]
210pub enum HostPermissions {
211 Private,
213 Mirror,
215}
216
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
219#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
220#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
221#[serde(rename_all = "lowercase")]
222pub enum SecurityProfile {
223 #[default]
227 Default,
228
229 Restricted,
233}
234
235#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
241#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
242#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
243#[serde(rename_all = "snake_case")]
244pub enum DeploymentProfile {
245 #[default]
247 SingleTenant,
248
249 MultiTenant,
251}
252
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
255#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
256#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
257#[serde(default)]
258pub struct MountOptions {
259 pub readonly: bool,
263
264 pub noexec: bool,
268
269 pub nosuid: bool,
271
272 pub nodev: bool,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
279#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
280pub enum VolumeKind {
281 Directory,
283
284 Disk,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
291#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
292pub struct VolumeSpec {
293 pub name: String,
295
296 pub kind: VolumeKind,
298
299 pub quota_mib: Option<u32>,
301
302 pub capacity_mib: Option<u32>,
304
305 pub labels: Vec<(String, String)>,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
312#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
313pub enum NamedVolumeMode {
314 Existing,
316
317 Create,
319
320 EnsureExists,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
327#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
328pub struct NamedVolumeCreate {
329 pub mode: NamedVolumeMode,
331
332 pub name: String,
334
335 pub kind: VolumeKind,
337
338 pub quota_mib: Option<u32>,
340
341 pub capacity_mib: Option<u32>,
343
344 pub labels: Vec<(String, String)>,
346}
347
348#[derive(Clone)]
350#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
351#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
352#[cfg_attr(feature = "ts", ts(tag = "type"))]
353pub enum VolumeMount {
354 Bind {
356 #[cfg_attr(feature = "ts", ts(type = "string"))]
358 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
359 host: PathBuf,
360 guest: String,
362 options: MountOptions,
364 stat_virtualization: StatVirtualization,
366 host_permissions: HostPermissions,
368 follow_root_symlinks: bool,
375 quota_mib: Option<u32>,
381 },
382
383 Named {
385 name: String,
387 guest: String,
389 create: Option<NamedVolumeCreate>,
393 options: MountOptions,
395 stat_virtualization: StatVirtualization,
397 host_permissions: HostPermissions,
399 follow_root_symlinks: bool,
404 },
405
406 Tmpfs {
408 guest: String,
410 size_mib: Option<u32>,
412 options: MountOptions,
414 },
415
416 DiskImage {
418 #[cfg_attr(feature = "ts", ts(type = "string"))]
420 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
421 host: PathBuf,
422 guest: String,
424 format: DiskImageFormat,
426 fstype: Option<String>,
428 options: MountOptions,
430 },
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
435#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
436#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
437pub enum Patch {
438 Text {
440 path: String,
442 content: String,
444 mode: Option<u32>,
446 replace: bool,
448 },
449
450 File {
452 path: String,
454 content: Vec<u8>,
456 mode: Option<u32>,
458 replace: bool,
460 },
461
462 CopyFile {
464 #[cfg_attr(feature = "ts", ts(type = "string"))]
466 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
467 src: PathBuf,
468 dst: String,
470 mode: Option<u32>,
472 replace: bool,
474 },
475
476 CopyDir {
478 #[cfg_attr(feature = "ts", ts(type = "string"))]
480 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
481 src: PathBuf,
482 dst: String,
484 replace: bool,
486 },
487
488 Symlink {
490 target: String,
492 link: String,
494 replace: bool,
496 },
497
498 Mkdir {
500 path: String,
502 mode: Option<u32>,
504 },
505
506 Remove {
508 path: String,
510 },
511
512 Append {
514 path: String,
516 content: String,
518 },
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
529#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
530#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
531#[serde(default)]
532pub struct NetworkSpec {
533 pub enabled: bool,
535
536 #[serde(skip_serializing_if = "Option::is_none")]
538 pub interface: Option<InterfaceOverrides>,
539
540 pub ports: Vec<PublishedPortSpec>,
542
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub policy: Option<NetworkPolicy>,
546
547 #[serde(skip_serializing_if = "Option::is_none")]
549 pub dns: Option<DnsConfig>,
550
551 #[serde(skip_serializing_if = "Option::is_none")]
553 pub tls: Option<TlsConfig>,
554
555 #[serde(skip_serializing_if = "Option::is_none")]
557 pub secrets: Option<SecretsConfig>,
558
559 pub max_connections: Option<usize>,
561
562 #[serde(skip_serializing_if = "Option::is_none")]
564 pub rate_limiter: Option<NetworkRateLimiterConfig>,
565
566 pub trust_host_cas: bool,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
572#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
573#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
574pub struct PublishedPortSpec {
575 pub host_port: u16,
577
578 pub guest_port: u16,
580
581 #[serde(default)]
583 pub protocol: PortProtocol,
584
585 pub host_bind: String,
587}
588
589#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
591#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
592#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
593pub enum PortProtocol {
594 #[default]
596 #[serde(rename = "tcp")]
597 Tcp,
598
599 #[serde(rename = "udp")]
601 Udp,
602}
603
604#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
610#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
611#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
612#[serde(default)]
613pub struct VsockSpec {
614 pub routes: Vec<VsockRouteSpec>,
616}
617
618impl VsockSpec {
619 pub fn is_empty(&self) -> bool {
621 self.routes.is_empty()
622 }
623}
624
625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
627#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
628#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
629pub struct VsockRouteSpec {
630 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
632 pub host_socket: PathBuf,
633
634 pub port: u32,
636
637 #[serde(default)]
639 pub socket_type: VsockSocketType,
640}
641
642#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
644#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
645#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
646#[serde(rename_all = "snake_case")]
647pub enum VsockSocketType {
648 #[default]
650 Stream,
651
652 Dgram,
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
662#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
663#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
664pub struct HandoffInit {
665 pub cmd: String,
669
670 #[serde(default)]
672 pub args: Vec<String>,
673
674 #[serde(default)]
676 pub env: Vec<(String, String)>,
677}
678
679#[derive(Debug, Default, Clone, Serialize, Deserialize)]
685#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
686#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
687pub struct SandboxPolicy {
688 #[serde(default)]
697 pub ephemeral: bool,
698
699 pub max_duration_secs: Option<u64>,
701
702 pub idle_timeout_secs: Option<u64>,
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize)]
717#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
718pub struct SnapshotSpec {
719 pub name: String,
721
722 #[serde(default)]
725 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
726 pub dest_dir: Option<PathBuf>,
727
728 pub source_sandbox: String,
730
731 pub labels: Vec<(String, String)>,
733
734 pub force: bool,
736
737 pub record_integrity: bool,
739
740 #[serde(default)]
746 pub resumable: bool,
747}
748
749#[derive(Debug, Default, Clone, Serialize, Deserialize)]
757#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
758#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
759#[serde(default)]
760pub struct SandboxSpec {
761 pub name: String,
763
764 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
766 pub image: RootfsSource,
767
768 pub resources: SandboxResources,
770
771 pub runtime: SandboxRuntimeOptions,
773
774 pub env: Vec<EnvVar>,
776
777 pub labels: BTreeMap<String, String>,
779
780 pub rlimits: Vec<Rlimit>,
782
783 pub mounts: Vec<VolumeMount>,
785
786 pub patches: Vec<Patch>,
788
789 pub network: NetworkSpec,
791
792 #[serde(default, skip_serializing_if = "VsockSpec::is_empty")]
794 pub vsock: VsockSpec,
795
796 pub init: Option<HandoffInit>,
798
799 pub pull_policy: PullPolicy,
801
802 pub security_profile: SecurityProfile,
804
805 pub deployment_profile: DeploymentProfile,
811
812 pub lifecycle: SandboxPolicy,
814}
815
816#[derive(Debug, Clone, Serialize)]
818#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
819#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
820pub struct SandboxResources {
821 pub cpus: u8,
823
824 pub memory_mib: u32,
826
827 pub max_cpus: u8,
829
830 pub max_memory_mib: u32,
832
833 #[serde(default, skip_serializing_if = "CpuPlacement::is_inherit")]
835 pub cpu_placement: CpuPlacement,
836
837 #[serde(default, skip_serializing_if = "Option::is_none")]
839 pub placement_profile: Option<String>,
840
841 #[serde(default, skip_serializing_if = "TransparentHugePagePolicy::is_madvise")]
843 pub thp: TransparentHugePagePolicy,
844}
845
846#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
848#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
849#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
850#[serde(rename_all = "lowercase")]
851pub enum CpuPlacement {
852 #[default]
854 Inherit,
855
856 Auto,
858
859 Spread,
861
862 Compact,
864}
865
866#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
868#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
869#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
870#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
871pub enum NumaPlacement {
872 PreferSingle,
874 StrictSingle,
876 Inherit,
878}
879
880#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
882#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
883#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
884#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
885pub enum MemoryPlacement {
886 FollowCpu,
888 Inherit,
890}
891
892#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
894#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
895#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
896#[serde(deny_unknown_fields)]
897pub struct PlacementProfile {
898 pub numa: NumaPlacement,
900 pub memory: MemoryPlacement,
902}
903
904#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
906#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
907#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
908#[serde(rename_all = "lowercase")]
909pub enum TransparentHugePagePolicy {
910 Always,
912
913 #[default]
915 Madvise,
916
917 Never,
919}
920
921#[derive(Debug, Clone, Serialize, Deserialize)]
923#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
924#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
925#[serde(default)]
926pub struct SandboxRuntimeOptions {
927 pub workdir: Option<String>,
929
930 pub shell: Option<String>,
932
933 pub scripts: BTreeMap<String, String>,
935
936 pub entrypoint: Option<Vec<String>>,
938
939 pub cmd: Option<Vec<String>>,
941
942 pub hostname: Option<String>,
944
945 pub user: Option<String>,
947
948 pub log_level: Option<SandboxLogLevel>,
950
951 pub metrics_sample_interval_ms: Option<u64>,
953
954 pub disable_metrics_sample: bool,
956}
957
958#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
960#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
961#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
962pub struct EnvVar {
963 pub key: String,
965
966 pub value: String,
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
972#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
973#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
974#[serde(rename_all = "lowercase")]
975pub enum SandboxLogLevel {
976 Error,
978
979 Warn,
981
982 Info,
984
985 Debug,
987
988 Trace,
990}
991
992#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
998#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
999#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1000pub enum RlimitResource {
1001 Cpu,
1003 Fsize,
1005 Data,
1007 Stack,
1009 Core,
1011 Rss,
1013 Nproc,
1015 Nofile,
1017 Memlock,
1019 As,
1021 Locks,
1023 Sigpending,
1025 Msgqueue,
1027 Nice,
1029 Rtprio,
1031 Rttime,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1037#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1038#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1039pub struct Rlimit {
1040 pub resource: RlimitResource,
1042
1043 pub soft: u64,
1045
1046 pub hard: u64,
1048}
1049
1050#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1056#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1057#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1058#[serde(rename_all = "lowercase")]
1059pub enum LogSource {
1060 Stdout,
1062
1063 Stderr,
1065
1066 Output,
1068
1069 System,
1071}
1072
1073impl DiskImageFormat {
1078 pub fn as_str(&self) -> &'static str {
1080 match self {
1081 Self::Qcow2 => "qcow2",
1082 Self::Raw => "raw",
1083 Self::Vmdk => "vmdk",
1084 }
1085 }
1086
1087 pub fn from_extension(ext: &str) -> Option<Self> {
1091 match ext {
1092 "qcow2" => Some(Self::Qcow2),
1093 "raw" => Some(Self::Raw),
1094 "vmdk" => Some(Self::Vmdk),
1095 _ => None,
1096 }
1097 }
1098}
1099
1100impl OciRootfsSource {
1101 pub fn new(reference: impl Into<String>) -> Self {
1103 Self {
1104 reference: reference.into(),
1105 root_disk: None,
1106 }
1107 }
1108}
1109
1110impl TransparentHugePagePolicy {
1111 pub fn is_madvise(&self) -> bool {
1113 matches!(self, Self::Madvise)
1114 }
1115
1116 pub fn as_str(self) -> &'static str {
1118 match self {
1119 Self::Always => "always",
1120 Self::Madvise => "madvise",
1121 Self::Never => "never",
1122 }
1123 }
1124}
1125
1126impl RootDisk {
1127 pub fn managed(size_mib: u32) -> Self {
1129 Self::Managed {
1130 size_mib: Some(size_mib),
1131 }
1132 }
1133
1134 pub fn tmpfs(size_mib: u32) -> Self {
1136 Self::Tmpfs {
1137 size_mib: Some(size_mib),
1138 }
1139 }
1140
1141 pub fn flat(size_mib: u32) -> Self {
1143 Self::Flat {
1144 size_mib: Some(size_mib),
1145 fstype: None,
1146 clone: FlatClone::Auto,
1147 }
1148 }
1149
1150 pub fn size_mib(&self) -> Option<u32> {
1152 match self {
1153 Self::Managed { size_mib } | Self::Tmpfs { size_mib } | Self::Flat { size_mib, .. } => {
1154 *size_mib
1155 }
1156 Self::DiskImage { .. } => None,
1157 }
1158 }
1159
1160 pub fn kind_str(&self) -> &'static str {
1162 match self {
1163 Self::Managed { .. } => "managed",
1164 Self::Tmpfs { .. } => "tmpfs",
1165 Self::DiskImage { .. } => "disk-image",
1166 Self::Flat { .. } => "flat",
1167 }
1168 }
1169
1170 pub fn is_managed(&self) -> bool {
1172 matches!(self, Self::Managed { .. })
1173 }
1174}
1175
1176impl FlatClone {
1177 pub const fn as_str(self) -> &'static str {
1179 match self {
1180 Self::Auto => "auto",
1181 Self::Copy => "copy",
1182 Self::Reflink => "reflink",
1183 }
1184 }
1185
1186 pub const fn is_auto(&self) -> bool {
1188 matches!(self, Self::Auto)
1189 }
1190}
1191
1192impl RootfsSource {
1193 pub fn oci(reference: impl Into<String>) -> Self {
1195 Self::Oci(OciRootfsSource::new(reference))
1196 }
1197
1198 pub fn oci_reference(&self) -> Option<&str> {
1200 match self {
1201 Self::Oci(oci) => Some(&oci.reference),
1202 _ => None,
1203 }
1204 }
1205
1206 pub fn oci_root_disk(&self) -> Option<&RootDisk> {
1208 match self {
1209 Self::Oci(oci) => oci.root_disk.as_ref(),
1210 _ => None,
1211 }
1212 }
1213
1214 pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
1217 match self {
1218 Self::Oci(oci) => match &oci.root_disk {
1219 Some(RootDisk::Managed { size_mib }) => *size_mib,
1220 Some(_) => None,
1221 None => None,
1222 },
1223 _ => None,
1224 }
1225 }
1226}
1227
1228impl EnvVar {
1229 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1231 Self {
1232 key: key.into(),
1233 value: value.into(),
1234 }
1235 }
1236
1237 pub fn as_pair(&self) -> (&str, &str) {
1239 (&self.key, &self.value)
1240 }
1241}
1242
1243impl VolumeKind {
1244 pub fn as_str(self) -> &'static str {
1246 match self {
1247 Self::Directory => "dir",
1248 Self::Disk => "disk",
1249 }
1250 }
1251
1252 pub fn from_db_value(value: &str) -> Self {
1254 match value {
1255 "disk" => Self::Disk,
1256 _ => Self::Directory,
1257 }
1258 }
1259}
1260
1261impl VolumeSpec {
1262 pub fn new(name: impl Into<String>) -> Self {
1264 Self {
1265 name: name.into(),
1266 kind: VolumeKind::Directory,
1267 quota_mib: None,
1268 capacity_mib: None,
1269 labels: Vec::new(),
1270 }
1271 }
1272}
1273
1274impl NamedVolumeCreate {
1275 pub fn mode(&self) -> NamedVolumeMode {
1277 self.mode
1278 }
1279
1280 pub fn name(&self) -> &str {
1282 &self.name
1283 }
1284
1285 pub fn kind(&self) -> VolumeKind {
1287 self.kind
1288 }
1289
1290 pub fn quota_mib(&self) -> Option<u32> {
1292 self.quota_mib
1293 }
1294
1295 pub fn capacity_mib(&self) -> Option<u32> {
1297 self.capacity_mib
1298 }
1299
1300 pub fn labels(&self) -> &[(String, String)] {
1302 &self.labels
1303 }
1304}
1305
1306impl VolumeMount {
1307 pub fn guest(&self) -> &str {
1309 match self {
1310 Self::Bind { guest, .. }
1311 | Self::Named { guest, .. }
1312 | Self::Tmpfs { guest, .. }
1313 | Self::DiskImage { guest, .. } => guest,
1314 }
1315 }
1316
1317 pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1319 match self {
1320 Self::Named { create, .. } => create.as_ref(),
1321 _ => None,
1322 }
1323 }
1324}
1325
1326impl RlimitResource {
1327 pub fn as_str(&self) -> &'static str {
1329 match self {
1330 Self::Cpu => "cpu",
1331 Self::Fsize => "fsize",
1332 Self::Data => "data",
1333 Self::Stack => "stack",
1334 Self::Core => "core",
1335 Self::Rss => "rss",
1336 Self::Nproc => "nproc",
1337 Self::Nofile => "nofile",
1338 Self::Memlock => "memlock",
1339 Self::As => "as",
1340 Self::Locks => "locks",
1341 Self::Sigpending => "sigpending",
1342 Self::Msgqueue => "msgqueue",
1343 Self::Nice => "nice",
1344 Self::Rtprio => "rtprio",
1345 Self::Rttime => "rttime",
1346 }
1347 }
1348}
1349
1350impl LogSource {
1351 pub fn effective(requested: &[Self]) -> Vec<Self> {
1353 if requested.is_empty() {
1354 vec![Self::Stdout, Self::Stderr, Self::Output]
1355 } else {
1356 let mut sources = requested.to_vec();
1357 sources.sort_by_key(|src| match src {
1358 Self::Stdout => 0,
1359 Self::Stderr => 1,
1360 Self::Output => 2,
1361 Self::System => 3,
1362 });
1363 sources.dedup();
1364 sources
1365 }
1366 }
1367}
1368
1369impl SandboxLogLevel {
1370 pub const fn as_str(self) -> &'static str {
1372 match self {
1373 Self::Error => "error",
1374 Self::Warn => "warn",
1375 Self::Info => "info",
1376 Self::Debug => "debug",
1377 Self::Trace => "trace",
1378 }
1379 }
1380}
1381
1382impl std::fmt::Display for DiskImageFormat {
1387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1388 f.write_str(self.as_str())
1389 }
1390}
1391
1392impl FromStr for DiskImageFormat {
1393 type Err = String;
1394
1395 fn from_str(s: &str) -> Result<Self, Self::Err> {
1396 match s {
1397 "qcow2" => Ok(Self::Qcow2),
1398 "raw" => Ok(Self::Raw),
1399 "vmdk" => Ok(Self::Vmdk),
1400 _ => Err(format!("unknown disk image format: {s}")),
1401 }
1402 }
1403}
1404
1405impl fmt::Display for TransparentHugePagePolicy {
1406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1407 f.write_str(self.as_str())
1408 }
1409}
1410
1411impl FromStr for TransparentHugePagePolicy {
1412 type Err = String;
1413
1414 fn from_str(value: &str) -> Result<Self, Self::Err> {
1415 match value {
1416 "always" => Ok(Self::Always),
1417 "madvise" => Ok(Self::Madvise),
1418 "never" => Ok(Self::Never),
1419 _ => Err(format!(
1420 "unknown transparent huge-page policy: {value}; expected always, madvise, or never"
1421 )),
1422 }
1423 }
1424}
1425
1426impl Default for RootfsSource {
1427 fn default() -> Self {
1428 Self::oci(String::new())
1429 }
1430}
1431
1432impl Default for SandboxResources {
1433 fn default() -> Self {
1434 Self {
1435 cpus: DEFAULT_SANDBOX_CPUS,
1436 memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1437 max_cpus: DEFAULT_SANDBOX_CPUS,
1438 max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1439 cpu_placement: CpuPlacement::Inherit,
1440 placement_profile: None,
1441 thp: TransparentHugePagePolicy::Madvise,
1442 }
1443 }
1444}
1445
1446impl<'de> Deserialize<'de> for SandboxResources {
1447 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1448 where
1449 D: serde::Deserializer<'de>,
1450 {
1451 #[derive(Deserialize)]
1452 struct RawResources {
1453 #[serde(default = "default_sandbox_cpus")]
1454 cpus: u8,
1455 #[serde(default = "default_sandbox_memory_mib")]
1456 memory_mib: u32,
1457 max_cpus: Option<u8>,
1458 max_memory_mib: Option<u32>,
1459 #[serde(default)]
1460 cpu_placement: CpuPlacement,
1461 #[serde(default)]
1462 placement_profile: Option<String>,
1463 #[serde(default)]
1464 thp: TransparentHugePagePolicy,
1465 }
1466
1467 let raw = RawResources::deserialize(deserializer)?;
1468 Ok(Self {
1469 cpus: raw.cpus,
1470 memory_mib: raw.memory_mib,
1471 max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1475 max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1476 cpu_placement: raw.cpu_placement,
1477 placement_profile: raw.placement_profile,
1478 thp: raw.thp,
1479 })
1480 }
1481}
1482
1483impl CpuPlacement {
1484 pub const fn is_inherit(&self) -> bool {
1486 matches!(self, Self::Inherit)
1487 }
1488}
1489
1490impl std::fmt::Display for CpuPlacement {
1491 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1492 f.write_str(match self {
1493 Self::Inherit => "inherit",
1494 Self::Auto => "auto",
1495 Self::Spread => "spread",
1496 Self::Compact => "compact",
1497 })
1498 }
1499}
1500
1501impl FromStr for CpuPlacement {
1502 type Err = String;
1503
1504 fn from_str(value: &str) -> Result<Self, Self::Err> {
1505 match value {
1506 "inherit" => Ok(Self::Inherit),
1507 "auto" => Ok(Self::Auto),
1508 "spread" => Ok(Self::Spread),
1509 "compact" => Ok(Self::Compact),
1510 _ => Err(format!(
1511 "unknown CPU placement: {value} (expected: inherit, auto, spread, compact)"
1512 )),
1513 }
1514 }
1515}
1516
1517impl Default for SandboxRuntimeOptions {
1518 fn default() -> Self {
1519 Self {
1520 workdir: None,
1521 shell: None,
1522 scripts: BTreeMap::new(),
1523 entrypoint: None,
1524 cmd: None,
1525 hostname: None,
1526 user: None,
1527 log_level: None,
1528 metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1529 disable_metrics_sample: false,
1530 }
1531 }
1532}
1533
1534impl Default for NetworkSpec {
1535 fn default() -> Self {
1536 Self {
1537 enabled: true,
1538 interface: None,
1539 ports: Vec::new(),
1540 policy: None,
1541 dns: None,
1542 tls: None,
1543 secrets: None,
1544 max_connections: None,
1545 rate_limiter: None,
1546 trust_host_cas: false,
1547 }
1548 }
1549}
1550
1551impl Default for PublishedPortSpec {
1552 fn default() -> Self {
1553 Self {
1554 host_port: 0,
1555 guest_port: 0,
1556 protocol: PortProtocol::Tcp,
1557 host_bind: "127.0.0.1".into(),
1558 }
1559 }
1560}
1561
1562impl From<(String, String)> for EnvVar {
1563 fn from((key, value): (String, String)) -> Self {
1564 Self { key, value }
1565 }
1566}
1567
1568impl From<EnvVar> for (String, String) {
1569 fn from(var: EnvVar) -> Self {
1570 (var.key, var.value)
1571 }
1572}
1573
1574impl FromStr for SandboxLogLevel {
1575 type Err = String;
1576
1577 fn from_str(s: &str) -> Result<Self, Self::Err> {
1578 match s {
1579 "error" => Ok(Self::Error),
1580 "warn" => Ok(Self::Warn),
1581 "info" => Ok(Self::Info),
1582 "debug" => Ok(Self::Debug),
1583 "trace" => Ok(Self::Trace),
1584 _ => Err(format!("unknown sandbox log level: {s}")),
1585 }
1586 }
1587}
1588
1589impl Serialize for VolumeMount {
1590 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1591 use serde::ser::SerializeMap;
1592
1593 match self {
1594 Self::Bind {
1595 host,
1596 guest,
1597 options,
1598 stat_virtualization,
1599 host_permissions,
1600 follow_root_symlinks,
1601 quota_mib,
1602 } => {
1603 let mut map = serializer.serialize_map(Some(8))?;
1604 map.serialize_entry("type", "Bind")?;
1605 map.serialize_entry("host", host)?;
1606 map.serialize_entry("guest", guest)?;
1607 map.serialize_entry("options", options)?;
1608 map.serialize_entry("stat_virtualization", stat_virtualization)?;
1609 map.serialize_entry("host_permissions", host_permissions)?;
1610 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1611 map.serialize_entry("quota_mib", quota_mib)?;
1612 map.end()
1613 }
1614 Self::Named {
1615 name,
1616 guest,
1617 create: _,
1618 options,
1619 stat_virtualization,
1620 host_permissions,
1621 follow_root_symlinks,
1622 } => {
1623 let mut map = serializer.serialize_map(Some(7))?;
1624 map.serialize_entry("type", "Named")?;
1625 map.serialize_entry("name", name)?;
1626 map.serialize_entry("guest", guest)?;
1627 map.serialize_entry("options", options)?;
1628 map.serialize_entry("stat_virtualization", stat_virtualization)?;
1629 map.serialize_entry("host_permissions", host_permissions)?;
1630 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1631 map.end()
1632 }
1633 Self::Tmpfs {
1634 guest,
1635 size_mib,
1636 options,
1637 } => {
1638 let mut map = serializer.serialize_map(Some(4))?;
1639 map.serialize_entry("type", "Tmpfs")?;
1640 map.serialize_entry("guest", guest)?;
1641 map.serialize_entry("size_mib", size_mib)?;
1642 map.serialize_entry("options", options)?;
1643 map.end()
1644 }
1645 Self::DiskImage {
1646 host,
1647 guest,
1648 format,
1649 fstype,
1650 options,
1651 } => {
1652 let mut map = serializer.serialize_map(Some(6))?;
1653 map.serialize_entry("type", "DiskImage")?;
1654 map.serialize_entry("host", host)?;
1655 map.serialize_entry("guest", guest)?;
1656 map.serialize_entry("format", format)?;
1657 map.serialize_entry("fstype", fstype)?;
1658 map.serialize_entry("options", options)?;
1659 map.end()
1660 }
1661 }
1662 }
1663}
1664
1665impl<'de> Deserialize<'de> for VolumeMount {
1666 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1667 fn default_strict() -> StatVirtualization {
1668 StatVirtualization::Strict
1669 }
1670
1671 fn default_private() -> HostPermissions {
1672 HostPermissions::Private
1673 }
1674
1675 #[derive(Deserialize)]
1676 #[serde(tag = "type")]
1677 enum VolumeMountHelper {
1678 Bind {
1679 host: PathBuf,
1680 guest: String,
1681 #[serde(default)]
1682 options: Option<MountOptions>,
1683 #[serde(default)]
1684 readonly: bool,
1685 #[serde(default = "default_strict")]
1686 stat_virtualization: StatVirtualization,
1687 #[serde(default = "default_private")]
1688 host_permissions: HostPermissions,
1689 #[serde(default)]
1690 follow_root_symlinks: bool,
1691 #[serde(default)]
1692 quota_mib: Option<u32>,
1693 },
1694 Named {
1695 name: String,
1696 guest: String,
1697 #[serde(default)]
1698 options: Option<MountOptions>,
1699 #[serde(default)]
1700 readonly: bool,
1701 #[serde(default = "default_strict")]
1702 stat_virtualization: StatVirtualization,
1703 #[serde(default = "default_private")]
1704 host_permissions: HostPermissions,
1705 #[serde(default)]
1706 follow_root_symlinks: bool,
1707 },
1708 Tmpfs {
1709 guest: String,
1710 #[serde(default)]
1711 size_mib: Option<u32>,
1712 #[serde(default)]
1713 options: Option<MountOptions>,
1714 #[serde(default)]
1715 readonly: bool,
1716 },
1717 DiskImage {
1718 host: PathBuf,
1719 guest: String,
1720 format: DiskImageFormat,
1721 #[serde(default)]
1722 fstype: Option<String>,
1723 #[serde(default)]
1724 options: Option<MountOptions>,
1725 #[serde(default)]
1726 readonly: bool,
1727 },
1728 }
1729
1730 let helper = VolumeMountHelper::deserialize(deserializer)?;
1731 Ok(match helper {
1732 VolumeMountHelper::Bind {
1733 host,
1734 guest,
1735 options,
1736 readonly,
1737 stat_virtualization,
1738 host_permissions,
1739 follow_root_symlinks,
1740 quota_mib,
1741 } => Self::Bind {
1742 host,
1743 guest,
1744 options: decode_mount_options(options, readonly),
1745 stat_virtualization,
1746 host_permissions,
1747 follow_root_symlinks,
1748 quota_mib,
1749 },
1750 VolumeMountHelper::Named {
1751 name,
1752 guest,
1753 options,
1754 readonly,
1755 stat_virtualization,
1756 host_permissions,
1757 follow_root_symlinks,
1758 } => Self::Named {
1759 name,
1760 guest,
1761 create: None,
1762 options: decode_mount_options(options, readonly),
1763 stat_virtualization,
1764 host_permissions,
1765 follow_root_symlinks,
1766 },
1767 VolumeMountHelper::Tmpfs {
1768 guest,
1769 size_mib,
1770 options,
1771 readonly,
1772 } => Self::Tmpfs {
1773 guest,
1774 size_mib,
1775 options: decode_mount_options(options, readonly),
1776 },
1777 VolumeMountHelper::DiskImage {
1778 host,
1779 guest,
1780 format,
1781 fstype,
1782 options,
1783 readonly,
1784 } => Self::DiskImage {
1785 host,
1786 guest,
1787 format,
1788 fstype,
1789 options: decode_mount_options(options, readonly),
1790 },
1791 })
1792 }
1793}
1794
1795impl fmt::Debug for VolumeMount {
1796 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1797 match self {
1798 Self::Bind {
1799 host,
1800 guest,
1801 options,
1802 stat_virtualization,
1803 host_permissions,
1804 follow_root_symlinks,
1805 quota_mib,
1806 } => f
1807 .debug_struct("Bind")
1808 .field("host", host)
1809 .field("guest", guest)
1810 .field("options", options)
1811 .field("stat_virtualization", stat_virtualization)
1812 .field("host_permissions", host_permissions)
1813 .field("follow_root_symlinks", follow_root_symlinks)
1814 .field("quota_mib", quota_mib)
1815 .finish(),
1816 Self::Named {
1817 name,
1818 guest,
1819 create,
1820 options,
1821 stat_virtualization,
1822 host_permissions,
1823 follow_root_symlinks,
1824 } => f
1825 .debug_struct("Named")
1826 .field("name", name)
1827 .field("guest", guest)
1828 .field("create", create)
1829 .field("options", options)
1830 .field("stat_virtualization", stat_virtualization)
1831 .field("host_permissions", host_permissions)
1832 .field("follow_root_symlinks", follow_root_symlinks)
1833 .finish(),
1834 Self::Tmpfs {
1835 guest,
1836 size_mib,
1837 options,
1838 } => f
1839 .debug_struct("Tmpfs")
1840 .field("guest", guest)
1841 .field("size_mib", size_mib)
1842 .field("options", options)
1843 .finish(),
1844 Self::DiskImage {
1845 host,
1846 guest,
1847 format,
1848 fstype,
1849 options,
1850 } => f
1851 .debug_struct("DiskImage")
1852 .field("host", host)
1853 .field("guest", guest)
1854 .field("format", format)
1855 .field("fstype", fstype)
1856 .field("options", options)
1857 .finish(),
1858 }
1859 }
1860}
1861
1862impl TryFrom<&str> for RlimitResource {
1864 type Error = String;
1865
1866 fn try_from(s: &str) -> Result<Self, Self::Error> {
1867 match s.to_ascii_lowercase().as_str() {
1868 "cpu" => Ok(Self::Cpu),
1869 "fsize" => Ok(Self::Fsize),
1870 "data" => Ok(Self::Data),
1871 "stack" => Ok(Self::Stack),
1872 "core" => Ok(Self::Core),
1873 "rss" => Ok(Self::Rss),
1874 "nproc" => Ok(Self::Nproc),
1875 "nofile" => Ok(Self::Nofile),
1876 "memlock" => Ok(Self::Memlock),
1877 "as" => Ok(Self::As),
1878 "locks" => Ok(Self::Locks),
1879 "sigpending" => Ok(Self::Sigpending),
1880 "msgqueue" => Ok(Self::Msgqueue),
1881 "nice" => Ok(Self::Nice),
1882 "rtprio" => Ok(Self::Rtprio),
1883 "rttime" => Ok(Self::Rttime),
1884 _ => Err(format!("unknown rlimit resource: {s}")),
1885 }
1886 }
1887}
1888
1889fn default_sandbox_cpus() -> u8 {
1894 DEFAULT_SANDBOX_CPUS
1895}
1896
1897fn default_sandbox_memory_mib() -> u32 {
1898 DEFAULT_SANDBOX_MEMORY_MIB
1899}
1900
1901fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
1902 options.unwrap_or(MountOptions {
1903 readonly,
1904 ..MountOptions::default()
1905 })
1906}
1907
1908pub(crate) fn default_strict() -> StatVirtualization {
1910 StatVirtualization::Strict
1911}
1912
1913pub(crate) fn default_private() -> HostPermissions {
1915 HostPermissions::Private
1916}
1917
1918pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
1920
1921#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1928#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1929#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1930pub struct SecretsConfig {
1931 #[serde(default)]
1933 pub secrets: Vec<SecretEntry>,
1934
1935 #[serde(default)]
1937 pub on_violation: ViolationAction,
1938}
1939
1940#[derive(Clone, Serialize, Deserialize)]
1945#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1946#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1947pub struct SecretEntry {
1948 pub env_var: String,
1954
1955 #[serde(default = "empty_secret_value")]
1964 #[cfg_attr(feature = "ts", ts(type = "string"))]
1965 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
1966 pub value: Zeroizing<String>,
1967
1968 #[serde(default, skip_serializing_if = "Option::is_none")]
1972 pub source: Option<SecretSource>,
1973
1974 pub placeholder: String,
1979
1980 #[serde(default)]
1982 pub allowed_hosts: Vec<HostPattern>,
1983
1984 #[serde(default)]
1986 pub injection: SecretInjection,
1987
1988 #[serde(default, skip_serializing_if = "Option::is_none")]
1990 pub on_violation: Option<ViolationAction>,
1991
1992 #[serde(default = "default_true")]
1997 pub require_tls_identity: bool,
1998}
1999
2000#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2002#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2003#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2004#[serde(rename_all = "kebab-case")]
2005pub enum HostPattern {
2006 #[serde(alias = "Exact")]
2008 Exact(String),
2009 #[serde(alias = "Wildcard")]
2011 Wildcard(String),
2012 #[serde(alias = "Any")]
2014 Any,
2015}
2016
2017#[derive(Debug, Clone, Serialize, Deserialize)]
2019#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2020#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2021pub struct SecretInjection {
2022 #[serde(default = "default_true")]
2024 pub headers: bool,
2025
2026 #[serde(default = "default_true")]
2028 pub basic_auth: bool,
2029
2030 #[serde(default)]
2032 pub query_params: bool,
2033
2034 #[serde(default)]
2042 pub body: bool,
2043}
2044
2045#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2047#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2048#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2049#[serde(rename_all = "kebab-case")]
2050pub enum ViolationAction {
2051 #[serde(alias = "Block")]
2053 Block,
2054 #[default]
2056 #[serde(alias = "BlockAndLog", alias = "block_and_log")]
2057 BlockAndLog,
2058 #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
2060 BlockAndTerminate,
2061 #[serde(alias = "Passthrough")]
2063 Passthrough(Vec<HostPattern>),
2064}
2065
2066#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2068pub enum SecretConfigError {
2069 #[error("secret #{secret_index}: env_var must not be empty")]
2071 EmptyEnvVar {
2072 secret_index: usize,
2074 },
2075
2076 #[error("secret #{secret_index}: env_var must not contain `=`")]
2078 EnvVarContainsEquals {
2079 secret_index: usize,
2081 },
2082
2083 #[error("secret #{secret_index}: env_var must not contain NUL")]
2085 EnvVarContainsNul {
2086 secret_index: usize,
2088 },
2089
2090 #[error("secret #{secret_index}: at least one allowed host is required")]
2092 MissingAllowedHosts {
2093 secret_index: usize,
2095 },
2096
2097 #[error("secret #{secret_index}: placeholder must not be empty")]
2099 EmptyPlaceholder {
2100 secret_index: usize,
2102 },
2103
2104 #[error(
2106 "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
2107 )]
2108 PlaceholderTooLong {
2109 secret_index: usize,
2111 actual_bytes: usize,
2113 max_bytes: usize,
2115 },
2116
2117 #[error("secret #{secret_index}: placeholder must not contain NUL")]
2119 PlaceholderContainsNul {
2120 secret_index: usize,
2122 },
2123
2124 #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
2126 PlaceholderContainsLineBreak {
2127 secret_index: usize,
2129 },
2130}
2131
2132impl SecretsConfig {
2133 pub fn validate(&self) -> Result<(), SecretConfigError> {
2135 for (index, secret) in self.secrets.iter().enumerate() {
2136 secret.validate(index)?;
2137 }
2138 Ok(())
2139 }
2140}
2141
2142impl SecretEntry {
2143 pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
2145 validate_env_var(&self.env_var, secret_index)?;
2146
2147 if self.allowed_hosts.is_empty() {
2148 return Err(SecretConfigError::MissingAllowedHosts { secret_index });
2149 }
2150
2151 validate_placeholder(&self.placeholder, secret_index)
2152 }
2153}
2154
2155impl fmt::Debug for SecretEntry {
2157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2158 f.debug_struct("SecretEntry")
2159 .field("env_var", &self.env_var)
2160 .field("value", &"[REDACTED]")
2161 .field("source", &self.source)
2162 .field("placeholder", &self.placeholder)
2163 .field("allowed_hosts", &self.allowed_hosts)
2164 .field("injection", &self.injection)
2165 .field("on_violation", &self.on_violation)
2166 .field("require_tls_identity", &self.require_tls_identity)
2167 .finish()
2168 }
2169}
2170
2171impl HostPattern {
2172 pub fn parse(host: &str) -> Self {
2175 if host == "*" {
2176 HostPattern::Any
2177 } else if host.starts_with("*.") {
2178 HostPattern::Wildcard(host.to_string())
2179 } else {
2180 HostPattern::Exact(host.to_string())
2181 }
2182 }
2183
2184 pub fn matches(&self, hostname: &str) -> bool {
2189 match self {
2190 HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
2191 HostPattern::Wildcard(pattern) => {
2192 if let Some(suffix) = pattern.strip_prefix("*.") {
2193 hostname.eq_ignore_ascii_case(suffix)
2194 || (hostname.len() > suffix.len() + 1
2195 && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
2196 && hostname[hostname.len() - suffix.len()..]
2197 .eq_ignore_ascii_case(suffix))
2198 } else {
2199 hostname.eq_ignore_ascii_case(pattern)
2200 }
2201 }
2202 HostPattern::Any => true,
2203 }
2204 }
2205}
2206
2207impl Default for SecretInjection {
2208 fn default() -> Self {
2209 Self {
2210 headers: true,
2211 basic_auth: true,
2212 query_params: false,
2213 body: false,
2214 }
2215 }
2216}
2217
2218fn default_true() -> bool {
2219 true
2220}
2221
2222fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2223 if env_var.is_empty() {
2224 return Err(SecretConfigError::EmptyEnvVar { secret_index });
2225 }
2226 if env_var.contains('=') {
2227 return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
2228 }
2229 if env_var.contains('\0') {
2230 return Err(SecretConfigError::EnvVarContainsNul { secret_index });
2231 }
2232 Ok(())
2233}
2234
2235fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2236 if placeholder.is_empty() {
2237 return Err(SecretConfigError::EmptyPlaceholder { secret_index });
2238 }
2239
2240 let actual_bytes = placeholder.len();
2241 if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
2242 return Err(SecretConfigError::PlaceholderTooLong {
2243 secret_index,
2244 actual_bytes,
2245 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2246 });
2247 }
2248
2249 if placeholder.contains('\0') {
2250 return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
2251 }
2252 if placeholder.contains('\r') || placeholder.contains('\n') {
2253 return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
2254 }
2255
2256 Ok(())
2257}
2258
2259#[derive(Debug, Clone, Serialize, Deserialize)]
2269#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2270#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2271pub struct TlsConfig {
2272 #[serde(default)]
2274 pub enabled: bool,
2275
2276 #[serde(default = "default_intercepted_ports")]
2278 pub intercepted_ports: Vec<u16>,
2279
2280 #[serde(default)]
2282 pub bypass: Vec<String>,
2283
2284 #[serde(default = "default_true")]
2286 pub verify_upstream: bool,
2287
2288 #[serde(default = "default_true")]
2291 pub block_quic_on_intercept: bool,
2292
2293 #[serde(default)]
2295 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
2296 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
2297 pub upstream_ca_cert: Vec<PathBuf>,
2298
2299 #[serde(default, alias = "scoped_upstream_ca_certs")]
2301 pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
2302
2303 #[serde(default)]
2305 pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
2306
2307 #[serde(default, alias = "ca")]
2310 pub intercept_ca: InterceptCaConfig,
2311
2312 #[serde(default)]
2314 pub cache: CertCacheConfig,
2315}
2316
2317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2319#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2320#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2321pub struct InterceptCaConfig {
2322 #[serde(default)]
2325 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2326 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2327 pub cert_path: Option<PathBuf>,
2328
2329 #[serde(default)]
2332 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2333 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2334 pub key_path: Option<PathBuf>,
2335}
2336
2337#[derive(Debug, Clone, Serialize, Deserialize)]
2339#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2340#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2341pub struct CertCacheConfig {
2342 #[serde(default = "default_cache_capacity")]
2344 pub capacity: usize,
2345
2346 #[serde(default = "default_cert_validity_hours")]
2348 pub validity_hours: u64,
2349}
2350
2351#[derive(Debug, Clone, Serialize, Deserialize)]
2353#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2354#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2355pub struct ScopedUpstreamCaCert {
2356 pub pattern: String,
2358
2359 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2361 #[cfg_attr(feature = "ts", ts(type = "string"))]
2362 pub path: PathBuf,
2363}
2364
2365#[derive(Debug, Clone, Serialize, Deserialize)]
2367#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2368#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2369pub struct ScopedVerifyUpstream {
2370 pub pattern: String,
2372
2373 pub verify: bool,
2375}
2376
2377impl Default for TlsConfig {
2378 fn default() -> Self {
2379 Self {
2380 enabled: false,
2381 intercepted_ports: default_intercepted_ports(),
2382 bypass: Vec::new(),
2383 verify_upstream: true,
2384 block_quic_on_intercept: true,
2385 upstream_ca_cert: Vec::new(),
2386 scoped_upstream_ca_cert: Vec::new(),
2387 scoped_verify_upstream: Vec::new(),
2388 intercept_ca: InterceptCaConfig::default(),
2389 cache: CertCacheConfig::default(),
2390 }
2391 }
2392}
2393
2394impl Default for CertCacheConfig {
2395 fn default() -> Self {
2396 Self {
2397 capacity: default_cache_capacity(),
2398 validity_hours: default_cert_validity_hours(),
2399 }
2400 }
2401}
2402
2403fn default_intercepted_ports() -> Vec<u16> {
2404 vec![443]
2405}
2406
2407fn default_cache_capacity() -> usize {
2408 1000
2409}
2410
2411fn default_cert_validity_hours() -> u64 {
2412 24
2413}
2414
2415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2421#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2422#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2423#[serde(rename_all = "snake_case")]
2424pub enum Action {
2425 Allow,
2427 Deny,
2429}
2430
2431#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2433#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2434#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2435#[serde(rename_all = "snake_case")]
2436pub enum Direction {
2437 Egress,
2439 Ingress,
2441 Any,
2443}
2444
2445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2447#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2448#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2449#[serde(rename_all = "snake_case")]
2450pub enum Protocol {
2451 Tcp,
2453 Udp,
2455 Icmpv4,
2457 Icmpv6,
2459}
2460
2461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2463#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2464#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2465#[serde(rename_all = "snake_case")]
2466pub enum DestinationGroup {
2467 Public,
2469 Loopback,
2471 Private,
2473 LinkLocal,
2475 Metadata,
2477 Multicast,
2479 Host,
2481}
2482
2483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2490#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2491#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2492#[serde(rename_all = "snake_case")]
2493pub enum Destination {
2494 Any,
2496 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2498 Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
2499 Domain(String),
2501 DomainSuffix(String),
2503 Group(DestinationGroup),
2505}
2506
2507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2509#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2510#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2511pub struct PortRange {
2512 pub start: u16,
2514 pub end: u16,
2516}
2517
2518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2521#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2522#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2523pub struct Rule {
2524 pub direction: Direction,
2526 pub destination: Destination,
2528 #[serde(default)]
2530 pub protocols: Vec<Protocol>,
2531 #[serde(default)]
2533 pub ports: Vec<PortRange>,
2534 pub action: Action,
2536}
2537
2538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2541#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2542#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2543pub struct NetworkPolicy {
2544 #[serde(default = "action_deny")]
2546 pub default_egress: Action,
2547 #[serde(default = "action_deny")]
2549 pub default_ingress: Action,
2550 #[serde(default)]
2552 pub rules: Vec<Rule>,
2553}
2554
2555fn action_deny() -> Action {
2558 Action::Deny
2559}
2560
2561#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2567#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2568#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2569#[serde(default)]
2570pub struct DnsConfig {
2571 pub rebind_protection: bool,
2573 pub nameservers: Vec<String>,
2576 pub query_timeout_ms: u64,
2578}
2579
2580impl Default for DnsConfig {
2581 fn default() -> Self {
2582 Self {
2583 rebind_protection: true,
2584 nameservers: Vec::new(),
2585 query_timeout_ms: 5000,
2586 }
2587 }
2588}
2589
2590#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2594#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2595#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2596#[serde(default)]
2597pub struct InterfaceOverrides {
2598 #[serde(skip_serializing_if = "Option::is_none")]
2600 pub mac: Option<[u8; 6]>,
2601 #[serde(skip_serializing_if = "Option::is_none")]
2603 pub mtu: Option<u16>,
2604 #[serde(skip_serializing_if = "Option::is_none")]
2606 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2607 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2608 pub ipv4_address: Option<Ipv4Addr>,
2609 #[serde(skip_serializing_if = "Option::is_none")]
2611 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2612 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2613 pub ipv4_pool: Option<Ipv4Network>,
2614 #[serde(skip_serializing_if = "Option::is_none")]
2616 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2617 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2618 pub ipv6_address: Option<Ipv6Addr>,
2619 #[serde(skip_serializing_if = "Option::is_none")]
2621 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2622 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2623 pub ipv6_pool: Option<Ipv6Network>,
2624}
2625
2626fn empty_secret_value() -> Zeroizing<String> {
2627 Zeroizing::new(String::new())
2628}
2629
2630#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2636pub enum NetworkRateLimitDirection {
2637 Egress,
2639 Ingress,
2641}
2642
2643#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2645#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2646#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2647#[serde(default)]
2648pub struct NetworkRateLimiterConfig {
2649 #[serde(skip_serializing_if = "Option::is_none")]
2651 pub egress: Option<RateLimiterConfig>,
2652
2653 #[serde(skip_serializing_if = "Option::is_none")]
2655 pub ingress: Option<RateLimiterConfig>,
2656}
2657
2658#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2664#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2665#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2666#[serde(default)]
2667pub struct RateLimiterConfig {
2668 #[serde(skip_serializing_if = "Option::is_none")]
2670 pub bandwidth: Option<TokenBucketConfig>,
2671
2672 #[serde(skip_serializing_if = "Option::is_none")]
2674 pub ops: Option<TokenBucketConfig>,
2675}
2676
2677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2683#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2684#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2685pub struct TokenBucketConfig {
2686 pub size: u64,
2688
2689 pub refill_time_ms: u64,
2692
2693 #[serde(default)]
2695 pub one_time_burst: u64,
2696}
2697
2698#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2700pub enum RateLimitConfigError {
2701 #[error("rate limiter must configure at least one of bandwidth or ops")]
2703 EmptyLimiter,
2704
2705 #[error("{bucket} bucket: size must be greater than zero")]
2707 ZeroSize {
2708 bucket: &'static str,
2710 },
2711
2712 #[error("{bucket} bucket: refill_time_ms must be greater than zero")]
2714 ZeroRefillTime {
2715 bucket: &'static str,
2717 },
2718}
2719
2720impl RateLimiterConfig {
2721 pub fn validate(&self) -> Result<(), RateLimitConfigError> {
2723 if self.bandwidth.is_none() && self.ops.is_none() {
2724 return Err(RateLimitConfigError::EmptyLimiter);
2725 }
2726 if let Some(bandwidth) = &self.bandwidth {
2727 bandwidth.validate("bandwidth")?;
2728 }
2729 if let Some(ops) = &self.ops {
2730 ops.validate("ops")?;
2731 }
2732 Ok(())
2733 }
2734}
2735
2736impl TokenBucketConfig {
2737 pub fn validate(&self, bucket: &'static str) -> Result<(), RateLimitConfigError> {
2739 if self.size == 0 {
2740 return Err(RateLimitConfigError::ZeroSize { bucket });
2741 }
2742 if self.refill_time_ms == 0 {
2743 return Err(RateLimitConfigError::ZeroRefillTime { bucket });
2744 }
2745 Ok(())
2746 }
2747}
2748
2749impl fmt::Display for NetworkRateLimitDirection {
2750 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2751 match self {
2752 Self::Egress => f.write_str("egress"),
2753 Self::Ingress => f.write_str("ingress"),
2754 }
2755 }
2756}
2757
2758#[cfg(test)]
2763mod tests {
2764 use super::*;
2765
2766 #[test]
2767 fn disk_image_format_from_extension() {
2768 assert_eq!(
2769 DiskImageFormat::from_extension("qcow2"),
2770 Some(DiskImageFormat::Qcow2)
2771 );
2772 assert_eq!(
2773 DiskImageFormat::from_extension("raw"),
2774 Some(DiskImageFormat::Raw)
2775 );
2776 assert_eq!(
2777 DiskImageFormat::from_extension("vmdk"),
2778 Some(DiskImageFormat::Vmdk)
2779 );
2780 assert_eq!(DiskImageFormat::from_extension("ext4"), None);
2781 assert_eq!(DiskImageFormat::from_extension(""), None);
2782 }
2783
2784 #[test]
2785 fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
2786 let resources: SandboxResources =
2787 serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
2788
2789 assert_eq!(resources.cpus, 4);
2790 assert_eq!(resources.max_cpus, 4);
2791 assert_eq!(resources.memory_mib, 2048);
2792 assert_eq!(resources.max_memory_mib, 2048);
2793 assert_eq!(resources.cpu_placement, CpuPlacement::Inherit);
2794 assert_eq!(resources.thp, TransparentHugePagePolicy::Madvise);
2795 assert_eq!(
2796 serde_json::to_value(resources).unwrap(),
2797 serde_json::json!({
2798 "cpus": 4,
2799 "memory_mib": 2048,
2800 "max_cpus": 4,
2801 "max_memory_mib": 2048
2802 })
2803 );
2804 }
2805
2806 #[test]
2807 fn cpu_placement_omits_inherit_and_roundtrips_managed_policies() {
2808 let inherited = serde_json::to_value(SandboxResources::default()).unwrap();
2809 assert!(inherited.get("cpu_placement").is_none());
2810
2811 for policy in [
2812 CpuPlacement::Auto,
2813 CpuPlacement::Spread,
2814 CpuPlacement::Compact,
2815 ] {
2816 let resources = SandboxResources {
2817 cpu_placement: policy,
2818 ..Default::default()
2819 };
2820 let json = serde_json::to_string(&resources).unwrap();
2821 let decoded: SandboxResources = serde_json::from_str(&json).unwrap();
2822
2823 assert_eq!(decoded.cpu_placement, policy);
2824 assert_eq!(policy.to_string().parse::<CpuPlacement>().unwrap(), policy);
2825 }
2826 }
2827
2828 #[test]
2829 fn transparent_huge_page_policy_roundtrips_non_default() {
2830 let resources: SandboxResources = serde_json::from_str(
2831 r#"{"cpus":2,"memory_mib":8192,"max_cpus":2,"max_memory_mib":8192,"thp":"always"}"#,
2832 )
2833 .unwrap();
2834
2835 assert_eq!(resources.thp, TransparentHugePagePolicy::Always);
2836 assert_eq!(
2837 serde_json::to_value(resources).unwrap()["thp"],
2838 serde_json::json!("always")
2839 );
2840 assert_eq!(
2841 "never".parse::<TransparentHugePagePolicy>().unwrap(),
2842 TransparentHugePagePolicy::Never
2843 );
2844 assert!("auto".parse::<TransparentHugePagePolicy>().is_err());
2845 }
2846
2847 #[test]
2848 fn disk_image_format_display_roundtrip() {
2849 for format in [
2850 DiskImageFormat::Qcow2,
2851 DiskImageFormat::Raw,
2852 DiskImageFormat::Vmdk,
2853 ] {
2854 let rendered = format.to_string();
2855 let parsed: DiskImageFormat = rendered.parse().unwrap();
2856 assert_eq!(parsed, format);
2857 }
2858 }
2859
2860 #[test]
2861 fn disk_image_format_from_str_unknown() {
2862 assert!("ext4".parse::<DiskImageFormat>().is_err());
2863 }
2864
2865 #[test]
2866 fn log_source_effective_uses_default_user_program_sources() {
2867 assert_eq!(
2868 LogSource::effective(&[]),
2869 vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
2870 );
2871 }
2872
2873 #[test]
2874 fn log_source_effective_sorts_and_deduplicates_requested_sources() {
2875 assert_eq!(
2876 LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
2877 vec![LogSource::Stdout, LogSource::System]
2878 );
2879 }
2880
2881 #[test]
2882 fn rlimit_resource_parses_case_insensitively() {
2883 assert_eq!(
2884 RlimitResource::try_from("NOFILE").unwrap(),
2885 RlimitResource::Nofile
2886 );
2887 assert!(RlimitResource::try_from("bogus").is_err());
2888 }
2889
2890 #[test]
2891 fn sandbox_policy_serde_roundtrip() {
2892 let policy = SandboxPolicy {
2893 ephemeral: true,
2894 max_duration_secs: Some(3600),
2895 idle_timeout_secs: Some(120),
2896 };
2897
2898 let json = serde_json::to_string(&policy).unwrap();
2899 let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
2900
2901 assert!(decoded.ephemeral);
2902 assert_eq!(decoded.max_duration_secs, Some(3600));
2903 assert_eq!(decoded.idle_timeout_secs, Some(120));
2904 }
2905
2906 #[test]
2907 fn sandbox_policy_defaults_to_persistent() {
2908 assert!(!SandboxPolicy::default().ephemeral);
2909 }
2910
2911 #[test]
2912 fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
2913 let decoded: SandboxPolicy =
2916 serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
2917 assert!(!decoded.ephemeral);
2918 assert_eq!(decoded.max_duration_secs, Some(60));
2919 }
2920
2921 #[test]
2922 fn sandbox_spec_default_uses_static_resource_defaults() {
2923 let spec = SandboxSpec::default();
2924
2925 assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
2926 assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
2927 assert_eq!(
2928 spec.runtime.metrics_sample_interval_ms,
2929 Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
2930 );
2931 assert_eq!(spec.deployment_profile, DeploymentProfile::SingleTenant);
2932 }
2933
2934 #[test]
2935 fn deployment_profile_uses_stable_snake_case_wire_values() {
2936 assert_eq!(
2937 serde_json::to_string(&DeploymentProfile::MultiTenant).unwrap(),
2938 r#""multi_tenant""#
2939 );
2940 assert_eq!(
2941 serde_json::from_str::<DeploymentProfile>(r#""single_tenant""#).unwrap(),
2942 DeploymentProfile::SingleTenant
2943 );
2944 }
2945
2946 #[test]
2947 fn sandbox_log_level_roundtrips_lowercase_values() {
2948 for (input, expected) in [
2949 ("error", SandboxLogLevel::Error),
2950 ("warn", SandboxLogLevel::Warn),
2951 ("info", SandboxLogLevel::Info),
2952 ("debug", SandboxLogLevel::Debug),
2953 ("trace", SandboxLogLevel::Trace),
2954 ] {
2955 let parsed: SandboxLogLevel = input.parse().unwrap();
2956 assert_eq!(parsed, expected);
2957 assert_eq!(parsed.as_str(), input);
2958 }
2959 }
2960}