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#[config_patch(serde_lossless)]
596#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
597#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
598#[serde(default)]
599pub struct HttpConfig {
600 pub deny_response: bool,
602
603 pub deny_response_format: HttpDenyResponseFormat,
605
606 #[serde(skip_serializing_if = "Option::is_none")]
609 pub deny_message: Option<String>,
610
611 #[serde(skip_serializing_if = "Option::is_none")]
615 pub network_deny_message: Option<String>,
616
617 #[serde(skip_serializing_if = "Option::is_none")]
620 pub secret_deny_message: Option<String>,
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
627#[config_patch(serde_lossless)]
628#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
629#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
630#[serde(default)]
631pub struct NetworkSpec {
632 pub enabled: bool,
634
635 #[serde(skip_serializing_if = "Option::is_none")]
637 #[config_patch(nested)]
638 pub interface: Option<InterfaceOverrides>,
639
640 pub ports: Vec<PublishedPortSpec>,
642
643 #[serde(skip_serializing_if = "Option::is_none")]
645 pub policy: Option<NetworkPolicy>,
646
647 #[serde(skip_serializing_if = "Option::is_none")]
649 #[config_patch(nested)]
650 pub dns: Option<DnsConfig>,
651
652 #[serde(skip_serializing_if = "Option::is_none")]
654 #[config_patch(nested)]
655 pub tls: Option<TlsConfig>,
656
657 pub strict: bool,
659
660 #[serde(skip_serializing_if = "Option::is_none")]
662 #[config_patch(nested)]
663 pub secrets: Option<SecretsConfig>,
664
665 #[serde(rename = "max_connections", alias = "max_tcp_connections")]
668 pub max_tcp_connections: Option<usize>,
669
670 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub max_inbound_tcp_connections: Option<usize>,
673
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub max_udp_connections: Option<usize>,
677
678 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub tcp_accept_queue_size: Option<u32>,
682
683 #[serde(skip_serializing_if = "Option::is_none")]
685 #[config_patch(nested)]
686 pub rate_limiter: Option<NetworkRateLimiterConfig>,
687
688 #[serde(default = "default_nat64_prefixes")]
690 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
691 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
692 pub nat64_prefixes: Vec<Ipv6Network>,
693
694 pub trust_host_cas: bool,
696
697 #[config_patch(nested)]
699 #[serde(default)]
700 pub http: HttpConfig,
701
702 #[serde(skip_serializing_if = "Option::is_none")]
706 #[config_patch(nullable)]
707 pub outbound_proxy: Option<OutboundProxy>,
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
712#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
713#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
714#[serde(tag = "protocol", rename_all = "lowercase")]
715#[non_exhaustive]
716pub enum OutboundProxy {
717 #[serde(rename = "http_connect")]
719 HttpConnect {
720 address: String,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
724 credentials: Option<HttpConnectCredentials>,
725 },
726
727 Socks4 {
729 address: String,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
733 user_id: Option<String>,
734 },
735
736 Socks5 {
738 address: String,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
742 credentials: Option<Socks5Credentials>,
743 },
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
752#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
753pub struct HttpConnectCredentials {
754 pub username: String,
756
757 pub password: SecretSource,
759}
760
761#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
767#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
768pub struct Socks5Credentials {
769 pub username: String,
771
772 pub password: SecretSource,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize)]
778#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
779#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
780pub struct PublishedPortSpec {
781 pub host_port: u16,
783
784 pub guest_port: u16,
786
787 #[serde(default)]
789 pub protocol: PortProtocol,
790
791 pub host_bind: String,
793}
794
795#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
797#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
798#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
799pub enum PortProtocol {
800 #[default]
802 #[serde(rename = "tcp")]
803 Tcp,
804
805 #[serde(rename = "udp")]
807 Udp,
808}
809
810#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
816#[config_patch(serde_lossless)]
817#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
818#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
819#[serde(default)]
820pub struct VsockSpec {
821 pub routes: Vec<VsockRouteSpec>,
823}
824
825impl VsockSpec {
826 pub fn is_empty(&self) -> bool {
828 self.routes.is_empty()
829 }
830}
831
832#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
834#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
835#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
836pub struct VsockRouteSpec {
837 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
839 pub host_socket: PathBuf,
840
841 pub port: u32,
843
844 #[serde(default)]
846 pub socket_type: VsockSocketType,
847}
848
849#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
851#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
852#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
853#[serde(rename_all = "snake_case")]
854pub enum VsockSocketType {
855 #[default]
857 Stream,
858
859 Dgram,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize)]
869#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
870#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
871pub struct HandoffInit {
872 pub cmd: String,
876
877 #[serde(default)]
879 pub args: Vec<String>,
880
881 #[serde(default)]
883 pub env: Vec<(String, String)>,
884}
885
886#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
892#[config_patch(serde_lossless)]
893#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
894#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
895pub struct SandboxPolicy {
896 #[serde(default)]
905 pub ephemeral: bool,
906
907 pub max_duration_secs: Option<u64>,
909
910 pub idle_timeout_secs: Option<u64>,
912}
913
914#[derive(Debug, Clone, Serialize, Deserialize)]
924#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
925pub struct SnapshotSpec {
926 #[serde(default)]
928 pub guest_flush: crate::GuestFlush,
929 pub name: String,
931
932 #[serde(default)]
934 pub group: Option<String>,
935
936 #[serde(default)]
938 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
939 pub dest_dir: Option<PathBuf>,
940
941 pub source_sandbox: String,
943
944 pub labels: Vec<(String, String)>,
946
947 pub force: bool,
949
950 pub record_integrity: bool,
952
953 #[serde(default)]
955 pub full: bool,
956}
957
958#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigPatch)]
966#[config_patch(serde_lossless)]
967#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
968#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
969#[serde(default)]
970pub struct SandboxSpec {
971 pub name: String,
973
974 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
976 pub image: RootfsSource,
977
978 #[config_patch(nested)]
980 pub resources: SandboxResources,
981
982 #[config_patch(nested)]
984 pub runtime: SandboxRuntimeOptions,
985
986 #[config_patch(merge_with = merge_env_vars)]
988 pub env: Vec<EnvVar>,
989
990 #[config_patch(merge)]
992 pub labels: BTreeMap<String, String>,
993
994 pub rlimits: Vec<Rlimit>,
996
997 pub mounts: Vec<VolumeMount>,
999
1000 pub patches: Vec<Patch>,
1002
1003 #[config_patch(nested)]
1005 pub network: NetworkSpec,
1006
1007 #[serde(default, skip_serializing_if = "VsockSpec::is_empty")]
1009 #[config_patch(nested)]
1010 pub vsock: VsockSpec,
1011
1012 pub init: Option<HandoffInit>,
1014
1015 pub pull_policy: PullPolicy,
1017
1018 pub security_profile: SecurityProfile,
1020
1021 pub deployment_profile: DeploymentProfile,
1027
1028 #[config_patch(nested)]
1030 pub lifecycle: SandboxPolicy,
1031}
1032
1033#[derive(Debug, Clone, Serialize, ConfigPatch)]
1035#[config_patch(serde_lossless)]
1036#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1037#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1038pub struct SandboxResources {
1039 pub cpus: u8,
1041
1042 pub memory_mib: u32,
1044
1045 pub max_cpus: u8,
1047
1048 pub max_memory_mib: u32,
1050
1051 #[serde(default, skip_serializing_if = "CpuPlacement::is_inherit")]
1053 pub cpu_placement: CpuPlacement,
1054
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 #[config_patch(nullable)]
1059 pub placement_profile: Option<String>,
1060
1061 #[serde(default, skip_serializing_if = "TransparentHugePagePolicy::is_madvise")]
1063 pub thp: TransparentHugePagePolicy,
1064}
1065
1066#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1068#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1069#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1070#[serde(rename_all = "lowercase")]
1071pub enum CpuPlacement {
1072 #[default]
1074 Inherit,
1075
1076 Auto,
1078
1079 Spread,
1081
1082 Compact,
1084}
1085
1086#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1088#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1089#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1090#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1091pub enum NumaPlacement {
1092 PreferSingle,
1094 StrictSingle,
1096 Inherit,
1098}
1099
1100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1102#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1103#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1104#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
1105pub enum MemoryPlacement {
1106 FollowCpu,
1108 Inherit,
1110}
1111
1112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1114#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1115#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1116#[serde(deny_unknown_fields)]
1117pub struct PlacementProfile {
1118 pub numa: NumaPlacement,
1120 pub memory: MemoryPlacement,
1122}
1123
1124#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1126#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1127#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1128#[serde(rename_all = "lowercase")]
1129pub enum TransparentHugePagePolicy {
1130 Always,
1132
1133 #[default]
1135 Madvise,
1136
1137 Never,
1139}
1140
1141#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1146#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1147#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1148#[serde(rename_all = "lowercase")]
1149pub enum GuestClockPolicy {
1150 #[default]
1155 Sync,
1156
1157 Off,
1162}
1163
1164#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
1166#[config_patch(serde_lossless)]
1167#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1168#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1169#[serde(default)]
1170pub struct SandboxRuntimeOptions {
1171 #[config_patch(nullable)]
1174 pub workdir: Option<String>,
1175
1176 #[config_patch(nullable)]
1179 pub shell: Option<String>,
1180
1181 #[config_patch(merge)]
1183 pub scripts: BTreeMap<String, String>,
1184
1185 pub entrypoint: Option<Vec<String>>,
1187
1188 pub cmd: Option<Vec<String>>,
1190
1191 pub hostname: Option<String>,
1193
1194 pub user: Option<String>,
1196
1197 #[config_patch(nullable)]
1200 pub log_level: Option<SandboxLogLevel>,
1201
1202 #[config_patch(nullable)]
1205 pub metrics_sample_interval_ms: Option<u64>,
1206
1207 pub disable_metrics_sample: bool,
1209
1210 #[serde(default, skip_serializing_if = "Option::is_none")]
1213 pub guest_clock: Option<GuestClockPolicy>,
1214}
1215
1216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1218#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1220pub struct EnvVar {
1221 pub key: String,
1223
1224 pub value: String,
1226}
1227
1228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1230#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1231#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1232#[serde(rename_all = "lowercase")]
1233pub enum SandboxLogLevel {
1234 Error,
1236
1237 Warn,
1239
1240 Info,
1242
1243 Debug,
1245
1246 Trace,
1248}
1249
1250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1256#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1257#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1258pub enum RlimitResource {
1259 Cpu,
1261 Fsize,
1263 Data,
1265 Stack,
1267 Core,
1269 Rss,
1271 Nproc,
1273 Nofile,
1275 Memlock,
1277 As,
1279 Locks,
1281 Sigpending,
1283 Msgqueue,
1285 Nice,
1287 Rtprio,
1289 Rttime,
1291}
1292
1293#[derive(Debug, Clone, Serialize, Deserialize)]
1295#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1296#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1297pub struct Rlimit {
1298 pub resource: RlimitResource,
1300
1301 pub soft: u64,
1303
1304 pub hard: u64,
1306}
1307
1308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1314#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1315#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1316#[serde(rename_all = "lowercase")]
1317pub enum LogSource {
1318 Stdout,
1320
1321 Stderr,
1323
1324 Output,
1326
1327 System,
1329}
1330
1331impl SandboxResourcesPatch {
1336 pub fn has_cpus(&self) -> bool {
1338 self.cpus.is_some()
1339 }
1340
1341 pub fn has_memory_mib(&self) -> bool {
1343 self.memory_mib.is_some()
1344 }
1345
1346 pub fn has_max_cpus(&self) -> bool {
1348 self.max_cpus.is_some()
1349 }
1350
1351 pub fn has_max_memory_mib(&self) -> bool {
1353 self.max_memory_mib.is_some()
1354 }
1355}
1356
1357impl DiskImageFormat {
1358 pub fn as_str(&self) -> &'static str {
1360 match self {
1361 Self::Qcow2 => "qcow2",
1362 Self::Raw => "raw",
1363 Self::Vmdk => "vmdk",
1364 }
1365 }
1366
1367 pub fn from_extension(ext: &str) -> Option<Self> {
1371 match ext {
1372 "qcow2" => Some(Self::Qcow2),
1373 "raw" => Some(Self::Raw),
1374 "vmdk" => Some(Self::Vmdk),
1375 _ => None,
1376 }
1377 }
1378}
1379
1380impl OciRootfsSource {
1381 pub fn new(reference: impl Into<String>) -> Self {
1383 Self {
1384 reference: reference.into(),
1385 root_disk: None,
1386 }
1387 }
1388}
1389
1390impl TransparentHugePagePolicy {
1391 pub fn is_madvise(&self) -> bool {
1393 matches!(self, Self::Madvise)
1394 }
1395
1396 pub fn as_str(self) -> &'static str {
1398 match self {
1399 Self::Always => "always",
1400 Self::Madvise => "madvise",
1401 Self::Never => "never",
1402 }
1403 }
1404}
1405
1406impl GuestClockPolicy {
1407 pub fn is_sync(&self) -> bool {
1409 matches!(self, Self::Sync)
1410 }
1411
1412 pub fn as_str(self) -> &'static str {
1414 match self {
1415 Self::Sync => "sync",
1416 Self::Off => "off",
1417 }
1418 }
1419}
1420
1421impl RootDisk {
1422 pub fn managed(size_mib: u32) -> Self {
1424 Self::Managed {
1425 size_mib: Some(size_mib),
1426 }
1427 }
1428
1429 pub fn tmpfs(size_mib: u32) -> Self {
1431 Self::Tmpfs {
1432 size_mib: Some(size_mib),
1433 }
1434 }
1435
1436 pub fn flat(size_mib: u32) -> Self {
1438 Self::Flat {
1439 size_mib: Some(size_mib),
1440 fstype: None,
1441 clone: FlatClone::Auto,
1442 }
1443 }
1444
1445 pub fn size_mib(&self) -> Option<u32> {
1447 match self {
1448 Self::Managed { size_mib } | Self::Tmpfs { size_mib } | Self::Flat { size_mib, .. } => {
1449 *size_mib
1450 }
1451 Self::DiskImage { .. } => None,
1452 }
1453 }
1454
1455 pub fn kind_str(&self) -> &'static str {
1457 match self {
1458 Self::Managed { .. } => "managed",
1459 Self::Tmpfs { .. } => "tmpfs",
1460 Self::DiskImage { .. } => "disk-image",
1461 Self::Flat { .. } => "flat",
1462 }
1463 }
1464
1465 pub fn is_managed(&self) -> bool {
1467 matches!(self, Self::Managed { .. })
1468 }
1469}
1470
1471impl FlatClone {
1472 pub const fn as_str(self) -> &'static str {
1474 match self {
1475 Self::Auto => "auto",
1476 Self::Copy => "copy",
1477 Self::Reflink => "reflink",
1478 }
1479 }
1480
1481 pub const fn is_auto(&self) -> bool {
1483 matches!(self, Self::Auto)
1484 }
1485}
1486
1487impl RootfsSource {
1488 pub fn oci(reference: impl Into<String>) -> Self {
1490 Self::Oci(OciRootfsSource::new(reference))
1491 }
1492
1493 pub fn oci_reference(&self) -> Option<&str> {
1495 match self {
1496 Self::Oci(oci) => Some(&oci.reference),
1497 _ => None,
1498 }
1499 }
1500
1501 pub fn oci_root_disk(&self) -> Option<&RootDisk> {
1503 match self {
1504 Self::Oci(oci) => oci.root_disk.as_ref(),
1505 _ => None,
1506 }
1507 }
1508
1509 pub fn oci_managed_root_disk_size_mib(&self) -> Option<u32> {
1512 match self {
1513 Self::Oci(oci) => match &oci.root_disk {
1514 Some(RootDisk::Managed { size_mib }) => *size_mib,
1515 Some(_) => None,
1516 None => None,
1517 },
1518 _ => None,
1519 }
1520 }
1521}
1522
1523impl EnvVar {
1524 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
1526 Self {
1527 key: key.into(),
1528 value: value.into(),
1529 }
1530 }
1531
1532 pub fn as_pair(&self) -> (&str, &str) {
1534 (&self.key, &self.value)
1535 }
1536}
1537
1538impl VolumeKind {
1539 pub fn as_str(self) -> &'static str {
1541 match self {
1542 Self::Directory => "dir",
1543 Self::Disk => "disk",
1544 }
1545 }
1546
1547 pub fn from_db_value(value: &str) -> Self {
1549 match value {
1550 "disk" => Self::Disk,
1551 _ => Self::Directory,
1552 }
1553 }
1554}
1555
1556impl VolumeSpec {
1557 pub fn new(name: impl Into<String>) -> Self {
1559 Self {
1560 name: name.into(),
1561 kind: VolumeKind::Directory,
1562 quota_mib: None,
1563 capacity_mib: None,
1564 labels: Vec::new(),
1565 }
1566 }
1567}
1568
1569impl NamedVolumeCreate {
1570 pub fn mode(&self) -> NamedVolumeMode {
1572 self.mode
1573 }
1574
1575 pub fn name(&self) -> &str {
1577 &self.name
1578 }
1579
1580 pub fn kind(&self) -> VolumeKind {
1582 self.kind
1583 }
1584
1585 pub fn quota_mib(&self) -> Option<u32> {
1587 self.quota_mib
1588 }
1589
1590 pub fn capacity_mib(&self) -> Option<u32> {
1592 self.capacity_mib
1593 }
1594
1595 pub fn labels(&self) -> &[(String, String)] {
1597 &self.labels
1598 }
1599}
1600
1601impl VolumeMount {
1602 pub fn guest(&self) -> &str {
1604 match self {
1605 Self::Bind { guest, .. }
1606 | Self::Owned { guest, .. }
1607 | Self::Named { guest, .. }
1608 | Self::Tmpfs { guest, .. }
1609 | Self::DiskImage { guest, .. } => guest,
1610 }
1611 }
1612
1613 fn guest_mut(&mut self) -> &mut String {
1614 match self {
1615 Self::Bind { guest, .. }
1616 | Self::Owned { guest, .. }
1617 | Self::Named { guest, .. }
1618 | Self::Tmpfs { guest, .. }
1619 | Self::DiskImage { guest, .. } => guest,
1620 }
1621 }
1622
1623 pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
1625 match self {
1626 Self::Named { create, .. } => create.as_ref(),
1627 _ => None,
1628 }
1629 }
1630}
1631
1632pub fn owned_volume_mount_id(guest: &str) -> String {
1639 use std::fmt::Write as _;
1640 let slug: String = guest
1641 .trim_start_matches('/')
1642 .chars()
1643 .take(11)
1644 .map(|character| {
1645 if character.is_ascii_alphanumeric() || character == '-' {
1646 character
1647 } else {
1648 '_'
1649 }
1650 })
1651 .collect();
1652 let mut id = if slug.is_empty() {
1653 String::new()
1654 } else {
1655 format!("{slug}_")
1656 };
1657 for byte in Sha256::digest(guest.as_bytes()).iter().take(4) {
1658 let _ = write!(id, "{byte:02x}");
1659 }
1660 id
1661}
1662
1663pub fn canonicalize_volume_mounts(mounts: &mut [VolumeMount]) -> TypesResult<()> {
1670 for mount in mounts.iter_mut() {
1671 let canonical = canonical_guest_mount_path(mount.guest())?;
1672 *mount.guest_mut() = canonical;
1673 }
1674
1675 mounts.sort_by_cached_key(|mount| guest_mount_order_key(mount.guest()));
1676
1677 for pair in mounts.windows(2) {
1678 if pair[0].guest() == pair[1].guest() {
1679 return Err(TypesError::invalid_config(format!(
1680 "multiple volumes cannot mount the same guest path: {}",
1681 pair[0].guest()
1682 )));
1683 }
1684 }
1685
1686 Ok(())
1687}
1688
1689fn canonical_guest_mount_path(guest: &str) -> TypesResult<String> {
1690 let path = Utf8UnixPath::new(guest);
1691
1692 if !path.is_valid() {
1693 return Err(TypesError::invalid_config(format!(
1694 "guest mount path must be a valid Unix path: {guest}"
1695 )));
1696 }
1697 if !path.is_absolute() {
1698 return Err(TypesError::invalid_config(format!(
1699 "guest mount path must be absolute: {guest}"
1700 )));
1701 }
1702 if path
1703 .components()
1704 .any(|component| matches!(component, Utf8UnixComponent::ParentDir))
1705 {
1706 return Err(TypesError::invalid_config(format!(
1707 "guest mount path must not contain '..': {guest}"
1708 )));
1709 }
1710 if guest.contains(':') || guest.contains(';') || guest.contains(',') {
1711 return Err(TypesError::invalid_config(format!(
1712 "guest mount path must not contain ':', ';', or ',': {guest}"
1713 )));
1714 }
1715
1716 let canonical = path.normalize().to_string();
1717 if canonical == "/" {
1718 return Err(TypesError::invalid_config(
1719 "cannot mount a volume at guest root /",
1720 ));
1721 }
1722
1723 Ok(canonical)
1724}
1725
1726fn guest_mount_order_key(guest: &str) -> (usize, String) {
1727 let path = Utf8UnixPath::new(guest);
1728 let depth = path.components().filter(Utf8Component::is_normal).count();
1729 (depth, guest.to_owned())
1730}
1731
1732impl RlimitResource {
1733 pub fn as_str(&self) -> &'static str {
1735 match self {
1736 Self::Cpu => "cpu",
1737 Self::Fsize => "fsize",
1738 Self::Data => "data",
1739 Self::Stack => "stack",
1740 Self::Core => "core",
1741 Self::Rss => "rss",
1742 Self::Nproc => "nproc",
1743 Self::Nofile => "nofile",
1744 Self::Memlock => "memlock",
1745 Self::As => "as",
1746 Self::Locks => "locks",
1747 Self::Sigpending => "sigpending",
1748 Self::Msgqueue => "msgqueue",
1749 Self::Nice => "nice",
1750 Self::Rtprio => "rtprio",
1751 Self::Rttime => "rttime",
1752 }
1753 }
1754}
1755
1756impl LogSource {
1757 pub fn effective(requested: &[Self]) -> Vec<Self> {
1759 if requested.is_empty() {
1760 vec![Self::Stdout, Self::Stderr, Self::Output]
1761 } else {
1762 let mut sources = requested.to_vec();
1763 sources.sort_by_key(|src| match src {
1764 Self::Stdout => 0,
1765 Self::Stderr => 1,
1766 Self::Output => 2,
1767 Self::System => 3,
1768 });
1769 sources.dedup();
1770 sources
1771 }
1772 }
1773}
1774
1775impl SandboxLogLevel {
1776 pub const fn as_str(self) -> &'static str {
1778 match self {
1779 Self::Error => "error",
1780 Self::Warn => "warn",
1781 Self::Info => "info",
1782 Self::Debug => "debug",
1783 Self::Trace => "trace",
1784 }
1785 }
1786}
1787
1788impl std::fmt::Display for DiskImageFormat {
1793 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1794 f.write_str(self.as_str())
1795 }
1796}
1797
1798impl FromStr for DiskImageFormat {
1799 type Err = String;
1800
1801 fn from_str(s: &str) -> Result<Self, Self::Err> {
1802 match s {
1803 "qcow2" => Ok(Self::Qcow2),
1804 "raw" => Ok(Self::Raw),
1805 "vmdk" => Ok(Self::Vmdk),
1806 _ => Err(format!("unknown disk image format: {s}")),
1807 }
1808 }
1809}
1810
1811impl fmt::Display for TransparentHugePagePolicy {
1812 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1813 f.write_str(self.as_str())
1814 }
1815}
1816
1817impl FromStr for TransparentHugePagePolicy {
1818 type Err = String;
1819
1820 fn from_str(value: &str) -> Result<Self, Self::Err> {
1821 match value {
1822 "always" => Ok(Self::Always),
1823 "madvise" => Ok(Self::Madvise),
1824 "never" => Ok(Self::Never),
1825 _ => Err(format!(
1826 "unknown transparent huge-page policy: {value}; expected always, madvise, or never"
1827 )),
1828 }
1829 }
1830}
1831
1832impl fmt::Display for GuestClockPolicy {
1833 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1834 f.write_str(self.as_str())
1835 }
1836}
1837
1838impl FromStr for GuestClockPolicy {
1839 type Err = String;
1840
1841 fn from_str(value: &str) -> Result<Self, Self::Err> {
1842 match value {
1843 "sync" => Ok(Self::Sync),
1844 "off" => Ok(Self::Off),
1845 _ => Err(format!(
1846 "unknown guest clock policy: {value}; expected sync or off"
1847 )),
1848 }
1849 }
1850}
1851
1852impl Default for RootfsSource {
1853 fn default() -> Self {
1854 Self::oci(String::new())
1855 }
1856}
1857
1858impl Default for SandboxResources {
1859 fn default() -> Self {
1860 Self {
1861 cpus: DEFAULT_SANDBOX_CPUS,
1862 memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1863 max_cpus: DEFAULT_SANDBOX_CPUS,
1864 max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
1865 cpu_placement: CpuPlacement::Inherit,
1866 placement_profile: None,
1867 thp: TransparentHugePagePolicy::Madvise,
1868 }
1869 }
1870}
1871
1872impl<'de> Deserialize<'de> for SandboxResources {
1873 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1874 where
1875 D: serde::Deserializer<'de>,
1876 {
1877 #[derive(Deserialize)]
1878 struct RawResources {
1879 #[serde(default = "default_sandbox_cpus")]
1880 cpus: u8,
1881 #[serde(default = "default_sandbox_memory_mib")]
1882 memory_mib: u32,
1883 max_cpus: Option<u8>,
1884 max_memory_mib: Option<u32>,
1885 #[serde(default)]
1886 cpu_placement: CpuPlacement,
1887 #[serde(default)]
1888 placement_profile: Option<String>,
1889 #[serde(default)]
1890 thp: TransparentHugePagePolicy,
1891 }
1892
1893 let raw = RawResources::deserialize(deserializer)?;
1894 Ok(Self {
1895 cpus: raw.cpus,
1896 memory_mib: raw.memory_mib,
1897 max_cpus: raw.max_cpus.unwrap_or(raw.cpus),
1901 max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
1902 cpu_placement: raw.cpu_placement,
1903 placement_profile: raw.placement_profile,
1904 thp: raw.thp,
1905 })
1906 }
1907}
1908
1909impl CpuPlacement {
1910 pub const fn is_inherit(&self) -> bool {
1912 matches!(self, Self::Inherit)
1913 }
1914}
1915
1916impl std::fmt::Display for CpuPlacement {
1917 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1918 f.write_str(match self {
1919 Self::Inherit => "inherit",
1920 Self::Auto => "auto",
1921 Self::Spread => "spread",
1922 Self::Compact => "compact",
1923 })
1924 }
1925}
1926
1927impl FromStr for CpuPlacement {
1928 type Err = String;
1929
1930 fn from_str(value: &str) -> Result<Self, Self::Err> {
1931 match value {
1932 "inherit" => Ok(Self::Inherit),
1933 "auto" => Ok(Self::Auto),
1934 "spread" => Ok(Self::Spread),
1935 "compact" => Ok(Self::Compact),
1936 _ => Err(format!(
1937 "unknown CPU placement: {value} (expected: inherit, auto, spread, compact)"
1938 )),
1939 }
1940 }
1941}
1942
1943impl Default for SandboxRuntimeOptions {
1944 fn default() -> Self {
1945 Self {
1946 workdir: None,
1947 shell: None,
1948 scripts: BTreeMap::new(),
1949 entrypoint: None,
1950 cmd: None,
1951 hostname: None,
1952 user: None,
1953 log_level: None,
1954 metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
1955 disable_metrics_sample: false,
1956 guest_clock: None,
1957 }
1958 }
1959}
1960
1961impl Default for HttpConfig {
1962 fn default() -> Self {
1963 Self {
1964 deny_response: true,
1965 deny_response_format: HttpDenyResponseFormat::Json,
1966 deny_message: None,
1967 network_deny_message: None,
1968 secret_deny_message: None,
1969 }
1970 }
1971}
1972
1973impl Default for NetworkSpec {
1974 fn default() -> Self {
1975 Self {
1976 enabled: true,
1977 interface: None,
1978 ports: Vec::new(),
1979 policy: None,
1980 dns: None,
1981 tls: None,
1982 strict: true,
1983 secrets: None,
1984 max_tcp_connections: None,
1985 max_udp_connections: None,
1986 tcp_accept_queue_size: None,
1987 max_inbound_tcp_connections: None,
1988 rate_limiter: None,
1989 nat64_prefixes: default_nat64_prefixes(),
1990 trust_host_cas: false,
1991 outbound_proxy: None,
1992 http: HttpConfig::default(),
1993 }
1994 }
1995}
1996
1997pub(crate) fn default_nat64_prefixes() -> Vec<Ipv6Network> {
1998 vec![
1999 WELL_KNOWN_NAT64_PREFIX
2000 .parse()
2001 .expect("well-known NAT64 prefix must be valid"),
2002 ]
2003}
2004
2005impl Default for PublishedPortSpec {
2006 fn default() -> Self {
2007 Self {
2008 host_port: 0,
2009 guest_port: 0,
2010 protocol: PortProtocol::Tcp,
2011 host_bind: "127.0.0.1".into(),
2012 }
2013 }
2014}
2015
2016impl From<(String, String)> for EnvVar {
2017 fn from((key, value): (String, String)) -> Self {
2018 Self { key, value }
2019 }
2020}
2021
2022impl From<EnvVar> for (String, String) {
2023 fn from(var: EnvVar) -> Self {
2024 (var.key, var.value)
2025 }
2026}
2027
2028impl FromStr for SandboxLogLevel {
2029 type Err = String;
2030
2031 fn from_str(s: &str) -> Result<Self, Self::Err> {
2032 match s {
2033 "error" => Ok(Self::Error),
2034 "warn" => Ok(Self::Warn),
2035 "info" => Ok(Self::Info),
2036 "debug" => Ok(Self::Debug),
2037 "trace" => Ok(Self::Trace),
2038 _ => Err(format!("unknown sandbox log level: {s}")),
2039 }
2040 }
2041}
2042
2043impl std::fmt::Display for SandboxLogLevel {
2044 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2045 formatter.write_str(self.as_str())
2046 }
2047}
2048
2049impl Serialize for VolumeMount {
2050 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2051 use serde::ser::SerializeMap;
2052
2053 match self {
2054 Self::Owned {
2055 guest,
2056 storage,
2057 options,
2058 stat_virtualization,
2059 host_permissions,
2060 } => {
2061 let mut map = serializer.serialize_map(Some(6))?;
2064 map.serialize_entry("type", "Owned")?;
2065 map.serialize_entry("guest", guest)?;
2066 map.serialize_entry("storage", storage)?;
2067 map.serialize_entry("options", options)?;
2068 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2069 map.serialize_entry("host_permissions", host_permissions)?;
2070 map.end()
2071 }
2072 Self::Bind {
2073 host,
2074 guest,
2075 options,
2076 stat_virtualization,
2077 host_permissions,
2078 follow_root_symlinks,
2079 quota_mib,
2080 } => {
2081 let mut map = serializer.serialize_map(Some(8))?;
2082 map.serialize_entry("type", "Bind")?;
2083 map.serialize_entry("host", host)?;
2084 map.serialize_entry("guest", guest)?;
2085 map.serialize_entry("options", options)?;
2086 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2087 map.serialize_entry("host_permissions", host_permissions)?;
2088 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2089 map.serialize_entry("quota_mib", quota_mib)?;
2090 map.end()
2091 }
2092 Self::Named {
2093 name,
2094 guest,
2095 create: _,
2096 options,
2097 stat_virtualization,
2098 host_permissions,
2099 follow_root_symlinks,
2100 } => {
2101 let mut map = serializer.serialize_map(Some(7))?;
2102 map.serialize_entry("type", "Named")?;
2103 map.serialize_entry("name", name)?;
2104 map.serialize_entry("guest", guest)?;
2105 map.serialize_entry("options", options)?;
2106 map.serialize_entry("stat_virtualization", stat_virtualization)?;
2107 map.serialize_entry("host_permissions", host_permissions)?;
2108 map.serialize_entry("follow_root_symlinks", follow_root_symlinks)?;
2109 map.end()
2110 }
2111 Self::Tmpfs {
2112 guest,
2113 size_mib,
2114 options,
2115 } => {
2116 let mut map = serializer.serialize_map(Some(4))?;
2117 map.serialize_entry("type", "Tmpfs")?;
2118 map.serialize_entry("guest", guest)?;
2119 map.serialize_entry("size_mib", size_mib)?;
2120 map.serialize_entry("options", options)?;
2121 map.end()
2122 }
2123 Self::DiskImage {
2124 host,
2125 guest,
2126 format,
2127 fstype,
2128 options,
2129 } => {
2130 let mut map = serializer.serialize_map(Some(6))?;
2131 map.serialize_entry("type", "DiskImage")?;
2132 map.serialize_entry("host", host)?;
2133 map.serialize_entry("guest", guest)?;
2134 map.serialize_entry("format", format)?;
2135 map.serialize_entry("fstype", fstype)?;
2136 map.serialize_entry("options", options)?;
2137 map.end()
2138 }
2139 }
2140 }
2141}
2142
2143impl<'de> Deserialize<'de> for VolumeMount {
2144 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2145 fn default_strict() -> StatVirtualization {
2146 StatVirtualization::Strict
2147 }
2148
2149 fn default_private() -> HostPermissions {
2150 HostPermissions::Private
2151 }
2152
2153 #[derive(Deserialize)]
2154 #[serde(tag = "type")]
2155 enum VolumeMountHelper {
2156 Owned {
2157 guest: String,
2158 storage: OwnedVolumeStorage,
2159 #[serde(default)]
2160 options: MountOptions,
2161 #[serde(default = "default_strict")]
2162 stat_virtualization: StatVirtualization,
2163 #[serde(default = "default_private")]
2164 host_permissions: HostPermissions,
2165 },
2166 Bind {
2167 host: PathBuf,
2168 guest: String,
2169 #[serde(default)]
2170 options: Option<MountOptions>,
2171 #[serde(default)]
2172 readonly: bool,
2173 #[serde(default = "default_strict")]
2174 stat_virtualization: StatVirtualization,
2175 #[serde(default = "default_private")]
2176 host_permissions: HostPermissions,
2177 #[serde(default)]
2178 follow_root_symlinks: bool,
2179 #[serde(default)]
2180 quota_mib: Option<u32>,
2181 },
2182 Named {
2183 name: String,
2184 guest: String,
2185 #[serde(default)]
2186 options: Option<MountOptions>,
2187 #[serde(default)]
2188 readonly: bool,
2189 #[serde(default = "default_strict")]
2190 stat_virtualization: StatVirtualization,
2191 #[serde(default = "default_private")]
2192 host_permissions: HostPermissions,
2193 #[serde(default)]
2194 follow_root_symlinks: bool,
2195 },
2196 Tmpfs {
2197 guest: String,
2198 #[serde(default)]
2199 size_mib: Option<u32>,
2200 #[serde(default)]
2201 options: Option<MountOptions>,
2202 #[serde(default)]
2203 readonly: bool,
2204 },
2205 DiskImage {
2206 host: PathBuf,
2207 guest: String,
2208 format: DiskImageFormat,
2209 #[serde(default)]
2210 fstype: Option<String>,
2211 #[serde(default)]
2212 options: Option<MountOptions>,
2213 #[serde(default)]
2214 readonly: bool,
2215 },
2216 }
2217
2218 let helper = VolumeMountHelper::deserialize(deserializer)?;
2219 Ok(match helper {
2220 VolumeMountHelper::Owned {
2221 guest,
2222 storage,
2223 options,
2224 stat_virtualization,
2225 host_permissions,
2226 } => Self::Owned {
2227 guest,
2228 storage,
2229 options,
2230 stat_virtualization,
2231 host_permissions,
2232 },
2233 VolumeMountHelper::Bind {
2234 host,
2235 guest,
2236 options,
2237 readonly,
2238 stat_virtualization,
2239 host_permissions,
2240 follow_root_symlinks,
2241 quota_mib,
2242 } => Self::Bind {
2243 host,
2244 guest,
2245 options: decode_mount_options(options, readonly),
2246 stat_virtualization,
2247 host_permissions,
2248 follow_root_symlinks,
2249 quota_mib,
2250 },
2251 VolumeMountHelper::Named {
2252 name,
2253 guest,
2254 options,
2255 readonly,
2256 stat_virtualization,
2257 host_permissions,
2258 follow_root_symlinks,
2259 } => Self::Named {
2260 name,
2261 guest,
2262 create: None,
2263 options: decode_mount_options(options, readonly),
2264 stat_virtualization,
2265 host_permissions,
2266 follow_root_symlinks,
2267 },
2268 VolumeMountHelper::Tmpfs {
2269 guest,
2270 size_mib,
2271 options,
2272 readonly,
2273 } => Self::Tmpfs {
2274 guest,
2275 size_mib,
2276 options: decode_mount_options(options, readonly),
2277 },
2278 VolumeMountHelper::DiskImage {
2279 host,
2280 guest,
2281 format,
2282 fstype,
2283 options,
2284 readonly,
2285 } => Self::DiskImage {
2286 host,
2287 guest,
2288 format,
2289 fstype,
2290 options: decode_mount_options(options, readonly),
2291 },
2292 })
2293 }
2294}
2295
2296impl fmt::Debug for VolumeMount {
2297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2298 match self {
2299 Self::Owned {
2300 guest,
2301 storage,
2302 options,
2303 stat_virtualization,
2304 host_permissions,
2305 } => f
2306 .debug_struct("Owned")
2307 .field("guest", guest)
2308 .field("storage", storage)
2309 .field("options", options)
2310 .field("stat_virtualization", stat_virtualization)
2311 .field("host_permissions", host_permissions)
2312 .finish(),
2313 Self::Bind {
2314 host,
2315 guest,
2316 options,
2317 stat_virtualization,
2318 host_permissions,
2319 follow_root_symlinks,
2320 quota_mib,
2321 } => f
2322 .debug_struct("Bind")
2323 .field("host", host)
2324 .field("guest", guest)
2325 .field("options", options)
2326 .field("stat_virtualization", stat_virtualization)
2327 .field("host_permissions", host_permissions)
2328 .field("follow_root_symlinks", follow_root_symlinks)
2329 .field("quota_mib", quota_mib)
2330 .finish(),
2331 Self::Named {
2332 name,
2333 guest,
2334 create,
2335 options,
2336 stat_virtualization,
2337 host_permissions,
2338 follow_root_symlinks,
2339 } => f
2340 .debug_struct("Named")
2341 .field("name", name)
2342 .field("guest", guest)
2343 .field("create", create)
2344 .field("options", options)
2345 .field("stat_virtualization", stat_virtualization)
2346 .field("host_permissions", host_permissions)
2347 .field("follow_root_symlinks", follow_root_symlinks)
2348 .finish(),
2349 Self::Tmpfs {
2350 guest,
2351 size_mib,
2352 options,
2353 } => f
2354 .debug_struct("Tmpfs")
2355 .field("guest", guest)
2356 .field("size_mib", size_mib)
2357 .field("options", options)
2358 .finish(),
2359 Self::DiskImage {
2360 host,
2361 guest,
2362 format,
2363 fstype,
2364 options,
2365 } => f
2366 .debug_struct("DiskImage")
2367 .field("host", host)
2368 .field("guest", guest)
2369 .field("format", format)
2370 .field("fstype", fstype)
2371 .field("options", options)
2372 .finish(),
2373 }
2374 }
2375}
2376
2377impl TryFrom<&str> for RlimitResource {
2379 type Error = String;
2380
2381 fn try_from(s: &str) -> Result<Self, Self::Error> {
2382 match s.to_ascii_lowercase().as_str() {
2383 "cpu" => Ok(Self::Cpu),
2384 "fsize" => Ok(Self::Fsize),
2385 "data" => Ok(Self::Data),
2386 "stack" => Ok(Self::Stack),
2387 "core" => Ok(Self::Core),
2388 "rss" => Ok(Self::Rss),
2389 "nproc" => Ok(Self::Nproc),
2390 "nofile" => Ok(Self::Nofile),
2391 "memlock" => Ok(Self::Memlock),
2392 "as" => Ok(Self::As),
2393 "locks" => Ok(Self::Locks),
2394 "sigpending" => Ok(Self::Sigpending),
2395 "msgqueue" => Ok(Self::Msgqueue),
2396 "nice" => Ok(Self::Nice),
2397 "rtprio" => Ok(Self::Rtprio),
2398 "rttime" => Ok(Self::Rttime),
2399 _ => Err(format!("unknown rlimit resource: {s}")),
2400 }
2401 }
2402}
2403
2404fn default_sandbox_cpus() -> u8 {
2409 DEFAULT_SANDBOX_CPUS
2410}
2411
2412fn default_sandbox_memory_mib() -> u32 {
2413 DEFAULT_SANDBOX_MEMORY_MIB
2414}
2415
2416fn decode_mount_options(options: Option<MountOptions>, readonly: bool) -> MountOptions {
2417 options.unwrap_or(MountOptions {
2418 readonly,
2419 ..MountOptions::default()
2420 })
2421}
2422
2423fn merge_env_vars(base: &mut Vec<EnvVar>, higher: Vec<EnvVar>) {
2424 for value in higher {
2425 match base.iter_mut().find(|current| current.key == value.key) {
2426 Some(current) => *current = value,
2427 None => base.push(value),
2428 }
2429 }
2430}
2431
2432fn merge_secret_entries(base: &mut Vec<SecretEntry>, higher: Vec<SecretEntry>) {
2433 for value in higher {
2434 match base
2435 .iter_mut()
2436 .find(|current| current.env_var == value.env_var)
2437 {
2438 Some(current) => *current = value,
2439 None => base.push(value),
2440 }
2441 }
2442}
2443
2444pub(crate) fn default_strict() -> StatVirtualization {
2446 StatVirtualization::Strict
2447}
2448
2449pub(crate) fn default_private() -> HostPermissions {
2451 HostPermissions::Private
2452}
2453
2454pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
2456
2457#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigPatch)]
2468#[config_patch(serde_lossless)]
2469#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2470#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2471pub struct SecretsConfig {
2472 #[doc(hidden)]
2475 #[serde(default, skip_serializing_if = "Option::is_none")]
2476 #[cfg_attr(feature = "ts", ts(skip))]
2477 #[cfg_attr(feature = "utoipa", schema(ignore))]
2478 pub passthrough_hosts: Option<Vec<HostPattern>>,
2479
2480 #[serde(default)]
2482 #[config_patch(merge_with = merge_secret_entries)]
2483 pub secrets: Vec<SecretEntry>,
2484
2485 #[serde(default)]
2487 pub violation_action: SecretViolationAction,
2488}
2489
2490#[derive(Clone, Serialize, Deserialize)]
2495#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2496#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2497pub struct SecretEntry {
2498 pub env_var: String,
2504
2505 #[serde(default = "empty_secret_value")]
2514 #[cfg_attr(feature = "ts", ts(type = "string"))]
2515 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
2516 pub value: Zeroizing<String>,
2517
2518 #[serde(default, skip_serializing_if = "Option::is_none")]
2522 pub source: Option<SecretSource>,
2523
2524 pub placeholder: String,
2529
2530 #[serde(default)]
2532 pub allowed_hosts: Vec<HostPattern>,
2533
2534 #[serde(default)]
2536 pub substitution: SecretSubstitution,
2537
2538 #[serde(default)]
2540 pub passthrough_hosts: Vec<HostPattern>,
2541
2542 #[serde(default, skip_serializing_if = "Option::is_none")]
2544 pub violation_action: Option<SecretViolationAction>,
2545
2546 #[serde(default = "default_true")]
2551 pub require_tls_identity: bool,
2552}
2553
2554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2556#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2557#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2558#[serde(rename_all = "kebab-case")]
2559pub enum HostPattern {
2560 #[serde(alias = "Exact")]
2562 Exact(String),
2563 #[serde(alias = "Wildcard")]
2565 Wildcard(String),
2566 #[serde(alias = "Any")]
2568 Any,
2569}
2570
2571#[derive(Debug, Clone, Serialize, Deserialize)]
2573#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2574#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2575pub struct SecretSubstitution {
2576 #[serde(default = "default_true")]
2578 pub headers: bool,
2579
2580 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2599 pub header_fields: Vec<String>,
2600
2601 #[serde(default)]
2603 pub query: bool,
2604
2605 #[serde(default)]
2613 pub body: bool,
2614}
2615
2616#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2618#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2619#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2620#[serde(rename_all = "kebab-case")]
2621pub enum SecretViolationAction {
2622 #[serde(alias = "Block")]
2624 Block,
2625 #[default]
2627 #[serde(alias = "BlockAndLog", alias = "block_and_log")]
2628 BlockAndLog,
2629 #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
2631 BlockAndTerminate,
2632}
2633
2634#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2636pub enum SecretConfigError {
2637 #[error("secret #{secret_index}: env_var must not be empty")]
2639 EmptyEnvVar {
2640 secret_index: usize,
2642 },
2643
2644 #[error("secret #{secret_index}: env_var must not contain `=`")]
2646 EnvVarContainsEquals {
2647 secret_index: usize,
2649 },
2650
2651 #[error("secret #{secret_index}: env_var must not contain NUL")]
2653 EnvVarContainsNul {
2654 secret_index: usize,
2656 },
2657
2658 #[error("secret #{secret_index}: at least one allowed host is required")]
2660 MissingAllowedHosts {
2661 secret_index: usize,
2663 },
2664
2665 #[error("secret #{secret_index}: at least one substitution location is required")]
2667 MissingSubstitutionLocation {
2668 secret_index: usize,
2670 },
2671
2672 #[error(
2674 "secret #{secret_index}: header field substitutions require header substitution to be enabled"
2675 )]
2676 HeaderFieldsRequireHeaders {
2677 secret_index: usize,
2679 },
2680
2681 #[error("secret #{secret_index}: invalid header field name {field:?}")]
2683 InvalidHeaderFieldName {
2684 secret_index: usize,
2686 field: String,
2688 },
2689
2690 #[error("secret #{secret_index}: placeholder must not be empty")]
2692 EmptyPlaceholder {
2693 secret_index: usize,
2695 },
2696
2697 #[error(
2699 "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
2700 )]
2701 PlaceholderTooLong {
2702 secret_index: usize,
2704 actual_bytes: usize,
2706 max_bytes: usize,
2708 },
2709
2710 #[error("secret #{secret_index}: placeholder must not contain NUL")]
2712 PlaceholderContainsNul {
2713 secret_index: usize,
2715 },
2716
2717 #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
2719 PlaceholderContainsLineBreak {
2720 secret_index: usize,
2722 },
2723}
2724
2725impl SecretsConfig {
2726 pub fn has_tls_identity_secrets(&self) -> bool {
2728 self.secrets
2729 .iter()
2730 .any(|secret| secret.require_tls_identity)
2731 }
2732
2733 pub fn contains_env_var(&self, env_var: &str) -> bool {
2735 self.secrets.iter().any(|secret| secret.env_var == env_var)
2736 }
2737
2738 pub fn validate(&self) -> Result<(), SecretConfigError> {
2740 for (index, secret) in self.secrets.iter().enumerate() {
2741 secret.validate(index)?;
2742 }
2743 Ok(())
2744 }
2745}
2746
2747impl SecretEntry {
2748 pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
2750 validate_env_var(&self.env_var, secret_index)?;
2751
2752 if self.allowed_hosts.is_empty() {
2753 return Err(SecretConfigError::MissingAllowedHosts { secret_index });
2754 }
2755
2756 if !self.substitution.headers && !self.substitution.query && !self.substitution.body {
2757 return Err(SecretConfigError::MissingSubstitutionLocation { secret_index });
2758 }
2759
2760 if !self.substitution.headers && !self.substitution.header_fields.is_empty() {
2761 return Err(SecretConfigError::HeaderFieldsRequireHeaders { secret_index });
2762 }
2763
2764 for field in &self.substitution.header_fields {
2765 validate_header_field_name(field, secret_index)?;
2766 }
2767
2768 validate_placeholder(&self.placeholder, secret_index)
2769 }
2770}
2771
2772impl fmt::Debug for SecretEntry {
2774 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2775 f.debug_struct("SecretEntry")
2776 .field("env_var", &self.env_var)
2777 .field("value", &"[REDACTED]")
2778 .field("source", &self.source)
2779 .field("placeholder", &self.placeholder)
2780 .field("allowed_hosts", &self.allowed_hosts)
2781 .field("substitution", &self.substitution)
2782 .field("passthrough_hosts", &self.passthrough_hosts)
2783 .field("violation_action", &self.violation_action)
2784 .field("require_tls_identity", &self.require_tls_identity)
2785 .finish()
2786 }
2787}
2788
2789impl HostPattern {
2790 pub fn parse(host: &str) -> Self {
2793 if host == "*" {
2794 HostPattern::Any
2795 } else if host.starts_with("*.") {
2796 HostPattern::Wildcard(host.to_string())
2797 } else {
2798 HostPattern::Exact(host.to_string())
2799 }
2800 }
2801
2802 pub fn matches(&self, hostname: &str) -> bool {
2807 match self {
2808 HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
2809 HostPattern::Wildcard(pattern) => {
2810 if let Some(suffix) = pattern.strip_prefix("*.") {
2811 hostname.eq_ignore_ascii_case(suffix)
2812 || (hostname.len() > suffix.len() + 1
2813 && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
2814 && hostname[hostname.len() - suffix.len()..]
2815 .eq_ignore_ascii_case(suffix))
2816 } else {
2817 hostname.eq_ignore_ascii_case(pattern)
2818 }
2819 }
2820 HostPattern::Any => true,
2821 }
2822 }
2823}
2824
2825impl Default for SecretSubstitution {
2826 fn default() -> Self {
2827 Self {
2828 headers: true,
2829 header_fields: Vec::new(),
2830 query: false,
2831 body: false,
2832 }
2833 }
2834}
2835
2836fn default_true() -> bool {
2837 true
2838}
2839
2840fn validate_header_field_name(field: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2842 if field.is_empty() || !field.bytes().all(is_http_tchar) {
2843 return Err(SecretConfigError::InvalidHeaderFieldName {
2844 secret_index,
2845 field: field.to_string(),
2846 });
2847 }
2848 Ok(())
2849}
2850
2851fn is_http_tchar(byte: u8) -> bool {
2853 byte.is_ascii_alphanumeric()
2854 || matches!(
2855 byte,
2856 b'!' | b'#'
2857 | b'$'
2858 | b'%'
2859 | b'&'
2860 | b'\''
2861 | b'*'
2862 | b'+'
2863 | b'-'
2864 | b'.'
2865 | b'^'
2866 | b'_'
2867 | b'`'
2868 | b'|'
2869 | b'~'
2870 )
2871}
2872
2873fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2874 if env_var.is_empty() {
2875 return Err(SecretConfigError::EmptyEnvVar { secret_index });
2876 }
2877 if env_var.contains('=') {
2878 return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
2879 }
2880 if env_var.contains('\0') {
2881 return Err(SecretConfigError::EnvVarContainsNul { secret_index });
2882 }
2883 Ok(())
2884}
2885
2886fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
2887 if placeholder.is_empty() {
2888 return Err(SecretConfigError::EmptyPlaceholder { secret_index });
2889 }
2890
2891 let actual_bytes = placeholder.len();
2892 if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
2893 return Err(SecretConfigError::PlaceholderTooLong {
2894 secret_index,
2895 actual_bytes,
2896 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
2897 });
2898 }
2899
2900 if placeholder.contains('\0') {
2901 return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
2902 }
2903 if placeholder.contains('\r') || placeholder.contains('\n') {
2904 return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
2905 }
2906
2907 Ok(())
2908}
2909
2910#[derive(Debug, Clone, Serialize, Deserialize, ConfigPatch)]
2920#[config_patch(serde_lossless)]
2921#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2922#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2923pub struct TlsConfig {
2924 #[serde(default)]
2926 pub enabled: bool,
2927
2928 #[serde(default = "default_intercepted_ports")]
2930 pub intercepted_ports: Vec<u16>,
2931
2932 #[serde(default)]
2934 pub bypass: Vec<String>,
2935
2936 #[serde(default = "default_true")]
2938 pub verify_upstream: bool,
2939
2940 #[serde(default = "default_true")]
2943 pub block_quic_on_intercept: bool,
2944
2945 #[serde(default)]
2947 #[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
2948 #[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
2949 pub upstream_ca_cert: Vec<PathBuf>,
2950
2951 #[serde(default, alias = "scoped_upstream_ca_certs")]
2953 pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
2954
2955 #[serde(default)]
2957 pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
2958
2959 #[serde(default, alias = "ca")]
2962 pub intercept_ca: InterceptCaConfig,
2963
2964 #[serde(default)]
2966 pub cache: CertCacheConfig,
2967}
2968
2969#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2971#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2972#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2973pub struct InterceptCaConfig {
2974 #[serde(default)]
2977 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2978 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2979 pub cert_path: Option<PathBuf>,
2980
2981 #[serde(default)]
2984 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
2985 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
2986 pub key_path: Option<PathBuf>,
2987}
2988
2989#[derive(Debug, Clone, Serialize, Deserialize)]
2991#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2992#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
2993pub struct CertCacheConfig {
2994 #[serde(default = "default_cache_capacity")]
2996 pub capacity: usize,
2997
2998 #[serde(default = "default_cert_validity_hours")]
3000 pub validity_hours: u64,
3001}
3002
3003#[derive(Debug, Clone, Serialize, Deserialize)]
3005#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3006#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3007pub struct ScopedUpstreamCaCert {
3008 pub pattern: String,
3010
3011 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
3013 #[cfg_attr(feature = "ts", ts(type = "string"))]
3014 pub path: PathBuf,
3015}
3016
3017#[derive(Debug, Clone, Serialize, Deserialize)]
3019#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3020#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3021pub struct ScopedVerifyUpstream {
3022 pub pattern: String,
3024
3025 pub verify: bool,
3027}
3028
3029impl Default for TlsConfig {
3030 fn default() -> Self {
3031 Self {
3032 enabled: false,
3033 intercepted_ports: default_intercepted_ports(),
3034 bypass: Vec::new(),
3035 verify_upstream: true,
3036 block_quic_on_intercept: true,
3037 upstream_ca_cert: Vec::new(),
3038 scoped_upstream_ca_cert: Vec::new(),
3039 scoped_verify_upstream: Vec::new(),
3040 intercept_ca: InterceptCaConfig::default(),
3041 cache: CertCacheConfig::default(),
3042 }
3043 }
3044}
3045
3046impl Default for CertCacheConfig {
3047 fn default() -> Self {
3048 Self {
3049 capacity: default_cache_capacity(),
3050 validity_hours: default_cert_validity_hours(),
3051 }
3052 }
3053}
3054
3055fn default_intercepted_ports() -> Vec<u16> {
3056 vec![443]
3057}
3058
3059fn default_cache_capacity() -> usize {
3060 1000
3061}
3062
3063fn default_cert_validity_hours() -> u64 {
3064 24
3065}
3066
3067#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3073#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3074#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3075#[serde(rename_all = "snake_case")]
3076pub enum Action {
3077 Allow,
3079 Deny,
3081}
3082
3083#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3085#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3086#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3087#[serde(rename_all = "snake_case")]
3088pub enum Direction {
3089 Egress,
3091 Ingress,
3093 Any,
3095}
3096
3097#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3099#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3100#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3101#[serde(rename_all = "snake_case")]
3102pub enum Protocol {
3103 Tcp,
3105 Udp,
3107 Icmpv4,
3109 Icmpv6,
3111}
3112
3113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3115#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3116#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3117#[serde(rename_all = "snake_case")]
3118pub enum DestinationGroup {
3119 Public,
3121 Loopback,
3123 Private,
3125 LinkLocal,
3127 Metadata,
3129 Multicast,
3131 Host,
3133}
3134
3135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3142#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3143#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3144#[serde(rename_all = "snake_case")]
3145pub enum Destination {
3146 Any,
3148 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
3150 Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
3151 Domain(String),
3153 DomainSuffix(String),
3155 Group(DestinationGroup),
3157}
3158
3159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3161#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3162#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3163pub struct PortRange {
3164 pub start: u16,
3166 pub end: u16,
3168}
3169
3170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3173#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3174#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3175pub struct Rule {
3176 pub direction: Direction,
3178 pub destination: Destination,
3180 #[serde(default)]
3182 pub protocols: Vec<Protocol>,
3183 #[serde(default)]
3185 pub ports: Vec<PortRange>,
3186 pub action: Action,
3188}
3189
3190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3193#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3194#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3195pub struct NetworkPolicy {
3196 #[serde(default = "action_deny")]
3198 pub default_egress: Action,
3199 #[serde(default = "action_deny")]
3201 pub default_ingress: Action,
3202 #[serde(default)]
3204 pub rules: Vec<Rule>,
3205}
3206
3207fn action_deny() -> Action {
3210 Action::Deny
3211}
3212
3213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3219#[config_patch(serde_lossless)]
3220#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3221#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3222#[serde(default)]
3223pub struct DnsConfig {
3224 pub rebind_protection: bool,
3226 pub nameservers: Vec<String>,
3229 pub query_timeout_ms: u64,
3231}
3232
3233impl Default for DnsConfig {
3234 fn default() -> Self {
3235 Self {
3236 rebind_protection: true,
3237 nameservers: Vec::new(),
3238 query_timeout_ms: 5000,
3239 }
3240 }
3241}
3242
3243#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3247#[config_patch(serde_lossless)]
3248#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3249#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3250#[serde(default)]
3251pub struct InterfaceOverrides {
3252 #[serde(skip_serializing_if = "Option::is_none")]
3254 pub mac: Option<[u8; 6]>,
3255 #[serde(skip_serializing_if = "Option::is_none")]
3257 pub mtu: Option<u16>,
3258 #[serde(skip_serializing_if = "Option::is_none")]
3260 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3261 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3262 pub ipv4_address: Option<Ipv4Addr>,
3263 #[serde(skip_serializing_if = "Option::is_none")]
3265 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3266 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3267 pub ipv4_pool: Option<Ipv4Network>,
3268 #[serde(skip_serializing_if = "Option::is_none")]
3270 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3271 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3272 pub ipv6_address: Option<Ipv6Addr>,
3273 #[serde(skip_serializing_if = "Option::is_none")]
3275 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
3276 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
3277 pub ipv6_pool: Option<Ipv6Network>,
3278}
3279
3280fn empty_secret_value() -> Zeroizing<String> {
3281 Zeroizing::new(String::new())
3282}
3283
3284#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3290pub enum NetworkRateLimitDirection {
3291 Egress,
3293 Ingress,
3295}
3296
3297#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ConfigPatch)]
3299#[config_patch(serde_lossless)]
3300#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3301#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3302#[serde(default)]
3303pub struct NetworkRateLimiterConfig {
3304 #[serde(skip_serializing_if = "Option::is_none")]
3306 pub egress: Option<RateLimiterConfig>,
3307
3308 #[serde(skip_serializing_if = "Option::is_none")]
3310 pub ingress: Option<RateLimiterConfig>,
3311}
3312
3313#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
3319#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3320#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3321#[serde(default)]
3322pub struct RateLimiterConfig {
3323 #[serde(skip_serializing_if = "Option::is_none")]
3325 pub bandwidth: Option<TokenBucketConfig>,
3326
3327 #[serde(skip_serializing_if = "Option::is_none")]
3329 pub ops: Option<TokenBucketConfig>,
3330}
3331
3332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3338#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
3339#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
3340pub struct TokenBucketConfig {
3341 pub size: u64,
3343
3344 pub refill_time_ms: u64,
3347
3348 #[serde(default)]
3350 pub one_time_burst: u64,
3351}
3352
3353#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3355pub enum RateLimitConfigError {
3356 #[error("rate limiter must configure at least one of bandwidth or ops")]
3358 EmptyLimiter,
3359
3360 #[error("{bucket} bucket: size must be greater than zero")]
3362 ZeroSize {
3363 bucket: &'static str,
3365 },
3366
3367 #[error("{bucket} bucket: refill_time_ms must be greater than zero")]
3369 ZeroRefillTime {
3370 bucket: &'static str,
3372 },
3373}
3374
3375impl RateLimiterConfig {
3376 pub fn validate(&self) -> Result<(), RateLimitConfigError> {
3378 if self.bandwidth.is_none() && self.ops.is_none() {
3379 return Err(RateLimitConfigError::EmptyLimiter);
3380 }
3381 if let Some(bandwidth) = &self.bandwidth {
3382 bandwidth.validate("bandwidth")?;
3383 }
3384 if let Some(ops) = &self.ops {
3385 ops.validate("ops")?;
3386 }
3387 Ok(())
3388 }
3389}
3390
3391impl TokenBucketConfig {
3392 pub fn validate(&self, bucket: &'static str) -> Result<(), RateLimitConfigError> {
3394 if self.size == 0 {
3395 return Err(RateLimitConfigError::ZeroSize { bucket });
3396 }
3397 if self.refill_time_ms == 0 {
3398 return Err(RateLimitConfigError::ZeroRefillTime { bucket });
3399 }
3400 Ok(())
3401 }
3402}
3403
3404impl fmt::Display for NetworkRateLimitDirection {
3405 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3406 match self {
3407 Self::Egress => f.write_str("egress"),
3408 Self::Ingress => f.write_str("ingress"),
3409 }
3410 }
3411}
3412
3413#[cfg(test)]
3418mod tests {
3419 use super::*;
3420
3421 fn secret_entry(env_var: &str, require_tls_identity: bool) -> SecretEntry {
3422 SecretEntry {
3423 env_var: env_var.to_owned(),
3424 value: Zeroizing::new("secret".to_owned()),
3425 source: None,
3426 placeholder: format!("$MSB_{env_var}"),
3427 allowed_hosts: vec![HostPattern::Any],
3428 substitution: SecretSubstitution::default(),
3429 passthrough_hosts: Vec::new(),
3430 violation_action: None,
3431 require_tls_identity,
3432 }
3433 }
3434
3435 fn tmpfs_mount(guest: &str) -> VolumeMount {
3436 VolumeMount::Tmpfs {
3437 guest: guest.to_owned(),
3438 size_mib: None,
3439 options: MountOptions::default(),
3440 }
3441 }
3442
3443 #[test]
3444 fn mount_options_omit_unset_owner_but_accept_missing_fields() {
3445 let value = serde_json::to_value(MountOptions::default()).unwrap();
3446 assert!(value.get("override_uid").is_none());
3447 assert!(value.get("override_gid").is_none());
3448
3449 let decoded: MountOptions = serde_json::from_value(value).unwrap();
3450 assert_eq!(decoded.override_uid, None);
3451 assert_eq!(decoded.override_gid, None);
3452 }
3453
3454 #[test]
3455 fn volume_mounts_are_canonicalized_and_ordered_parent_first() {
3456 let mut mounts = vec![
3457 tmpfs_mount("/workspace//persist/./logs/"),
3458 tmpfs_mount("/alpha/z"),
3459 tmpfs_mount("/workspace"),
3460 ];
3461
3462 canonicalize_volume_mounts(&mut mounts).unwrap();
3463
3464 assert_eq!(
3465 mounts.iter().map(VolumeMount::guest).collect::<Vec<_>>(),
3466 vec!["/workspace", "/alpha/z", "/workspace/persist/logs"]
3467 );
3468 }
3469
3470 #[test]
3471 fn secrets_config_queries_entries() {
3472 let mut config = SecretsConfig {
3473 secrets: vec![secret_entry("HTTP_TOKEN", false)],
3474 ..Default::default()
3475 };
3476
3477 assert!(!config.has_tls_identity_secrets());
3478 assert!(config.contains_env_var("HTTP_TOKEN"));
3479 assert!(!config.contains_env_var("MISSING"));
3480
3481 config.secrets.push(secret_entry("API_KEY", true));
3482 assert!(config.has_tls_identity_secrets());
3483 }
3484
3485 #[test]
3486 fn volume_mounts_reject_duplicate_canonical_paths() {
3487 let mut mounts = vec![tmpfs_mount("/data/cache"), tmpfs_mount("/data//./cache/")];
3488
3489 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3490
3491 assert!(error.to_string().contains("same guest path: /data/cache"));
3492 }
3493
3494 #[test]
3495 fn volume_mounts_reject_parent_components_before_normalizing() {
3496 let mut mounts = vec![tmpfs_mount("/workspace/../secrets")];
3497
3498 let error = canonicalize_volume_mounts(&mut mounts).unwrap_err();
3499
3500 assert!(error.to_string().contains("must not contain '..'"));
3501 }
3502
3503 #[test]
3504 fn disk_image_format_from_extension() {
3505 assert_eq!(
3506 DiskImageFormat::from_extension("qcow2"),
3507 Some(DiskImageFormat::Qcow2)
3508 );
3509 assert_eq!(
3510 DiskImageFormat::from_extension("raw"),
3511 Some(DiskImageFormat::Raw)
3512 );
3513 assert_eq!(
3514 DiskImageFormat::from_extension("vmdk"),
3515 Some(DiskImageFormat::Vmdk)
3516 );
3517 assert_eq!(DiskImageFormat::from_extension("ext4"), None);
3518 assert_eq!(DiskImageFormat::from_extension(""), None);
3519 }
3520
3521 fn secret_with_header_fields(fields: Vec<&str>) -> SecretEntry {
3522 SecretEntry {
3523 env_var: "API_KEY".into(),
3524 value: Zeroizing::new("secret".into()),
3525 source: None,
3526 placeholder: "$MSB_API_KEY".into(),
3527 allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
3528 substitution: SecretSubstitution {
3529 headers: true,
3530 header_fields: fields.into_iter().map(ToString::to_string).collect(),
3531 query: false,
3532 body: false,
3533 },
3534 passthrough_hosts: Vec::new(),
3535 violation_action: None,
3536 require_tls_identity: true,
3537 }
3538 }
3539
3540 #[test]
3541 fn secret_substitution_header_fields_round_trip_and_default() {
3542 let entry = secret_with_header_fields(vec!["Authorization", "X-Api-Key"]);
3543 entry.validate(0).expect("valid header fields");
3544 let json = serde_json::to_value(&entry).unwrap();
3545 assert_eq!(
3546 json["substitution"]["header_fields"],
3547 serde_json::json!(["Authorization", "X-Api-Key"])
3548 );
3549
3550 let omitted: SecretSubstitution =
3551 serde_json::from_value(serde_json::json!({ "headers": true })).unwrap();
3552 assert!(omitted.header_fields.is_empty());
3553
3554 let default = SecretSubstitution::default();
3556 assert!(
3557 serde_json::to_value(&default)
3558 .unwrap()
3559 .get("header_fields")
3560 .is_none()
3561 );
3562 }
3563
3564 #[test]
3565 fn secret_validation_rejects_invalid_header_fields() {
3566 for field in ["", "bad header", "bad:name", "bad\0name"] {
3567 let entry = secret_with_header_fields(vec![field]);
3568 let error = entry.validate(0).unwrap_err();
3569 assert!(
3570 matches!(error, SecretConfigError::InvalidHeaderFieldName { .. }),
3571 "{field:?} should be rejected, got {error}"
3572 );
3573 }
3574 }
3575
3576 #[test]
3577 fn secret_validation_rejects_header_fields_when_headers_disabled() {
3578 let mut entry = secret_with_header_fields(vec!["authorization"]);
3581 entry.substitution.headers = false;
3582 entry.substitution.query = true;
3583 let error = entry.validate(0).unwrap_err();
3584 assert!(
3585 matches!(error, SecretConfigError::HeaderFieldsRequireHeaders { .. }),
3586 "{error}"
3587 );
3588
3589 entry.substitution.header_fields.clear();
3591 entry
3592 .validate(0)
3593 .expect("disabled headers without fields is valid");
3594 }
3595
3596 #[test]
3597 fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
3598 let resources: SandboxResources =
3599 serde_json::from_str(r#"{"cpus":4,"memory_mib":2048}"#).unwrap();
3600
3601 assert_eq!(resources.cpus, 4);
3602 assert_eq!(resources.max_cpus, 4);
3603 assert_eq!(resources.memory_mib, 2048);
3604 assert_eq!(resources.max_memory_mib, 2048);
3605 assert_eq!(resources.cpu_placement, CpuPlacement::Inherit);
3606 assert_eq!(resources.thp, TransparentHugePagePolicy::Madvise);
3607 assert_eq!(
3608 serde_json::to_value(resources).unwrap(),
3609 serde_json::json!({
3610 "cpus": 4,
3611 "memory_mib": 2048,
3612 "max_cpus": 4,
3613 "max_memory_mib": 2048
3614 })
3615 );
3616 }
3617
3618 #[test]
3619 fn cpu_placement_omits_inherit_and_roundtrips_managed_policies() {
3620 let inherited = serde_json::to_value(SandboxResources::default()).unwrap();
3621 assert!(inherited.get("cpu_placement").is_none());
3622
3623 for policy in [
3624 CpuPlacement::Auto,
3625 CpuPlacement::Spread,
3626 CpuPlacement::Compact,
3627 ] {
3628 let resources = SandboxResources {
3629 cpu_placement: policy,
3630 ..Default::default()
3631 };
3632 let json = serde_json::to_string(&resources).unwrap();
3633 let decoded: SandboxResources = serde_json::from_str(&json).unwrap();
3634
3635 assert_eq!(decoded.cpu_placement, policy);
3636 assert_eq!(policy.to_string().parse::<CpuPlacement>().unwrap(), policy);
3637 }
3638 }
3639
3640 #[test]
3641 fn guest_clock_policy_is_omitted_until_set_and_roundtrips() {
3642 let defaults = serde_json::to_value(SandboxRuntimeOptions::default()).unwrap();
3643 assert!(defaults.get("guest_clock").is_none());
3644
3645 let legacy: SandboxRuntimeOptions = serde_json::from_str(r#"{"workdir":"/app"}"#).unwrap();
3646 assert_eq!(legacy.guest_clock, None);
3647
3648 for policy in [GuestClockPolicy::Sync, GuestClockPolicy::Off] {
3649 let runtime = SandboxRuntimeOptions {
3650 guest_clock: Some(policy),
3651 ..Default::default()
3652 };
3653 let json = serde_json::to_value(&runtime).unwrap();
3654 assert_eq!(json["guest_clock"], serde_json::json!(policy.as_str()));
3655 let decoded: SandboxRuntimeOptions = serde_json::from_value(json).unwrap();
3656 assert_eq!(decoded.guest_clock, Some(policy));
3657 assert_eq!(
3658 policy.to_string().parse::<GuestClockPolicy>().unwrap(),
3659 policy
3660 );
3661 }
3662
3663 assert_eq!(GuestClockPolicy::default(), GuestClockPolicy::Sync);
3664 assert!("host_sync".parse::<GuestClockPolicy>().is_err());
3665 }
3666
3667 #[test]
3668 fn transparent_huge_page_policy_roundtrips_non_default() {
3669 let resources: SandboxResources = serde_json::from_str(
3670 r#"{"cpus":2,"memory_mib":8192,"max_cpus":2,"max_memory_mib":8192,"thp":"always"}"#,
3671 )
3672 .unwrap();
3673
3674 assert_eq!(resources.thp, TransparentHugePagePolicy::Always);
3675 assert_eq!(
3676 serde_json::to_value(resources).unwrap()["thp"],
3677 serde_json::json!("always")
3678 );
3679 assert_eq!(
3680 "never".parse::<TransparentHugePagePolicy>().unwrap(),
3681 TransparentHugePagePolicy::Never
3682 );
3683 assert!("auto".parse::<TransparentHugePagePolicy>().is_err());
3684 }
3685
3686 #[test]
3687 fn disk_image_format_display_roundtrip() {
3688 for format in [
3689 DiskImageFormat::Qcow2,
3690 DiskImageFormat::Raw,
3691 DiskImageFormat::Vmdk,
3692 ] {
3693 let rendered = format.to_string();
3694 let parsed: DiskImageFormat = rendered.parse().unwrap();
3695 assert_eq!(parsed, format);
3696 }
3697 }
3698
3699 #[test]
3700 fn disk_image_format_from_str_unknown() {
3701 assert!("ext4".parse::<DiskImageFormat>().is_err());
3702 }
3703
3704 #[test]
3705 fn log_source_effective_uses_default_user_program_sources() {
3706 assert_eq!(
3707 LogSource::effective(&[]),
3708 vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
3709 );
3710 }
3711
3712 #[test]
3713 fn log_source_effective_sorts_and_deduplicates_requested_sources() {
3714 assert_eq!(
3715 LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
3716 vec![LogSource::Stdout, LogSource::System]
3717 );
3718 }
3719
3720 #[test]
3721 fn rlimit_resource_parses_case_insensitively() {
3722 assert_eq!(
3723 RlimitResource::try_from("NOFILE").unwrap(),
3724 RlimitResource::Nofile
3725 );
3726 assert!(RlimitResource::try_from("bogus").is_err());
3727 }
3728
3729 #[test]
3730 fn sandbox_policy_serde_roundtrip() {
3731 let policy = SandboxPolicy {
3732 ephemeral: true,
3733 max_duration_secs: Some(3600),
3734 idle_timeout_secs: Some(120),
3735 };
3736
3737 let json = serde_json::to_string(&policy).unwrap();
3738 let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
3739
3740 assert!(decoded.ephemeral);
3741 assert_eq!(decoded.max_duration_secs, Some(3600));
3742 assert_eq!(decoded.idle_timeout_secs, Some(120));
3743 }
3744
3745 #[test]
3746 fn sandbox_policy_defaults_to_persistent() {
3747 assert!(!SandboxPolicy::default().ephemeral);
3748 }
3749
3750 #[test]
3751 fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
3752 let decoded: SandboxPolicy =
3755 serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
3756 assert!(!decoded.ephemeral);
3757 assert_eq!(decoded.max_duration_secs, Some(60));
3758 }
3759
3760 #[test]
3761 fn sandbox_spec_default_uses_static_resource_defaults() {
3762 let spec = SandboxSpec::default();
3763
3764 assert_eq!(spec.resources.cpus, DEFAULT_SANDBOX_CPUS);
3765 assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
3766 assert_eq!(
3767 spec.runtime.metrics_sample_interval_ms,
3768 Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
3769 );
3770 assert_eq!(spec.deployment_profile, DeploymentProfile::SingleTenant);
3771 }
3772
3773 #[test]
3774 fn deployment_profile_uses_stable_snake_case_wire_values() {
3775 assert_eq!(
3776 serde_json::to_string(&DeploymentProfile::MultiTenant).unwrap(),
3777 r#""multi_tenant""#
3778 );
3779 assert_eq!(
3780 serde_json::from_str::<DeploymentProfile>(r#""single_tenant""#).unwrap(),
3781 DeploymentProfile::SingleTenant
3782 );
3783 }
3784
3785 #[test]
3786 fn sandbox_log_level_roundtrips_lowercase_values() {
3787 for (input, expected) in [
3788 ("error", SandboxLogLevel::Error),
3789 ("warn", SandboxLogLevel::Warn),
3790 ("info", SandboxLogLevel::Info),
3791 ("debug", SandboxLogLevel::Debug),
3792 ("trace", SandboxLogLevel::Trace),
3793 ] {
3794 let parsed: SandboxLogLevel = input.parse().unwrap();
3795 assert_eq!(parsed, expected);
3796 assert_eq!(parsed.as_str(), input);
3797 }
3798 }
3799}