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 microsandbox_types_macros::ConfigPatch;
11use serde::{Deserialize, Serialize};
12use typed_path::{Utf8Component, Utf8UnixComponent, Utf8UnixPath};
13use zeroize::Zeroizing;
14
15use crate::modify::SecretSource;
16use crate::{TypesError, TypesResult};
17
18pub const DEFAULT_SANDBOX_CPUS: u8 = 1;
24
25pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
27
28pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
38#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
39pub enum DiskImageFormat {
40 Qcow2,
42 Raw,
44 Vmdk,
46}
47
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
51#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
52#[serde(rename_all = "kebab-case")]
53pub enum FlatClone {
54 #[default]
56 Auto,
57
58 Copy,
60
61 Reflink,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
68pub enum RootfsSource {
69 Bind {
71 #[cfg_attr(feature = "ts", ts(type = "string"))]
73 path: PathBuf,
74 #[serde(default)]
81 follow_root_symlinks: bool,
82 },
83
84 Oci(OciRootfsSource),
86
87 DiskImage {
89 #[cfg_attr(feature = "ts", ts(type = "string"))]
91 path: PathBuf,
92 format: DiskImageFormat,
94 fstype: Option<String>,
96 },
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
102#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
103pub struct OciRootfsSource {
104 pub reference: String,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub root_disk: Option<RootDisk>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
119#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
120#[serde(tag = "kind", rename_all = "kebab-case")]
121pub enum RootDisk {
122 Managed {
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 size_mib: Option<u32>,
128 },
129
130 Tmpfs {
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 size_mib: Option<u32>,
136 },
137
138 DiskImage {
141 #[cfg_attr(feature = "ts", ts(type = "string"))]
143 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
144 path: PathBuf,
145 format: DiskImageFormat,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 fstype: Option<String>,
150 },
151
152 Flat {
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 size_mib: Option<u32>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 fstype: Option<String>,
164 #[serde(default, skip_serializing_if = "FlatClone::is_auto")]
166 clone: FlatClone,
167 },
168}
169
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
172#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
173#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
174pub enum PullPolicy {
175 #[default]
177 IfMissing,
178
179 Always,
181
182 Never,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
195#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
196#[serde(rename_all = "lowercase")]
197pub enum StatVirtualization {
198 Strict,
200 Relaxed,
202 Off,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
211#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
212#[serde(rename_all = "lowercase")]
213pub enum HostPermissions {
214 Private,
216 Mirror,
218}
219
220#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
222#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
223#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
224#[serde(rename_all = "lowercase")]
225pub enum SecurityProfile {
226 #[default]
230 Default,
231
232 Restricted,
236}
237
238#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
244#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
245#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
246#[serde(rename_all = "snake_case")]
247pub enum DeploymentProfile {
248 #[default]
250 SingleTenant,
251
252 MultiTenant,
254}
255
256#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
258#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
259#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
260#[serde(default)]
261pub struct MountOptions {
262 pub readonly: bool,
266
267 pub noexec: bool,
271
272 pub nosuid: bool,
274
275 pub nodev: bool,
277
278 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub override_uid: Option<u32>,
287
288 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub override_gid: Option<u32>,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
298#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
299pub enum VolumeKind {
300 Directory,
302
303 Disk,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
310#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
311pub struct VolumeSpec {
312 pub name: String,
314
315 pub kind: VolumeKind,
317
318 pub quota_mib: Option<u32>,
320
321 pub capacity_mib: Option<u32>,
323
324 pub labels: Vec<(String, String)>,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
331#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
332pub enum NamedVolumeMode {
333 Existing,
335
336 Create,
338
339 EnsureExists,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
345#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
346#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
347pub struct NamedVolumeCreate {
348 pub mode: NamedVolumeMode,
350
351 pub name: String,
353
354 pub kind: VolumeKind,
356
357 pub quota_mib: Option<u32>,
359
360 pub capacity_mib: Option<u32>,
362
363 pub labels: Vec<(String, String)>,
365}
366
367#[derive(Clone)]
369#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
370#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
371#[cfg_attr(feature = "ts", ts(tag = "type"))]
372pub enum VolumeMount {
373 Bind {
375 #[cfg_attr(feature = "ts", ts(type = "string"))]
377 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
378 host: PathBuf,
379 guest: String,
381 options: MountOptions,
383 stat_virtualization: StatVirtualization,
385 host_permissions: HostPermissions,
387 follow_root_symlinks: bool,
394 quota_mib: Option<u32>,
400 },
401
402 Named {
404 name: String,
406 guest: String,
408 create: Option<NamedVolumeCreate>,
412 options: MountOptions,
414 stat_virtualization: StatVirtualization,
416 host_permissions: HostPermissions,
418 follow_root_symlinks: bool,
423 },
424
425 Tmpfs {
427 guest: String,
429 size_mib: Option<u32>,
431 options: MountOptions,
433 },
434
435 DiskImage {
437 #[cfg_attr(feature = "ts", ts(type = "string"))]
439 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
440 host: PathBuf,
441 guest: String,
443 format: DiskImageFormat,
445 fstype: Option<String>,
447 options: MountOptions,
449 },
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
454#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
455#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
456pub enum Patch {
457 Text {
459 path: String,
461 content: String,
463 mode: Option<u32>,
465 replace: bool,
467 },
468
469 File {
471 path: String,
473 content: Vec<u8>,
475 mode: Option<u32>,
477 replace: bool,
479 },
480
481 CopyFile {
483 #[cfg_attr(feature = "ts", ts(type = "string"))]
485 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
486 src: PathBuf,
487 dst: String,
489 mode: Option<u32>,
491 replace: bool,
493 },
494
495 CopyDir {
497 #[cfg_attr(feature = "ts", ts(type = "string"))]
499 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
500 src: PathBuf,
501 dst: String,
503 replace: bool,
505 },
506
507 Symlink {
509 target: String,
511 link: String,
513 replace: bool,
515 },
516
517 Mkdir {
519 path: String,
521 mode: Option<u32>,
523 },
524
525 Remove {
527 path: String,
529 },
530
531 Append {
533 path: String,
535 content: String,
537 },
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
548#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
549#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
550#[serde(default)]
551pub struct NetworkSpec {
552 pub enabled: bool,
554
555 #[serde(skip_serializing_if = "Option::is_none")]
557 #[config_patch(nested)]
558 pub interface: Option<InterfaceOverrides>,
559
560 pub ports: Vec<PublishedPortSpec>,
562
563 #[serde(skip_serializing_if = "Option::is_none")]
565 pub policy: Option<NetworkPolicy>,
566
567 #[serde(skip_serializing_if = "Option::is_none")]
569 #[config_patch(nested)]
570 pub dns: Option<DnsConfig>,
571
572 #[serde(skip_serializing_if = "Option::is_none")]
574 #[config_patch(nested)]
575 pub tls: Option<TlsConfig>,
576
577 #[serde(skip_serializing_if = "Option::is_none")]
579 #[config_patch(nested)]
580 pub secrets: Option<SecretsConfig>,
581
582 pub max_connections: Option<usize>,
584
585 #[serde(skip_serializing_if = "Option::is_none")]
587 #[config_patch(nested)]
588 pub rate_limiter: Option<NetworkRateLimiterConfig>,
589
590 pub trust_host_cas: bool,
592
593 #[serde(skip_serializing_if = "Option::is_none")]
596 pub outbound_proxy: Option<OutboundProxy>,
597}
598
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
602#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
603#[serde(tag = "protocol", rename_all = "lowercase")]
604#[non_exhaustive]
605pub enum OutboundProxy {
606 Socks4 {
608 address: String,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
612 user_id: Option<String>,
613 },
614
615 Socks5 {
617 address: String,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 credentials: Option<Socks5Credentials>,
622 },
623}
624
625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
631#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
632pub struct Socks5Credentials {
633 pub username: String,
635
636 pub password: SecretSource,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize)]
642#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
643#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
644pub struct PublishedPortSpec {
645 pub host_port: u16,
647
648 pub guest_port: u16,
650
651 #[serde(default)]
653 pub protocol: PortProtocol,
654
655 pub host_bind: String,
657}
658
659#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
661#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
662#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
663pub enum PortProtocol {
664 #[default]
666 #[serde(rename = "tcp")]
667 Tcp,
668
669 #[serde(rename = "udp")]
671 Udp,
672}
673
674#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
680#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
681#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
682#[serde(default)]
683pub struct VsockSpec {
684 pub routes: Vec<VsockRouteSpec>,
686}
687
688impl VsockSpec {
689 pub fn is_empty(&self) -> bool {
691 self.routes.is_empty()
692 }
693}
694
695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
697#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
698#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
699pub struct VsockRouteSpec {
700 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
702 pub host_socket: PathBuf,
703
704 pub port: u32,
706
707 #[serde(default)]
709 pub socket_type: VsockSocketType,
710}
711
712#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
714#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
715#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
716#[serde(rename_all = "snake_case")]
717pub enum VsockSocketType {
718 #[default]
720 Stream,
721
722 Dgram,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize)]
732#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
733#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
734pub struct HandoffInit {
735 pub cmd: String,
739
740 #[serde(default)]
742 pub args: Vec<String>,
743
744 #[serde(default)]
746 pub env: Vec<(String, String)>,
747}
748
749#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
755#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
756#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
757pub struct SandboxPolicy {
758 #[serde(default)]
767 pub ephemeral: bool,
768
769 pub max_duration_secs: Option<u64>,
771
772 pub idle_timeout_secs: Option<u64>,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize)]
787#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
788pub struct SnapshotSpec {
789 pub name: String,
791
792 #[serde(default)]
795 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
796 pub dest_dir: Option<PathBuf>,
797
798 pub source_sandbox: String,
800
801 pub labels: Vec<(String, String)>,
803
804 pub force: bool,
806
807 pub record_integrity: bool,
809
810 #[serde(default)]
816 pub resumable: bool,
817}
818
819#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
827#[config_patch(name = SandboxConfigPatch)]
828#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
829#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
830#[serde(default)]
831pub struct SandboxSpec {
832 pub name: String,
834
835 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
837 pub image: RootfsSource,
838
839 #[config_patch(nested)]
841 pub resources: SandboxResources,
842
843 #[config_patch(nested)]
845 pub runtime: SandboxRuntimeOptions,
846
847 #[config_patch(merge_with = merge_env_vars)]
849 pub env: Vec<EnvVar>,
850
851 #[config_patch(merge)]
853 pub labels: BTreeMap<String, String>,
854
855 pub rlimits: Vec<Rlimit>,
857
858 pub mounts: Vec<VolumeMount>,
860
861 pub patches: Vec<Patch>,
863
864 #[config_patch(nested)]
866 pub network: NetworkSpec,
867
868 #[serde(default, skip_serializing_if = "VsockSpec::is_empty")]
870 #[config_patch(nested)]
871 pub vsock: VsockSpec,
872
873 pub init: Option<HandoffInit>,
875
876 pub pull_policy: PullPolicy,
878
879 pub security_profile: SecurityProfile,
881
882 pub deployment_profile: DeploymentProfile,
888
889 #[config_patch(nested)]
891 pub lifecycle: SandboxPolicy,
892}
893
894#[derive(Debug, Clone, Serialize, ConfigPatch)]
896#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
897#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
898pub struct SandboxResources {
899 pub cpus: u8,
901
902 pub memory_mib: u32,
904
905 pub max_cpus: u8,
907
908 pub max_memory_mib: u32,
910
911 #[serde(default, skip_serializing_if = "CpuPlacement::is_inherit")]
913 pub cpu_placement: CpuPlacement,
914
915 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub placement_profile: Option<String>,
918
919 #[serde(default, skip_serializing_if = "TransparentHugePagePolicy::is_madvise")]
921 pub thp: TransparentHugePagePolicy,
922}
923
924#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
926#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
927#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
928#[serde(rename_all = "lowercase")]
929pub enum CpuPlacement {
930 #[default]
932 Inherit,
933
934 Auto,
936
937 Spread,
939
940 Compact,
942}
943
944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
946#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
947#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
948#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
949pub enum NumaPlacement {
950 PreferSingle,
952 StrictSingle,
954 Inherit,
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
960#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
961#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
962#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
963pub enum MemoryPlacement {
964 FollowCpu,
966 Inherit,
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(deny_unknown_fields)]
975pub struct PlacementProfile {
976 pub numa: NumaPlacement,
978 pub memory: MemoryPlacement,
980}
981
982#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
984#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
985#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
986#[serde(rename_all = "lowercase")]
987pub enum TransparentHugePagePolicy {
988 Always,
990
991 #[default]
993 Madvise,
994
995 Never,
997}
998
999#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
1001#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1002#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1003#[serde(default)]
1004pub struct SandboxRuntimeOptions {
1005 pub workdir: Option<String>,
1007
1008 pub shell: Option<String>,
1010
1011 #[config_patch(merge)]
1013 pub scripts: BTreeMap<String, String>,
1014
1015 pub entrypoint: Option<Vec<String>>,
1017
1018 pub cmd: Option<Vec<String>>,
1020
1021 pub hostname: Option<String>,
1023
1024 pub user: Option<String>,
1026
1027 pub log_level: Option<SandboxLogLevel>,
1029
1030 pub metrics_sample_interval_ms: Option<u64>,
1032
1033 pub disable_metrics_sample: bool,
1035}
1036
1037#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1039#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1040#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1041pub struct EnvVar {
1042 pub key: String,
1044
1045 pub value: String,
1047}
1048
1049#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1051#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1052#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1053#[serde(rename_all = "lowercase")]
1054pub enum SandboxLogLevel {
1055 Error,
1057
1058 Warn,
1060
1061 Info,
1063
1064 Debug,
1066
1067 Trace,
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1077#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1078#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1079pub enum RlimitResource {
1080 Cpu,
1082 Fsize,
1084 Data,
1086 Stack,
1088 Core,
1090 Rss,
1092 Nproc,
1094 Nofile,
1096 Memlock,
1098 As,
1100 Locks,
1102 Sigpending,
1104 Msgqueue,
1106 Nice,
1108 Rtprio,
1110 Rttime,
1112}
1113
1114#[derive(Debug, Clone, Serialize, Deserialize)]
1116#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1117#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1118pub struct Rlimit {
1119 pub resource: RlimitResource,
1121
1122 pub soft: u64,
1124
1125 pub hard: u64,
1127}
1128
1129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1135#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1136#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1137#[serde(rename_all = "lowercase")]
1138pub enum LogSource {
1139 Stdout,
1141
1142 Stderr,
1144
1145 Output,
1147
1148 System,
1150}
1151
1152impl DiskImageFormat {
1157 pub fn as_str(&self) -> &'static str {
1159 match self {
1160 Self::Qcow2 => "qcow2",
1161 Self::Raw => "raw",
1162 Self::Vmdk => "vmdk",
1163 }
1164 }
1165
1166 pub fn from_extension(ext: &str) -> Option<Self> {
1170 match ext {
1171 "qcow2" => Some(Self::Qcow2),
1172 "raw" => Some(Self::Raw),
1173 "vmdk" => Some(Self::Vmdk),
1174 _ => None,
1175 }
1176 }
1177}
1178
1179impl OciRootfsSource {
1180 pub fn new(reference: impl Into<String>) -> Self {
1182 Self {
1183 reference: reference.into(),
1184 root_disk: None,
1185 }
1186 }
1187}
1188
1189impl TransparentHugePagePolicy {
1190 pub fn is_madvise(&self) -> bool {
1192 matches!(self, Self::Madvise)
1193 }
1194
1195 pub fn as_str(self) -> &'static str {
1197 match self {
1198 Self::Always => "always",
1199 Self::Madvise => "madvise",
1200 Self::Never => "never",
1201 }
1202 }
1203}
1204
1205impl RootDisk {
1206 pub fn managed(size_mib: u32) -> Self {
1208 Self::Managed {
1209 size_mib: Some(size_mib),
1210 }
1211 }
1212
1213 pub fn tmpfs(size_mib: u32) -> Self {
1215 Self::Tmpfs {
1216 size_mib: Some(size_mib),
1217 }
1218 }
1219
1220 pub fn flat(size_mib: u32) -> Self {
1222 Self::Flat {
1223 size_mib: Some(size_mib),
1224 fstype: None,
1225 clone: FlatClone::Auto,
1226 }
1227 }
1228
1229 pub fn size_mib(&self) -> Option<u32> {
1231 match self {
1232 Self::Managed { size_mib } | Self::Tmpfs { size_mib } | Self::Flat { size_mib, .. } => {
1233 *size_mib
1234 }
1235 Self::DiskImage { .. } => None,
1236 }
1237 }
1238
1239 pub fn kind_str(&self) -> &'static str {
1241 match self {
1242 Self::Managed { .. } => "managed",
1243 Self::Tmpfs { .. } => "tmpfs",
1244 Self::DiskImage { .. } => "disk-image",
1245 Self::Flat { .. } => "flat",
1246 }
1247 }
1248
1249 pub fn is_managed(&self) -> bool {
1251 matches!(self, Self::Managed { .. })
1252 }
1253}
1254
1255impl FlatClone {
1256 pub const fn as_str(self) -> &'static str {
1258 match self {
1259 Self::Auto => "auto",
1260 Self::Copy => "copy",
1261 Self::Reflink => "reflink",
1262 }
1263 }
1264
1265 pub const fn is_auto(&self) -> bool {
1267 matches!(self, Self::Auto)
1268 }
1269}
1270
1271impl RootfsSource {
1272 pub fn oci(reference: impl Into<String>) -> Self {
1274 Self::Oci(OciRootfsSource::new(reference))
1275 }
1276
1277 pub fn oci_reference(&self) -> Option<&str> {
1279 match self {
1280 Self::Oci(oci) => Some(&oci.reference),
1281 _ => None,
1282 }
1283 }
1284
1285 pub fn oci_root_disk(&self) -> Option<&RootDisk> {
1287 match self {
1288 Self::Oci(oci) => oci.root_disk.as_ref(),
1289 _ => None,
1290 }
1291 }
1292
1293 pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
1296 match self {
1297 Self::Oci(oci) => match &oci.root_disk {
1298 Some(RootDisk::Managed { size_mib }) => *size_mib,
1299 Some(_) => None,
1300 None => None,
1301 },
1302 _ => None,
1303 }
1304 }
1305}
1306
1307impl EnvVar {
1308 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1310 Self {
1311 key: key.into(),
1312 value: value.into(),
1313 }
1314 }
1315
1316 pub fn as_pair(&self) -> (&str, &str) {
1318 (&self.key, &self.value)
1319 }
1320}
1321
1322impl VolumeKind {
1323 pub fn as_str(self) -> &'static str {
1325 match self {
1326 Self::Directory => "dir",
1327 Self::Disk => "disk",
1328 }
1329 }
1330
1331 pub fn from_db_value(value: &str) -> Self {
1333 match value {
1334 "disk" => Self::Disk,
1335 _ => Self::Directory,
1336 }
1337 }
1338}
1339
1340impl VolumeSpec {
1341 pub fn new(name: impl Into<String>) -> Self {
1343 Self {
1344 name: name.into(),
1345 kind: VolumeKind::Directory,
1346 quota_mib: None,
1347 capacity_mib: None,
1348 labels: Vec::new(),
1349 }
1350 }
1351}
1352
1353impl NamedVolumeCreate {
1354 pub fn mode(&self) -> NamedVolumeMode {
1356 self.mode
1357 }
1358
1359 pub fn name(&self) -> &str {
1361 &self.name
1362 }
1363
1364 pub fn kind(&self) -> VolumeKind {
1366 self.kind
1367 }
1368
1369 pub fn quota_mib(&self) -> Option<u32> {
1371 self.quota_mib
1372 }
1373
1374 pub fn capacity_mib(&self) -> Option<u32> {
1376 self.capacity_mib
1377 }
1378
1379 pub fn labels(&self) -> &[(String, String)] {
1381 &self.labels
1382 }
1383}
1384
1385impl VolumeMount {
1386 pub fn guest(&self) -> &str {
1388 match self {
1389 Self::Bind { guest, .. }
1390 | Self::Named { guest, .. }
1391 | Self::Tmpfs { guest, .. }
1392 | Self::DiskImage { guest, .. } => guest,
1393 }
1394 }
1395
1396 fn guest_mut(&mut self) -> &mut String {
1397 match self {
1398 Self::Bind { guest, .. }
1399 | Self::Named { guest, .. }
1400 | Self::Tmpfs { guest, .. }
1401 | Self::DiskImage { guest, .. } => guest,
1402 }
1403 }
1404
1405 pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1407 match self {
1408 Self::Named { create, .. } => create.as_ref(),
1409 _ => None,
1410 }
1411 }
1412}
1413
1414pub fn canonicalize_volume_mounts(mounts: &mut [VolumeMount]) -> TypesResult<()> {
1425 for mount in mounts.iter_mut() {
1426 let canonical = canonical_guest_mount_path(mount.guest())?;
1427 *mount.guest_mut() = canonical;
1428 }
1429
1430 mounts.sort_by_cached_key(|mount| guest_mount_order_key(mount.guest()));
1431
1432 for pair in mounts.windows(2) {
1433 if pair[0].guest() == pair[1].guest() {
1434 return Err(TypesError::invalid_config(format!(
1435 "multiple volumes cannot mount the same guest path: {}",
1436 pair[0].guest()
1437 )));
1438 }
1439 }
1440
1441 Ok(())
1442}
1443
1444fn canonical_guest_mount_path(guest: &str) -> TypesResult<String> {
1445 let path = Utf8UnixPath::new(guest);
1446
1447 if !path.is_valid() {
1448 return Err(TypesError::invalid_config(format!(
1449 "guest mount path must be a valid Unix path: {guest}"
1450 )));
1451 }
1452 if !path.is_absolute() {
1453 return Err(TypesError::invalid_config(format!(
1454 "guest mount path must be absolute: {guest}"
1455 )));
1456 }
1457 if path
1458 .components()
1459 .any(|component| matches!(component, Utf8UnixComponent::ParentDir))
1460 {
1461 return Err(TypesError::invalid_config(format!(
1462 "guest mount path must not contain '..': {guest}"
1463 )));
1464 }
1465 if guest.contains(':') || guest.contains(';') || guest.contains(',') {
1466 return Err(TypesError::invalid_config(format!(
1467 "guest mount path must not contain ':', ';', or ',': {guest}"
1468 )));
1469 }
1470
1471 let canonical = path.normalize().to_string();
1472 if canonical == "/" {
1473 return Err(TypesError::invalid_config(
1474 "cannot mount a volume at guest root /",
1475 ));
1476 }
1477
1478 Ok(canonical)
1479}
1480
1481fn guest_mount_order_key(guest: &str) -> (usize, String) {
1482 let path = Utf8UnixPath::new(guest);
1483 let depth = path.components().filter(Utf8Component::is_normal).count();
1484 (depth, guest.to_owned())
1485}
1486
1487impl RlimitResource {
1488 pub fn as_str(&self) -> &'static str {
1490 match self {
1491 Self::Cpu => "cpu",
1492 Self::Fsize => "fsize",
1493 Self::Data => "data",
1494 Self::Stack => "stack",
1495 Self::Core => "core",
1496 Self::Rss => "rss",
1497 Self::Nproc => "nproc",
1498 Self::Nofile => "nofile",
1499 Self::Memlock => "memlock",
1500 Self::As => "as",
1501 Self::Locks => "locks",
1502 Self::Sigpending => "sigpending",
1503 Self::Msgqueue => "msgqueue",
1504 Self::Nice => "nice",
1505 Self::Rtprio => "rtprio",
1506 Self::Rttime => "rttime",
1507 }
1508 }
1509}
1510
1511impl LogSource {
1512 pub fn effective(requested: &[Self]) -> Vec<Self> {
1514 if requested.is_empty() {
1515 vec![Self::Stdout, Self::Stderr, Self::Output]
1516 } else {
1517 let mut sources = requested.to_vec();
1518 sources.sort_by_key(|src| match src {
1519 Self::Stdout => 0,
1520 Self::Stderr => 1,
1521 Self::Output => 2,
1522 Self::System => 3,
1523 });
1524 sources.dedup();
1525 sources
1526 }
1527 }
1528}
1529
1530impl SandboxLogLevel {
1531 pub const fn as_str(self) -> &'static str {
1533 match self {
1534 Self::Error => "error",
1535 Self::Warn => "warn",
1536 Self::Info => "info",
1537 Self::Debug => "debug",
1538 Self::Trace => "trace",
1539 }
1540 }
1541}
1542
1543impl std::fmt::Display for DiskImageFormat {
1548 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1549 f.write_str(self.as_str())
1550 }
1551}
1552
1553impl FromStr for DiskImageFormat {
1554 type Err = String;
1555
1556 fn from_str(s: &str) -> Result<Self, Self::Err> {
1557 match s {
1558 "qcow2" => Ok(Self::Qcow2),
1559 "raw" => Ok(Self::Raw),
1560 "vmdk" => Ok(Self::Vmdk),
1561 _ => Err(format!("unknown disk image format: {s}")),
1562 }
1563 }
1564}
1565
1566impl fmt::Display for TransparentHugePagePolicy {
1567 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1568 f.write_str(self.as_str())
1569 }
1570}
1571
1572impl FromStr for TransparentHugePagePolicy {
1573 type Err = String;
1574
1575 fn from_str(value: &str) -> Result<Self, Self::Err> {
1576 match value {
1577 "always" => Ok(Self::Always),
1578 "madvise" => Ok(Self::Madvise),
1579 "never" => Ok(Self::Never),
1580 _ => Err(format!(
1581 "unknown transparent huge-page policy: {value}; expected always, madvise, or never"
1582 )),
1583 }
1584 }
1585}
1586
1587impl Default for RootfsSource {
1588 fn default() -> Self {
1589 Self::oci(String::new())
1590 }
1591}
1592
1593impl Default for SandboxResources {
1594 fn default() -> Self {
1595 Self {
1596 cpus: DEFAULT_SANDBOX_CPUS,
1597 memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1598 max_cpus: DEFAULT_SANDBOX_CPUS,
1599 max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1600 cpu_placement: CpuPlacement::Inherit,
1601 placement_profile: None,
1602 thp: TransparentHugePagePolicy::Madvise,
1603 }
1604 }
1605}
1606
1607impl<'de> Deserialize<'de> for SandboxResources {
1608 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1609 where
1610 D: serde::Deserializer<'de>,
1611 {
1612 #[derive(Deserialize)]
1613 struct RawResources {
1614 #[serde(default = "default_sandbox_cpus")]
1615 cpus: u8,
1616 #[serde(default = "default_sandbox_memory_mib")]
1617 memory_mib: u32,
1618 max_cpus: Option<u8>,
1619 max_memory_mib: Option<u32>,
1620 #[serde(default)]
1621 cpu_placement: CpuPlacement,
1622 #[serde(default)]
1623 placement_profile: Option<String>,
1624 #[serde(default)]
1625 thp: TransparentHugePagePolicy,
1626 }
1627
1628 let raw = RawResources::deserialize(deserializer)?;
1629 Ok(Self {
1630 cpus: raw.cpus,
1631 memory_mib: raw.memory_mib,
1632 max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1636 max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1637 cpu_placement: raw.cpu_placement,
1638 placement_profile: raw.placement_profile,
1639 thp: raw.thp,
1640 })
1641 }
1642}
1643
1644impl CpuPlacement {
1645 pub const fn is_inherit(&self) -> bool {
1647 matches!(self, Self::Inherit)
1648 }
1649}
1650
1651impl std::fmt::Display for CpuPlacement {
1652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1653 f.write_str(match self {
1654 Self::Inherit => "inherit",
1655 Self::Auto => "auto",
1656 Self::Spread => "spread",
1657 Self::Compact => "compact",
1658 })
1659 }
1660}
1661
1662impl FromStr for CpuPlacement {
1663 type Err = String;
1664
1665 fn from_str(value: &str) -> Result<Self, Self::Err> {
1666 match value {
1667 "inherit" => Ok(Self::Inherit),
1668 "auto" => Ok(Self::Auto),
1669 "spread" => Ok(Self::Spread),
1670 "compact" => Ok(Self::Compact),
1671 _ => Err(format!(
1672 "unknown CPU placement: {value} (expected: inherit, auto, spread, compact)"
1673 )),
1674 }
1675 }
1676}
1677
1678impl Default for SandboxRuntimeOptions {
1679 fn default() -> Self {
1680 Self {
1681 workdir: None,
1682 shell: None,
1683 scripts: BTreeMap::new(),
1684 entrypoint: None,
1685 cmd: None,
1686 hostname: None,
1687 user: None,
1688 log_level: None,
1689 metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1690 disable_metrics_sample: false,
1691 }
1692 }
1693}
1694
1695impl Default for NetworkSpec {
1696 fn default() -> Self {
1697 Self {
1698 enabled: true,
1699 interface: None,
1700 ports: Vec::new(),
1701 policy: None,
1702 dns: None,
1703 tls: None,
1704 secrets: None,
1705 max_connections: None,
1706 rate_limiter: None,
1707 trust_host_cas: false,
1708 outbound_proxy: None,
1709 }
1710 }
1711}
1712
1713impl Default for PublishedPortSpec {
1714 fn default() -> Self {
1715 Self {
1716 host_port: 0,
1717 guest_port: 0,
1718 protocol: PortProtocol::Tcp,
1719 host_bind: "127.0.0.1".into(),
1720 }
1721 }
1722}
1723
1724impl From<(String, String)> for EnvVar {
1725 fn from((key, value): (String, String)) -> Self {
1726 Self { key, value }
1727 }
1728}
1729
1730impl From<EnvVar> for (String, String) {
1731 fn from(var: EnvVar) -> Self {
1732 (var.key, var.value)
1733 }
1734}
1735
1736impl FromStr for SandboxLogLevel {
1737 type Err = String;
1738
1739 fn from_str(s: &str) -> Result<Self, Self::Err> {
1740 match s {
1741 "error" => Ok(Self::Error),
1742 "warn" => Ok(Self::Warn),
1743 "info" => Ok(Self::Info),
1744 "debug" => Ok(Self::Debug),
1745 "trace" => Ok(Self::Trace),
1746 _ => Err(format!("unknown sandbox log level: {s}")),
1747 }
1748 }
1749}
1750
1751impl Serialize for VolumeMount {
1752 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1753 use serde::ser::SerializeMap;
1754
1755 match self {
1756 Self::Bind {
1757 host,
1758 guest,
1759 options,
1760 stat_virtualization,
1761 host_permissions,
1762 follow_root_symlinks,
1763 quota_mib,
1764 } => {
1765 let mut map = serializer.serialize_map(Some(8))?;
1766 map.serialize_entry("type", "Bind")?;
1767 map.serialize_entry("host", host)?;
1768 map.serialize_entry("guest", guest)?;
1769 map.serialize_entry("options", options)?;
1770 map.serialize_entry("stat_virtualization", stat_virtualization)?;
1771 map.serialize_entry("host_permissions", host_permissions)?;
1772 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1773 map.serialize_entry("quota_mib", quota_mib)?;
1774 map.end()
1775 }
1776 Self::Named {
1777 name,
1778 guest,
1779 create: _,
1780 options,
1781 stat_virtualization,
1782 host_permissions,
1783 follow_root_symlinks,
1784 } => {
1785 let mut map = serializer.serialize_map(Some(7))?;
1786 map.serialize_entry("type", "Named")?;
1787 map.serialize_entry("name", name)?;
1788 map.serialize_entry("guest", guest)?;
1789 map.serialize_entry("options", options)?;
1790 map.serialize_entry("stat_virtualization", stat_virtualization)?;
1791 map.serialize_entry("host_permissions", host_permissions)?;
1792 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
1793 map.end()
1794 }
1795 Self::Tmpfs {
1796 guest,
1797 size_mib,
1798 options,
1799 } => {
1800 let mut map = serializer.serialize_map(Some(4))?;
1801 map.serialize_entry("type", "Tmpfs")?;
1802 map.serialize_entry("guest", guest)?;
1803 map.serialize_entry("size_mib", size_mib)?;
1804 map.serialize_entry("options", options)?;
1805 map.end()
1806 }
1807 Self::DiskImage {
1808 host,
1809 guest,
1810 format,
1811 fstype,
1812 options,
1813 } => {
1814 let mut map = serializer.serialize_map(Some(6))?;
1815 map.serialize_entry("type", "DiskImage")?;
1816 map.serialize_entry("host", host)?;
1817 map.serialize_entry("guest", guest)?;
1818 map.serialize_entry("format", format)?;
1819 map.serialize_entry("fstype", fstype)?;
1820 map.serialize_entry("options", options)?;
1821 map.end()
1822 }
1823 }
1824 }
1825}
1826
1827impl<'de> Deserialize<'de> for VolumeMount {
1828 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1829 fn default_strict() -> StatVirtualization {
1830 StatVirtualization::Strict
1831 }
1832
1833 fn default_private() -> HostPermissions {
1834 HostPermissions::Private
1835 }
1836
1837 #[derive(Deserialize)]
1838 #[serde(tag = "type")]
1839 enum VolumeMountHelper {
1840 Bind {
1841 host: PathBuf,
1842 guest: String,
1843 #[serde(default)]
1844 options: Option<MountOptions>,
1845 #[serde(default)]
1846 readonly: bool,
1847 #[serde(default = "default_strict")]
1848 stat_virtualization: StatVirtualization,
1849 #[serde(default = "default_private")]
1850 host_permissions: HostPermissions,
1851 #[serde(default)]
1852 follow_root_symlinks: bool,
1853 #[serde(default)]
1854 quota_mib: Option<u32>,
1855 },
1856 Named {
1857 name: String,
1858 guest: String,
1859 #[serde(default)]
1860 options: Option<MountOptions>,
1861 #[serde(default)]
1862 readonly: bool,
1863 #[serde(default = "default_strict")]
1864 stat_virtualization: StatVirtualization,
1865 #[serde(default = "default_private")]
1866 host_permissions: HostPermissions,
1867 #[serde(default)]
1868 follow_root_symlinks: bool,
1869 },
1870 Tmpfs {
1871 guest: String,
1872 #[serde(default)]
1873 size_mib: Option<u32>,
1874 #[serde(default)]
1875 options: Option<MountOptions>,
1876 #[serde(default)]
1877 readonly: bool,
1878 },
1879 DiskImage {
1880 host: PathBuf,
1881 guest: String,
1882 format: DiskImageFormat,
1883 #[serde(default)]
1884 fstype: Option<String>,
1885 #[serde(default)]
1886 options: Option<MountOptions>,
1887 #[serde(default)]
1888 readonly: bool,
1889 },
1890 }
1891
1892 let helper = VolumeMountHelper::deserialize(deserializer)?;
1893 Ok(match helper {
1894 VolumeMountHelper::Bind {
1895 host,
1896 guest,
1897 options,
1898 readonly,
1899 stat_virtualization,
1900 host_permissions,
1901 follow_root_symlinks,
1902 quota_mib,
1903 } => Self::Bind {
1904 host,
1905 guest,
1906 options: decode_mount_options(options, readonly),
1907 stat_virtualization,
1908 host_permissions,
1909 follow_root_symlinks,
1910 quota_mib,
1911 },
1912 VolumeMountHelper::Named {
1913 name,
1914 guest,
1915 options,
1916 readonly,
1917 stat_virtualization,
1918 host_permissions,
1919 follow_root_symlinks,
1920 } => Self::Named {
1921 name,
1922 guest,
1923 create: None,
1924 options: decode_mount_options(options, readonly),
1925 stat_virtualization,
1926 host_permissions,
1927 follow_root_symlinks,
1928 },
1929 VolumeMountHelper::Tmpfs {
1930 guest,
1931 size_mib,
1932 options,
1933 readonly,
1934 } => Self::Tmpfs {
1935 guest,
1936 size_mib,
1937 options: decode_mount_options(options, readonly),
1938 },
1939 VolumeMountHelper::DiskImage {
1940 host,
1941 guest,
1942 format,
1943 fstype,
1944 options,
1945 readonly,
1946 } => Self::DiskImage {
1947 host,
1948 guest,
1949 format,
1950 fstype,
1951 options: decode_mount_options(options, readonly),
1952 },
1953 })
1954 }
1955}
1956
1957impl fmt::Debug for VolumeMount {
1958 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1959 match self {
1960 Self::Bind {
1961 host,
1962 guest,
1963 options,
1964 stat_virtualization,
1965 host_permissions,
1966 follow_root_symlinks,
1967 quota_mib,
1968 } => f
1969 .debug_struct("Bind")
1970 .field("host", host)
1971 .field("guest", guest)
1972 .field("options", options)
1973 .field("stat_virtualization", stat_virtualization)
1974 .field("host_permissions", host_permissions)
1975 .field("follow_root_symlinks", follow_root_symlinks)
1976 .field("quota_mib", quota_mib)
1977 .finish(),
1978 Self::Named {
1979 name,
1980 guest,
1981 create,
1982 options,
1983 stat_virtualization,
1984 host_permissions,
1985 follow_root_symlinks,
1986 } => f
1987 .debug_struct("Named")
1988 .field("name", name)
1989 .field("guest", guest)
1990 .field("create", create)
1991 .field("options", options)
1992 .field("stat_virtualization", stat_virtualization)
1993 .field("host_permissions", host_permissions)
1994 .field("follow_root_symlinks", follow_root_symlinks)
1995 .finish(),
1996 Self::Tmpfs {
1997 guest,
1998 size_mib,
1999 options,
2000 } => f
2001 .debug_struct("Tmpfs")
2002 .field("guest", guest)
2003 .field("size_mib", size_mib)
2004 .field("options", options)
2005 .finish(),
2006 Self::DiskImage {
2007 host,
2008 guest,
2009 format,
2010 fstype,
2011 options,
2012 } => f
2013 .debug_struct("DiskImage")
2014 .field("host", host)
2015 .field("guest", guest)
2016 .field("format", format)
2017 .field("fstype", fstype)
2018 .field("options", options)
2019 .finish(),
2020 }
2021 }
2022}
2023
2024impl TryFrom<&str> for RlimitResource {
2026 type Error = String;
2027
2028 fn try_from(s: &str) -> Result<Self, Self::Error> {
2029 match s.to_ascii_lowercase().as_str() {
2030 "cpu" => Ok(Self::Cpu),
2031 "fsize" => Ok(Self::Fsize),
2032 "data" => Ok(Self::Data),
2033 "stack" => Ok(Self::Stack),
2034 "core" => Ok(Self::Core),
2035 "rss" => Ok(Self::Rss),
2036 "nproc" => Ok(Self::Nproc),
2037 "nofile" => Ok(Self::Nofile),
2038 "memlock" => Ok(Self::Memlock),
2039 "as" => Ok(Self::As),
2040 "locks" => Ok(Self::Locks),
2041 "sigpending" => Ok(Self::Sigpending),
2042 "msgqueue" => Ok(Self::Msgqueue),
2043 "nice" => Ok(Self::Nice),
2044 "rtprio" => Ok(Self::Rtprio),
2045 "rttime" => Ok(Self::Rttime),
2046 _ => Err(format!("unknown rlimit resource: {s}")),
2047 }
2048 }
2049}
2050
2051fn default_sandbox_cpus() -> u8 {
2056 DEFAULT_SANDBOX_CPUS
2057}
2058
2059fn default_sandbox_memory_mib() -> u32 {
2060 DEFAULT_SANDBOX_MEMORY_MIB
2061}
2062
2063fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
2064 options.unwrap_or(MountOptions {
2065 readonly,
2066 ..MountOptions::default()
2067 })
2068}
2069
2070fn merge_env_vars(base: &mut Vec<EnvVar>, higher: Vec<EnvVar>) {
2071 for value in higher {
2072 match base.iter_mut().find(|current| current.key == value.key) {
2073 Some(current) => *current = value,
2074 None => base.push(value),
2075 }
2076 }
2077}
2078
2079fn merge_secret_entries(base: &mut Vec<SecretEntry>, higher: Vec<SecretEntry>) {
2080 for value in higher {
2081 match base
2082 .iter_mut()
2083 .find(|current| current.env_var == value.env_var)
2084 {
2085 Some(current) => *current = value,
2086 None => base.push(value),
2087 }
2088 }
2089}
2090
2091pub(crate) fn default_strict() -> StatVirtualization {
2093 StatVirtualization::Strict
2094}
2095
2096pub(crate) fn default_private() -> HostPermissions {
2098 HostPermissions::Private
2099}
2100
2101pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
2103
2104#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigPatch)]
2111#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2112#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2113pub struct SecretsConfig {
2114 #[serde(default)]
2116 #[config_patch(merge_with = merge_secret_entries)]
2117 pub secrets: Vec<SecretEntry>,
2118
2119 #[serde(default)]
2121 pub on_violation: ViolationAction,
2122}
2123
2124#[derive(Clone, Serialize, Deserialize)]
2129#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2130#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2131pub struct SecretEntry {
2132 pub env_var: String,
2138
2139 #[serde(default = "empty_secret_value")]
2148 #[cfg_attr(feature = "ts", ts(type = "string"))]
2149 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2150 pub value: Zeroizing<String>,
2151
2152 #[serde(default, skip_serializing_if = "Option::is_none")]
2156 pub source: Option<SecretSource>,
2157
2158 pub placeholder: String,
2163
2164 #[serde(default)]
2166 pub allowed_hosts: Vec<HostPattern>,
2167
2168 #[serde(default)]
2170 pub injection: SecretInjection,
2171
2172 #[serde(default, skip_serializing_if = "Option::is_none")]
2174 pub on_violation: Option<ViolationAction>,
2175
2176 #[serde(default = "default_true")]
2181 pub require_tls_identity: bool,
2182}
2183
2184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2186#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2187#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2188#[serde(rename_all = "kebab-case")]
2189pub enum HostPattern {
2190 #[serde(alias = "Exact")]
2192 Exact(String),
2193 #[serde(alias = "Wildcard")]
2195 Wildcard(String),
2196 #[serde(alias = "Any")]
2198 Any,
2199}
2200
2201#[derive(Debug, Clone, Serialize, Deserialize)]
2203#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2204#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2205pub struct SecretInjection {
2206 #[serde(default = "default_true")]
2208 pub headers: bool,
2209
2210 #[serde(default = "default_true")]
2212 pub basic_auth: bool,
2213
2214 #[serde(default)]
2216 pub query_params: bool,
2217
2218 #[serde(default)]
2226 pub body: bool,
2227}
2228
2229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2231#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2232#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2233#[serde(rename_all = "kebab-case")]
2234pub enum ViolationAction {
2235 #[serde(alias = "Block")]
2237 Block,
2238 #[default]
2240 #[serde(alias = "BlockAndLog", alias = "block_and_log")]
2241 BlockAndLog,
2242 #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
2244 BlockAndTerminate,
2245 #[serde(alias = "Passthrough")]
2247 Passthrough(Vec<HostPattern>),
2248}
2249
2250#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2252pub enum SecretConfigError {
2253 #[error("secret #{secret_index}: env_var must not be empty")]
2255 EmptyEnvVar {
2256 secret_index: usize,
2258 },
2259
2260 #[error("secret #{secret_index}: env_var must not contain `=`")]
2262 EnvVarContainsEquals {
2263 secret_index: usize,
2265 },
2266
2267 #[error("secret #{secret_index}: env_var must not contain NUL")]
2269 EnvVarContainsNul {
2270 secret_index: usize,
2272 },
2273
2274 #[error("secret #{secret_index}: at least one allowed host is required")]
2276 MissingAllowedHosts {
2277 secret_index: usize,
2279 },
2280
2281 #[error("secret #{secret_index}: placeholder must not be empty")]
2283 EmptyPlaceholder {
2284 secret_index: usize,
2286 },
2287
2288 #[error(
2290 "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
2291 )]
2292 PlaceholderTooLong {
2293 secret_index: usize,
2295 actual_bytes: usize,
2297 max_bytes: usize,
2299 },
2300
2301 #[error("secret #{secret_index}: placeholder must not contain NUL")]
2303 PlaceholderContainsNul {
2304 secret_index: usize,
2306 },
2307
2308 #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
2310 PlaceholderContainsLineBreak {
2311 secret_index: usize,
2313 },
2314}
2315
2316impl SecretsConfig {
2317 pub fn validate(&self) -> Result<(), SecretConfigError> {
2319 for (index, secret) in self.secrets.iter().enumerate() {
2320 secret.validate(index)?;
2321 }
2322 Ok(())
2323 }
2324}
2325
2326impl SecretEntry {
2327 pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
2329 validate_env_var(&self.env_var, secret_index)?;
2330
2331 if self.allowed_hosts.is_empty() {
2332 return Err(SecretConfigError::MissingAllowedHosts { secret_index });
2333 }
2334
2335 validate_placeholder(&self.placeholder, secret_index)
2336 }
2337}
2338
2339impl fmt::Debug for SecretEntry {
2341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2342 f.debug_struct("SecretEntry")
2343 .field("env_var", &self.env_var)
2344 .field("value", &"[REDACTED]")
2345 .field("source", &self.source)
2346 .field("placeholder", &self.placeholder)
2347 .field("allowed_hosts", &self.allowed_hosts)
2348 .field("injection", &self.injection)
2349 .field("on_violation", &self.on_violation)
2350 .field("require_tls_identity", &self.require_tls_identity)
2351 .finish()
2352 }
2353}
2354
2355impl HostPattern {
2356 pub fn parse(host: &str) -> Self {
2359 if host == "*" {
2360 HostPattern::Any
2361 } else if host.starts_with("*.") {
2362 HostPattern::Wildcard(host.to_string())
2363 } else {
2364 HostPattern::Exact(host.to_string())
2365 }
2366 }
2367
2368 pub fn matches(&self, hostname: &str) -> bool {
2373 match self {
2374 HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
2375 HostPattern::Wildcard(pattern) => {
2376 if let Some(suffix) = pattern.strip_prefix("*.") {
2377 hostname.eq_ignore_ascii_case(suffix)
2378 || (hostname.len() > suffix.len() + 1
2379 && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
2380 && hostname[hostname.len() - suffix.len()..]
2381 .eq_ignore_ascii_case(suffix))
2382 } else {
2383 hostname.eq_ignore_ascii_case(pattern)
2384 }
2385 }
2386 HostPattern::Any => true,
2387 }
2388 }
2389}
2390
2391impl Default for SecretInjection {
2392 fn default() -> Self {
2393 Self {
2394 headers: true,
2395 basic_auth: true,
2396 query_params: false,
2397 body: false,
2398 }
2399 }
2400}
2401
2402fn default_true() -> bool {
2403 true
2404}
2405
2406fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2407 if env_var.is_empty() {
2408 return Err(SecretConfigError::EmptyEnvVar { secret_index });
2409 }
2410 if env_var.contains('=') {
2411 return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
2412 }
2413 if env_var.contains('\0') {
2414 return Err(SecretConfigError::EnvVarContainsNul { secret_index });
2415 }
2416 Ok(())
2417}
2418
2419fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2420 if placeholder.is_empty() {
2421 return Err(SecretConfigError::EmptyPlaceholder { secret_index });
2422 }
2423
2424 let actual_bytes = placeholder.len();
2425 if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
2426 return Err(SecretConfigError::PlaceholderTooLong {
2427 secret_index,
2428 actual_bytes,
2429 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2430 });
2431 }
2432
2433 if placeholder.contains('\0') {
2434 return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
2435 }
2436 if placeholder.contains('\r') || placeholder.contains('\n') {
2437 return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
2438 }
2439
2440 Ok(())
2441}
2442
2443#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
2453#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2454#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2455pub struct TlsConfig {
2456 #[serde(default)]
2458 pub enabled: bool,
2459
2460 #[serde(default = "default_intercepted_ports")]
2462 pub intercepted_ports: Vec<u16>,
2463
2464 #[serde(default)]
2466 pub bypass: Vec<String>,
2467
2468 #[serde(default = "default_true")]
2470 pub verify_upstream: bool,
2471
2472 #[serde(default = "default_true")]
2475 pub block_quic_on_intercept: bool,
2476
2477 #[serde(default)]
2479 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
2480 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
2481 pub upstream_ca_cert: Vec<PathBuf>,
2482
2483 #[serde(default, alias = "scoped_upstream_ca_certs")]
2485 pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
2486
2487 #[serde(default)]
2489 pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
2490
2491 #[serde(default, alias = "ca")]
2494 pub intercept_ca: InterceptCaConfig,
2495
2496 #[serde(default)]
2498 pub cache: CertCacheConfig,
2499}
2500
2501#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2503#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2504#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2505pub struct InterceptCaConfig {
2506 #[serde(default)]
2509 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2510 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2511 pub cert_path: Option<PathBuf>,
2512
2513 #[serde(default)]
2516 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2517 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2518 pub key_path: Option<PathBuf>,
2519}
2520
2521#[derive(Debug, Clone, Serialize, Deserialize)]
2523#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2524#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2525pub struct CertCacheConfig {
2526 #[serde(default = "default_cache_capacity")]
2528 pub capacity: usize,
2529
2530 #[serde(default = "default_cert_validity_hours")]
2532 pub validity_hours: u64,
2533}
2534
2535#[derive(Debug, Clone, Serialize, Deserialize)]
2537#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2538#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2539pub struct ScopedUpstreamCaCert {
2540 pub pattern: String,
2542
2543 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2545 #[cfg_attr(feature = "ts", ts(type = "string"))]
2546 pub path: PathBuf,
2547}
2548
2549#[derive(Debug, Clone, Serialize, Deserialize)]
2551#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2552#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2553pub struct ScopedVerifyUpstream {
2554 pub pattern: String,
2556
2557 pub verify: bool,
2559}
2560
2561impl Default for TlsConfig {
2562 fn default() -> Self {
2563 Self {
2564 enabled: false,
2565 intercepted_ports: default_intercepted_ports(),
2566 bypass: Vec::new(),
2567 verify_upstream: true,
2568 block_quic_on_intercept: true,
2569 upstream_ca_cert: Vec::new(),
2570 scoped_upstream_ca_cert: Vec::new(),
2571 scoped_verify_upstream: Vec::new(),
2572 intercept_ca: InterceptCaConfig::default(),
2573 cache: CertCacheConfig::default(),
2574 }
2575 }
2576}
2577
2578impl Default for CertCacheConfig {
2579 fn default() -> Self {
2580 Self {
2581 capacity: default_cache_capacity(),
2582 validity_hours: default_cert_validity_hours(),
2583 }
2584 }
2585}
2586
2587fn default_intercepted_ports() -> Vec<u16> {
2588 vec![443]
2589}
2590
2591fn default_cache_capacity() -> usize {
2592 1000
2593}
2594
2595fn default_cert_validity_hours() -> u64 {
2596 24
2597}
2598
2599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2605#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2606#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2607#[serde(rename_all = "snake_case")]
2608pub enum Action {
2609 Allow,
2611 Deny,
2613}
2614
2615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2617#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2618#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2619#[serde(rename_all = "snake_case")]
2620pub enum Direction {
2621 Egress,
2623 Ingress,
2625 Any,
2627}
2628
2629#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2631#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2632#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2633#[serde(rename_all = "snake_case")]
2634pub enum Protocol {
2635 Tcp,
2637 Udp,
2639 Icmpv4,
2641 Icmpv6,
2643}
2644
2645#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2647#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2648#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2649#[serde(rename_all = "snake_case")]
2650pub enum DestinationGroup {
2651 Public,
2653 Loopback,
2655 Private,
2657 LinkLocal,
2659 Metadata,
2661 Multicast,
2663 Host,
2665}
2666
2667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2674#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2675#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2676#[serde(rename_all = "snake_case")]
2677pub enum Destination {
2678 Any,
2680 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2682 Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
2683 Domain(String),
2685 DomainSuffix(String),
2687 Group(DestinationGroup),
2689}
2690
2691#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2693#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2694#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2695pub struct PortRange {
2696 pub start: u16,
2698 pub end: u16,
2700}
2701
2702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2705#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2706#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2707pub struct Rule {
2708 pub direction: Direction,
2710 pub destination: Destination,
2712 #[serde(default)]
2714 pub protocols: Vec<Protocol>,
2715 #[serde(default)]
2717 pub ports: Vec<PortRange>,
2718 pub action: Action,
2720}
2721
2722#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2725#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2726#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2727pub struct NetworkPolicy {
2728 #[serde(default = "action_deny")]
2730 pub default_egress: Action,
2731 #[serde(default = "action_deny")]
2733 pub default_ingress: Action,
2734 #[serde(default)]
2736 pub rules: Vec<Rule>,
2737}
2738
2739fn action_deny() -> Action {
2742 Action::Deny
2743}
2744
2745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
2751#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2752#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2753#[serde(default)]
2754pub struct DnsConfig {
2755 pub rebind_protection: bool,
2757 pub nameservers: Vec<String>,
2760 pub query_timeout_ms: u64,
2762}
2763
2764impl Default for DnsConfig {
2765 fn default() -> Self {
2766 Self {
2767 rebind_protection: true,
2768 nameservers: Vec::new(),
2769 query_timeout_ms: 5000,
2770 }
2771 }
2772}
2773
2774#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
2778#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2779#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2780#[serde(default)]
2781pub struct InterfaceOverrides {
2782 #[serde(skip_serializing_if = "Option::is_none")]
2784 pub mac: Option<[u8; 6]>,
2785 #[serde(skip_serializing_if = "Option::is_none")]
2787 pub mtu: Option<u16>,
2788 #[serde(skip_serializing_if = "Option::is_none")]
2790 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2791 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2792 pub ipv4_address: Option<Ipv4Addr>,
2793 #[serde(skip_serializing_if = "Option::is_none")]
2795 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2796 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2797 pub ipv4_pool: Option<Ipv4Network>,
2798 #[serde(skip_serializing_if = "Option::is_none")]
2800 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2801 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2802 pub ipv6_address: Option<Ipv6Addr>,
2803 #[serde(skip_serializing_if = "Option::is_none")]
2805 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2806 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2807 pub ipv6_pool: Option<Ipv6Network>,
2808}
2809
2810fn empty_secret_value() -> Zeroizing<String> {
2811 Zeroizing::new(String::new())
2812}
2813
2814#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2820pub enum NetworkRateLimitDirection {
2821 Egress,
2823 Ingress,
2825}
2826
2827#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
2829#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2830#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2831#[serde(default)]
2832pub struct NetworkRateLimiterConfig {
2833 #[serde(skip_serializing_if = "Option::is_none")]
2835 pub egress: Option<RateLimiterConfig>,
2836
2837 #[serde(skip_serializing_if = "Option::is_none")]
2839 pub ingress: Option<RateLimiterConfig>,
2840}
2841
2842#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2848#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2849#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2850#[serde(default)]
2851pub struct RateLimiterConfig {
2852 #[serde(skip_serializing_if = "Option::is_none")]
2854 pub bandwidth: Option<TokenBucketConfig>,
2855
2856 #[serde(skip_serializing_if = "Option::is_none")]
2858 pub ops: Option<TokenBucketConfig>,
2859}
2860
2861#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2867#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2868#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2869pub struct TokenBucketConfig {
2870 pub size: u64,
2872
2873 pub refill_time_ms: u64,
2876
2877 #[serde(default)]
2879 pub one_time_burst: u64,
2880}
2881
2882#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2884pub enum RateLimitConfigError {
2885 #[error("rate limiter must configure at least one of bandwidth or ops")]
2887 EmptyLimiter,
2888
2889 #[error("{bucket} bucket: size must be greater than zero")]
2891 ZeroSize {
2892 bucket: &'static str,
2894 },
2895
2896 #[error("{bucket} bucket: refill_time_ms must be greater than zero")]
2898 ZeroRefillTime {
2899 bucket: &'static str,
2901 },
2902}
2903
2904impl RateLimiterConfig {
2905 pub fn validate(&self) -> Result<(), RateLimitConfigError> {
2907 if self.bandwidth.is_none() && self.ops.is_none() {
2908 return Err(RateLimitConfigError::EmptyLimiter);
2909 }
2910 if let Some(bandwidth) = &self.bandwidth {
2911 bandwidth.validate("bandwidth")?;
2912 }
2913 if let Some(ops) = &self.ops {
2914 ops.validate("ops")?;
2915 }
2916 Ok(())
2917 }
2918}
2919
2920impl TokenBucketConfig {
2921 pub fn validate(&self, bucket: &'static str) -> Result<(), RateLimitConfigError> {
2923 if self.size == 0 {
2924 return Err(RateLimitConfigError::ZeroSize { bucket });
2925 }
2926 if self.refill_time_ms == 0 {
2927 return Err(RateLimitConfigError::ZeroRefillTime { bucket });
2928 }
2929 Ok(())
2930 }
2931}
2932
2933impl fmt::Display for NetworkRateLimitDirection {
2934 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2935 match self {
2936 Self::Egress => f.write_str("egress"),
2937 Self::Ingress => f.write_str("ingress"),
2938 }
2939 }
2940}
2941
2942#[cfg(test)]
2947mod tests {
2948 use super::*;
2949
2950 fn tmpfs_mount(guest: &str) -> VolumeMount {
2951 VolumeMount::Tmpfs {
2952 guest: guest.to_owned(),
2953 size_mib: None,
2954 options: MountOptions::default(),
2955 }
2956 }
2957
2958 #[test]
2959 fn mount_options_omit_unset_owner_but_accept_missing_fields() {
2960 let value = serde_json::to_value(MountOptions::default()).unwrap();
2961 assert!(value.get("override_uid").is_none());
2962 assert!(value.get("override_gid").is_none());
2963
2964 let decoded: MountOptions = serde_json::from_value(value).unwrap();
2965 assert_eq!(decoded.override_uid, None);
2966 assert_eq!(decoded.override_gid, None);
2967 }
2968
2969 #[test]
2970 fn volume_mounts_are_canonicalized_and_ordered_parent_first() {
2971 let mut mounts = vec![
2972 tmpfs_mount("/workspace//persist/./logs/"),
2973 tmpfs_mount("/alpha/z"),
2974 tmpfs_mount("/workspace"),
2975 ];
2976
2977 canonicalize_volume_mounts(&mut mounts).unwrap();
2978
2979 assert_eq!(
2980 mounts.iter().map(VolumeMount::guest).collect::<Vec<_>>(),
2981 vec!["/workspace", "/alpha/z", "/workspace/persist/logs"]
2982 );
2983 }
2984
2985 #[test]
2986 fn volume_mounts_reject_duplicate_canonical_paths() {
2987 let mut mounts = vec![tmpfs_mount("/data/cache"), tmpfs_mount("/data//./cache/")];
2988
2989 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
2990
2991 assert!(error.to_string().contains("same guest path: /data/cache"));
2992 }
2993
2994 #[test]
2995 fn volume_mounts_reject_parent_components_before_normalizing() {
2996 let mut mounts = vec![tmpfs_mount("/workspace/../secrets")];
2997
2998 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
2999
3000 assert!(error.to_string().contains("must not contain '..'"));
3001 }
3002
3003 #[test]
3004 fn disk_image_format_from_extension() {
3005 assert_eq!(
3006 DiskImageFormat::from_extension("qcow2"),
3007 Some(DiskImageFormat::Qcow2)
3008 );
3009 assert_eq!(
3010 DiskImageFormat::from_extension("raw"),
3011 Some(DiskImageFormat::Raw)
3012 );
3013 assert_eq!(
3014 DiskImageFormat::from_extension("vmdk"),
3015 Some(DiskImageFormat::Vmdk)
3016 );
3017 assert_eq!(DiskImageFormat::from_extension("ext4"), None);
3018 assert_eq!(DiskImageFormat::from_extension(""), None);
3019 }
3020
3021 #[test]
3022 fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
3023 let resources: SandboxResources =
3024 serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
3025
3026 assert_eq!(resources.cpus, 4);
3027 assert_eq!(resources.max_cpus, 4);
3028 assert_eq!(resources.memory_mib, 2048);
3029 assert_eq!(resources.max_memory_mib, 2048);
3030 assert_eq!(resources.cpu_placement, CpuPlacement::Inherit);
3031 assert_eq!(resources.thp, TransparentHugePagePolicy::Madvise);
3032 assert_eq!(
3033 serde_json::to_value(resources).unwrap(),
3034 serde_json::json!({
3035 "cpus": 4,
3036 "memory_mib": 2048,
3037 "max_cpus": 4,
3038 "max_memory_mib": 2048
3039 })
3040 );
3041 }
3042
3043 #[test]
3044 fn cpu_placement_omits_inherit_and_roundtrips_managed_policies() {
3045 let inherited = serde_json::to_value(SandboxResources::default()).unwrap();
3046 assert!(inherited.get("cpu_placement").is_none());
3047
3048 for policy in [
3049 CpuPlacement::Auto,
3050 CpuPlacement::Spread,
3051 CpuPlacement::Compact,
3052 ] {
3053 let resources = SandboxResources {
3054 cpu_placement: policy,
3055 ..Default::default()
3056 };
3057 let json = serde_json::to_string(&resources).unwrap();
3058 let decoded: SandboxResources = serde_json::from_str(&json).unwrap();
3059
3060 assert_eq!(decoded.cpu_placement, policy);
3061 assert_eq!(policy.to_string().parse::<CpuPlacement>().unwrap(), policy);
3062 }
3063 }
3064
3065 #[test]
3066 fn transparent_huge_page_policy_roundtrips_non_default() {
3067 let resources: SandboxResources = serde_json::from_str(
3068 r#"{"cpus":2,"memory_mib":8192,"max_cpus":2,"max_memory_mib":8192,"thp":"always"}"#,
3069 )
3070 .unwrap();
3071
3072 assert_eq!(resources.thp, TransparentHugePagePolicy::Always);
3073 assert_eq!(
3074 serde_json::to_value(resources).unwrap()["thp"],
3075 serde_json::json!("always")
3076 );
3077 assert_eq!(
3078 "never".parse::<TransparentHugePagePolicy>().unwrap(),
3079 TransparentHugePagePolicy::Never
3080 );
3081 assert!("auto".parse::<TransparentHugePagePolicy>().is_err());
3082 }
3083
3084 #[test]
3085 fn disk_image_format_display_roundtrip() {
3086 for format in [
3087 DiskImageFormat::Qcow2,
3088 DiskImageFormat::Raw,
3089 DiskImageFormat::Vmdk,
3090 ] {
3091 let rendered = format.to_string();
3092 let parsed: DiskImageFormat = rendered.parse().unwrap();
3093 assert_eq!(parsed, format);
3094 }
3095 }
3096
3097 #[test]
3098 fn disk_image_format_from_str_unknown() {
3099 assert!("ext4".parse::<DiskImageFormat>().is_err());
3100 }
3101
3102 #[test]
3103 fn log_source_effective_uses_default_user_program_sources() {
3104 assert_eq!(
3105 LogSource::effective(&[]),
3106 vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
3107 );
3108 }
3109
3110 #[test]
3111 fn log_source_effective_sorts_and_deduplicates_requested_sources() {
3112 assert_eq!(
3113 LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
3114 vec![LogSource::Stdout, LogSource::System]
3115 );
3116 }
3117
3118 #[test]
3119 fn rlimit_resource_parses_case_insensitively() {
3120 assert_eq!(
3121 RlimitResource::try_from("NOFILE").unwrap(),
3122 RlimitResource::Nofile
3123 );
3124 assert!(RlimitResource::try_from("bogus").is_err());
3125 }
3126
3127 #[test]
3128 fn sandbox_policy_serde_roundtrip() {
3129 let policy = SandboxPolicy {
3130 ephemeral: true,
3131 max_duration_secs: Some(3600),
3132 idle_timeout_secs: Some(120),
3133 };
3134
3135 let json = serde_json::to_string(&policy).unwrap();
3136 let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
3137
3138 assert!(decoded.ephemeral);
3139 assert_eq!(decoded.max_duration_secs, Some(3600));
3140 assert_eq!(decoded.idle_timeout_secs, Some(120));
3141 }
3142
3143 #[test]
3144 fn sandbox_policy_defaults_to_persistent() {
3145 assert!(!SandboxPolicy::default().ephemeral);
3146 }
3147
3148 #[test]
3149 fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
3150 let decoded: SandboxPolicy =
3153 serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
3154 assert!(!decoded.ephemeral);
3155 assert_eq!(decoded.max_duration_secs, Some(60));
3156 }
3157
3158 #[test]
3159 fn sandbox_spec_default_uses_static_resource_defaults() {
3160 let spec = SandboxSpec::default();
3161
3162 assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
3163 assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
3164 assert_eq!(
3165 spec.runtime.metrics_sample_interval_ms,
3166 Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
3167 );
3168 assert_eq!(spec.deployment_profile, DeploymentProfile::SingleTenant);
3169 }
3170
3171 #[test]
3172 fn deployment_profile_uses_stable_snake_case_wire_values() {
3173 assert_eq!(
3174 serde_json::to_string(&DeploymentProfile::MultiTenant).unwrap(),
3175 r#""multi_tenant""#
3176 );
3177 assert_eq!(
3178 serde_json::from_str::<DeploymentProfile>(r#""single_tenant""#).unwrap(),
3179 DeploymentProfile::SingleTenant
3180 );
3181 }
3182
3183 #[test]
3184 fn sandbox_log_level_roundtrips_lowercase_values() {
3185 for (input, expected) in [
3186 ("error", SandboxLogLevel::Error),
3187 ("warn", SandboxLogLevel::Warn),
3188 ("info", SandboxLogLevel::Info),
3189 ("debug", SandboxLogLevel::Debug),
3190 ("trace", SandboxLogLevel::Trace),
3191 ] {
3192 let parsed: SandboxLogLevel = input.parse().unwrap();
3193 assert_eq!(parsed, expected);
3194 assert_eq!(parsed.as_str(), input);
3195 }
3196 }
3197}