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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
581#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
582#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
583#[serde(rename_all = "snake_case")]
584pub enum HttpDenyResponseFormat {
585 Text,
587
588 #[default]
590 Json,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
595#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
596#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
597#[serde(default)]
598pub struct HttpConfig {
599 pub deny_response: bool,
601
602 pub deny_response_format: HttpDenyResponseFormat,
604
605 #[serde(skip_serializing_if = "Option::is_none")]
608 pub deny_message: Option<String>,
609
610 #[serde(skip_serializing_if = "Option::is_none")]
614 pub network_deny_message: Option<String>,
615
616 #[serde(skip_serializing_if = "Option::is_none")]
619 pub secret_deny_message: Option<String>,
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
626#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
627#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
628#[serde(default)]
629pub struct NetworkSpec {
630 pub enabled: bool,
632
633 #[serde(skip_serializing_if = "Option::is_none")]
635 #[config_patch(nested)]
636 pub interface: Option<InterfaceOverrides>,
637
638 pub ports: Vec<PublishedPortSpec>,
640
641 #[serde(skip_serializing_if = "Option::is_none")]
643 pub policy: Option<NetworkPolicy>,
644
645 #[serde(skip_serializing_if = "Option::is_none")]
647 #[config_patch(nested)]
648 pub dns: Option<DnsConfig>,
649
650 #[serde(skip_serializing_if = "Option::is_none")]
652 #[config_patch(nested)]
653 pub tls: Option<TlsConfig>,
654
655 pub strict: bool,
657
658 #[serde(skip_serializing_if = "Option::is_none")]
660 #[config_patch(nested)]
661 pub secrets: Option<SecretsConfig>,
662
663 #[serde(rename = "max_connections", alias = "max_tcp_connections")]
666 pub max_tcp_connections: Option<usize>,
667
668 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub max_inbound_tcp_connections: Option<usize>,
671
672 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub max_udp_connections: Option<usize>,
675
676 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub tcp_accept_queue_size: Option<u32>,
680
681 #[serde(skip_serializing_if = "Option::is_none")]
683 #[config_patch(nested)]
684 pub rate_limiter: Option<NetworkRateLimiterConfig>,
685
686 #[serde(default = "default_nat64_prefixes")]
688 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
689 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
690 pub nat64_prefixes: Vec<Ipv6Network>,
691
692 pub trust_host_cas: bool,
694
695 #[config_patch(nested)]
697 #[serde(default)]
698 pub http: HttpConfig,
699
700 #[serde(skip_serializing_if = "Option::is_none")]
704 #[config_patch(nullable)]
705 pub outbound_proxy: Option<OutboundProxy>,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
711#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
712#[serde(tag = "protocol", rename_all = "lowercase")]
713#[non_exhaustive]
714pub enum OutboundProxy {
715 #[serde(rename = "http_connect")]
717 HttpConnect {
718 address: String,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
722 credentials: Option<HttpConnectCredentials>,
723 },
724
725 Socks4 {
727 address: String,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 user_id: Option<String>,
732 },
733
734 Socks5 {
736 address: String,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 credentials: Option<Socks5Credentials>,
741 },
742}
743
744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
749#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
750#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
751pub struct HttpConnectCredentials {
752 pub username: String,
754
755 pub password: SecretSource,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
764#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
765#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
766pub struct Socks5Credentials {
767 pub username: String,
769
770 pub password: SecretSource,
772}
773
774#[derive(Debug, Clone, Serialize, Deserialize)]
776#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
777#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
778pub struct PublishedPortSpec {
779 pub host_port: u16,
781
782 pub guest_port: u16,
784
785 #[serde(default)]
787 pub protocol: PortProtocol,
788
789 pub host_bind: String,
791}
792
793#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
795#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
796#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
797pub enum PortProtocol {
798 #[default]
800 #[serde(rename = "tcp")]
801 Tcp,
802
803 #[serde(rename = "udp")]
805 Udp,
806}
807
808#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
814#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
815#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
816#[serde(default)]
817pub struct VsockSpec {
818 pub routes: Vec<VsockRouteSpec>,
820}
821
822impl VsockSpec {
823 pub fn is_empty(&self) -> bool {
825 self.routes.is_empty()
826 }
827}
828
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
831#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
832#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
833pub struct VsockRouteSpec {
834 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
836 pub host_socket: PathBuf,
837
838 pub port: u32,
840
841 #[serde(default)]
843 pub socket_type: VsockSocketType,
844}
845
846#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
848#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
849#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
850#[serde(rename_all = "snake_case")]
851pub enum VsockSocketType {
852 #[default]
854 Stream,
855
856 Dgram,
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize)]
866#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
867#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
868pub struct HandoffInit {
869 pub cmd: String,
873
874 #[serde(default)]
876 pub args: Vec<String>,
877
878 #[serde(default)]
880 pub env: Vec<(String, String)>,
881}
882
883#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
889#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
890#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
891pub struct SandboxPolicy {
892 #[serde(default)]
901 pub ephemeral: bool,
902
903 pub max_duration_secs: Option<u64>,
905
906 pub idle_timeout_secs: Option<u64>,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize)]
920#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
921pub struct SnapshotSpec {
922 #[serde(default)]
924 pub guest_flush: crate::GuestFlush,
925 pub name: String,
927
928 #[serde(default)]
930 pub group: Option<String>,
931
932 #[serde(default)]
934 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
935 pub dest_dir: Option<PathBuf>,
936
937 pub source_sandbox: String,
939
940 pub labels: Vec<(String, String)>,
942
943 pub force: bool,
945
946 pub record_integrity: bool,
948
949 #[serde(default)]
951 pub full: bool,
952}
953
954#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
962#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
963#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
964#[serde(default)]
965pub struct SandboxSpec {
966 pub name: String,
968
969 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
971 pub image: RootfsSource,
972
973 #[config_patch(nested)]
975 pub resources: SandboxResources,
976
977 #[config_patch(nested)]
979 pub runtime: SandboxRuntimeOptions,
980
981 #[config_patch(merge_with = merge_env_vars)]
983 pub env: Vec<EnvVar>,
984
985 #[config_patch(merge)]
987 pub labels: BTreeMap<String, String>,
988
989 pub rlimits: Vec<Rlimit>,
991
992 pub mounts: Vec<VolumeMount>,
994
995 pub patches: Vec<Patch>,
997
998 #[config_patch(nested)]
1000 pub network: NetworkSpec,
1001
1002 #[serde(default, skip_serializing_if = "VsockSpec::is_empty")]
1004 #[config_patch(nested)]
1005 pub vsock: VsockSpec,
1006
1007 pub init: Option<HandoffInit>,
1009
1010 pub pull_policy: PullPolicy,
1012
1013 pub security_profile: SecurityProfile,
1015
1016 pub deployment_profile: DeploymentProfile,
1022
1023 #[config_patch(nested)]
1025 pub lifecycle: SandboxPolicy,
1026}
1027
1028#[derive(Debug, Clone, Serialize, ConfigPatch)]
1030#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1031#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1032pub struct SandboxResources {
1033 pub cpus: u8,
1035
1036 pub memory_mib: u32,
1038
1039 pub max_cpus: u8,
1041
1042 pub max_memory_mib: u32,
1044
1045 #[serde(default, skip_serializing_if = "CpuPlacement::is_inherit")]
1047 pub cpu_placement: CpuPlacement,
1048
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 #[config_patch(nullable)]
1053 pub placement_profile: Option<String>,
1054
1055 #[serde(default, skip_serializing_if = "TransparentHugePagePolicy::is_madvise")]
1057 pub thp: TransparentHugePagePolicy,
1058}
1059
1060#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1063#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1064#[serde(rename_all = "lowercase")]
1065pub enum CpuPlacement {
1066 #[default]
1068 Inherit,
1069
1070 Auto,
1072
1073 Spread,
1075
1076 Compact,
1078}
1079
1080#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1082#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1083#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1084#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1085pub enum NumaPlacement {
1086 PreferSingle,
1088 StrictSingle,
1090 Inherit,
1092}
1093
1094#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1096#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1097#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1098#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1099pub enum MemoryPlacement {
1100 FollowCpu,
1102 Inherit,
1104}
1105
1106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1108#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1109#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1110#[serde(deny_unknown_fields)]
1111pub struct PlacementProfile {
1112 pub numa: NumaPlacement,
1114 pub memory: MemoryPlacement,
1116}
1117
1118#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1120#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1121#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1122#[serde(rename_all = "lowercase")]
1123pub enum TransparentHugePagePolicy {
1124 Always,
1126
1127 #[default]
1129 Madvise,
1130
1131 Never,
1133}
1134
1135#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1140#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1141#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1142#[serde(rename_all = "lowercase")]
1143pub enum GuestClockPolicy {
1144 #[default]
1149 Sync,
1150
1151 Off,
1156}
1157
1158#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
1160#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1161#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1162#[serde(default)]
1163pub struct SandboxRuntimeOptions {
1164 #[config_patch(nullable)]
1167 pub workdir: Option<String>,
1168
1169 #[config_patch(nullable)]
1172 pub shell: Option<String>,
1173
1174 #[config_patch(merge)]
1176 pub scripts: BTreeMap<String, String>,
1177
1178 pub entrypoint: Option<Vec<String>>,
1180
1181 pub cmd: Option<Vec<String>>,
1183
1184 pub hostname: Option<String>,
1186
1187 pub user: Option<String>,
1189
1190 #[config_patch(nullable)]
1193 pub log_level: Option<SandboxLogLevel>,
1194
1195 #[config_patch(nullable)]
1198 pub metrics_sample_interval_ms: Option<u64>,
1199
1200 pub disable_metrics_sample: bool,
1202
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1206 pub guest_clock: Option<GuestClockPolicy>,
1207}
1208
1209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1212#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1213pub struct EnvVar {
1214 pub key: String,
1216
1217 pub value: String,
1219}
1220
1221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1223#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1225#[serde(rename_all = "lowercase")]
1226pub enum SandboxLogLevel {
1227 Error,
1229
1230 Warn,
1232
1233 Info,
1235
1236 Debug,
1238
1239 Trace,
1241}
1242
1243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1249#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1250#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1251pub enum RlimitResource {
1252 Cpu,
1254 Fsize,
1256 Data,
1258 Stack,
1260 Core,
1262 Rss,
1264 Nproc,
1266 Nofile,
1268 Memlock,
1270 As,
1272 Locks,
1274 Sigpending,
1276 Msgqueue,
1278 Nice,
1280 Rtprio,
1282 Rttime,
1284}
1285
1286#[derive(Debug, Clone, Serialize, Deserialize)]
1288#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1289#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1290pub struct Rlimit {
1291 pub resource: RlimitResource,
1293
1294 pub soft: u64,
1296
1297 pub hard: u64,
1299}
1300
1301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1307#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1308#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1309#[serde(rename_all = "lowercase")]
1310pub enum LogSource {
1311 Stdout,
1313
1314 Stderr,
1316
1317 Output,
1319
1320 System,
1322}
1323
1324impl SandboxResourcesPatch {
1329 pub fn has_cpus(&self) -> bool {
1331 self.cpus.is_some()
1332 }
1333
1334 pub fn has_memory_mib(&self) -> bool {
1336 self.memory_mib.is_some()
1337 }
1338
1339 pub fn has_max_cpus(&self) -> bool {
1341 self.max_cpus.is_some()
1342 }
1343
1344 pub fn has_max_memory_mib(&self) -> bool {
1346 self.max_memory_mib.is_some()
1347 }
1348}
1349
1350impl DiskImageFormat {
1351 pub fn as_str(&self) -> &'static str {
1353 match self {
1354 Self::Qcow2 => "qcow2",
1355 Self::Raw => "raw",
1356 Self::Vmdk => "vmdk",
1357 }
1358 }
1359
1360 pub fn from_extension(ext: &str) -> Option<Self> {
1364 match ext {
1365 "qcow2" => Some(Self::Qcow2),
1366 "raw" => Some(Self::Raw),
1367 "vmdk" => Some(Self::Vmdk),
1368 _ => None,
1369 }
1370 }
1371}
1372
1373impl OciRootfsSource {
1374 pub fn new(reference: impl Into<String>) -> Self {
1376 Self {
1377 reference: reference.into(),
1378 root_disk: None,
1379 }
1380 }
1381}
1382
1383impl TransparentHugePagePolicy {
1384 pub fn is_madvise(&self) -> bool {
1386 matches!(self, Self::Madvise)
1387 }
1388
1389 pub fn as_str(self) -> &'static str {
1391 match self {
1392 Self::Always => "always",
1393 Self::Madvise => "madvise",
1394 Self::Never => "never",
1395 }
1396 }
1397}
1398
1399impl GuestClockPolicy {
1400 pub fn is_sync(&self) -> bool {
1402 matches!(self, Self::Sync)
1403 }
1404
1405 pub fn as_str(self) -> &'static str {
1407 match self {
1408 Self::Sync => "sync",
1409 Self::Off => "off",
1410 }
1411 }
1412}
1413
1414impl RootDisk {
1415 pub fn managed(size_mib: u32) -> Self {
1417 Self::Managed {
1418 size_mib: Some(size_mib),
1419 }
1420 }
1421
1422 pub fn tmpfs(size_mib: u32) -> Self {
1424 Self::Tmpfs {
1425 size_mib: Some(size_mib),
1426 }
1427 }
1428
1429 pub fn flat(size_mib: u32) -> Self {
1431 Self::Flat {
1432 size_mib: Some(size_mib),
1433 fstype: None,
1434 clone: FlatClone::Auto,
1435 }
1436 }
1437
1438 pub fn size_mib(&self) -> Option<u32> {
1440 match self {
1441 Self::Managed { size_mib } | Self::Tmpfs { size_mib } | Self::Flat { size_mib, .. } => {
1442 *size_mib
1443 }
1444 Self::DiskImage { .. } => None,
1445 }
1446 }
1447
1448 pub fn kind_str(&self) -> &'static str {
1450 match self {
1451 Self::Managed { .. } => "managed",
1452 Self::Tmpfs { .. } => "tmpfs",
1453 Self::DiskImage { .. } => "disk-image",
1454 Self::Flat { .. } => "flat",
1455 }
1456 }
1457
1458 pub fn is_managed(&self) -> bool {
1460 matches!(self, Self::Managed { .. })
1461 }
1462}
1463
1464impl FlatClone {
1465 pub const fn as_str(self) -> &'static str {
1467 match self {
1468 Self::Auto => "auto",
1469 Self::Copy => "copy",
1470 Self::Reflink => "reflink",
1471 }
1472 }
1473
1474 pub const fn is_auto(&self) -> bool {
1476 matches!(self, Self::Auto)
1477 }
1478}
1479
1480impl RootfsSource {
1481 pub fn oci(reference: impl Into<String>) -> Self {
1483 Self::Oci(OciRootfsSource::new(reference))
1484 }
1485
1486 pub fn oci_reference(&self) -> Option<&str> {
1488 match self {
1489 Self::Oci(oci) => Some(&oci.reference),
1490 _ => None,
1491 }
1492 }
1493
1494 pub fn oci_root_disk(&self) -> Option<&RootDisk> {
1496 match self {
1497 Self::Oci(oci) => oci.root_disk.as_ref(),
1498 _ => None,
1499 }
1500 }
1501
1502 pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
1505 match self {
1506 Self::Oci(oci) => match &oci.root_disk {
1507 Some(RootDisk::Managed { size_mib }) => *size_mib,
1508 Some(_) => None,
1509 None => None,
1510 },
1511 _ => None,
1512 }
1513 }
1514}
1515
1516impl EnvVar {
1517 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1519 Self {
1520 key: key.into(),
1521 value: value.into(),
1522 }
1523 }
1524
1525 pub fn as_pair(&self) -> (&str, &str) {
1527 (&self.key, &self.value)
1528 }
1529}
1530
1531impl VolumeKind {
1532 pub fn as_str(self) -> &'static str {
1534 match self {
1535 Self::Directory => "dir",
1536 Self::Disk => "disk",
1537 }
1538 }
1539
1540 pub fn from_db_value(value: &str) -> Self {
1542 match value {
1543 "disk" => Self::Disk,
1544 _ => Self::Directory,
1545 }
1546 }
1547}
1548
1549impl VolumeSpec {
1550 pub fn new(name: impl Into<String>) -> Self {
1552 Self {
1553 name: name.into(),
1554 kind: VolumeKind::Directory,
1555 quota_mib: None,
1556 capacity_mib: None,
1557 labels: Vec::new(),
1558 }
1559 }
1560}
1561
1562impl NamedVolumeCreate {
1563 pub fn mode(&self) -> NamedVolumeMode {
1565 self.mode
1566 }
1567
1568 pub fn name(&self) -> &str {
1570 &self.name
1571 }
1572
1573 pub fn kind(&self) -> VolumeKind {
1575 self.kind
1576 }
1577
1578 pub fn quota_mib(&self) -> Option<u32> {
1580 self.quota_mib
1581 }
1582
1583 pub fn capacity_mib(&self) -> Option<u32> {
1585 self.capacity_mib
1586 }
1587
1588 pub fn labels(&self) -> &[(String, String)] {
1590 &self.labels
1591 }
1592}
1593
1594impl VolumeMount {
1595 pub fn guest(&self) -> &str {
1597 match self {
1598 Self::Bind { guest, .. }
1599 | Self::Owned { guest, .. }
1600 | Self::Named { guest, .. }
1601 | Self::Tmpfs { guest, .. }
1602 | Self::DiskImage { guest, .. } => guest,
1603 }
1604 }
1605
1606 fn guest_mut(&mut self) -> &mut String {
1607 match self {
1608 Self::Bind { guest, .. }
1609 | Self::Owned { guest, .. }
1610 | Self::Named { guest, .. }
1611 | Self::Tmpfs { guest, .. }
1612 | Self::DiskImage { guest, .. } => guest,
1613 }
1614 }
1615
1616 pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1618 match self {
1619 Self::Named { create, .. } => create.as_ref(),
1620 _ => None,
1621 }
1622 }
1623}
1624
1625pub fn owned_volume_mount_id(guest: &str) -> String {
1632 use std::fmt::Write as _;
1633 let slug: String = guest
1634 .trim_start_matches('/')
1635 .chars()
1636 .take(11)
1637 .map(|character| {
1638 if character.is_ascii_alphanumeric() || character == '-' {
1639 character
1640 } else {
1641 '_'
1642 }
1643 })
1644 .collect();
1645 let mut id = if slug.is_empty() {
1646 String::new()
1647 } else {
1648 format!("{slug}_")
1649 };
1650 for byte in Sha256::digest(guest.as_bytes()).iter().take(4) {
1651 let _ = write!(id, "{byte:02x}");
1652 }
1653 id
1654}
1655
1656pub fn canonicalize_volume_mounts(mounts: &mut [VolumeMount]) -> TypesResult<()> {
1663 for mount in mounts.iter_mut() {
1664 let canonical = canonical_guest_mount_path(mount.guest())?;
1665 *mount.guest_mut() = canonical;
1666 }
1667
1668 mounts.sort_by_cached_key(|mount| guest_mount_order_key(mount.guest()));
1669
1670 for pair in mounts.windows(2) {
1671 if pair[0].guest() == pair[1].guest() {
1672 return Err(TypesError::invalid_config(format!(
1673 "multiple volumes cannot mount the same guest path: {}",
1674 pair[0].guest()
1675 )));
1676 }
1677 }
1678
1679 Ok(())
1680}
1681
1682fn canonical_guest_mount_path(guest: &str) -> TypesResult<String> {
1683 let path = Utf8UnixPath::new(guest);
1684
1685 if !path.is_valid() {
1686 return Err(TypesError::invalid_config(format!(
1687 "guest mount path must be a valid Unix path: {guest}"
1688 )));
1689 }
1690 if !path.is_absolute() {
1691 return Err(TypesError::invalid_config(format!(
1692 "guest mount path must be absolute: {guest}"
1693 )));
1694 }
1695 if path
1696 .components()
1697 .any(|component| matches!(component, Utf8UnixComponent::ParentDir))
1698 {
1699 return Err(TypesError::invalid_config(format!(
1700 "guest mount path must not contain '..': {guest}"
1701 )));
1702 }
1703 if guest.contains(':') || guest.contains(';') || guest.contains(',') {
1704 return Err(TypesError::invalid_config(format!(
1705 "guest mount path must not contain ':', ';', or ',': {guest}"
1706 )));
1707 }
1708
1709 let canonical = path.normalize().to_string();
1710 if canonical == "/" {
1711 return Err(TypesError::invalid_config(
1712 "cannot mount a volume at guest root /",
1713 ));
1714 }
1715
1716 Ok(canonical)
1717}
1718
1719fn guest_mount_order_key(guest: &str) -> (usize, String) {
1720 let path = Utf8UnixPath::new(guest);
1721 let depth = path.components().filter(Utf8Component::is_normal).count();
1722 (depth, guest.to_owned())
1723}
1724
1725impl RlimitResource {
1726 pub fn as_str(&self) -> &'static str {
1728 match self {
1729 Self::Cpu => "cpu",
1730 Self::Fsize => "fsize",
1731 Self::Data => "data",
1732 Self::Stack => "stack",
1733 Self::Core => "core",
1734 Self::Rss => "rss",
1735 Self::Nproc => "nproc",
1736 Self::Nofile => "nofile",
1737 Self::Memlock => "memlock",
1738 Self::As => "as",
1739 Self::Locks => "locks",
1740 Self::Sigpending => "sigpending",
1741 Self::Msgqueue => "msgqueue",
1742 Self::Nice => "nice",
1743 Self::Rtprio => "rtprio",
1744 Self::Rttime => "rttime",
1745 }
1746 }
1747}
1748
1749impl LogSource {
1750 pub fn effective(requested: &[Self]) -> Vec<Self> {
1752 if requested.is_empty() {
1753 vec![Self::Stdout, Self::Stderr, Self::Output]
1754 } else {
1755 let mut sources = requested.to_vec();
1756 sources.sort_by_key(|src| match src {
1757 Self::Stdout => 0,
1758 Self::Stderr => 1,
1759 Self::Output => 2,
1760 Self::System => 3,
1761 });
1762 sources.dedup();
1763 sources
1764 }
1765 }
1766}
1767
1768impl SandboxLogLevel {
1769 pub const fn as_str(self) -> &'static str {
1771 match self {
1772 Self::Error => "error",
1773 Self::Warn => "warn",
1774 Self::Info => "info",
1775 Self::Debug => "debug",
1776 Self::Trace => "trace",
1777 }
1778 }
1779}
1780
1781impl std::fmt::Display for DiskImageFormat {
1786 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1787 f.write_str(self.as_str())
1788 }
1789}
1790
1791impl FromStr for DiskImageFormat {
1792 type Err = String;
1793
1794 fn from_str(s: &str) -> Result<Self, Self::Err> {
1795 match s {
1796 "qcow2" => Ok(Self::Qcow2),
1797 "raw" => Ok(Self::Raw),
1798 "vmdk" => Ok(Self::Vmdk),
1799 _ => Err(format!("unknown disk image format: {s}")),
1800 }
1801 }
1802}
1803
1804impl fmt::Display for TransparentHugePagePolicy {
1805 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1806 f.write_str(self.as_str())
1807 }
1808}
1809
1810impl FromStr for TransparentHugePagePolicy {
1811 type Err = String;
1812
1813 fn from_str(value: &str) -> Result<Self, Self::Err> {
1814 match value {
1815 "always" => Ok(Self::Always),
1816 "madvise" => Ok(Self::Madvise),
1817 "never" => Ok(Self::Never),
1818 _ => Err(format!(
1819 "unknown transparent huge-page policy: {value}; expected always, madvise, or never"
1820 )),
1821 }
1822 }
1823}
1824
1825impl fmt::Display for GuestClockPolicy {
1826 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1827 f.write_str(self.as_str())
1828 }
1829}
1830
1831impl FromStr for GuestClockPolicy {
1832 type Err = String;
1833
1834 fn from_str(value: &str) -> Result<Self, Self::Err> {
1835 match value {
1836 "sync" => Ok(Self::Sync),
1837 "off" => Ok(Self::Off),
1838 _ => Err(format!(
1839 "unknown guest clock policy: {value}; expected sync or off"
1840 )),
1841 }
1842 }
1843}
1844
1845impl Default for RootfsSource {
1846 fn default() -> Self {
1847 Self::oci(String::new())
1848 }
1849}
1850
1851impl Default for SandboxResources {
1852 fn default() -> Self {
1853 Self {
1854 cpus: DEFAULT_SANDBOX_CPUS,
1855 memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1856 max_cpus: DEFAULT_SANDBOX_CPUS,
1857 max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1858 cpu_placement: CpuPlacement::Inherit,
1859 placement_profile: None,
1860 thp: TransparentHugePagePolicy::Madvise,
1861 }
1862 }
1863}
1864
1865impl<'de> Deserialize<'de> for SandboxResources {
1866 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1867 where
1868 D: serde::Deserializer<'de>,
1869 {
1870 #[derive(Deserialize)]
1871 struct RawResources {
1872 #[serde(default = "default_sandbox_cpus")]
1873 cpus: u8,
1874 #[serde(default = "default_sandbox_memory_mib")]
1875 memory_mib: u32,
1876 max_cpus: Option<u8>,
1877 max_memory_mib: Option<u32>,
1878 #[serde(default)]
1879 cpu_placement: CpuPlacement,
1880 #[serde(default)]
1881 placement_profile: Option<String>,
1882 #[serde(default)]
1883 thp: TransparentHugePagePolicy,
1884 }
1885
1886 let raw = RawResources::deserialize(deserializer)?;
1887 Ok(Self {
1888 cpus: raw.cpus,
1889 memory_mib: raw.memory_mib,
1890 max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1894 max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1895 cpu_placement: raw.cpu_placement,
1896 placement_profile: raw.placement_profile,
1897 thp: raw.thp,
1898 })
1899 }
1900}
1901
1902impl CpuPlacement {
1903 pub const fn is_inherit(&self) -> bool {
1905 matches!(self, Self::Inherit)
1906 }
1907}
1908
1909impl std::fmt::Display for CpuPlacement {
1910 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1911 f.write_str(match self {
1912 Self::Inherit => "inherit",
1913 Self::Auto => "auto",
1914 Self::Spread => "spread",
1915 Self::Compact => "compact",
1916 })
1917 }
1918}
1919
1920impl FromStr for CpuPlacement {
1921 type Err = String;
1922
1923 fn from_str(value: &str) -> Result<Self, Self::Err> {
1924 match value {
1925 "inherit" => Ok(Self::Inherit),
1926 "auto" => Ok(Self::Auto),
1927 "spread" => Ok(Self::Spread),
1928 "compact" => Ok(Self::Compact),
1929 _ => Err(format!(
1930 "unknown CPU placement: {value} (expected: inherit, auto, spread, compact)"
1931 )),
1932 }
1933 }
1934}
1935
1936impl Default for SandboxRuntimeOptions {
1937 fn default() -> Self {
1938 Self {
1939 workdir: None,
1940 shell: None,
1941 scripts: BTreeMap::new(),
1942 entrypoint: None,
1943 cmd: None,
1944 hostname: None,
1945 user: None,
1946 log_level: None,
1947 metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1948 disable_metrics_sample: false,
1949 guest_clock: None,
1950 }
1951 }
1952}
1953
1954impl Default for HttpConfig {
1955 fn default() -> Self {
1956 Self {
1957 deny_response: true,
1958 deny_response_format: HttpDenyResponseFormat::Json,
1959 deny_message: None,
1960 network_deny_message: None,
1961 secret_deny_message: None,
1962 }
1963 }
1964}
1965
1966impl Default for NetworkSpec {
1967 fn default() -> Self {
1968 Self {
1969 enabled: true,
1970 interface: None,
1971 ports: Vec::new(),
1972 policy: None,
1973 dns: None,
1974 tls: None,
1975 strict: true,
1976 secrets: None,
1977 max_tcp_connections: None,
1978 max_udp_connections: None,
1979 tcp_accept_queue_size: None,
1980 max_inbound_tcp_connections: None,
1981 rate_limiter: None,
1982 nat64_prefixes: default_nat64_prefixes(),
1983 trust_host_cas: false,
1984 outbound_proxy: None,
1985 http: HttpConfig::default(),
1986 }
1987 }
1988}
1989
1990pub(crate) fn default_nat64_prefixes() -> Vec<Ipv6Network> {
1991 vec![
1992 WELL_KNOWN_NAT64_PREFIX
1993 .parse()
1994 .expect("well-known NAT64 prefix must be valid"),
1995 ]
1996}
1997
1998impl Default for PublishedPortSpec {
1999 fn default() -> Self {
2000 Self {
2001 host_port: 0,
2002 guest_port: 0,
2003 protocol: PortProtocol::Tcp,
2004 host_bind: "127.0.0.1".into(),
2005 }
2006 }
2007}
2008
2009impl From<(String, String)> for EnvVar {
2010 fn from((key, value): (String, String)) -> Self {
2011 Self { key, value }
2012 }
2013}
2014
2015impl From<EnvVar> for (String, String) {
2016 fn from(var: EnvVar) -> Self {
2017 (var.key, var.value)
2018 }
2019}
2020
2021impl FromStr for SandboxLogLevel {
2022 type Err = String;
2023
2024 fn from_str(s: &str) -> Result<Self, Self::Err> {
2025 match s {
2026 "error" => Ok(Self::Error),
2027 "warn" => Ok(Self::Warn),
2028 "info" => Ok(Self::Info),
2029 "debug" => Ok(Self::Debug),
2030 "trace" => Ok(Self::Trace),
2031 _ => Err(format!("unknown sandbox log level: {s}")),
2032 }
2033 }
2034}
2035
2036impl std::fmt::Display for SandboxLogLevel {
2037 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2038 formatter.write_str(self.as_str())
2039 }
2040}
2041
2042impl Serialize for VolumeMount {
2043 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2044 use serde::ser::SerializeMap;
2045
2046 match self {
2047 Self::Owned {
2048 guest,
2049 storage,
2050 options,
2051 stat_virtualization,
2052 host_permissions,
2053 } => {
2054 let mut map = serializer.serialize_map(Some(6))?;
2057 map.serialize_entry("type", "Owned")?;
2058 map.serialize_entry("guest", guest)?;
2059 map.serialize_entry("storage", storage)?;
2060 map.serialize_entry("options", options)?;
2061 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2062 map.serialize_entry("host_permissions", host_permissions)?;
2063 map.end()
2064 }
2065 Self::Bind {
2066 host,
2067 guest,
2068 options,
2069 stat_virtualization,
2070 host_permissions,
2071 follow_root_symlinks,
2072 quota_mib,
2073 } => {
2074 let mut map = serializer.serialize_map(Some(8))?;
2075 map.serialize_entry("type", "Bind")?;
2076 map.serialize_entry("host", host)?;
2077 map.serialize_entry("guest", guest)?;
2078 map.serialize_entry("options", options)?;
2079 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2080 map.serialize_entry("host_permissions", host_permissions)?;
2081 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2082 map.serialize_entry("quota_mib", quota_mib)?;
2083 map.end()
2084 }
2085 Self::Named {
2086 name,
2087 guest,
2088 create: _,
2089 options,
2090 stat_virtualization,
2091 host_permissions,
2092 follow_root_symlinks,
2093 } => {
2094 let mut map = serializer.serialize_map(Some(7))?;
2095 map.serialize_entry("type", "Named")?;
2096 map.serialize_entry("name", name)?;
2097 map.serialize_entry("guest", guest)?;
2098 map.serialize_entry("options", options)?;
2099 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2100 map.serialize_entry("host_permissions", host_permissions)?;
2101 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2102 map.end()
2103 }
2104 Self::Tmpfs {
2105 guest,
2106 size_mib,
2107 options,
2108 } => {
2109 let mut map = serializer.serialize_map(Some(4))?;
2110 map.serialize_entry("type", "Tmpfs")?;
2111 map.serialize_entry("guest", guest)?;
2112 map.serialize_entry("size_mib", size_mib)?;
2113 map.serialize_entry("options", options)?;
2114 map.end()
2115 }
2116 Self::DiskImage {
2117 host,
2118 guest,
2119 format,
2120 fstype,
2121 options,
2122 } => {
2123 let mut map = serializer.serialize_map(Some(6))?;
2124 map.serialize_entry("type", "DiskImage")?;
2125 map.serialize_entry("host", host)?;
2126 map.serialize_entry("guest", guest)?;
2127 map.serialize_entry("format", format)?;
2128 map.serialize_entry("fstype", fstype)?;
2129 map.serialize_entry("options", options)?;
2130 map.end()
2131 }
2132 }
2133 }
2134}
2135
2136impl<'de> Deserialize<'de> for VolumeMount {
2137 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2138 fn default_strict() -> StatVirtualization {
2139 StatVirtualization::Strict
2140 }
2141
2142 fn default_private() -> HostPermissions {
2143 HostPermissions::Private
2144 }
2145
2146 #[derive(Deserialize)]
2147 #[serde(tag = "type")]
2148 enum VolumeMountHelper {
2149 Owned {
2150 guest: String,
2151 storage: OwnedVolumeStorage,
2152 #[serde(default)]
2153 options: MountOptions,
2154 #[serde(default = "default_strict")]
2155 stat_virtualization: StatVirtualization,
2156 #[serde(default = "default_private")]
2157 host_permissions: HostPermissions,
2158 },
2159 Bind {
2160 host: PathBuf,
2161 guest: String,
2162 #[serde(default)]
2163 options: Option<MountOptions>,
2164 #[serde(default)]
2165 readonly: bool,
2166 #[serde(default = "default_strict")]
2167 stat_virtualization: StatVirtualization,
2168 #[serde(default = "default_private")]
2169 host_permissions: HostPermissions,
2170 #[serde(default)]
2171 follow_root_symlinks: bool,
2172 #[serde(default)]
2173 quota_mib: Option<u32>,
2174 },
2175 Named {
2176 name: String,
2177 guest: String,
2178 #[serde(default)]
2179 options: Option<MountOptions>,
2180 #[serde(default)]
2181 readonly: bool,
2182 #[serde(default = "default_strict")]
2183 stat_virtualization: StatVirtualization,
2184 #[serde(default = "default_private")]
2185 host_permissions: HostPermissions,
2186 #[serde(default)]
2187 follow_root_symlinks: bool,
2188 },
2189 Tmpfs {
2190 guest: String,
2191 #[serde(default)]
2192 size_mib: Option<u32>,
2193 #[serde(default)]
2194 options: Option<MountOptions>,
2195 #[serde(default)]
2196 readonly: bool,
2197 },
2198 DiskImage {
2199 host: PathBuf,
2200 guest: String,
2201 format: DiskImageFormat,
2202 #[serde(default)]
2203 fstype: Option<String>,
2204 #[serde(default)]
2205 options: Option<MountOptions>,
2206 #[serde(default)]
2207 readonly: bool,
2208 },
2209 }
2210
2211 let helper = VolumeMountHelper::deserialize(deserializer)?;
2212 Ok(match helper {
2213 VolumeMountHelper::Owned {
2214 guest,
2215 storage,
2216 options,
2217 stat_virtualization,
2218 host_permissions,
2219 } => Self::Owned {
2220 guest,
2221 storage,
2222 options,
2223 stat_virtualization,
2224 host_permissions,
2225 },
2226 VolumeMountHelper::Bind {
2227 host,
2228 guest,
2229 options,
2230 readonly,
2231 stat_virtualization,
2232 host_permissions,
2233 follow_root_symlinks,
2234 quota_mib,
2235 } => Self::Bind {
2236 host,
2237 guest,
2238 options: decode_mount_options(options, readonly),
2239 stat_virtualization,
2240 host_permissions,
2241 follow_root_symlinks,
2242 quota_mib,
2243 },
2244 VolumeMountHelper::Named {
2245 name,
2246 guest,
2247 options,
2248 readonly,
2249 stat_virtualization,
2250 host_permissions,
2251 follow_root_symlinks,
2252 } => Self::Named {
2253 name,
2254 guest,
2255 create: None,
2256 options: decode_mount_options(options, readonly),
2257 stat_virtualization,
2258 host_permissions,
2259 follow_root_symlinks,
2260 },
2261 VolumeMountHelper::Tmpfs {
2262 guest,
2263 size_mib,
2264 options,
2265 readonly,
2266 } => Self::Tmpfs {
2267 guest,
2268 size_mib,
2269 options: decode_mount_options(options, readonly),
2270 },
2271 VolumeMountHelper::DiskImage {
2272 host,
2273 guest,
2274 format,
2275 fstype,
2276 options,
2277 readonly,
2278 } => Self::DiskImage {
2279 host,
2280 guest,
2281 format,
2282 fstype,
2283 options: decode_mount_options(options, readonly),
2284 },
2285 })
2286 }
2287}
2288
2289impl fmt::Debug for VolumeMount {
2290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2291 match self {
2292 Self::Owned {
2293 guest,
2294 storage,
2295 options,
2296 stat_virtualization,
2297 host_permissions,
2298 } => f
2299 .debug_struct("Owned")
2300 .field("guest", guest)
2301 .field("storage", storage)
2302 .field("options", options)
2303 .field("stat_virtualization", stat_virtualization)
2304 .field("host_permissions", host_permissions)
2305 .finish(),
2306 Self::Bind {
2307 host,
2308 guest,
2309 options,
2310 stat_virtualization,
2311 host_permissions,
2312 follow_root_symlinks,
2313 quota_mib,
2314 } => f
2315 .debug_struct("Bind")
2316 .field("host", host)
2317 .field("guest", guest)
2318 .field("options", options)
2319 .field("stat_virtualization", stat_virtualization)
2320 .field("host_permissions", host_permissions)
2321 .field("follow_root_symlinks", follow_root_symlinks)
2322 .field("quota_mib", quota_mib)
2323 .finish(),
2324 Self::Named {
2325 name,
2326 guest,
2327 create,
2328 options,
2329 stat_virtualization,
2330 host_permissions,
2331 follow_root_symlinks,
2332 } => f
2333 .debug_struct("Named")
2334 .field("name", name)
2335 .field("guest", guest)
2336 .field("create", create)
2337 .field("options", options)
2338 .field("stat_virtualization", stat_virtualization)
2339 .field("host_permissions", host_permissions)
2340 .field("follow_root_symlinks", follow_root_symlinks)
2341 .finish(),
2342 Self::Tmpfs {
2343 guest,
2344 size_mib,
2345 options,
2346 } => f
2347 .debug_struct("Tmpfs")
2348 .field("guest", guest)
2349 .field("size_mib", size_mib)
2350 .field("options", options)
2351 .finish(),
2352 Self::DiskImage {
2353 host,
2354 guest,
2355 format,
2356 fstype,
2357 options,
2358 } => f
2359 .debug_struct("DiskImage")
2360 .field("host", host)
2361 .field("guest", guest)
2362 .field("format", format)
2363 .field("fstype", fstype)
2364 .field("options", options)
2365 .finish(),
2366 }
2367 }
2368}
2369
2370impl TryFrom<&str> for RlimitResource {
2372 type Error = String;
2373
2374 fn try_from(s: &str) -> Result<Self, Self::Error> {
2375 match s.to_ascii_lowercase().as_str() {
2376 "cpu" => Ok(Self::Cpu),
2377 "fsize" => Ok(Self::Fsize),
2378 "data" => Ok(Self::Data),
2379 "stack" => Ok(Self::Stack),
2380 "core" => Ok(Self::Core),
2381 "rss" => Ok(Self::Rss),
2382 "nproc" => Ok(Self::Nproc),
2383 "nofile" => Ok(Self::Nofile),
2384 "memlock" => Ok(Self::Memlock),
2385 "as" => Ok(Self::As),
2386 "locks" => Ok(Self::Locks),
2387 "sigpending" => Ok(Self::Sigpending),
2388 "msgqueue" => Ok(Self::Msgqueue),
2389 "nice" => Ok(Self::Nice),
2390 "rtprio" => Ok(Self::Rtprio),
2391 "rttime" => Ok(Self::Rttime),
2392 _ => Err(format!("unknown rlimit resource: {s}")),
2393 }
2394 }
2395}
2396
2397fn default_sandbox_cpus() -> u8 {
2402 DEFAULT_SANDBOX_CPUS
2403}
2404
2405fn default_sandbox_memory_mib() -> u32 {
2406 DEFAULT_SANDBOX_MEMORY_MIB
2407}
2408
2409fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
2410 options.unwrap_or(MountOptions {
2411 readonly,
2412 ..MountOptions::default()
2413 })
2414}
2415
2416fn merge_env_vars(base: &mut Vec<EnvVar>, higher: Vec<EnvVar>) {
2417 for value in higher {
2418 match base.iter_mut().find(|current| current.key == value.key) {
2419 Some(current) => *current = value,
2420 None => base.push(value),
2421 }
2422 }
2423}
2424
2425fn merge_secret_entries(base: &mut Vec<SecretEntry>, higher: Vec<SecretEntry>) {
2426 for value in higher {
2427 match base
2428 .iter_mut()
2429 .find(|current| current.env_var == value.env_var)
2430 {
2431 Some(current) => *current = value,
2432 None => base.push(value),
2433 }
2434 }
2435}
2436
2437pub(crate) fn default_strict() -> StatVirtualization {
2439 StatVirtualization::Strict
2440}
2441
2442pub(crate) fn default_private() -> HostPermissions {
2444 HostPermissions::Private
2445}
2446
2447pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
2449
2450#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigPatch)]
2461#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2462#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2463pub struct SecretsConfig {
2464 #[doc(hidden)]
2467 #[serde(default, skip_serializing_if = "Option::is_none")]
2468 #[cfg_attr(feature = "ts", ts(skip))]
2469 #[cfg_attr(feature = "utoipa", schema(ignore))]
2470 pub passthrough_hosts: Option<Vec<HostPattern>>,
2471
2472 #[serde(default)]
2474 #[config_patch(merge_with = merge_secret_entries)]
2475 pub secrets: Vec<SecretEntry>,
2476
2477 #[serde(default)]
2479 pub violation_action: SecretViolationAction,
2480}
2481
2482#[derive(Clone, Serialize, Deserialize)]
2487#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2488#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2489pub struct SecretEntry {
2490 pub env_var: String,
2496
2497 #[serde(default = "empty_secret_value")]
2506 #[cfg_attr(feature = "ts", ts(type = "string"))]
2507 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2508 pub value: Zeroizing<String>,
2509
2510 #[serde(default, skip_serializing_if = "Option::is_none")]
2514 pub source: Option<SecretSource>,
2515
2516 pub placeholder: String,
2521
2522 #[serde(default)]
2524 pub allowed_hosts: Vec<HostPattern>,
2525
2526 #[serde(default)]
2528 pub substitution: SecretSubstitution,
2529
2530 #[serde(default)]
2532 pub passthrough_hosts: Vec<HostPattern>,
2533
2534 #[serde(default, skip_serializing_if = "Option::is_none")]
2536 pub violation_action: Option<SecretViolationAction>,
2537
2538 #[serde(default = "default_true")]
2543 pub require_tls_identity: bool,
2544}
2545
2546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2548#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2549#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2550#[serde(rename_all = "kebab-case")]
2551pub enum HostPattern {
2552 #[serde(alias = "Exact")]
2554 Exact(String),
2555 #[serde(alias = "Wildcard")]
2557 Wildcard(String),
2558 #[serde(alias = "Any")]
2560 Any,
2561}
2562
2563#[derive(Debug, Clone, Serialize, Deserialize)]
2565#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2566#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2567pub struct SecretSubstitution {
2568 #[serde(default = "default_true")]
2570 pub headers: bool,
2571
2572 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2591 pub header_fields: Vec<String>,
2592
2593 #[serde(default)]
2595 pub query: bool,
2596
2597 #[serde(default)]
2605 pub body: bool,
2606}
2607
2608#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2610#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2611#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2612#[serde(rename_all = "kebab-case")]
2613pub enum SecretViolationAction {
2614 #[serde(alias = "Block")]
2616 Block,
2617 #[default]
2619 #[serde(alias = "BlockAndLog", alias = "block_and_log")]
2620 BlockAndLog,
2621 #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
2623 BlockAndTerminate,
2624}
2625
2626#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2628pub enum SecretConfigError {
2629 #[error("secret #{secret_index}: env_var must not be empty")]
2631 EmptyEnvVar {
2632 secret_index: usize,
2634 },
2635
2636 #[error("secret #{secret_index}: env_var must not contain `=`")]
2638 EnvVarContainsEquals {
2639 secret_index: usize,
2641 },
2642
2643 #[error("secret #{secret_index}: env_var must not contain NUL")]
2645 EnvVarContainsNul {
2646 secret_index: usize,
2648 },
2649
2650 #[error("secret #{secret_index}: at least one allowed host is required")]
2652 MissingAllowedHosts {
2653 secret_index: usize,
2655 },
2656
2657 #[error("secret #{secret_index}: at least one substitution location is required")]
2659 MissingSubstitutionLocation {
2660 secret_index: usize,
2662 },
2663
2664 #[error(
2666 "secret #{secret_index}: header field substitutions require header substitution to be enabled"
2667 )]
2668 HeaderFieldsRequireHeaders {
2669 secret_index: usize,
2671 },
2672
2673 #[error("secret #{secret_index}: invalid header field name {field:?}")]
2675 InvalidHeaderFieldName {
2676 secret_index: usize,
2678 field: String,
2680 },
2681
2682 #[error("secret #{secret_index}: placeholder must not be empty")]
2684 EmptyPlaceholder {
2685 secret_index: usize,
2687 },
2688
2689 #[error(
2691 "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
2692 )]
2693 PlaceholderTooLong {
2694 secret_index: usize,
2696 actual_bytes: usize,
2698 max_bytes: usize,
2700 },
2701
2702 #[error("secret #{secret_index}: placeholder must not contain NUL")]
2704 PlaceholderContainsNul {
2705 secret_index: usize,
2707 },
2708
2709 #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
2711 PlaceholderContainsLineBreak {
2712 secret_index: usize,
2714 },
2715}
2716
2717impl SecretsConfig {
2718 pub fn has_tls_identity_secrets(&self) -> bool {
2720 self.secrets
2721 .iter()
2722 .any(|secret| secret.require_tls_identity)
2723 }
2724
2725 pub fn contains_env_var(&self, env_var: &str) -> bool {
2727 self.secrets.iter().any(|secret| secret.env_var == env_var)
2728 }
2729
2730 pub fn validate(&self) -> Result<(), SecretConfigError> {
2732 for (index, secret) in self.secrets.iter().enumerate() {
2733 secret.validate(index)?;
2734 }
2735 Ok(())
2736 }
2737}
2738
2739impl SecretEntry {
2740 pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
2742 validate_env_var(&self.env_var, secret_index)?;
2743
2744 if self.allowed_hosts.is_empty() {
2745 return Err(SecretConfigError::MissingAllowedHosts { secret_index });
2746 }
2747
2748 if !self.substitution.headers && !self.substitution.query && !self.substitution.body {
2749 return Err(SecretConfigError::MissingSubstitutionLocation { secret_index });
2750 }
2751
2752 if !self.substitution.headers && !self.substitution.header_fields.is_empty() {
2753 return Err(SecretConfigError::HeaderFieldsRequireHeaders { secret_index });
2754 }
2755
2756 for field in &self.substitution.header_fields {
2757 validate_header_field_name(field, secret_index)?;
2758 }
2759
2760 validate_placeholder(&self.placeholder, secret_index)
2761 }
2762}
2763
2764impl fmt::Debug for SecretEntry {
2766 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2767 f.debug_struct("SecretEntry")
2768 .field("env_var", &self.env_var)
2769 .field("value", &"[REDACTED]")
2770 .field("source", &self.source)
2771 .field("placeholder", &self.placeholder)
2772 .field("allowed_hosts", &self.allowed_hosts)
2773 .field("substitution", &self.substitution)
2774 .field("passthrough_hosts", &self.passthrough_hosts)
2775 .field("violation_action", &self.violation_action)
2776 .field("require_tls_identity", &self.require_tls_identity)
2777 .finish()
2778 }
2779}
2780
2781impl HostPattern {
2782 pub fn parse(host: &str) -> Self {
2785 if host == "*" {
2786 HostPattern::Any
2787 } else if host.starts_with("*.") {
2788 HostPattern::Wildcard(host.to_string())
2789 } else {
2790 HostPattern::Exact(host.to_string())
2791 }
2792 }
2793
2794 pub fn matches(&self, hostname: &str) -> bool {
2799 match self {
2800 HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
2801 HostPattern::Wildcard(pattern) => {
2802 if let Some(suffix) = pattern.strip_prefix("*.") {
2803 hostname.eq_ignore_ascii_case(suffix)
2804 || (hostname.len() > suffix.len() + 1
2805 && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
2806 && hostname[hostname.len() - suffix.len()..]
2807 .eq_ignore_ascii_case(suffix))
2808 } else {
2809 hostname.eq_ignore_ascii_case(pattern)
2810 }
2811 }
2812 HostPattern::Any => true,
2813 }
2814 }
2815}
2816
2817impl Default for SecretSubstitution {
2818 fn default() -> Self {
2819 Self {
2820 headers: true,
2821 header_fields: Vec::new(),
2822 query: false,
2823 body: false,
2824 }
2825 }
2826}
2827
2828fn default_true() -> bool {
2829 true
2830}
2831
2832fn validate_header_field_name(field: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2834 if field.is_empty() || !field.bytes().all(is_http_tchar) {
2835 return Err(SecretConfigError::InvalidHeaderFieldName {
2836 secret_index,
2837 field: field.to_string(),
2838 });
2839 }
2840 Ok(())
2841}
2842
2843fn is_http_tchar(byte: u8) -> bool {
2845 byte.is_ascii_alphanumeric()
2846 || matches!(
2847 byte,
2848 b'!' | b'#'
2849 | b'$'
2850 | b'%'
2851 | b'&'
2852 | b'\''
2853 | b'*'
2854 | b'+'
2855 | b'-'
2856 | b'.'
2857 | b'^'
2858 | b'_'
2859 | b'`'
2860 | b'|'
2861 | b'~'
2862 )
2863}
2864
2865fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2866 if env_var.is_empty() {
2867 return Err(SecretConfigError::EmptyEnvVar { secret_index });
2868 }
2869 if env_var.contains('=') {
2870 return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
2871 }
2872 if env_var.contains('\0') {
2873 return Err(SecretConfigError::EnvVarContainsNul { secret_index });
2874 }
2875 Ok(())
2876}
2877
2878fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2879 if placeholder.is_empty() {
2880 return Err(SecretConfigError::EmptyPlaceholder { secret_index });
2881 }
2882
2883 let actual_bytes = placeholder.len();
2884 if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
2885 return Err(SecretConfigError::PlaceholderTooLong {
2886 secret_index,
2887 actual_bytes,
2888 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2889 });
2890 }
2891
2892 if placeholder.contains('\0') {
2893 return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
2894 }
2895 if placeholder.contains('\r') || placeholder.contains('\n') {
2896 return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
2897 }
2898
2899 Ok(())
2900}
2901
2902#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
2912#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2913#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2914pub struct TlsConfig {
2915 #[serde(default)]
2917 pub enabled: bool,
2918
2919 #[serde(default = "default_intercepted_ports")]
2921 pub intercepted_ports: Vec<u16>,
2922
2923 #[serde(default)]
2925 pub bypass: Vec<String>,
2926
2927 #[serde(default = "default_true")]
2929 pub verify_upstream: bool,
2930
2931 #[serde(default = "default_true")]
2934 pub block_quic_on_intercept: bool,
2935
2936 #[serde(default)]
2938 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
2939 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
2940 pub upstream_ca_cert: Vec<PathBuf>,
2941
2942 #[serde(default, alias = "scoped_upstream_ca_certs")]
2944 pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
2945
2946 #[serde(default)]
2948 pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
2949
2950 #[serde(default, alias = "ca")]
2953 pub intercept_ca: InterceptCaConfig,
2954
2955 #[serde(default)]
2957 pub cache: CertCacheConfig,
2958}
2959
2960#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2962#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2963#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2964pub struct InterceptCaConfig {
2965 #[serde(default)]
2968 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2969 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2970 pub cert_path: Option<PathBuf>,
2971
2972 #[serde(default)]
2975 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2976 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2977 pub key_path: Option<PathBuf>,
2978}
2979
2980#[derive(Debug, Clone, Serialize, Deserialize)]
2982#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2983#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2984pub struct CertCacheConfig {
2985 #[serde(default = "default_cache_capacity")]
2987 pub capacity: usize,
2988
2989 #[serde(default = "default_cert_validity_hours")]
2991 pub validity_hours: u64,
2992}
2993
2994#[derive(Debug, Clone, Serialize, Deserialize)]
2996#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2997#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2998pub struct ScopedUpstreamCaCert {
2999 pub pattern: String,
3001
3002 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
3004 #[cfg_attr(feature = "ts", ts(type = "string"))]
3005 pub path: PathBuf,
3006}
3007
3008#[derive(Debug, Clone, Serialize, Deserialize)]
3010#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3011#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3012pub struct ScopedVerifyUpstream {
3013 pub pattern: String,
3015
3016 pub verify: bool,
3018}
3019
3020impl Default for TlsConfig {
3021 fn default() -> Self {
3022 Self {
3023 enabled: false,
3024 intercepted_ports: default_intercepted_ports(),
3025 bypass: Vec::new(),
3026 verify_upstream: true,
3027 block_quic_on_intercept: true,
3028 upstream_ca_cert: Vec::new(),
3029 scoped_upstream_ca_cert: Vec::new(),
3030 scoped_verify_upstream: Vec::new(),
3031 intercept_ca: InterceptCaConfig::default(),
3032 cache: CertCacheConfig::default(),
3033 }
3034 }
3035}
3036
3037impl Default for CertCacheConfig {
3038 fn default() -> Self {
3039 Self {
3040 capacity: default_cache_capacity(),
3041 validity_hours: default_cert_validity_hours(),
3042 }
3043 }
3044}
3045
3046fn default_intercepted_ports() -> Vec<u16> {
3047 vec![443]
3048}
3049
3050fn default_cache_capacity() -> usize {
3051 1000
3052}
3053
3054fn default_cert_validity_hours() -> u64 {
3055 24
3056}
3057
3058#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3064#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3065#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3066#[serde(rename_all = "snake_case")]
3067pub enum Action {
3068 Allow,
3070 Deny,
3072}
3073
3074#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3076#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3077#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3078#[serde(rename_all = "snake_case")]
3079pub enum Direction {
3080 Egress,
3082 Ingress,
3084 Any,
3086}
3087
3088#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3090#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3091#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3092#[serde(rename_all = "snake_case")]
3093pub enum Protocol {
3094 Tcp,
3096 Udp,
3098 Icmpv4,
3100 Icmpv6,
3102}
3103
3104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3106#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3107#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3108#[serde(rename_all = "snake_case")]
3109pub enum DestinationGroup {
3110 Public,
3112 Loopback,
3114 Private,
3116 LinkLocal,
3118 Metadata,
3120 Multicast,
3122 Host,
3124}
3125
3126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3133#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3134#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3135#[serde(rename_all = "snake_case")]
3136pub enum Destination {
3137 Any,
3139 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
3141 Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
3142 Domain(String),
3144 DomainSuffix(String),
3146 Group(DestinationGroup),
3148}
3149
3150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3152#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3153#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3154pub struct PortRange {
3155 pub start: u16,
3157 pub end: u16,
3159}
3160
3161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3164#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3165#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3166pub struct Rule {
3167 pub direction: Direction,
3169 pub destination: Destination,
3171 #[serde(default)]
3173 pub protocols: Vec<Protocol>,
3174 #[serde(default)]
3176 pub ports: Vec<PortRange>,
3177 pub action: Action,
3179}
3180
3181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3184#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3185#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3186pub struct NetworkPolicy {
3187 #[serde(default = "action_deny")]
3189 pub default_egress: Action,
3190 #[serde(default = "action_deny")]
3192 pub default_ingress: Action,
3193 #[serde(default)]
3195 pub rules: Vec<Rule>,
3196}
3197
3198fn action_deny() -> Action {
3201 Action::Deny
3202}
3203
3204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3210#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3211#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3212#[serde(default)]
3213pub struct DnsConfig {
3214 pub rebind_protection: bool,
3216 pub nameservers: Vec<String>,
3219 pub query_timeout_ms: u64,
3221}
3222
3223impl Default for DnsConfig {
3224 fn default() -> Self {
3225 Self {
3226 rebind_protection: true,
3227 nameservers: Vec::new(),
3228 query_timeout_ms: 5000,
3229 }
3230 }
3231}
3232
3233#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3237#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3238#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3239#[serde(default)]
3240pub struct InterfaceOverrides {
3241 #[serde(skip_serializing_if = "Option::is_none")]
3243 pub mac: Option<[u8; 6]>,
3244 #[serde(skip_serializing_if = "Option::is_none")]
3246 pub mtu: Option<u16>,
3247 #[serde(skip_serializing_if = "Option::is_none")]
3249 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3250 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3251 pub ipv4_address: Option<Ipv4Addr>,
3252 #[serde(skip_serializing_if = "Option::is_none")]
3254 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3255 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3256 pub ipv4_pool: Option<Ipv4Network>,
3257 #[serde(skip_serializing_if = "Option::is_none")]
3259 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3260 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3261 pub ipv6_address: Option<Ipv6Addr>,
3262 #[serde(skip_serializing_if = "Option::is_none")]
3264 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3265 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3266 pub ipv6_pool: Option<Ipv6Network>,
3267}
3268
3269fn empty_secret_value() -> Zeroizing<String> {
3270 Zeroizing::new(String::new())
3271}
3272
3273#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3279pub enum NetworkRateLimitDirection {
3280 Egress,
3282 Ingress,
3284}
3285
3286#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3288#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3289#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3290#[serde(default)]
3291pub struct NetworkRateLimiterConfig {
3292 #[serde(skip_serializing_if = "Option::is_none")]
3294 pub egress: Option<RateLimiterConfig>,
3295
3296 #[serde(skip_serializing_if = "Option::is_none")]
3298 pub ingress: Option<RateLimiterConfig>,
3299}
3300
3301#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
3307#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3308#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3309#[serde(default)]
3310pub struct RateLimiterConfig {
3311 #[serde(skip_serializing_if = "Option::is_none")]
3313 pub bandwidth: Option<TokenBucketConfig>,
3314
3315 #[serde(skip_serializing_if = "Option::is_none")]
3317 pub ops: Option<TokenBucketConfig>,
3318}
3319
3320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3326#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3327#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3328pub struct TokenBucketConfig {
3329 pub size: u64,
3331
3332 pub refill_time_ms: u64,
3335
3336 #[serde(default)]
3338 pub one_time_burst: u64,
3339}
3340
3341#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3343pub enum RateLimitConfigError {
3344 #[error("rate limiter must configure at least one of bandwidth or ops")]
3346 EmptyLimiter,
3347
3348 #[error("{bucket} bucket: size must be greater than zero")]
3350 ZeroSize {
3351 bucket: &'static str,
3353 },
3354
3355 #[error("{bucket} bucket: refill_time_ms must be greater than zero")]
3357 ZeroRefillTime {
3358 bucket: &'static str,
3360 },
3361}
3362
3363impl RateLimiterConfig {
3364 pub fn validate(&self) -> Result<(), RateLimitConfigError> {
3366 if self.bandwidth.is_none() && self.ops.is_none() {
3367 return Err(RateLimitConfigError::EmptyLimiter);
3368 }
3369 if let Some(bandwidth) = &self.bandwidth {
3370 bandwidth.validate("bandwidth")?;
3371 }
3372 if let Some(ops) = &self.ops {
3373 ops.validate("ops")?;
3374 }
3375 Ok(())
3376 }
3377}
3378
3379impl TokenBucketConfig {
3380 pub fn validate(&self, bucket: &'static str) -> Result<(), RateLimitConfigError> {
3382 if self.size == 0 {
3383 return Err(RateLimitConfigError::ZeroSize { bucket });
3384 }
3385 if self.refill_time_ms == 0 {
3386 return Err(RateLimitConfigError::ZeroRefillTime { bucket });
3387 }
3388 Ok(())
3389 }
3390}
3391
3392impl fmt::Display for NetworkRateLimitDirection {
3393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3394 match self {
3395 Self::Egress => f.write_str("egress"),
3396 Self::Ingress => f.write_str("ingress"),
3397 }
3398 }
3399}
3400
3401#[cfg(test)]
3406mod tests {
3407 use super::*;
3408
3409 fn secret_entry(env_var: &str, require_tls_identity: bool) -> SecretEntry {
3410 SecretEntry {
3411 env_var: env_var.to_owned(),
3412 value: Zeroizing::new("secret".to_owned()),
3413 source: None,
3414 placeholder: format!("$MSB_{env_var}"),
3415 allowed_hosts: vec![HostPattern::Any],
3416 substitution: SecretSubstitution::default(),
3417 passthrough_hosts: Vec::new(),
3418 violation_action: None,
3419 require_tls_identity,
3420 }
3421 }
3422
3423 fn tmpfs_mount(guest: &str) -> VolumeMount {
3424 VolumeMount::Tmpfs {
3425 guest: guest.to_owned(),
3426 size_mib: None,
3427 options: MountOptions::default(),
3428 }
3429 }
3430
3431 #[test]
3432 fn mount_options_omit_unset_owner_but_accept_missing_fields() {
3433 let value = serde_json::to_value(MountOptions::default()).unwrap();
3434 assert!(value.get("override_uid").is_none());
3435 assert!(value.get("override_gid").is_none());
3436
3437 let decoded: MountOptions = serde_json::from_value(value).unwrap();
3438 assert_eq!(decoded.override_uid, None);
3439 assert_eq!(decoded.override_gid, None);
3440 }
3441
3442 #[test]
3443 fn volume_mounts_are_canonicalized_and_ordered_parent_first() {
3444 let mut mounts = vec![
3445 tmpfs_mount("/workspace//persist/./logs/"),
3446 tmpfs_mount("/alpha/z"),
3447 tmpfs_mount("/workspace"),
3448 ];
3449
3450 canonicalize_volume_mounts(&mut mounts).unwrap();
3451
3452 assert_eq!(
3453 mounts.iter().map(VolumeMount::guest).collect::<Vec<_>>(),
3454 vec!["/workspace", "/alpha/z", "/workspace/persist/logs"]
3455 );
3456 }
3457
3458 #[test]
3459 fn secrets_config_queries_entries() {
3460 let mut config = SecretsConfig {
3461 secrets: vec![secret_entry("HTTP_TOKEN", false)],
3462 ..Default::default()
3463 };
3464
3465 assert!(!config.has_tls_identity_secrets());
3466 assert!(config.contains_env_var("HTTP_TOKEN"));
3467 assert!(!config.contains_env_var("MISSING"));
3468
3469 config.secrets.push(secret_entry("API_KEY", true));
3470 assert!(config.has_tls_identity_secrets());
3471 }
3472
3473 #[test]
3474 fn volume_mounts_reject_duplicate_canonical_paths() {
3475 let mut mounts = vec![tmpfs_mount("/data/cache"), tmpfs_mount("/data//./cache/")];
3476
3477 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3478
3479 assert!(error.to_string().contains("same guest path: /data/cache"));
3480 }
3481
3482 #[test]
3483 fn volume_mounts_reject_parent_components_before_normalizing() {
3484 let mut mounts = vec![tmpfs_mount("/workspace/../secrets")];
3485
3486 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3487
3488 assert!(error.to_string().contains("must not contain '..'"));
3489 }
3490
3491 #[test]
3492 fn disk_image_format_from_extension() {
3493 assert_eq!(
3494 DiskImageFormat::from_extension("qcow2"),
3495 Some(DiskImageFormat::Qcow2)
3496 );
3497 assert_eq!(
3498 DiskImageFormat::from_extension("raw"),
3499 Some(DiskImageFormat::Raw)
3500 );
3501 assert_eq!(
3502 DiskImageFormat::from_extension("vmdk"),
3503 Some(DiskImageFormat::Vmdk)
3504 );
3505 assert_eq!(DiskImageFormat::from_extension("ext4"), None);
3506 assert_eq!(DiskImageFormat::from_extension(""), None);
3507 }
3508
3509 fn secret_with_header_fields(fields: Vec<&str>) -> SecretEntry {
3510 SecretEntry {
3511 env_var: "API_KEY".into(),
3512 value: Zeroizing::new("secret".into()),
3513 source: None,
3514 placeholder: "$MSB_API_KEY".into(),
3515 allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
3516 substitution: SecretSubstitution {
3517 headers: true,
3518 header_fields: fields.into_iter().map(ToString::to_string).collect(),
3519 query: false,
3520 body: false,
3521 },
3522 passthrough_hosts: Vec::new(),
3523 violation_action: None,
3524 require_tls_identity: true,
3525 }
3526 }
3527
3528 #[test]
3529 fn secret_substitution_header_fields_round_trip_and_default() {
3530 let entry = secret_with_header_fields(vec!["Authorization", "X-Api-Key"]);
3531 entry.validate(0).expect("valid header fields");
3532 let json = serde_json::to_value(&entry).unwrap();
3533 assert_eq!(
3534 json["substitution"]["header_fields"],
3535 serde_json::json!(["Authorization", "X-Api-Key"])
3536 );
3537
3538 let omitted: SecretSubstitution =
3539 serde_json::from_value(serde_json::json!({ "headers": true })).unwrap();
3540 assert!(omitted.header_fields.is_empty());
3541
3542 let default = SecretSubstitution::default();
3544 assert!(
3545 serde_json::to_value(&default)
3546 .unwrap()
3547 .get("header_fields")
3548 .is_none()
3549 );
3550 }
3551
3552 #[test]
3553 fn secret_validation_rejects_invalid_header_fields() {
3554 for field in ["", "bad header", "bad:name", "bad\0name"] {
3555 let entry = secret_with_header_fields(vec![field]);
3556 let error = entry.validate(0).unwrap_err();
3557 assert!(
3558 matches!(error, SecretConfigError::InvalidHeaderFieldName { .. }),
3559 "{field:?} should be rejected, got {error}"
3560 );
3561 }
3562 }
3563
3564 #[test]
3565 fn secret_validation_rejects_header_fields_when_headers_disabled() {
3566 let mut entry = secret_with_header_fields(vec!["authorization"]);
3569 entry.substitution.headers = false;
3570 entry.substitution.query = true;
3571 let error = entry.validate(0).unwrap_err();
3572 assert!(
3573 matches!(error, SecretConfigError::HeaderFieldsRequireHeaders { .. }),
3574 "{error}"
3575 );
3576
3577 entry.substitution.header_fields.clear();
3579 entry
3580 .validate(0)
3581 .expect("disabled headers without fields is valid");
3582 }
3583
3584 #[test]
3585 fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
3586 let resources: SandboxResources =
3587 serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
3588
3589 assert_eq!(resources.cpus, 4);
3590 assert_eq!(resources.max_cpus, 4);
3591 assert_eq!(resources.memory_mib, 2048);
3592 assert_eq!(resources.max_memory_mib, 2048);
3593 assert_eq!(resources.cpu_placement, CpuPlacement::Inherit);
3594 assert_eq!(resources.thp, TransparentHugePagePolicy::Madvise);
3595 assert_eq!(
3596 serde_json::to_value(resources).unwrap(),
3597 serde_json::json!({
3598 "cpus": 4,
3599 "memory_mib": 2048,
3600 "max_cpus": 4,
3601 "max_memory_mib": 2048
3602 })
3603 );
3604 }
3605
3606 #[test]
3607 fn cpu_placement_omits_inherit_and_roundtrips_managed_policies() {
3608 let inherited = serde_json::to_value(SandboxResources::default()).unwrap();
3609 assert!(inherited.get("cpu_placement").is_none());
3610
3611 for policy in [
3612 CpuPlacement::Auto,
3613 CpuPlacement::Spread,
3614 CpuPlacement::Compact,
3615 ] {
3616 let resources = SandboxResources {
3617 cpu_placement: policy,
3618 ..Default::default()
3619 };
3620 let json = serde_json::to_string(&resources).unwrap();
3621 let decoded: SandboxResources = serde_json::from_str(&json).unwrap();
3622
3623 assert_eq!(decoded.cpu_placement, policy);
3624 assert_eq!(policy.to_string().parse::<CpuPlacement>().unwrap(), policy);
3625 }
3626 }
3627
3628 #[test]
3629 fn guest_clock_policy_is_omitted_until_set_and_roundtrips() {
3630 let defaults = serde_json::to_value(SandboxRuntimeOptions::default()).unwrap();
3631 assert!(defaults.get("guest_clock").is_none());
3632
3633 let legacy: SandboxRuntimeOptions = serde_json::from_str(r#"{"workdir":"/app"}"#).unwrap();
3634 assert_eq!(legacy.guest_clock, None);
3635
3636 for policy in [GuestClockPolicy::Sync, GuestClockPolicy::Off] {
3637 let runtime = SandboxRuntimeOptions {
3638 guest_clock: Some(policy),
3639 ..Default::default()
3640 };
3641 let json = serde_json::to_value(&runtime).unwrap();
3642 assert_eq!(json["guest_clock"], serde_json::json!(policy.as_str()));
3643 let decoded: SandboxRuntimeOptions = serde_json::from_value(json).unwrap();
3644 assert_eq!(decoded.guest_clock, Some(policy));
3645 assert_eq!(
3646 policy.to_string().parse::<GuestClockPolicy>().unwrap(),
3647 policy
3648 );
3649 }
3650
3651 assert_eq!(GuestClockPolicy::default(), GuestClockPolicy::Sync);
3652 assert!("host_sync".parse::<GuestClockPolicy>().is_err());
3653 }
3654
3655 #[test]
3656 fn transparent_huge_page_policy_roundtrips_non_default() {
3657 let resources: SandboxResources = serde_json::from_str(
3658 r#"{"cpus":2,"memory_mib":8192,"max_cpus":2,"max_memory_mib":8192,"thp":"always"}"#,
3659 )
3660 .unwrap();
3661
3662 assert_eq!(resources.thp, TransparentHugePagePolicy::Always);
3663 assert_eq!(
3664 serde_json::to_value(resources).unwrap()["thp"],
3665 serde_json::json!("always")
3666 );
3667 assert_eq!(
3668 "never".parse::<TransparentHugePagePolicy>().unwrap(),
3669 TransparentHugePagePolicy::Never
3670 );
3671 assert!("auto".parse::<TransparentHugePagePolicy>().is_err());
3672 }
3673
3674 #[test]
3675 fn disk_image_format_display_roundtrip() {
3676 for format in [
3677 DiskImageFormat::Qcow2,
3678 DiskImageFormat::Raw,
3679 DiskImageFormat::Vmdk,
3680 ] {
3681 let rendered = format.to_string();
3682 let parsed: DiskImageFormat = rendered.parse().unwrap();
3683 assert_eq!(parsed, format);
3684 }
3685 }
3686
3687 #[test]
3688 fn disk_image_format_from_str_unknown() {
3689 assert!("ext4".parse::<DiskImageFormat>().is_err());
3690 }
3691
3692 #[test]
3693 fn log_source_effective_uses_default_user_program_sources() {
3694 assert_eq!(
3695 LogSource::effective(&[]),
3696 vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
3697 );
3698 }
3699
3700 #[test]
3701 fn log_source_effective_sorts_and_deduplicates_requested_sources() {
3702 assert_eq!(
3703 LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
3704 vec![LogSource::Stdout, LogSource::System]
3705 );
3706 }
3707
3708 #[test]
3709 fn rlimit_resource_parses_case_insensitively() {
3710 assert_eq!(
3711 RlimitResource::try_from("NOFILE").unwrap(),
3712 RlimitResource::Nofile
3713 );
3714 assert!(RlimitResource::try_from("bogus").is_err());
3715 }
3716
3717 #[test]
3718 fn sandbox_policy_serde_roundtrip() {
3719 let policy = SandboxPolicy {
3720 ephemeral: true,
3721 max_duration_secs: Some(3600),
3722 idle_timeout_secs: Some(120),
3723 };
3724
3725 let json = serde_json::to_string(&policy).unwrap();
3726 let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
3727
3728 assert!(decoded.ephemeral);
3729 assert_eq!(decoded.max_duration_secs, Some(3600));
3730 assert_eq!(decoded.idle_timeout_secs, Some(120));
3731 }
3732
3733 #[test]
3734 fn sandbox_policy_defaults_to_persistent() {
3735 assert!(!SandboxPolicy::default().ephemeral);
3736 }
3737
3738 #[test]
3739 fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
3740 let decoded: SandboxPolicy =
3743 serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
3744 assert!(!decoded.ephemeral);
3745 assert_eq!(decoded.max_duration_secs, Some(60));
3746 }
3747
3748 #[test]
3749 fn sandbox_spec_default_uses_static_resource_defaults() {
3750 let spec = SandboxSpec::default();
3751
3752 assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
3753 assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
3754 assert_eq!(
3755 spec.runtime.metrics_sample_interval_ms,
3756 Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
3757 );
3758 assert_eq!(spec.deployment_profile, DeploymentProfile::SingleTenant);
3759 }
3760
3761 #[test]
3762 fn deployment_profile_uses_stable_snake_case_wire_values() {
3763 assert_eq!(
3764 serde_json::to_string(&DeploymentProfile::MultiTenant).unwrap(),
3765 r#""multi_tenant""#
3766 );
3767 assert_eq!(
3768 serde_json::from_str::<DeploymentProfile>(r#""single_tenant""#).unwrap(),
3769 DeploymentProfile::SingleTenant
3770 );
3771 }
3772
3773 #[test]
3774 fn sandbox_log_level_roundtrips_lowercase_values() {
3775 for (input, expected) in [
3776 ("error", SandboxLogLevel::Error),
3777 ("warn", SandboxLogLevel::Warn),
3778 ("info", SandboxLogLevel::Info),
3779 ("debug", SandboxLogLevel::Debug),
3780 ("trace", SandboxLogLevel::Trace),
3781 ] {
3782 let parsed: SandboxLogLevel = input.parse().unwrap();
3783 assert_eq!(parsed, expected);
3784 assert_eq!(parsed.as_str(), input);
3785 }
3786 }
3787}