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 sha2::{Digest, Sha256};
13use typed_path::{Utf8Component, Utf8UnixComponent, Utf8UnixPath};
14use zeroize::Zeroizing;
15
16use crate::modify::SecretSource;
17use crate::{TypesError, TypesResult};
18
19pub const DEFAULT_SANDBOX_CPUS: u8 = 1;
25
26pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
28
29pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
31
32pub const WELL_KNOWN_NAT64_PREFIX: &str = "64:ff9b::/96";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
42#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
43pub enum DiskImageFormat {
44 Qcow2,
46 Raw,
48 Vmdk,
50}
51
52#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
55#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
56#[serde(rename_all = "kebab-case")]
57pub enum FlatClone {
58 #[default]
60 Auto,
61
62 Copy,
64
65 Reflink,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
72pub enum RootfsSource {
73 Bind {
75 #[cfg_attr(feature = "ts", ts(type = "string"))]
77 path: PathBuf,
78 #[serde(default)]
85 follow_root_symlinks: bool,
86 },
87
88 Oci(OciRootfsSource),
90
91 DiskImage {
93 #[cfg_attr(feature = "ts", ts(type = "string"))]
95 path: PathBuf,
96 format: DiskImageFormat,
98 fstype: Option<String>,
100 },
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
106#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
107pub struct OciRootfsSource {
108 pub reference: String,
110
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub root_disk: Option<RootDisk>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
123#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
124#[serde(tag = "kind", rename_all = "kebab-case")]
125pub enum RootDisk {
126 Managed {
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 size_mib: Option<u32>,
132 },
133
134 Tmpfs {
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 size_mib: Option<u32>,
140 },
141
142 DiskImage {
145 #[cfg_attr(feature = "ts", ts(type = "string"))]
147 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
148 path: PathBuf,
149 format: DiskImageFormat,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 fstype: Option<String>,
154 },
155
156 Flat {
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 size_mib: Option<u32>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 fstype: Option<String>,
168 #[serde(default, skip_serializing_if = "FlatClone::is_auto")]
170 clone: FlatClone,
171 },
172}
173
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
176#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
177#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
178pub enum PullPolicy {
179 #[default]
181 IfMissing,
182
183 Always,
185
186 Never,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
199#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
200#[serde(rename_all = "lowercase")]
201pub enum StatVirtualization {
202 Strict,
204 Relaxed,
206 Off,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
215#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
216#[serde(rename_all = "lowercase")]
217pub enum HostPermissions {
218 Private,
220 Mirror,
222}
223
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
226#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
227#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
228#[serde(rename_all = "lowercase")]
229pub enum SecurityProfile {
230 #[default]
234 Default,
235
236 Restricted,
240}
241
242#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
248#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
249#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
250#[serde(rename_all = "snake_case")]
251pub enum DeploymentProfile {
252 #[default]
254 SingleTenant,
255
256 MultiTenant,
258}
259
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
262#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
263#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
264#[serde(default)]
265pub struct MountOptions {
266 pub readonly: bool,
270
271 pub noexec: bool,
275
276 pub nosuid: bool,
278
279 pub nodev: bool,
281
282 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub override_uid: Option<u32>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub override_gid: Option<u32>,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
302#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
303pub enum VolumeKind {
304 Directory,
306
307 Disk,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
314#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
315pub struct VolumeSpec {
316 pub name: String,
318
319 pub kind: VolumeKind,
321
322 pub quota_mib: Option<u32>,
324
325 pub capacity_mib: Option<u32>,
327
328 pub labels: Vec<(String, String)>,
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
334#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
335#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
336pub enum NamedVolumeMode {
337 Existing,
339
340 Create,
342
343 EnsureExists,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
350#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
351pub struct NamedVolumeCreate {
352 pub mode: NamedVolumeMode,
354
355 pub name: String,
357
358 pub kind: VolumeKind,
360
361 pub quota_mib: Option<u32>,
363
364 pub capacity_mib: Option<u32>,
366
367 pub labels: Vec<(String, String)>,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
374#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
375#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
376pub enum OwnedVolumeStorage {
377 Directory {
379 quota_mib: Option<u32>,
381 },
382 Disk {
384 capacity_mib: u32,
386 },
387}
388
389#[derive(Clone)]
391#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
392#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
393#[cfg_attr(feature = "ts", ts(tag = "type"))]
394pub enum VolumeMount {
395 Owned {
397 guest: String,
399 storage: OwnedVolumeStorage,
401 options: MountOptions,
403 stat_virtualization: StatVirtualization,
405 host_permissions: HostPermissions,
407 },
408 Bind {
410 #[cfg_attr(feature = "ts", ts(type = "string"))]
412 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
413 host: PathBuf,
414 guest: String,
416 options: MountOptions,
418 stat_virtualization: StatVirtualization,
420 host_permissions: HostPermissions,
422 follow_root_symlinks: bool,
429 quota_mib: Option<u32>,
435 },
436
437 Named {
439 name: String,
441 guest: String,
443 create: Option<NamedVolumeCreate>,
447 options: MountOptions,
449 stat_virtualization: StatVirtualization,
451 host_permissions: HostPermissions,
453 follow_root_symlinks: bool,
458 },
459
460 Tmpfs {
462 guest: String,
464 size_mib: Option<u32>,
466 options: MountOptions,
468 },
469
470 DiskImage {
472 #[cfg_attr(feature = "ts", ts(type = "string"))]
474 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
475 host: PathBuf,
476 guest: String,
478 format: DiskImageFormat,
480 fstype: Option<String>,
482 options: MountOptions,
484 },
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
489#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
490#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
491pub enum Patch {
492 Text {
494 path: String,
496 content: String,
498 mode: Option<u32>,
500 replace: bool,
502 },
503
504 File {
506 path: String,
508 content: Vec<u8>,
510 mode: Option<u32>,
512 replace: bool,
514 },
515
516 CopyFile {
518 #[cfg_attr(feature = "ts", ts(type = "string"))]
520 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
521 src: PathBuf,
522 dst: String,
524 mode: Option<u32>,
526 replace: bool,
528 },
529
530 CopyDir {
532 #[cfg_attr(feature = "ts", ts(type = "string"))]
534 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
535 src: PathBuf,
536 dst: String,
538 replace: bool,
540 },
541
542 Symlink {
544 target: String,
546 link: String,
548 replace: bool,
550 },
551
552 Mkdir {
554 path: String,
556 mode: Option<u32>,
558 },
559
560 Remove {
562 path: String,
564 },
565
566 Append {
568 path: String,
570 content: String,
572 },
573}
574
575#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigPatch)]
581#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
582#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
583#[serde(default)]
584pub struct HttpConfig {
585 pub deny_response: bool,
587
588 #[serde(skip_serializing_if = "Option::is_none")]
592 pub deny_message: Option<String>,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
599#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
600#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
601#[serde(default)]
602pub struct NetworkSpec {
603 pub enabled: bool,
605
606 #[serde(skip_serializing_if = "Option::is_none")]
608 #[config_patch(nested)]
609 pub interface: Option<InterfaceOverrides>,
610
611 pub ports: Vec<PublishedPortSpec>,
613
614 #[serde(skip_serializing_if = "Option::is_none")]
616 pub policy: Option<NetworkPolicy>,
617
618 #[serde(skip_serializing_if = "Option::is_none")]
620 #[config_patch(nested)]
621 pub dns: Option<DnsConfig>,
622
623 #[serde(skip_serializing_if = "Option::is_none")]
625 #[config_patch(nested)]
626 pub tls: Option<TlsConfig>,
627
628 pub strict: bool,
630
631 #[serde(skip_serializing_if = "Option::is_none")]
633 #[config_patch(nested)]
634 pub secrets: Option<SecretsConfig>,
635
636 #[serde(rename = "max_connections", alias = "max_tcp_connections")]
639 pub max_tcp_connections: Option<usize>,
640
641 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub max_udp_connections: Option<usize>,
644
645 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub tcp_accept_queue_size: Option<u32>,
649
650 #[serde(skip_serializing_if = "Option::is_none")]
652 #[config_patch(nested)]
653 pub rate_limiter: Option<NetworkRateLimiterConfig>,
654
655 #[serde(default = "default_nat64_prefixes")]
657 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
658 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
659 pub nat64_prefixes: Vec<Ipv6Network>,
660
661 pub trust_host_cas: bool,
663
664 #[config_patch(nested)]
666 pub http: HttpConfig,
667
668 #[serde(skip_serializing_if = "Option::is_none")]
672 #[config_patch(nullable)]
673 pub outbound_proxy: Option<OutboundProxy>,
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
679#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
680#[serde(tag = "protocol", rename_all = "lowercase")]
681#[non_exhaustive]
682pub enum OutboundProxy {
683 #[serde(rename = "http_connect")]
685 HttpConnect {
686 address: String,
688 },
689
690 Socks4 {
692 address: String,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
696 user_id: Option<String>,
697 },
698
699 Socks5 {
701 address: String,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
705 credentials: Option<Socks5Credentials>,
706 },
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
714#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
715#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
716pub struct Socks5Credentials {
717 pub username: String,
719
720 pub password: SecretSource,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize)]
726#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
727#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
728pub struct PublishedPortSpec {
729 pub host_port: u16,
731
732 pub guest_port: u16,
734
735 #[serde(default)]
737 pub protocol: PortProtocol,
738
739 pub host_bind: String,
741}
742
743#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
745#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
746#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
747pub enum PortProtocol {
748 #[default]
750 #[serde(rename = "tcp")]
751 Tcp,
752
753 #[serde(rename = "udp")]
755 Udp,
756}
757
758#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
764#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
765#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
766#[serde(default)]
767pub struct VsockSpec {
768 pub routes: Vec<VsockRouteSpec>,
770}
771
772impl VsockSpec {
773 pub fn is_empty(&self) -> bool {
775 self.routes.is_empty()
776 }
777}
778
779#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
782#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
783pub struct VsockRouteSpec {
784 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
786 pub host_socket: PathBuf,
787
788 pub port: u32,
790
791 #[serde(default)]
793 pub socket_type: VsockSocketType,
794}
795
796#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
798#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
799#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
800#[serde(rename_all = "snake_case")]
801pub enum VsockSocketType {
802 #[default]
804 Stream,
805
806 Dgram,
808}
809
810#[derive(Debug, Clone, Serialize, Deserialize)]
816#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
817#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
818pub struct HandoffInit {
819 pub cmd: String,
823
824 #[serde(default)]
826 pub args: Vec<String>,
827
828 #[serde(default)]
830 pub env: Vec<(String, String)>,
831}
832
833#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
839#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
840#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
841pub struct SandboxPolicy {
842 #[serde(default)]
851 pub ephemeral: bool,
852
853 pub max_duration_secs: Option<u64>,
855
856 pub idle_timeout_secs: Option<u64>,
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize)]
870#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
871pub struct SnapshotSpec {
872 #[serde(default)]
874 pub guest_flush: crate::GuestFlush,
875 pub name: String,
877
878 #[serde(default)]
880 pub group: Option<String>,
881
882 #[serde(default)]
884 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
885 pub dest_dir: Option<PathBuf>,
886
887 pub source_sandbox: String,
889
890 pub labels: Vec<(String, String)>,
892
893 pub force: bool,
895
896 pub record_integrity: bool,
898
899 #[serde(default)]
901 pub full: bool,
902}
903
904#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
912#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
913#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
914#[serde(default)]
915pub struct SandboxSpec {
916 pub name: String,
918
919 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
921 pub image: RootfsSource,
922
923 #[config_patch(nested)]
925 pub resources: SandboxResources,
926
927 #[config_patch(nested)]
929 pub runtime: SandboxRuntimeOptions,
930
931 #[config_patch(merge_with = merge_env_vars)]
933 pub env: Vec<EnvVar>,
934
935 #[config_patch(merge)]
937 pub labels: BTreeMap<String, String>,
938
939 pub rlimits: Vec<Rlimit>,
941
942 pub mounts: Vec<VolumeMount>,
944
945 pub patches: Vec<Patch>,
947
948 #[config_patch(nested)]
950 pub network: NetworkSpec,
951
952 #[serde(default, skip_serializing_if = "VsockSpec::is_empty")]
954 #[config_patch(nested)]
955 pub vsock: VsockSpec,
956
957 pub init: Option<HandoffInit>,
959
960 pub pull_policy: PullPolicy,
962
963 pub security_profile: SecurityProfile,
965
966 pub deployment_profile: DeploymentProfile,
972
973 #[config_patch(nested)]
975 pub lifecycle: SandboxPolicy,
976}
977
978#[derive(Debug, Clone, Serialize, ConfigPatch)]
980#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
981#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
982pub struct SandboxResources {
983 pub cpus: u8,
985
986 pub memory_mib: u32,
988
989 pub max_cpus: u8,
991
992 pub max_memory_mib: u32,
994
995 #[serde(default, skip_serializing_if = "CpuPlacement::is_inherit")]
997 pub cpu_placement: CpuPlacement,
998
999 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 #[config_patch(nullable)]
1003 pub placement_profile: Option<String>,
1004
1005 #[serde(default, skip_serializing_if = "TransparentHugePagePolicy::is_madvise")]
1007 pub thp: TransparentHugePagePolicy,
1008}
1009
1010#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1012#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1013#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1014#[serde(rename_all = "lowercase")]
1015pub enum CpuPlacement {
1016 #[default]
1018 Inherit,
1019
1020 Auto,
1022
1023 Spread,
1025
1026 Compact,
1028}
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1032#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1033#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1034#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1035pub enum NumaPlacement {
1036 PreferSingle,
1038 StrictSingle,
1040 Inherit,
1042}
1043
1044#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1046#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1047#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1048#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1049pub enum MemoryPlacement {
1050 FollowCpu,
1052 Inherit,
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1058#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1059#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1060#[serde(deny_unknown_fields)]
1061pub struct PlacementProfile {
1062 pub numa: NumaPlacement,
1064 pub memory: MemoryPlacement,
1066}
1067
1068#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1070#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1071#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1072#[serde(rename_all = "lowercase")]
1073pub enum TransparentHugePagePolicy {
1074 Always,
1076
1077 #[default]
1079 Madvise,
1080
1081 Never,
1083}
1084
1085#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1090#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1091#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1092#[serde(rename_all = "lowercase")]
1093pub enum GuestClockPolicy {
1094 #[default]
1099 Sync,
1100
1101 Off,
1106}
1107
1108#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
1110#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1111#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1112#[serde(default)]
1113pub struct SandboxRuntimeOptions {
1114 #[config_patch(nullable)]
1117 pub workdir: Option<String>,
1118
1119 #[config_patch(nullable)]
1122 pub shell: Option<String>,
1123
1124 #[config_patch(merge)]
1126 pub scripts: BTreeMap<String, String>,
1127
1128 pub entrypoint: Option<Vec<String>>,
1130
1131 pub cmd: Option<Vec<String>>,
1133
1134 pub hostname: Option<String>,
1136
1137 pub user: Option<String>,
1139
1140 #[config_patch(nullable)]
1143 pub log_level: Option<SandboxLogLevel>,
1144
1145 #[config_patch(nullable)]
1148 pub metrics_sample_interval_ms: Option<u64>,
1149
1150 pub disable_metrics_sample: bool,
1152
1153 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub guest_clock: Option<GuestClockPolicy>,
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1161#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1162#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1163pub struct EnvVar {
1164 pub key: String,
1166
1167 pub value: String,
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1173#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1174#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1175#[serde(rename_all = "lowercase")]
1176pub enum SandboxLogLevel {
1177 Error,
1179
1180 Warn,
1182
1183 Info,
1185
1186 Debug,
1188
1189 Trace,
1191}
1192
1193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1199#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1200#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1201pub enum RlimitResource {
1202 Cpu,
1204 Fsize,
1206 Data,
1208 Stack,
1210 Core,
1212 Rss,
1214 Nproc,
1216 Nofile,
1218 Memlock,
1220 As,
1222 Locks,
1224 Sigpending,
1226 Msgqueue,
1228 Nice,
1230 Rtprio,
1232 Rttime,
1234}
1235
1236#[derive(Debug, Clone, Serialize, Deserialize)]
1238#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1239#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1240pub struct Rlimit {
1241 pub resource: RlimitResource,
1243
1244 pub soft: u64,
1246
1247 pub hard: u64,
1249}
1250
1251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1257#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1258#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1259#[serde(rename_all = "lowercase")]
1260pub enum LogSource {
1261 Stdout,
1263
1264 Stderr,
1266
1267 Output,
1269
1270 System,
1272}
1273
1274impl SandboxResourcesPatch {
1279 pub fn has_cpus(&self) -> bool {
1281 self.cpus.is_some()
1282 }
1283
1284 pub fn has_memory_mib(&self) -> bool {
1286 self.memory_mib.is_some()
1287 }
1288
1289 pub fn has_max_cpus(&self) -> bool {
1291 self.max_cpus.is_some()
1292 }
1293
1294 pub fn has_max_memory_mib(&self) -> bool {
1296 self.max_memory_mib.is_some()
1297 }
1298}
1299
1300impl DiskImageFormat {
1301 pub fn as_str(&self) -> &'static str {
1303 match self {
1304 Self::Qcow2 => "qcow2",
1305 Self::Raw => "raw",
1306 Self::Vmdk => "vmdk",
1307 }
1308 }
1309
1310 pub fn from_extension(ext: &str) -> Option<Self> {
1314 match ext {
1315 "qcow2" => Some(Self::Qcow2),
1316 "raw" => Some(Self::Raw),
1317 "vmdk" => Some(Self::Vmdk),
1318 _ => None,
1319 }
1320 }
1321}
1322
1323impl OciRootfsSource {
1324 pub fn new(reference: impl Into<String>) -> Self {
1326 Self {
1327 reference: reference.into(),
1328 root_disk: None,
1329 }
1330 }
1331}
1332
1333impl TransparentHugePagePolicy {
1334 pub fn is_madvise(&self) -> bool {
1336 matches!(self, Self::Madvise)
1337 }
1338
1339 pub fn as_str(self) -> &'static str {
1341 match self {
1342 Self::Always => "always",
1343 Self::Madvise => "madvise",
1344 Self::Never => "never",
1345 }
1346 }
1347}
1348
1349impl GuestClockPolicy {
1350 pub fn is_sync(&self) -> bool {
1352 matches!(self, Self::Sync)
1353 }
1354
1355 pub fn as_str(self) -> &'static str {
1357 match self {
1358 Self::Sync => "sync",
1359 Self::Off => "off",
1360 }
1361 }
1362}
1363
1364impl RootDisk {
1365 pub fn managed(size_mib: u32) -> Self {
1367 Self::Managed {
1368 size_mib: Some(size_mib),
1369 }
1370 }
1371
1372 pub fn tmpfs(size_mib: u32) -> Self {
1374 Self::Tmpfs {
1375 size_mib: Some(size_mib),
1376 }
1377 }
1378
1379 pub fn flat(size_mib: u32) -> Self {
1381 Self::Flat {
1382 size_mib: Some(size_mib),
1383 fstype: None,
1384 clone: FlatClone::Auto,
1385 }
1386 }
1387
1388 pub fn size_mib(&self) -> Option<u32> {
1390 match self {
1391 Self::Managed { size_mib } | Self::Tmpfs { size_mib } | Self::Flat { size_mib, .. } => {
1392 *size_mib
1393 }
1394 Self::DiskImage { .. } => None,
1395 }
1396 }
1397
1398 pub fn kind_str(&self) -> &'static str {
1400 match self {
1401 Self::Managed { .. } => "managed",
1402 Self::Tmpfs { .. } => "tmpfs",
1403 Self::DiskImage { .. } => "disk-image",
1404 Self::Flat { .. } => "flat",
1405 }
1406 }
1407
1408 pub fn is_managed(&self) -> bool {
1410 matches!(self, Self::Managed { .. })
1411 }
1412}
1413
1414impl FlatClone {
1415 pub const fn as_str(self) -> &'static str {
1417 match self {
1418 Self::Auto => "auto",
1419 Self::Copy => "copy",
1420 Self::Reflink => "reflink",
1421 }
1422 }
1423
1424 pub const fn is_auto(&self) -> bool {
1426 matches!(self, Self::Auto)
1427 }
1428}
1429
1430impl RootfsSource {
1431 pub fn oci(reference: impl Into<String>) -> Self {
1433 Self::Oci(OciRootfsSource::new(reference))
1434 }
1435
1436 pub fn oci_reference(&self) -> Option<&str> {
1438 match self {
1439 Self::Oci(oci) => Some(&oci.reference),
1440 _ => None,
1441 }
1442 }
1443
1444 pub fn oci_root_disk(&self) -> Option<&RootDisk> {
1446 match self {
1447 Self::Oci(oci) => oci.root_disk.as_ref(),
1448 _ => None,
1449 }
1450 }
1451
1452 pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
1455 match self {
1456 Self::Oci(oci) => match &oci.root_disk {
1457 Some(RootDisk::Managed { size_mib }) => *size_mib,
1458 Some(_) => None,
1459 None => None,
1460 },
1461 _ => None,
1462 }
1463 }
1464}
1465
1466impl EnvVar {
1467 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1469 Self {
1470 key: key.into(),
1471 value: value.into(),
1472 }
1473 }
1474
1475 pub fn as_pair(&self) -> (&str, &str) {
1477 (&self.key, &self.value)
1478 }
1479}
1480
1481impl VolumeKind {
1482 pub fn as_str(self) -> &'static str {
1484 match self {
1485 Self::Directory => "dir",
1486 Self::Disk => "disk",
1487 }
1488 }
1489
1490 pub fn from_db_value(value: &str) -> Self {
1492 match value {
1493 "disk" => Self::Disk,
1494 _ => Self::Directory,
1495 }
1496 }
1497}
1498
1499impl VolumeSpec {
1500 pub fn new(name: impl Into<String>) -> Self {
1502 Self {
1503 name: name.into(),
1504 kind: VolumeKind::Directory,
1505 quota_mib: None,
1506 capacity_mib: None,
1507 labels: Vec::new(),
1508 }
1509 }
1510}
1511
1512impl NamedVolumeCreate {
1513 pub fn mode(&self) -> NamedVolumeMode {
1515 self.mode
1516 }
1517
1518 pub fn name(&self) -> &str {
1520 &self.name
1521 }
1522
1523 pub fn kind(&self) -> VolumeKind {
1525 self.kind
1526 }
1527
1528 pub fn quota_mib(&self) -> Option<u32> {
1530 self.quota_mib
1531 }
1532
1533 pub fn capacity_mib(&self) -> Option<u32> {
1535 self.capacity_mib
1536 }
1537
1538 pub fn labels(&self) -> &[(String, String)] {
1540 &self.labels
1541 }
1542}
1543
1544impl VolumeMount {
1545 pub fn guest(&self) -> &str {
1547 match self {
1548 Self::Bind { guest, .. }
1549 | Self::Owned { guest, .. }
1550 | Self::Named { guest, .. }
1551 | Self::Tmpfs { guest, .. }
1552 | Self::DiskImage { guest, .. } => guest,
1553 }
1554 }
1555
1556 fn guest_mut(&mut self) -> &mut String {
1557 match self {
1558 Self::Bind { guest, .. }
1559 | Self::Owned { guest, .. }
1560 | Self::Named { guest, .. }
1561 | Self::Tmpfs { guest, .. }
1562 | Self::DiskImage { guest, .. } => guest,
1563 }
1564 }
1565
1566 pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1568 match self {
1569 Self::Named { create, .. } => create.as_ref(),
1570 _ => None,
1571 }
1572 }
1573}
1574
1575pub fn owned_volume_mount_id(guest: &str) -> String {
1582 use std::fmt::Write as _;
1583 let slug: String = guest
1584 .trim_start_matches('/')
1585 .chars()
1586 .take(11)
1587 .map(|character| {
1588 if character.is_ascii_alphanumeric() || character == '-' {
1589 character
1590 } else {
1591 '_'
1592 }
1593 })
1594 .collect();
1595 let mut id = if slug.is_empty() {
1596 String::new()
1597 } else {
1598 format!("{slug}_")
1599 };
1600 for byte in Sha256::digest(guest.as_bytes()).iter().take(4) {
1601 let _ = write!(id, "{byte:02x}");
1602 }
1603 id
1604}
1605
1606pub fn canonicalize_volume_mounts(mounts: &mut [VolumeMount]) -> TypesResult<()> {
1613 for mount in mounts.iter_mut() {
1614 let canonical = canonical_guest_mount_path(mount.guest())?;
1615 *mount.guest_mut() = canonical;
1616 }
1617
1618 mounts.sort_by_cached_key(|mount| guest_mount_order_key(mount.guest()));
1619
1620 for pair in mounts.windows(2) {
1621 if pair[0].guest() == pair[1].guest() {
1622 return Err(TypesError::invalid_config(format!(
1623 "multiple volumes cannot mount the same guest path: {}",
1624 pair[0].guest()
1625 )));
1626 }
1627 }
1628
1629 Ok(())
1630}
1631
1632fn canonical_guest_mount_path(guest: &str) -> TypesResult<String> {
1633 let path = Utf8UnixPath::new(guest);
1634
1635 if !path.is_valid() {
1636 return Err(TypesError::invalid_config(format!(
1637 "guest mount path must be a valid Unix path: {guest}"
1638 )));
1639 }
1640 if !path.is_absolute() {
1641 return Err(TypesError::invalid_config(format!(
1642 "guest mount path must be absolute: {guest}"
1643 )));
1644 }
1645 if path
1646 .components()
1647 .any(|component| matches!(component, Utf8UnixComponent::ParentDir))
1648 {
1649 return Err(TypesError::invalid_config(format!(
1650 "guest mount path must not contain '..': {guest}"
1651 )));
1652 }
1653 if guest.contains(':') || guest.contains(';') || guest.contains(',') {
1654 return Err(TypesError::invalid_config(format!(
1655 "guest mount path must not contain ':', ';', or ',': {guest}"
1656 )));
1657 }
1658
1659 let canonical = path.normalize().to_string();
1660 if canonical == "/" {
1661 return Err(TypesError::invalid_config(
1662 "cannot mount a volume at guest root /",
1663 ));
1664 }
1665
1666 Ok(canonical)
1667}
1668
1669fn guest_mount_order_key(guest: &str) -> (usize, String) {
1670 let path = Utf8UnixPath::new(guest);
1671 let depth = path.components().filter(Utf8Component::is_normal).count();
1672 (depth, guest.to_owned())
1673}
1674
1675impl RlimitResource {
1676 pub fn as_str(&self) -> &'static str {
1678 match self {
1679 Self::Cpu => "cpu",
1680 Self::Fsize => "fsize",
1681 Self::Data => "data",
1682 Self::Stack => "stack",
1683 Self::Core => "core",
1684 Self::Rss => "rss",
1685 Self::Nproc => "nproc",
1686 Self::Nofile => "nofile",
1687 Self::Memlock => "memlock",
1688 Self::As => "as",
1689 Self::Locks => "locks",
1690 Self::Sigpending => "sigpending",
1691 Self::Msgqueue => "msgqueue",
1692 Self::Nice => "nice",
1693 Self::Rtprio => "rtprio",
1694 Self::Rttime => "rttime",
1695 }
1696 }
1697}
1698
1699impl LogSource {
1700 pub fn effective(requested: &[Self]) -> Vec<Self> {
1702 if requested.is_empty() {
1703 vec![Self::Stdout, Self::Stderr, Self::Output]
1704 } else {
1705 let mut sources = requested.to_vec();
1706 sources.sort_by_key(|src| match src {
1707 Self::Stdout => 0,
1708 Self::Stderr => 1,
1709 Self::Output => 2,
1710 Self::System => 3,
1711 });
1712 sources.dedup();
1713 sources
1714 }
1715 }
1716}
1717
1718impl SandboxLogLevel {
1719 pub const fn as_str(self) -> &'static str {
1721 match self {
1722 Self::Error => "error",
1723 Self::Warn => "warn",
1724 Self::Info => "info",
1725 Self::Debug => "debug",
1726 Self::Trace => "trace",
1727 }
1728 }
1729}
1730
1731impl std::fmt::Display for DiskImageFormat {
1736 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1737 f.write_str(self.as_str())
1738 }
1739}
1740
1741impl FromStr for DiskImageFormat {
1742 type Err = String;
1743
1744 fn from_str(s: &str) -> Result<Self, Self::Err> {
1745 match s {
1746 "qcow2" => Ok(Self::Qcow2),
1747 "raw" => Ok(Self::Raw),
1748 "vmdk" => Ok(Self::Vmdk),
1749 _ => Err(format!("unknown disk image format: {s}")),
1750 }
1751 }
1752}
1753
1754impl fmt::Display for TransparentHugePagePolicy {
1755 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1756 f.write_str(self.as_str())
1757 }
1758}
1759
1760impl FromStr for TransparentHugePagePolicy {
1761 type Err = String;
1762
1763 fn from_str(value: &str) -> Result<Self, Self::Err> {
1764 match value {
1765 "always" => Ok(Self::Always),
1766 "madvise" => Ok(Self::Madvise),
1767 "never" => Ok(Self::Never),
1768 _ => Err(format!(
1769 "unknown transparent huge-page policy: {value}; expected always, madvise, or never"
1770 )),
1771 }
1772 }
1773}
1774
1775impl fmt::Display for GuestClockPolicy {
1776 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1777 f.write_str(self.as_str())
1778 }
1779}
1780
1781impl FromStr for GuestClockPolicy {
1782 type Err = String;
1783
1784 fn from_str(value: &str) -> Result<Self, Self::Err> {
1785 match value {
1786 "sync" => Ok(Self::Sync),
1787 "off" => Ok(Self::Off),
1788 _ => Err(format!(
1789 "unknown guest clock policy: {value}; expected sync or off"
1790 )),
1791 }
1792 }
1793}
1794
1795impl Default for RootfsSource {
1796 fn default() -> Self {
1797 Self::oci(String::new())
1798 }
1799}
1800
1801impl Default for SandboxResources {
1802 fn default() -> Self {
1803 Self {
1804 cpus: DEFAULT_SANDBOX_CPUS,
1805 memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1806 max_cpus: DEFAULT_SANDBOX_CPUS,
1807 max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1808 cpu_placement: CpuPlacement::Inherit,
1809 placement_profile: None,
1810 thp: TransparentHugePagePolicy::Madvise,
1811 }
1812 }
1813}
1814
1815impl<'de> Deserialize<'de> for SandboxResources {
1816 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1817 where
1818 D: serde::Deserializer<'de>,
1819 {
1820 #[derive(Deserialize)]
1821 struct RawResources {
1822 #[serde(default = "default_sandbox_cpus")]
1823 cpus: u8,
1824 #[serde(default = "default_sandbox_memory_mib")]
1825 memory_mib: u32,
1826 max_cpus: Option<u8>,
1827 max_memory_mib: Option<u32>,
1828 #[serde(default)]
1829 cpu_placement: CpuPlacement,
1830 #[serde(default)]
1831 placement_profile: Option<String>,
1832 #[serde(default)]
1833 thp: TransparentHugePagePolicy,
1834 }
1835
1836 let raw = RawResources::deserialize(deserializer)?;
1837 Ok(Self {
1838 cpus: raw.cpus,
1839 memory_mib: raw.memory_mib,
1840 max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1844 max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1845 cpu_placement: raw.cpu_placement,
1846 placement_profile: raw.placement_profile,
1847 thp: raw.thp,
1848 })
1849 }
1850}
1851
1852impl CpuPlacement {
1853 pub const fn is_inherit(&self) -> bool {
1855 matches!(self, Self::Inherit)
1856 }
1857}
1858
1859impl std::fmt::Display for CpuPlacement {
1860 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1861 f.write_str(match self {
1862 Self::Inherit => "inherit",
1863 Self::Auto => "auto",
1864 Self::Spread => "spread",
1865 Self::Compact => "compact",
1866 })
1867 }
1868}
1869
1870impl FromStr for CpuPlacement {
1871 type Err = String;
1872
1873 fn from_str(value: &str) -> Result<Self, Self::Err> {
1874 match value {
1875 "inherit" => Ok(Self::Inherit),
1876 "auto" => Ok(Self::Auto),
1877 "spread" => Ok(Self::Spread),
1878 "compact" => Ok(Self::Compact),
1879 _ => Err(format!(
1880 "unknown CPU placement: {value} (expected: inherit, auto, spread, compact)"
1881 )),
1882 }
1883 }
1884}
1885
1886impl Default for SandboxRuntimeOptions {
1887 fn default() -> Self {
1888 Self {
1889 workdir: None,
1890 shell: None,
1891 scripts: BTreeMap::new(),
1892 entrypoint: None,
1893 cmd: None,
1894 hostname: None,
1895 user: None,
1896 log_level: None,
1897 metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1898 disable_metrics_sample: false,
1899 guest_clock: None,
1900 }
1901 }
1902}
1903
1904impl Default for NetworkSpec {
1905 fn default() -> Self {
1906 Self {
1907 enabled: true,
1908 interface: None,
1909 ports: Vec::new(),
1910 policy: None,
1911 dns: None,
1912 tls: None,
1913 strict: true,
1914 secrets: None,
1915 max_tcp_connections: None,
1916 max_udp_connections: None,
1917 tcp_accept_queue_size: None,
1918 rate_limiter: None,
1919 nat64_prefixes: default_nat64_prefixes(),
1920 trust_host_cas: false,
1921 outbound_proxy: None,
1922 http: HttpConfig::default(),
1923 }
1924 }
1925}
1926
1927pub(crate) fn default_nat64_prefixes() -> Vec<Ipv6Network> {
1928 vec![
1929 WELL_KNOWN_NAT64_PREFIX
1930 .parse()
1931 .expect("well-known NAT64 prefix must be valid"),
1932 ]
1933}
1934
1935impl Default for PublishedPortSpec {
1936 fn default() -> Self {
1937 Self {
1938 host_port: 0,
1939 guest_port: 0,
1940 protocol: PortProtocol::Tcp,
1941 host_bind: "127.0.0.1".into(),
1942 }
1943 }
1944}
1945
1946impl From<(String, String)> for EnvVar {
1947 fn from((key, value): (String, String)) -> Self {
1948 Self { key, value }
1949 }
1950}
1951
1952impl From<EnvVar> for (String, String) {
1953 fn from(var: EnvVar) -> Self {
1954 (var.key, var.value)
1955 }
1956}
1957
1958impl FromStr for SandboxLogLevel {
1959 type Err = String;
1960
1961 fn from_str(s: &str) -> Result<Self, Self::Err> {
1962 match s {
1963 "error" => Ok(Self::Error),
1964 "warn" => Ok(Self::Warn),
1965 "info" => Ok(Self::Info),
1966 "debug" => Ok(Self::Debug),
1967 "trace" => Ok(Self::Trace),
1968 _ => Err(format!("unknown sandbox log level: {s}")),
1969 }
1970 }
1971}
1972
1973impl std::fmt::Display for SandboxLogLevel {
1974 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1975 formatter.write_str(self.as_str())
1976 }
1977}
1978
1979impl Serialize for VolumeMount {
1980 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1981 use serde::ser::SerializeMap;
1982
1983 match self {
1984 Self::Owned {
1985 guest,
1986 storage,
1987 options,
1988 stat_virtualization,
1989 host_permissions,
1990 } => {
1991 let mut map = serializer.serialize_map(Some(6))?;
1994 map.serialize_entry("type", "Owned")?;
1995 map.serialize_entry("guest", guest)?;
1996 map.serialize_entry("storage", storage)?;
1997 map.serialize_entry("options", options)?;
1998 map.serialize_entry("stat_virtualization", stat_virtualization)?;
1999 map.serialize_entry("host_permissions", host_permissions)?;
2000 map.end()
2001 }
2002 Self::Bind {
2003 host,
2004 guest,
2005 options,
2006 stat_virtualization,
2007 host_permissions,
2008 follow_root_symlinks,
2009 quota_mib,
2010 } => {
2011 let mut map = serializer.serialize_map(Some(8))?;
2012 map.serialize_entry("type", "Bind")?;
2013 map.serialize_entry("host", host)?;
2014 map.serialize_entry("guest", guest)?;
2015 map.serialize_entry("options", options)?;
2016 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2017 map.serialize_entry("host_permissions", host_permissions)?;
2018 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2019 map.serialize_entry("quota_mib", quota_mib)?;
2020 map.end()
2021 }
2022 Self::Named {
2023 name,
2024 guest,
2025 create: _,
2026 options,
2027 stat_virtualization,
2028 host_permissions,
2029 follow_root_symlinks,
2030 } => {
2031 let mut map = serializer.serialize_map(Some(7))?;
2032 map.serialize_entry("type", "Named")?;
2033 map.serialize_entry("name", name)?;
2034 map.serialize_entry("guest", guest)?;
2035 map.serialize_entry("options", options)?;
2036 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2037 map.serialize_entry("host_permissions", host_permissions)?;
2038 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2039 map.end()
2040 }
2041 Self::Tmpfs {
2042 guest,
2043 size_mib,
2044 options,
2045 } => {
2046 let mut map = serializer.serialize_map(Some(4))?;
2047 map.serialize_entry("type", "Tmpfs")?;
2048 map.serialize_entry("guest", guest)?;
2049 map.serialize_entry("size_mib", size_mib)?;
2050 map.serialize_entry("options", options)?;
2051 map.end()
2052 }
2053 Self::DiskImage {
2054 host,
2055 guest,
2056 format,
2057 fstype,
2058 options,
2059 } => {
2060 let mut map = serializer.serialize_map(Some(6))?;
2061 map.serialize_entry("type", "DiskImage")?;
2062 map.serialize_entry("host", host)?;
2063 map.serialize_entry("guest", guest)?;
2064 map.serialize_entry("format", format)?;
2065 map.serialize_entry("fstype", fstype)?;
2066 map.serialize_entry("options", options)?;
2067 map.end()
2068 }
2069 }
2070 }
2071}
2072
2073impl<'de> Deserialize<'de> for VolumeMount {
2074 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2075 fn default_strict() -> StatVirtualization {
2076 StatVirtualization::Strict
2077 }
2078
2079 fn default_private() -> HostPermissions {
2080 HostPermissions::Private
2081 }
2082
2083 #[derive(Deserialize)]
2084 #[serde(tag = "type")]
2085 enum VolumeMountHelper {
2086 Owned {
2087 guest: String,
2088 storage: OwnedVolumeStorage,
2089 #[serde(default)]
2090 options: MountOptions,
2091 #[serde(default = "default_strict")]
2092 stat_virtualization: StatVirtualization,
2093 #[serde(default = "default_private")]
2094 host_permissions: HostPermissions,
2095 },
2096 Bind {
2097 host: PathBuf,
2098 guest: String,
2099 #[serde(default)]
2100 options: Option<MountOptions>,
2101 #[serde(default)]
2102 readonly: bool,
2103 #[serde(default = "default_strict")]
2104 stat_virtualization: StatVirtualization,
2105 #[serde(default = "default_private")]
2106 host_permissions: HostPermissions,
2107 #[serde(default)]
2108 follow_root_symlinks: bool,
2109 #[serde(default)]
2110 quota_mib: Option<u32>,
2111 },
2112 Named {
2113 name: String,
2114 guest: String,
2115 #[serde(default)]
2116 options: Option<MountOptions>,
2117 #[serde(default)]
2118 readonly: bool,
2119 #[serde(default = "default_strict")]
2120 stat_virtualization: StatVirtualization,
2121 #[serde(default = "default_private")]
2122 host_permissions: HostPermissions,
2123 #[serde(default)]
2124 follow_root_symlinks: bool,
2125 },
2126 Tmpfs {
2127 guest: String,
2128 #[serde(default)]
2129 size_mib: Option<u32>,
2130 #[serde(default)]
2131 options: Option<MountOptions>,
2132 #[serde(default)]
2133 readonly: bool,
2134 },
2135 DiskImage {
2136 host: PathBuf,
2137 guest: String,
2138 format: DiskImageFormat,
2139 #[serde(default)]
2140 fstype: Option<String>,
2141 #[serde(default)]
2142 options: Option<MountOptions>,
2143 #[serde(default)]
2144 readonly: bool,
2145 },
2146 }
2147
2148 let helper = VolumeMountHelper::deserialize(deserializer)?;
2149 Ok(match helper {
2150 VolumeMountHelper::Owned {
2151 guest,
2152 storage,
2153 options,
2154 stat_virtualization,
2155 host_permissions,
2156 } => Self::Owned {
2157 guest,
2158 storage,
2159 options,
2160 stat_virtualization,
2161 host_permissions,
2162 },
2163 VolumeMountHelper::Bind {
2164 host,
2165 guest,
2166 options,
2167 readonly,
2168 stat_virtualization,
2169 host_permissions,
2170 follow_root_symlinks,
2171 quota_mib,
2172 } => Self::Bind {
2173 host,
2174 guest,
2175 options: decode_mount_options(options, readonly),
2176 stat_virtualization,
2177 host_permissions,
2178 follow_root_symlinks,
2179 quota_mib,
2180 },
2181 VolumeMountHelper::Named {
2182 name,
2183 guest,
2184 options,
2185 readonly,
2186 stat_virtualization,
2187 host_permissions,
2188 follow_root_symlinks,
2189 } => Self::Named {
2190 name,
2191 guest,
2192 create: None,
2193 options: decode_mount_options(options, readonly),
2194 stat_virtualization,
2195 host_permissions,
2196 follow_root_symlinks,
2197 },
2198 VolumeMountHelper::Tmpfs {
2199 guest,
2200 size_mib,
2201 options,
2202 readonly,
2203 } => Self::Tmpfs {
2204 guest,
2205 size_mib,
2206 options: decode_mount_options(options, readonly),
2207 },
2208 VolumeMountHelper::DiskImage {
2209 host,
2210 guest,
2211 format,
2212 fstype,
2213 options,
2214 readonly,
2215 } => Self::DiskImage {
2216 host,
2217 guest,
2218 format,
2219 fstype,
2220 options: decode_mount_options(options, readonly),
2221 },
2222 })
2223 }
2224}
2225
2226impl fmt::Debug for VolumeMount {
2227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2228 match self {
2229 Self::Owned {
2230 guest,
2231 storage,
2232 options,
2233 stat_virtualization,
2234 host_permissions,
2235 } => f
2236 .debug_struct("Owned")
2237 .field("guest", guest)
2238 .field("storage", storage)
2239 .field("options", options)
2240 .field("stat_virtualization", stat_virtualization)
2241 .field("host_permissions", host_permissions)
2242 .finish(),
2243 Self::Bind {
2244 host,
2245 guest,
2246 options,
2247 stat_virtualization,
2248 host_permissions,
2249 follow_root_symlinks,
2250 quota_mib,
2251 } => f
2252 .debug_struct("Bind")
2253 .field("host", host)
2254 .field("guest", guest)
2255 .field("options", options)
2256 .field("stat_virtualization", stat_virtualization)
2257 .field("host_permissions", host_permissions)
2258 .field("follow_root_symlinks", follow_root_symlinks)
2259 .field("quota_mib", quota_mib)
2260 .finish(),
2261 Self::Named {
2262 name,
2263 guest,
2264 create,
2265 options,
2266 stat_virtualization,
2267 host_permissions,
2268 follow_root_symlinks,
2269 } => f
2270 .debug_struct("Named")
2271 .field("name", name)
2272 .field("guest", guest)
2273 .field("create", create)
2274 .field("options", options)
2275 .field("stat_virtualization", stat_virtualization)
2276 .field("host_permissions", host_permissions)
2277 .field("follow_root_symlinks", follow_root_symlinks)
2278 .finish(),
2279 Self::Tmpfs {
2280 guest,
2281 size_mib,
2282 options,
2283 } => f
2284 .debug_struct("Tmpfs")
2285 .field("guest", guest)
2286 .field("size_mib", size_mib)
2287 .field("options", options)
2288 .finish(),
2289 Self::DiskImage {
2290 host,
2291 guest,
2292 format,
2293 fstype,
2294 options,
2295 } => f
2296 .debug_struct("DiskImage")
2297 .field("host", host)
2298 .field("guest", guest)
2299 .field("format", format)
2300 .field("fstype", fstype)
2301 .field("options", options)
2302 .finish(),
2303 }
2304 }
2305}
2306
2307impl TryFrom<&str> for RlimitResource {
2309 type Error = String;
2310
2311 fn try_from(s: &str) -> Result<Self, Self::Error> {
2312 match s.to_ascii_lowercase().as_str() {
2313 "cpu" => Ok(Self::Cpu),
2314 "fsize" => Ok(Self::Fsize),
2315 "data" => Ok(Self::Data),
2316 "stack" => Ok(Self::Stack),
2317 "core" => Ok(Self::Core),
2318 "rss" => Ok(Self::Rss),
2319 "nproc" => Ok(Self::Nproc),
2320 "nofile" => Ok(Self::Nofile),
2321 "memlock" => Ok(Self::Memlock),
2322 "as" => Ok(Self::As),
2323 "locks" => Ok(Self::Locks),
2324 "sigpending" => Ok(Self::Sigpending),
2325 "msgqueue" => Ok(Self::Msgqueue),
2326 "nice" => Ok(Self::Nice),
2327 "rtprio" => Ok(Self::Rtprio),
2328 "rttime" => Ok(Self::Rttime),
2329 _ => Err(format!("unknown rlimit resource: {s}")),
2330 }
2331 }
2332}
2333
2334fn default_sandbox_cpus() -> u8 {
2339 DEFAULT_SANDBOX_CPUS
2340}
2341
2342fn default_sandbox_memory_mib() -> u32 {
2343 DEFAULT_SANDBOX_MEMORY_MIB
2344}
2345
2346fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
2347 options.unwrap_or(MountOptions {
2348 readonly,
2349 ..MountOptions::default()
2350 })
2351}
2352
2353fn merge_env_vars(base: &mut Vec<EnvVar>, higher: Vec<EnvVar>) {
2354 for value in higher {
2355 match base.iter_mut().find(|current| current.key == value.key) {
2356 Some(current) => *current = value,
2357 None => base.push(value),
2358 }
2359 }
2360}
2361
2362fn merge_secret_entries(base: &mut Vec<SecretEntry>, higher: Vec<SecretEntry>) {
2363 for value in higher {
2364 match base
2365 .iter_mut()
2366 .find(|current| current.env_var == value.env_var)
2367 {
2368 Some(current) => *current = value,
2369 None => base.push(value),
2370 }
2371 }
2372}
2373
2374pub(crate) fn default_strict() -> StatVirtualization {
2376 StatVirtualization::Strict
2377}
2378
2379pub(crate) fn default_private() -> HostPermissions {
2381 HostPermissions::Private
2382}
2383
2384pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
2386
2387#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigPatch)]
2398#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2399#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2400pub struct SecretsConfig {
2401 #[doc(hidden)]
2404 #[serde(default, skip_serializing_if = "Option::is_none")]
2405 #[cfg_attr(feature = "ts", ts(skip))]
2406 #[cfg_attr(feature = "utoipa", schema(ignore))]
2407 pub passthrough_hosts: Option<Vec<HostPattern>>,
2408
2409 #[serde(default)]
2411 #[config_patch(merge_with = merge_secret_entries)]
2412 pub secrets: Vec<SecretEntry>,
2413
2414 #[serde(default)]
2416 pub violation_action: SecretViolationAction,
2417}
2418
2419#[derive(Clone, Serialize, Deserialize)]
2424#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2425#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2426pub struct SecretEntry {
2427 pub env_var: String,
2433
2434 #[serde(default = "empty_secret_value")]
2443 #[cfg_attr(feature = "ts", ts(type = "string"))]
2444 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2445 pub value: Zeroizing<String>,
2446
2447 #[serde(default, skip_serializing_if = "Option::is_none")]
2451 pub source: Option<SecretSource>,
2452
2453 pub placeholder: String,
2458
2459 #[serde(default)]
2461 pub allowed_hosts: Vec<HostPattern>,
2462
2463 #[serde(default)]
2465 pub substitution: SecretSubstitution,
2466
2467 #[serde(default)]
2469 pub passthrough_hosts: Vec<HostPattern>,
2470
2471 #[serde(default, skip_serializing_if = "Option::is_none")]
2473 pub violation_action: Option<SecretViolationAction>,
2474
2475 #[serde(default = "default_true")]
2480 pub require_tls_identity: bool,
2481}
2482
2483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2485#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2486#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2487#[serde(rename_all = "kebab-case")]
2488pub enum HostPattern {
2489 #[serde(alias = "Exact")]
2491 Exact(String),
2492 #[serde(alias = "Wildcard")]
2494 Wildcard(String),
2495 #[serde(alias = "Any")]
2497 Any,
2498}
2499
2500#[derive(Debug, Clone, Serialize, Deserialize)]
2502#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2503#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2504pub struct SecretSubstitution {
2505 #[serde(default = "default_true")]
2507 pub headers: bool,
2508
2509 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2528 pub header_fields: Vec<String>,
2529
2530 #[serde(default)]
2532 pub query: bool,
2533
2534 #[serde(default)]
2542 pub body: bool,
2543}
2544
2545#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2547#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2548#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2549#[serde(rename_all = "kebab-case")]
2550pub enum SecretViolationAction {
2551 #[serde(alias = "Block")]
2553 Block,
2554 #[default]
2556 #[serde(alias = "BlockAndLog", alias = "block_and_log")]
2557 BlockAndLog,
2558 #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
2560 BlockAndTerminate,
2561}
2562
2563#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2565pub enum SecretConfigError {
2566 #[error("secret #{secret_index}: env_var must not be empty")]
2568 EmptyEnvVar {
2569 secret_index: usize,
2571 },
2572
2573 #[error("secret #{secret_index}: env_var must not contain `=`")]
2575 EnvVarContainsEquals {
2576 secret_index: usize,
2578 },
2579
2580 #[error("secret #{secret_index}: env_var must not contain NUL")]
2582 EnvVarContainsNul {
2583 secret_index: usize,
2585 },
2586
2587 #[error("secret #{secret_index}: at least one allowed host is required")]
2589 MissingAllowedHosts {
2590 secret_index: usize,
2592 },
2593
2594 #[error("secret #{secret_index}: at least one substitution location is required")]
2596 MissingSubstitutionLocation {
2597 secret_index: usize,
2599 },
2600
2601 #[error(
2603 "secret #{secret_index}: header field substitutions require header substitution to be enabled"
2604 )]
2605 HeaderFieldsRequireHeaders {
2606 secret_index: usize,
2608 },
2609
2610 #[error("secret #{secret_index}: invalid header field name {field:?}")]
2612 InvalidHeaderFieldName {
2613 secret_index: usize,
2615 field: String,
2617 },
2618
2619 #[error("secret #{secret_index}: placeholder must not be empty")]
2621 EmptyPlaceholder {
2622 secret_index: usize,
2624 },
2625
2626 #[error(
2628 "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
2629 )]
2630 PlaceholderTooLong {
2631 secret_index: usize,
2633 actual_bytes: usize,
2635 max_bytes: usize,
2637 },
2638
2639 #[error("secret #{secret_index}: placeholder must not contain NUL")]
2641 PlaceholderContainsNul {
2642 secret_index: usize,
2644 },
2645
2646 #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
2648 PlaceholderContainsLineBreak {
2649 secret_index: usize,
2651 },
2652}
2653
2654impl SecretsConfig {
2655 pub fn has_tls_identity_secrets(&self) -> bool {
2657 self.secrets
2658 .iter()
2659 .any(|secret| secret.require_tls_identity)
2660 }
2661
2662 pub fn contains_env_var(&self, env_var: &str) -> bool {
2664 self.secrets.iter().any(|secret| secret.env_var == env_var)
2665 }
2666
2667 pub fn validate(&self) -> Result<(), SecretConfigError> {
2669 for (index, secret) in self.secrets.iter().enumerate() {
2670 secret.validate(index)?;
2671 }
2672 Ok(())
2673 }
2674}
2675
2676impl SecretEntry {
2677 pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
2679 validate_env_var(&self.env_var, secret_index)?;
2680
2681 if self.allowed_hosts.is_empty() {
2682 return Err(SecretConfigError::MissingAllowedHosts { secret_index });
2683 }
2684
2685 if !self.substitution.headers && !self.substitution.query && !self.substitution.body {
2686 return Err(SecretConfigError::MissingSubstitutionLocation { secret_index });
2687 }
2688
2689 if !self.substitution.headers && !self.substitution.header_fields.is_empty() {
2690 return Err(SecretConfigError::HeaderFieldsRequireHeaders { secret_index });
2691 }
2692
2693 for field in &self.substitution.header_fields {
2694 validate_header_field_name(field, secret_index)?;
2695 }
2696
2697 validate_placeholder(&self.placeholder, secret_index)
2698 }
2699}
2700
2701impl fmt::Debug for SecretEntry {
2703 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2704 f.debug_struct("SecretEntry")
2705 .field("env_var", &self.env_var)
2706 .field("value", &"[REDACTED]")
2707 .field("source", &self.source)
2708 .field("placeholder", &self.placeholder)
2709 .field("allowed_hosts", &self.allowed_hosts)
2710 .field("substitution", &self.substitution)
2711 .field("passthrough_hosts", &self.passthrough_hosts)
2712 .field("violation_action", &self.violation_action)
2713 .field("require_tls_identity", &self.require_tls_identity)
2714 .finish()
2715 }
2716}
2717
2718impl HostPattern {
2719 pub fn parse(host: &str) -> Self {
2722 if host == "*" {
2723 HostPattern::Any
2724 } else if host.starts_with("*.") {
2725 HostPattern::Wildcard(host.to_string())
2726 } else {
2727 HostPattern::Exact(host.to_string())
2728 }
2729 }
2730
2731 pub fn matches(&self, hostname: &str) -> bool {
2736 match self {
2737 HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
2738 HostPattern::Wildcard(pattern) => {
2739 if let Some(suffix) = pattern.strip_prefix("*.") {
2740 hostname.eq_ignore_ascii_case(suffix)
2741 || (hostname.len() > suffix.len() + 1
2742 && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
2743 && hostname[hostname.len() - suffix.len()..]
2744 .eq_ignore_ascii_case(suffix))
2745 } else {
2746 hostname.eq_ignore_ascii_case(pattern)
2747 }
2748 }
2749 HostPattern::Any => true,
2750 }
2751 }
2752}
2753
2754impl Default for SecretSubstitution {
2755 fn default() -> Self {
2756 Self {
2757 headers: true,
2758 header_fields: Vec::new(),
2759 query: false,
2760 body: false,
2761 }
2762 }
2763}
2764
2765fn default_true() -> bool {
2766 true
2767}
2768
2769fn validate_header_field_name(field: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2771 if field.is_empty() || !field.bytes().all(is_http_tchar) {
2772 return Err(SecretConfigError::InvalidHeaderFieldName {
2773 secret_index,
2774 field: field.to_string(),
2775 });
2776 }
2777 Ok(())
2778}
2779
2780fn is_http_tchar(byte: u8) -> bool {
2782 byte.is_ascii_alphanumeric()
2783 || matches!(
2784 byte,
2785 b'!' | b'#'
2786 | b'$'
2787 | b'%'
2788 | b'&'
2789 | b'\''
2790 | b'*'
2791 | b'+'
2792 | b'-'
2793 | b'.'
2794 | b'^'
2795 | b'_'
2796 | b'`'
2797 | b'|'
2798 | b'~'
2799 )
2800}
2801
2802fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2803 if env_var.is_empty() {
2804 return Err(SecretConfigError::EmptyEnvVar { secret_index });
2805 }
2806 if env_var.contains('=') {
2807 return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
2808 }
2809 if env_var.contains('\0') {
2810 return Err(SecretConfigError::EnvVarContainsNul { secret_index });
2811 }
2812 Ok(())
2813}
2814
2815fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2816 if placeholder.is_empty() {
2817 return Err(SecretConfigError::EmptyPlaceholder { secret_index });
2818 }
2819
2820 let actual_bytes = placeholder.len();
2821 if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
2822 return Err(SecretConfigError::PlaceholderTooLong {
2823 secret_index,
2824 actual_bytes,
2825 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2826 });
2827 }
2828
2829 if placeholder.contains('\0') {
2830 return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
2831 }
2832 if placeholder.contains('\r') || placeholder.contains('\n') {
2833 return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
2834 }
2835
2836 Ok(())
2837}
2838
2839#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
2849#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2850#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2851pub struct TlsConfig {
2852 #[serde(default)]
2854 pub enabled: bool,
2855
2856 #[serde(default = "default_intercepted_ports")]
2858 pub intercepted_ports: Vec<u16>,
2859
2860 #[serde(default)]
2862 pub bypass: Vec<String>,
2863
2864 #[serde(default = "default_true")]
2866 pub verify_upstream: bool,
2867
2868 #[serde(default = "default_true")]
2871 pub block_quic_on_intercept: bool,
2872
2873 #[serde(default)]
2875 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
2876 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
2877 pub upstream_ca_cert: Vec<PathBuf>,
2878
2879 #[serde(default, alias = "scoped_upstream_ca_certs")]
2881 pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
2882
2883 #[serde(default)]
2885 pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
2886
2887 #[serde(default, alias = "ca")]
2890 pub intercept_ca: InterceptCaConfig,
2891
2892 #[serde(default)]
2894 pub cache: CertCacheConfig,
2895}
2896
2897#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2899#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2900#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2901pub struct InterceptCaConfig {
2902 #[serde(default)]
2905 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2906 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2907 pub cert_path: Option<PathBuf>,
2908
2909 #[serde(default)]
2912 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2913 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2914 pub key_path: Option<PathBuf>,
2915}
2916
2917#[derive(Debug, Clone, Serialize, Deserialize)]
2919#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2920#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2921pub struct CertCacheConfig {
2922 #[serde(default = "default_cache_capacity")]
2924 pub capacity: usize,
2925
2926 #[serde(default = "default_cert_validity_hours")]
2928 pub validity_hours: u64,
2929}
2930
2931#[derive(Debug, Clone, Serialize, Deserialize)]
2933#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2934#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2935pub struct ScopedUpstreamCaCert {
2936 pub pattern: String,
2938
2939 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2941 #[cfg_attr(feature = "ts", ts(type = "string"))]
2942 pub path: PathBuf,
2943}
2944
2945#[derive(Debug, Clone, Serialize, Deserialize)]
2947#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2948#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2949pub struct ScopedVerifyUpstream {
2950 pub pattern: String,
2952
2953 pub verify: bool,
2955}
2956
2957impl Default for TlsConfig {
2958 fn default() -> Self {
2959 Self {
2960 enabled: false,
2961 intercepted_ports: default_intercepted_ports(),
2962 bypass: Vec::new(),
2963 verify_upstream: true,
2964 block_quic_on_intercept: true,
2965 upstream_ca_cert: Vec::new(),
2966 scoped_upstream_ca_cert: Vec::new(),
2967 scoped_verify_upstream: Vec::new(),
2968 intercept_ca: InterceptCaConfig::default(),
2969 cache: CertCacheConfig::default(),
2970 }
2971 }
2972}
2973
2974impl Default for CertCacheConfig {
2975 fn default() -> Self {
2976 Self {
2977 capacity: default_cache_capacity(),
2978 validity_hours: default_cert_validity_hours(),
2979 }
2980 }
2981}
2982
2983fn default_intercepted_ports() -> Vec<u16> {
2984 vec![443]
2985}
2986
2987fn default_cache_capacity() -> usize {
2988 1000
2989}
2990
2991fn default_cert_validity_hours() -> u64 {
2992 24
2993}
2994
2995#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3001#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3002#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3003#[serde(rename_all = "snake_case")]
3004pub enum Action {
3005 Allow,
3007 Deny,
3009}
3010
3011#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3013#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3014#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3015#[serde(rename_all = "snake_case")]
3016pub enum Direction {
3017 Egress,
3019 Ingress,
3021 Any,
3023}
3024
3025#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3027#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3028#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3029#[serde(rename_all = "snake_case")]
3030pub enum Protocol {
3031 Tcp,
3033 Udp,
3035 Icmpv4,
3037 Icmpv6,
3039}
3040
3041#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3043#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3044#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3045#[serde(rename_all = "snake_case")]
3046pub enum DestinationGroup {
3047 Public,
3049 Loopback,
3051 Private,
3053 LinkLocal,
3055 Metadata,
3057 Multicast,
3059 Host,
3061}
3062
3063#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3070#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3071#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3072#[serde(rename_all = "snake_case")]
3073pub enum Destination {
3074 Any,
3076 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
3078 Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
3079 Domain(String),
3081 DomainSuffix(String),
3083 Group(DestinationGroup),
3085}
3086
3087#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3089#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3090#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3091pub struct PortRange {
3092 pub start: u16,
3094 pub end: u16,
3096}
3097
3098#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3101#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3102#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3103pub struct Rule {
3104 pub direction: Direction,
3106 pub destination: Destination,
3108 #[serde(default)]
3110 pub protocols: Vec<Protocol>,
3111 #[serde(default)]
3113 pub ports: Vec<PortRange>,
3114 pub action: Action,
3116}
3117
3118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3121#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3122#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3123pub struct NetworkPolicy {
3124 #[serde(default = "action_deny")]
3126 pub default_egress: Action,
3127 #[serde(default = "action_deny")]
3129 pub default_ingress: Action,
3130 #[serde(default)]
3132 pub rules: Vec<Rule>,
3133}
3134
3135fn action_deny() -> Action {
3138 Action::Deny
3139}
3140
3141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3147#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3148#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3149#[serde(default)]
3150pub struct DnsConfig {
3151 pub rebind_protection: bool,
3153 pub nameservers: Vec<String>,
3156 pub query_timeout_ms: u64,
3158}
3159
3160impl Default for DnsConfig {
3161 fn default() -> Self {
3162 Self {
3163 rebind_protection: true,
3164 nameservers: Vec::new(),
3165 query_timeout_ms: 5000,
3166 }
3167 }
3168}
3169
3170#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3174#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3175#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3176#[serde(default)]
3177pub struct InterfaceOverrides {
3178 #[serde(skip_serializing_if = "Option::is_none")]
3180 pub mac: Option<[u8; 6]>,
3181 #[serde(skip_serializing_if = "Option::is_none")]
3183 pub mtu: Option<u16>,
3184 #[serde(skip_serializing_if = "Option::is_none")]
3186 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3187 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3188 pub ipv4_address: Option<Ipv4Addr>,
3189 #[serde(skip_serializing_if = "Option::is_none")]
3191 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3192 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3193 pub ipv4_pool: Option<Ipv4Network>,
3194 #[serde(skip_serializing_if = "Option::is_none")]
3196 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3197 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3198 pub ipv6_address: Option<Ipv6Addr>,
3199 #[serde(skip_serializing_if = "Option::is_none")]
3201 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3202 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3203 pub ipv6_pool: Option<Ipv6Network>,
3204}
3205
3206fn empty_secret_value() -> Zeroizing<String> {
3207 Zeroizing::new(String::new())
3208}
3209
3210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3216pub enum NetworkRateLimitDirection {
3217 Egress,
3219 Ingress,
3221}
3222
3223#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3225#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3226#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3227#[serde(default)]
3228pub struct NetworkRateLimiterConfig {
3229 #[serde(skip_serializing_if = "Option::is_none")]
3231 pub egress: Option<RateLimiterConfig>,
3232
3233 #[serde(skip_serializing_if = "Option::is_none")]
3235 pub ingress: Option<RateLimiterConfig>,
3236}
3237
3238#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
3244#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3245#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3246#[serde(default)]
3247pub struct RateLimiterConfig {
3248 #[serde(skip_serializing_if = "Option::is_none")]
3250 pub bandwidth: Option<TokenBucketConfig>,
3251
3252 #[serde(skip_serializing_if = "Option::is_none")]
3254 pub ops: Option<TokenBucketConfig>,
3255}
3256
3257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3263#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3264#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3265pub struct TokenBucketConfig {
3266 pub size: u64,
3268
3269 pub refill_time_ms: u64,
3272
3273 #[serde(default)]
3275 pub one_time_burst: u64,
3276}
3277
3278#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3280pub enum RateLimitConfigError {
3281 #[error("rate limiter must configure at least one of bandwidth or ops")]
3283 EmptyLimiter,
3284
3285 #[error("{bucket} bucket: size must be greater than zero")]
3287 ZeroSize {
3288 bucket: &'static str,
3290 },
3291
3292 #[error("{bucket} bucket: refill_time_ms must be greater than zero")]
3294 ZeroRefillTime {
3295 bucket: &'static str,
3297 },
3298}
3299
3300impl RateLimiterConfig {
3301 pub fn validate(&self) -> Result<(), RateLimitConfigError> {
3303 if self.bandwidth.is_none() && self.ops.is_none() {
3304 return Err(RateLimitConfigError::EmptyLimiter);
3305 }
3306 if let Some(bandwidth) = &self.bandwidth {
3307 bandwidth.validate("bandwidth")?;
3308 }
3309 if let Some(ops) = &self.ops {
3310 ops.validate("ops")?;
3311 }
3312 Ok(())
3313 }
3314}
3315
3316impl TokenBucketConfig {
3317 pub fn validate(&self, bucket: &'static str) -> Result<(), RateLimitConfigError> {
3319 if self.size == 0 {
3320 return Err(RateLimitConfigError::ZeroSize { bucket });
3321 }
3322 if self.refill_time_ms == 0 {
3323 return Err(RateLimitConfigError::ZeroRefillTime { bucket });
3324 }
3325 Ok(())
3326 }
3327}
3328
3329impl fmt::Display for NetworkRateLimitDirection {
3330 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3331 match self {
3332 Self::Egress => f.write_str("egress"),
3333 Self::Ingress => f.write_str("ingress"),
3334 }
3335 }
3336}
3337
3338#[cfg(test)]
3343mod tests {
3344 use super::*;
3345
3346 fn secret_entry(env_var: &str, require_tls_identity: bool) -> SecretEntry {
3347 SecretEntry {
3348 env_var: env_var.to_owned(),
3349 value: Zeroizing::new("secret".to_owned()),
3350 source: None,
3351 placeholder: format!("$MSB_{env_var}"),
3352 allowed_hosts: vec![HostPattern::Any],
3353 substitution: SecretSubstitution::default(),
3354 passthrough_hosts: Vec::new(),
3355 violation_action: None,
3356 require_tls_identity,
3357 }
3358 }
3359
3360 fn tmpfs_mount(guest: &str) -> VolumeMount {
3361 VolumeMount::Tmpfs {
3362 guest: guest.to_owned(),
3363 size_mib: None,
3364 options: MountOptions::default(),
3365 }
3366 }
3367
3368 #[test]
3369 fn mount_options_omit_unset_owner_but_accept_missing_fields() {
3370 let value = serde_json::to_value(MountOptions::default()).unwrap();
3371 assert!(value.get("override_uid").is_none());
3372 assert!(value.get("override_gid").is_none());
3373
3374 let decoded: MountOptions = serde_json::from_value(value).unwrap();
3375 assert_eq!(decoded.override_uid, None);
3376 assert_eq!(decoded.override_gid, None);
3377 }
3378
3379 #[test]
3380 fn volume_mounts_are_canonicalized_and_ordered_parent_first() {
3381 let mut mounts = vec![
3382 tmpfs_mount("/workspace//persist/./logs/"),
3383 tmpfs_mount("/alpha/z"),
3384 tmpfs_mount("/workspace"),
3385 ];
3386
3387 canonicalize_volume_mounts(&mut mounts).unwrap();
3388
3389 assert_eq!(
3390 mounts.iter().map(VolumeMount::guest).collect::<Vec<_>>(),
3391 vec!["/workspace", "/alpha/z", "/workspace/persist/logs"]
3392 );
3393 }
3394
3395 #[test]
3396 fn secrets_config_queries_entries() {
3397 let mut config = SecretsConfig {
3398 secrets: vec![secret_entry("HTTP_TOKEN", false)],
3399 ..Default::default()
3400 };
3401
3402 assert!(!config.has_tls_identity_secrets());
3403 assert!(config.contains_env_var("HTTP_TOKEN"));
3404 assert!(!config.contains_env_var("MISSING"));
3405
3406 config.secrets.push(secret_entry("API_KEY", true));
3407 assert!(config.has_tls_identity_secrets());
3408 }
3409
3410 #[test]
3411 fn volume_mounts_reject_duplicate_canonical_paths() {
3412 let mut mounts = vec![tmpfs_mount("/data/cache"), tmpfs_mount("/data//./cache/")];
3413
3414 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3415
3416 assert!(error.to_string().contains("same guest path: /data/cache"));
3417 }
3418
3419 #[test]
3420 fn volume_mounts_reject_parent_components_before_normalizing() {
3421 let mut mounts = vec![tmpfs_mount("/workspace/../secrets")];
3422
3423 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3424
3425 assert!(error.to_string().contains("must not contain '..'"));
3426 }
3427
3428 #[test]
3429 fn disk_image_format_from_extension() {
3430 assert_eq!(
3431 DiskImageFormat::from_extension("qcow2"),
3432 Some(DiskImageFormat::Qcow2)
3433 );
3434 assert_eq!(
3435 DiskImageFormat::from_extension("raw"),
3436 Some(DiskImageFormat::Raw)
3437 );
3438 assert_eq!(
3439 DiskImageFormat::from_extension("vmdk"),
3440 Some(DiskImageFormat::Vmdk)
3441 );
3442 assert_eq!(DiskImageFormat::from_extension("ext4"), None);
3443 assert_eq!(DiskImageFormat::from_extension(""), None);
3444 }
3445
3446 fn secret_with_header_fields(fields: Vec<&str>) -> SecretEntry {
3447 SecretEntry {
3448 env_var: "API_KEY".into(),
3449 value: Zeroizing::new("secret".into()),
3450 source: None,
3451 placeholder: "$MSB_API_KEY".into(),
3452 allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
3453 substitution: SecretSubstitution {
3454 headers: true,
3455 header_fields: fields.into_iter().map(ToString::to_string).collect(),
3456 query: false,
3457 body: false,
3458 },
3459 passthrough_hosts: Vec::new(),
3460 violation_action: None,
3461 require_tls_identity: true,
3462 }
3463 }
3464
3465 #[test]
3466 fn secret_substitution_header_fields_round_trip_and_default() {
3467 let entry = secret_with_header_fields(vec!["Authorization", "X-Api-Key"]);
3468 entry.validate(0).expect("valid header fields");
3469 let json = serde_json::to_value(&entry).unwrap();
3470 assert_eq!(
3471 json["substitution"]["header_fields"],
3472 serde_json::json!(["Authorization", "X-Api-Key"])
3473 );
3474
3475 let omitted: SecretSubstitution =
3476 serde_json::from_value(serde_json::json!({ "headers": true })).unwrap();
3477 assert!(omitted.header_fields.is_empty());
3478
3479 let default = SecretSubstitution::default();
3481 assert!(
3482 serde_json::to_value(&default)
3483 .unwrap()
3484 .get("header_fields")
3485 .is_none()
3486 );
3487 }
3488
3489 #[test]
3490 fn secret_validation_rejects_invalid_header_fields() {
3491 for field in ["", "bad header", "bad:name", "bad\0name"] {
3492 let entry = secret_with_header_fields(vec![field]);
3493 let error = entry.validate(0).unwrap_err();
3494 assert!(
3495 matches!(error, SecretConfigError::InvalidHeaderFieldName { .. }),
3496 "{field:?} should be rejected, got {error}"
3497 );
3498 }
3499 }
3500
3501 #[test]
3502 fn secret_validation_rejects_header_fields_when_headers_disabled() {
3503 let mut entry = secret_with_header_fields(vec!["authorization"]);
3506 entry.substitution.headers = false;
3507 entry.substitution.query = true;
3508 let error = entry.validate(0).unwrap_err();
3509 assert!(
3510 matches!(error, SecretConfigError::HeaderFieldsRequireHeaders { .. }),
3511 "{error}"
3512 );
3513
3514 entry.substitution.header_fields.clear();
3516 entry
3517 .validate(0)
3518 .expect("disabled headers without fields is valid");
3519 }
3520
3521 #[test]
3522 fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
3523 let resources: SandboxResources =
3524 serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
3525
3526 assert_eq!(resources.cpus, 4);
3527 assert_eq!(resources.max_cpus, 4);
3528 assert_eq!(resources.memory_mib, 2048);
3529 assert_eq!(resources.max_memory_mib, 2048);
3530 assert_eq!(resources.cpu_placement, CpuPlacement::Inherit);
3531 assert_eq!(resources.thp, TransparentHugePagePolicy::Madvise);
3532 assert_eq!(
3533 serde_json::to_value(resources).unwrap(),
3534 serde_json::json!({
3535 "cpus": 4,
3536 "memory_mib": 2048,
3537 "max_cpus": 4,
3538 "max_memory_mib": 2048
3539 })
3540 );
3541 }
3542
3543 #[test]
3544 fn cpu_placement_omits_inherit_and_roundtrips_managed_policies() {
3545 let inherited = serde_json::to_value(SandboxResources::default()).unwrap();
3546 assert!(inherited.get("cpu_placement").is_none());
3547
3548 for policy in [
3549 CpuPlacement::Auto,
3550 CpuPlacement::Spread,
3551 CpuPlacement::Compact,
3552 ] {
3553 let resources = SandboxResources {
3554 cpu_placement: policy,
3555 ..Default::default()
3556 };
3557 let json = serde_json::to_string(&resources).unwrap();
3558 let decoded: SandboxResources = serde_json::from_str(&json).unwrap();
3559
3560 assert_eq!(decoded.cpu_placement, policy);
3561 assert_eq!(policy.to_string().parse::<CpuPlacement>().unwrap(), policy);
3562 }
3563 }
3564
3565 #[test]
3566 fn guest_clock_policy_is_omitted_until_set_and_roundtrips() {
3567 let defaults = serde_json::to_value(SandboxRuntimeOptions::default()).unwrap();
3568 assert!(defaults.get("guest_clock").is_none());
3569
3570 let legacy: SandboxRuntimeOptions = serde_json::from_str(r#"{"workdir":"/app"}"#).unwrap();
3571 assert_eq!(legacy.guest_clock, None);
3572
3573 for policy in [GuestClockPolicy::Sync, GuestClockPolicy::Off] {
3574 let runtime = SandboxRuntimeOptions {
3575 guest_clock: Some(policy),
3576 ..Default::default()
3577 };
3578 let json = serde_json::to_value(&runtime).unwrap();
3579 assert_eq!(json["guest_clock"], serde_json::json!(policy.as_str()));
3580 let decoded: SandboxRuntimeOptions = serde_json::from_value(json).unwrap();
3581 assert_eq!(decoded.guest_clock, Some(policy));
3582 assert_eq!(
3583 policy.to_string().parse::<GuestClockPolicy>().unwrap(),
3584 policy
3585 );
3586 }
3587
3588 assert_eq!(GuestClockPolicy::default(), GuestClockPolicy::Sync);
3589 assert!("host_sync".parse::<GuestClockPolicy>().is_err());
3590 }
3591
3592 #[test]
3593 fn transparent_huge_page_policy_roundtrips_non_default() {
3594 let resources: SandboxResources = serde_json::from_str(
3595 r#"{"cpus":2,"memory_mib":8192,"max_cpus":2,"max_memory_mib":8192,"thp":"always"}"#,
3596 )
3597 .unwrap();
3598
3599 assert_eq!(resources.thp, TransparentHugePagePolicy::Always);
3600 assert_eq!(
3601 serde_json::to_value(resources).unwrap()["thp"],
3602 serde_json::json!("always")
3603 );
3604 assert_eq!(
3605 "never".parse::<TransparentHugePagePolicy>().unwrap(),
3606 TransparentHugePagePolicy::Never
3607 );
3608 assert!("auto".parse::<TransparentHugePagePolicy>().is_err());
3609 }
3610
3611 #[test]
3612 fn disk_image_format_display_roundtrip() {
3613 for format in [
3614 DiskImageFormat::Qcow2,
3615 DiskImageFormat::Raw,
3616 DiskImageFormat::Vmdk,
3617 ] {
3618 let rendered = format.to_string();
3619 let parsed: DiskImageFormat = rendered.parse().unwrap();
3620 assert_eq!(parsed, format);
3621 }
3622 }
3623
3624 #[test]
3625 fn disk_image_format_from_str_unknown() {
3626 assert!("ext4".parse::<DiskImageFormat>().is_err());
3627 }
3628
3629 #[test]
3630 fn log_source_effective_uses_default_user_program_sources() {
3631 assert_eq!(
3632 LogSource::effective(&[]),
3633 vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
3634 );
3635 }
3636
3637 #[test]
3638 fn log_source_effective_sorts_and_deduplicates_requested_sources() {
3639 assert_eq!(
3640 LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
3641 vec![LogSource::Stdout, LogSource::System]
3642 );
3643 }
3644
3645 #[test]
3646 fn rlimit_resource_parses_case_insensitively() {
3647 assert_eq!(
3648 RlimitResource::try_from("NOFILE").unwrap(),
3649 RlimitResource::Nofile
3650 );
3651 assert!(RlimitResource::try_from("bogus").is_err());
3652 }
3653
3654 #[test]
3655 fn sandbox_policy_serde_roundtrip() {
3656 let policy = SandboxPolicy {
3657 ephemeral: true,
3658 max_duration_secs: Some(3600),
3659 idle_timeout_secs: Some(120),
3660 };
3661
3662 let json = serde_json::to_string(&policy).unwrap();
3663 let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
3664
3665 assert!(decoded.ephemeral);
3666 assert_eq!(decoded.max_duration_secs, Some(3600));
3667 assert_eq!(decoded.idle_timeout_secs, Some(120));
3668 }
3669
3670 #[test]
3671 fn sandbox_policy_defaults_to_persistent() {
3672 assert!(!SandboxPolicy::default().ephemeral);
3673 }
3674
3675 #[test]
3676 fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
3677 let decoded: SandboxPolicy =
3680 serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
3681 assert!(!decoded.ephemeral);
3682 assert_eq!(decoded.max_duration_secs, Some(60));
3683 }
3684
3685 #[test]
3686 fn sandbox_spec_default_uses_static_resource_defaults() {
3687 let spec = SandboxSpec::default();
3688
3689 assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
3690 assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
3691 assert_eq!(
3692 spec.runtime.metrics_sample_interval_ms,
3693 Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
3694 );
3695 assert_eq!(spec.deployment_profile, DeploymentProfile::SingleTenant);
3696 }
3697
3698 #[test]
3699 fn deployment_profile_uses_stable_snake_case_wire_values() {
3700 assert_eq!(
3701 serde_json::to_string(&DeploymentProfile::MultiTenant).unwrap(),
3702 r#""multi_tenant""#
3703 );
3704 assert_eq!(
3705 serde_json::from_str::<DeploymentProfile>(r#""single_tenant""#).unwrap(),
3706 DeploymentProfile::SingleTenant
3707 );
3708 }
3709
3710 #[test]
3711 fn sandbox_log_level_roundtrips_lowercase_values() {
3712 for (input, expected) in [
3713 ("error", SandboxLogLevel::Error),
3714 ("warn", SandboxLogLevel::Warn),
3715 ("info", SandboxLogLevel::Info),
3716 ("debug", SandboxLogLevel::Debug),
3717 ("trace", SandboxLogLevel::Trace),
3718 ] {
3719 let parsed: SandboxLogLevel = input.parse().unwrap();
3720 assert_eq!(parsed, expected);
3721 assert_eq!(parsed.as_str(), input);
3722 }
3723 }
3724}