1use crate::filesystem::{
2 normalize_container_destination, normalize_provider_config_destination,
3 normalize_volume_destination, validate_production_rootfs_path, validate_provider_config_source,
4 validate_workspace_host_path,
5};
6use crate::isolation::{NamespaceConfig, UserNamespaceConfig};
7use crate::network::{CredentialBrokerConfig, EgressPolicy};
8use crate::resources::ResourceLimits;
9use crate::security::GVisorPlatform;
10use std::fs::OpenOptions;
11use std::os::unix::fs::FileTypeExt;
12use std::os::unix::fs::OpenOptionsExt;
13use std::os::unix::io::RawFd;
14use std::path::PathBuf;
15use std::time::Duration;
16
17pub const DEFAULT_HOME_PATH: &str = "/home/agent";
18pub const CREDENTIAL_BROKER_CONTAINER_ID_ENV: &str = "NUCLEUS_CONTAINER_ID";
19pub const CREDENTIAL_BROKER_TOKEN_ENV: &str = "NUCLEUS_CREDENTIAL_BROKER_TOKEN";
20
21#[must_use]
22fn is_credential_broker_identity_env(key: &str) -> bool {
23 key == CREDENTIAL_BROKER_CONTAINER_ID_ENV || key == CREDENTIAL_BROKER_TOKEN_ENV
24}
25
26fn open_dev_urandom() -> crate::error::Result<std::fs::File> {
27 let file = OpenOptions::new()
28 .read(true)
29 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
30 .open("/dev/urandom")
31 .map_err(|e| {
32 crate::error::NucleusError::ConfigError(format!(
33 "Failed to open /dev/urandom for container ID generation: {}",
34 e
35 ))
36 })?;
37
38 let metadata = file.metadata().map_err(|e| {
39 crate::error::NucleusError::ConfigError(format!("Failed to stat /dev/urandom: {}", e))
40 })?;
41 if !metadata.file_type().is_char_device() {
42 return Err(crate::error::NucleusError::ConfigError(
43 "/dev/urandom is not a character device".to_string(),
44 ));
45 }
46
47 Ok(file)
48}
49
50pub fn generate_container_id() -> crate::error::Result<String> {
52 use std::io::Read;
53
54 let mut buf = [0u8; 16];
55 let mut file = open_dev_urandom()?;
56 file.read_exact(&mut buf).map_err(|e| {
57 crate::error::NucleusError::ConfigError(format!(
58 "Failed to read secure random bytes for container ID generation: {}",
59 e
60 ))
61 })?;
62 Ok(hex::encode(buf))
63}
64
65#[derive(
69 Debug,
70 Clone,
71 Copy,
72 PartialEq,
73 Eq,
74 Default,
75 clap::ValueEnum,
76 serde::Serialize,
77 serde::Deserialize,
78)]
79#[serde(rename_all = "kebab-case")]
80pub enum TrustLevel {
81 Trusted,
83 #[default]
85 Untrusted,
86}
87
88#[derive(
93 Debug,
94 Clone,
95 Copy,
96 PartialEq,
97 Eq,
98 Default,
99 clap::ValueEnum,
100 serde::Serialize,
101 serde::Deserialize,
102)]
103#[serde(rename_all = "kebab-case")]
104pub enum ServiceMode {
105 #[default]
107 Agent,
108 #[value(name = "strict-agent", alias = "mitos-agent")]
111 #[serde(alias = "mitos-agent")]
112 StrictAgent,
113 Production,
120}
121
122impl ServiceMode {
123 pub fn label(self) -> &'static str {
124 match self {
125 Self::Agent => "Agent mode",
126 Self::StrictAgent => "Strict agent mode",
127 Self::Production => "Production mode",
128 }
129 }
130
131 pub fn enforces_strict_isolation(self) -> bool {
132 matches!(self, Self::StrictAgent | Self::Production)
133 }
134
135 pub fn requires_user_namespace_mapping(self) -> bool {
136 self.enforces_strict_isolation()
137 }
138
139 pub fn requires_cgroup_enforcement(self) -> bool {
140 self.enforces_strict_isolation()
141 }
142
143 pub fn requires_explicit_bridge_dns(self) -> bool {
144 self.enforces_strict_isolation()
145 }
146}
147
148#[derive(
153 Debug,
154 Clone,
155 Copy,
156 PartialEq,
157 Eq,
158 Default,
159 clap::ValueEnum,
160 serde::Serialize,
161 serde::Deserialize,
162)]
163pub enum RuntimeSelection {
164 #[default]
166 #[value(name = "gvisor")]
167 #[serde(rename = "gvisor")]
168 GVisor,
169 #[value(name = "native")]
171 #[serde(rename = "native")]
172 Native,
173}
174
175#[derive(
180 Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize,
181)]
182pub enum NetworkModeArg {
183 #[value(name = "none")]
185 #[serde(rename = "none")]
186 None,
187 #[value(name = "host")]
189 #[serde(rename = "host")]
190 Host,
191 #[value(name = "gvisor-host")]
193 #[serde(rename = "gvisor-host")]
194 GVisorHost,
195 #[value(name = "bridge")]
197 #[serde(rename = "bridge")]
198 Bridge,
199}
200
201#[allow(clippy::derivable_impls)]
202impl Default for NetworkModeArg {
203 fn default() -> Self {
204 Self::None
205 }
206}
207
208#[derive(
212 Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize,
213)]
214pub enum UsernsModeArg {
215 #[value(name = "nomap")]
218 #[serde(rename = "nomap")]
219 NoMap,
220 #[value(name = "keep-id")]
224 #[serde(rename = "keep-id")]
225 KeepId,
226 #[value(name = "auto")]
229 #[serde(rename = "auto")]
230 Auto,
231}
232
233#[allow(clippy::derivable_impls)]
234impl Default for UsernsModeArg {
235 fn default() -> Self {
236 Self::NoMap
237 }
238}
239
240#[derive(
242 Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize,
243)]
244#[serde(rename_all = "kebab-case")]
245pub enum KernelLockdownMode {
246 Integrity,
248 Confidentiality,
250}
251
252impl KernelLockdownMode {
253 pub fn as_str(self) -> &'static str {
254 match self {
255 Self::Integrity => "integrity",
256 Self::Confidentiality => "confidentiality",
257 }
258 }
259
260 pub fn accepts(self, active: Self) -> bool {
261 match self {
262 Self::Integrity => matches!(active, Self::Integrity | Self::Confidentiality),
263 Self::Confidentiality => matches!(active, Self::Confidentiality),
264 }
265 }
266}
267
268#[derive(Debug, Clone)]
270pub struct HealthCheck {
271 pub command: Vec<String>,
273 pub interval: Duration,
275 pub retries: u32,
277 pub start_period: Duration,
279 pub timeout: Duration,
281}
282
283impl Default for HealthCheck {
284 fn default() -> Self {
285 Self {
286 command: Vec::new(),
287 interval: Duration::from_secs(30),
288 retries: 3,
289 start_period: Duration::from_secs(5),
290 timeout: Duration::from_secs(5),
291 }
292 }
293}
294
295#[derive(Debug, Clone)]
297pub struct SecretMount {
298 pub source: PathBuf,
300 pub dest: PathBuf,
302 pub mode: u32,
304}
305
306#[derive(Debug, Clone)]
308pub struct ProviderConfigMount {
309 pub source: PathBuf,
311 pub dest: PathBuf,
313 pub read_only: bool,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct ProcessIdentity {
320 pub uid: u32,
322 pub gid: u32,
324 pub additional_gids: Vec<u32>,
326}
327
328impl ProcessIdentity {
329 pub fn root() -> Self {
331 Self {
332 uid: 0,
333 gid: 0,
334 additional_gids: Vec::new(),
335 }
336 }
337
338 pub fn is_root(&self) -> bool {
340 self.uid == 0 && self.gid == 0 && self.additional_gids.is_empty()
341 }
342}
343
344impl Default for ProcessIdentity {
345 fn default() -> Self {
346 Self::root()
347 }
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub struct ConsoleSize {
353 pub width: u16,
355 pub height: u16,
357}
358
359impl ConsoleSize {
360 pub const DEFAULT_WIDTH: u16 = 80;
361 pub const DEFAULT_HEIGHT: u16 = 24;
362
363 pub fn detect() -> Self {
366 Self::from_fd(libc::STDIN_FILENO)
367 .or_else(Self::from_env)
368 .unwrap_or_default()
369 }
370
371 fn from_fd(fd: RawFd) -> Option<Self> {
372 let mut winsize = libc::winsize {
373 ws_row: 0,
374 ws_col: 0,
375 ws_xpixel: 0,
376 ws_ypixel: 0,
377 };
378 let ret = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut winsize) };
379 if ret == 0 && winsize.ws_col > 0 && winsize.ws_row > 0 {
380 Some(Self {
381 width: winsize.ws_col,
382 height: winsize.ws_row,
383 })
384 } else {
385 None
386 }
387 }
388
389 fn from_env() -> Option<Self> {
390 let width = std::env::var("COLUMNS").ok()?.parse::<u16>().ok()?;
391 let height = std::env::var("LINES").ok()?.parse::<u16>().ok()?;
392 if width > 0 && height > 0 {
393 Some(Self { width, height })
394 } else {
395 None
396 }
397 }
398}
399
400impl Default for ConsoleSize {
401 fn default() -> Self {
402 Self {
403 width: Self::DEFAULT_WIDTH,
404 height: Self::DEFAULT_HEIGHT,
405 }
406 }
407}
408
409#[derive(Debug, Clone)]
411pub enum VolumeSource {
412 Bind { source: PathBuf },
414 Tmpfs { size: Option<String> },
416}
417
418#[derive(Debug, Clone)]
420pub struct VolumeMount {
421 pub source: VolumeSource,
423 pub dest: PathBuf,
425 pub read_only: bool,
427}
428
429#[derive(
431 Debug,
432 Clone,
433 Copy,
434 PartialEq,
435 Eq,
436 Default,
437 clap::ValueEnum,
438 serde::Serialize,
439 serde::Deserialize,
440)]
441#[serde(rename_all = "kebab-case")]
442pub enum RootfsMode {
443 #[default]
445 Bind,
446 Overlay,
448}
449
450#[derive(Debug, Clone)]
452pub struct RootfsOverlayConfig {
453 pub upperdir: PathBuf,
454 pub workdir: PathBuf,
455}
456
457#[derive(
459 Debug,
460 Clone,
461 Copy,
462 PartialEq,
463 Eq,
464 Default,
465 clap::ValueEnum,
466 serde::Serialize,
467 serde::Deserialize,
468)]
469#[serde(rename_all = "kebab-case")]
470pub enum WorkspaceMode {
471 #[default]
473 #[value(name = "bind-rw")]
474 BindRw,
475 #[value(name = "bind-ro")]
477 BindRo,
478 #[value(name = "copy-in-out")]
480 CopyInOut,
481}
482
483#[derive(Debug, Clone)]
485pub struct WorkspaceConfig {
486 pub host_path: Option<PathBuf>,
489 pub container_path: PathBuf,
491 pub mode: WorkspaceMode,
493 pub allow_execute: bool,
495 pub staging_path: Option<PathBuf>,
497}
498
499impl WorkspaceConfig {
500 pub fn new() -> Self {
501 Self::default()
502 }
503
504 pub fn with_host_path(mut self, host_path: PathBuf) -> Self {
505 self.host_path = Some(host_path);
506 self
507 }
508
509 pub fn with_mode(mut self, mode: WorkspaceMode) -> Self {
510 self.mode = mode;
511 self
512 }
513
514 pub fn with_allow_execute(mut self, allow_execute: bool) -> Self {
515 self.allow_execute = allow_execute;
516 self
517 }
518
519 pub fn with_staging_path(mut self, staging_path: PathBuf) -> Self {
520 self.staging_path = Some(staging_path);
521 self
522 }
523
524 pub fn is_read_only(&self) -> bool {
525 matches!(self.mode, WorkspaceMode::BindRo)
526 }
527
528 pub fn is_writable(&self) -> bool {
529 !self.is_read_only()
530 }
531
532 pub fn effective_host_path(&self) -> Option<&PathBuf> {
533 if self.mode == WorkspaceMode::CopyInOut {
534 self.staging_path.as_ref()
535 } else {
536 self.host_path.as_ref()
537 }
538 }
539}
540
541impl Default for WorkspaceConfig {
542 fn default() -> Self {
543 Self {
544 host_path: None,
545 container_path: PathBuf::from("/workspace"),
546 mode: WorkspaceMode::default(),
547 allow_execute: false,
548 staging_path: None,
549 }
550 }
551}
552
553#[derive(Debug, Clone)]
555pub enum ReadinessProbe {
556 Exec { command: Vec<String> },
558 TcpPort(u16),
560 SdNotify,
562}
563
564#[derive(
570 Debug,
571 Clone,
572 Copy,
573 PartialEq,
574 Eq,
575 Default,
576 clap::ValueEnum,
577 serde::Serialize,
578 serde::Deserialize,
579)]#[serde(rename_all = "kebab-case")]
580pub enum GpuVendor {
581 #[default]
583 Auto,
584 Nvidia,
586 Amd,
588 Intel,
590 All,
592}
593
594impl GpuVendor {
595 pub fn includes_nvidia(self) -> bool {
597 matches!(self, Self::Auto | Self::Nvidia | Self::All)
598 }
599
600 pub fn includes_amd(self) -> bool {
602 matches!(self, Self::Auto | Self::Amd | Self::All)
603 }
604
605 pub fn includes_intel(self) -> bool {
607 matches!(self, Self::Auto | Self::Intel | Self::All)
608 }
609}
610
611pub const DEFAULT_GPU_DRIVER_CAPABILITIES: &str = "compute,utility";
617
618pub const DEFAULT_GPU_VISIBLE_DEVICES: &str = "all";
620
621#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
630pub struct GpuPassthroughConfig {
631 #[serde(default)]
633 pub vendor: GpuVendor,
634 #[serde(default)]
637 pub devices: Vec<PathBuf>,
638 pub driver_capabilities: String,
641 pub visible_devices: String,
644 #[serde(default = "default_bind_driver_libraries")]
647 pub bind_driver_libraries: bool,
648}
649
650fn default_bind_driver_libraries() -> bool {
651 true
652}
653
654impl Default for GpuPassthroughConfig {
655 fn default() -> Self {
656 Self {
657 vendor: GpuVendor::Auto,
658 devices: Vec::new(),
659 driver_capabilities: DEFAULT_GPU_DRIVER_CAPABILITIES.to_string(),
660 visible_devices: DEFAULT_GPU_VISIBLE_DEVICES.to_string(),
661 bind_driver_libraries: true,
662 }
663 }
664}
665
666impl GpuPassthroughConfig {
667 pub fn is_enabled(&self) -> bool {
670 true
671 }
672}
673
674pub fn gpu_environment(gpu: &GpuPassthroughConfig) -> Vec<(&'static str, String)> {
681 let mut env = vec![
682 ("NVIDIA_VISIBLE_DEVICES", gpu.visible_devices.clone()),
683 ("NVIDIA_DRIVER_CAPABILITIES", gpu.driver_capabilities.clone()),
684 ];
685 if gpu.vendor.includes_nvidia() {
686 env.push((
687 "__EGL_VENDOR_LIBRARY_FILENAMES",
688 "/usr/share/glvnd/egl_vendor.d/10_nvidia.json".to_string(),
689 ));
690 }
691 env
692}
693
694#[derive(Debug, Clone)]
696pub struct ContainerConfig {
697 pub id: String,
699
700 pub name: String,
702
703 pub command: Vec<String>,
705
706 pub context_dir: Option<PathBuf>,
708
709 pub limits: ResourceLimits,
711
712 pub namespaces: NamespaceConfig,
714
715 pub user_ns_config: Option<UserNamespaceConfig>,
717
718 pub hostname: Option<String>,
720
721 pub use_gvisor: bool,
723
724 pub trust_level: TrustLevel,
726
727 pub network: crate::network::NetworkMode,
729
730 pub context_mode: crate::filesystem::ContextMode,
732
733 pub allow_degraded_security: bool,
735
736 pub allow_chroot_fallback: bool,
738
739 pub allow_host_network: bool,
741
742 pub proc_readonly: bool,
744
745 pub service_mode: ServiceMode,
747
748 pub rootfs_path: Option<PathBuf>,
751
752 pub rootfs_mode: RootfsMode,
754
755 pub rootfs_overlay: Option<RootfsOverlayConfig>,
758
759 pub egress_policy: Option<EgressPolicy>,
761
762 pub credential_broker: Option<CredentialBrokerConfig>,
765
766 pub credential_broker_token: String,
769
770 pub health_check: Option<HealthCheck>,
772
773 pub readiness_probe: Option<ReadinessProbe>,
775
776 pub secrets: Vec<SecretMount>,
778
779 pub volumes: Vec<VolumeMount>,
781
782 pub workspace: WorkspaceConfig,
784
785 pub home: PathBuf,
787
788 pub provider_configs: Vec<ProviderConfigMount>,
790
791 pub workdir: PathBuf,
793
794 pub environment: Vec<(String, String)>,
796
797 pub derived_environment: Vec<(String, String)>,
805
806 pub process_identity: ProcessIdentity,
808
809 pub config_hash: Option<u64>,
811
812 pub sd_notify: bool,
814
815 pub required_kernel_lockdown: Option<KernelLockdownMode>,
817
818 pub verify_context_integrity: bool,
820
821 pub verify_rootfs_attestation: bool,
823
824 pub seccomp_log_denied: bool,
826
827 pub gvisor_platform: GVisorPlatform,
829
830 pub seccomp_profile: Option<PathBuf>,
833
834 pub seccomp_profile_sha256: Option<String>,
836
837 pub seccomp_mode: SeccompMode,
839
840 pub seccomp_trace_log: Option<PathBuf>,
842
843 pub seccomp_allow_syscalls: Vec<String>,
847
848 pub caps_policy: Option<PathBuf>,
850
851 pub caps_policy_sha256: Option<String>,
853
854 pub landlock_policy: Option<PathBuf>,
856
857 pub landlock_policy_sha256: Option<String>,
859
860 pub hooks: Option<crate::security::OciHooks>,
862
863 pub pid_file: Option<PathBuf>,
865
866 pub console_socket: Option<PathBuf>,
868
869 pub terminal: bool,
871
872 pub console_size: ConsoleSize,
874
875 pub bundle_dir: Option<PathBuf>,
877
878 pub state_root: Option<PathBuf>,
881
882 pub gpu: Option<GpuPassthroughConfig>,
887}
888
889#[derive(
891 Debug,
892 Clone,
893 Copy,
894 PartialEq,
895 Eq,
896 Default,
897 clap::ValueEnum,
898 serde::Serialize,
899 serde::Deserialize,
900)]
901#[serde(rename_all = "kebab-case")]
902pub enum SeccompMode {
903 #[default]
905 Enforce,
906 Trace,
909}
910
911impl ContainerConfig {
912 pub fn try_new(name: Option<String>, command: Vec<String>) -> crate::error::Result<Self> {
917 Self::try_new_with_id(None, name, command)
918 }
919
920 pub fn try_new_with_id(
926 preset_id: Option<String>,
927 name: Option<String>,
928 command: Vec<String>,
929 ) -> crate::error::Result<Self> {
930 let id = match preset_id {
931 Some(id) => {
932 if id.len() != 32 || !id.chars().all(|c| c.is_ascii_hexdigit()) {
934 return Err(crate::error::NucleusError::ConfigError(format!(
935 "Invalid preset container ID '{}': must be 32 hex characters",
936 id
937 )));
938 }
939 id
940 }
941 None => generate_container_id()?,
942 };
943 let credential_broker_token = generate_container_id()?;
944 let name = name.unwrap_or_else(|| id.clone());
945 Ok(Self {
946 id,
947 name: name.clone(),
948 command,
949 context_dir: None,
950 limits: ResourceLimits::default(),
951 namespaces: NamespaceConfig::default(),
952 user_ns_config: None,
953 hostname: Some(name),
954 use_gvisor: true,
955 trust_level: TrustLevel::default(),
956 network: crate::network::NetworkMode::None,
957 context_mode: crate::filesystem::ContextMode::Copy,
958 allow_degraded_security: false,
959 allow_chroot_fallback: false,
960 allow_host_network: false,
961 proc_readonly: true,
962 service_mode: ServiceMode::default(),
963 rootfs_path: None,
964 rootfs_mode: RootfsMode::default(),
965 rootfs_overlay: None,
966 egress_policy: None,
967 credential_broker: None,
968 credential_broker_token,
969 health_check: None,
970 readiness_probe: None,
971 secrets: Vec::new(),
972 volumes: Vec::new(),
973 workspace: WorkspaceConfig::default(),
974 home: PathBuf::from(DEFAULT_HOME_PATH),
975 provider_configs: Vec::new(),
976 workdir: PathBuf::from("/workspace"),
977 environment: Vec::new(),
978 derived_environment: Vec::new(),
979 process_identity: ProcessIdentity::default(),
980 config_hash: None,
981 sd_notify: false,
982 required_kernel_lockdown: None,
983 verify_context_integrity: false,
984 verify_rootfs_attestation: false,
985 seccomp_log_denied: false,
986 gvisor_platform: GVisorPlatform::default(),
987 seccomp_profile: None,
988 seccomp_profile_sha256: None,
989 seccomp_mode: SeccompMode::default(),
990 seccomp_trace_log: None,
991 seccomp_allow_syscalls: Vec::new(),
992 caps_policy: None,
993 caps_policy_sha256: None,
994 landlock_policy: None,
995 landlock_policy_sha256: None,
996 hooks: None,
997 pid_file: None,
998 console_socket: None,
999 terminal: false,
1000 console_size: ConsoleSize::default(),
1001 bundle_dir: None,
1002 state_root: None,
1003 gpu: None,
1004 })
1005 }
1006
1007 #[must_use]
1009 pub fn with_rootless(mut self) -> Self {
1010 self.namespaces.user = true;
1011 self.user_ns_config = Some(UserNamespaceConfig::rootless());
1012 self
1013 }
1014
1015 #[must_use]
1017 pub fn with_user_namespace(mut self, config: UserNamespaceConfig) -> Self {
1018 self.namespaces.user = true;
1019 self.user_ns_config = Some(config);
1020 self
1021 }
1022
1023 #[must_use]
1024 pub fn with_context(mut self, dir: PathBuf) -> Self {
1025 self.context_dir = Some(dir);
1026 self
1027 }
1028
1029 #[must_use]
1030 pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
1031 self.limits = limits;
1032 self
1033 }
1034
1035 #[must_use]
1036 pub fn with_namespaces(mut self, namespaces: NamespaceConfig) -> Self {
1037 self.namespaces = namespaces;
1038 self
1039 }
1040
1041 #[must_use]
1042 pub fn with_hostname(mut self, hostname: Option<String>) -> Self {
1043 self.hostname = hostname;
1044 self
1045 }
1046
1047 #[must_use]
1048 pub fn with_gvisor(mut self, enabled: bool) -> Self {
1049 self.use_gvisor = enabled;
1050 self
1051 }
1052
1053 #[must_use]
1054 pub fn with_trust_level(mut self, level: TrustLevel) -> Self {
1055 self.trust_level = level;
1056 self
1057 }
1058
1059 #[must_use]
1061 pub fn with_oci_bundle(mut self) -> Self {
1062 self.use_gvisor = true;
1063 self
1064 }
1065
1066 #[must_use]
1067 pub fn with_network(mut self, mode: crate::network::NetworkMode) -> Self {
1068 self.network = mode;
1069 self
1070 }
1071
1072 #[must_use]
1073 pub fn with_context_mode(mut self, mode: crate::filesystem::ContextMode) -> Self {
1074 self.context_mode = mode;
1075 self
1076 }
1077
1078 #[must_use]
1079 pub fn with_allow_degraded_security(mut self, allow: bool) -> Self {
1080 self.allow_degraded_security = allow;
1081 self
1082 }
1083
1084 #[must_use]
1085 pub fn with_allow_chroot_fallback(mut self, allow: bool) -> Self {
1086 self.allow_chroot_fallback = allow;
1087 self
1088 }
1089
1090 #[must_use]
1091 pub fn with_allow_host_network(mut self, allow: bool) -> Self {
1092 self.allow_host_network = allow;
1093 self
1094 }
1095
1096 #[must_use]
1097 pub fn with_proc_readonly(mut self, proc_readonly: bool) -> Self {
1098 self.proc_readonly = proc_readonly;
1099 self
1100 }
1101
1102 #[must_use]
1103 pub fn with_service_mode(mut self, mode: ServiceMode) -> Self {
1104 self.service_mode = mode;
1105 self
1106 }
1107
1108 #[must_use]
1109 pub fn with_rootfs_path(mut self, path: PathBuf) -> Self {
1110 self.rootfs_path = Some(path);
1111 self
1112 }
1113
1114 #[must_use]
1115 pub fn with_rootfs_mode(mut self, mode: RootfsMode) -> Self {
1116 self.rootfs_mode = mode;
1117 self
1118 }
1119
1120 #[must_use]
1121 pub fn with_rootfs_overlay(mut self, upperdir: PathBuf, workdir: PathBuf) -> Self {
1122 self.rootfs_overlay = Some(RootfsOverlayConfig { upperdir, workdir });
1123 self
1124 }
1125
1126 #[must_use]
1127 pub fn with_egress_policy(mut self, policy: EgressPolicy) -> Self {
1128 self.egress_policy = Some(policy);
1129 self
1130 }
1131
1132 #[must_use]
1133 pub fn with_credential_broker(mut self, broker: CredentialBrokerConfig) -> Self {
1134 self.credential_broker = Some(broker);
1135 self = self.with_credential_broker_identity_env();
1136 self
1137 }
1138
1139 #[must_use]
1140 pub fn with_health_check(mut self, hc: HealthCheck) -> Self {
1141 self.health_check = Some(hc);
1142 self
1143 }
1144
1145 #[must_use]
1146 pub fn with_readiness_probe(mut self, probe: ReadinessProbe) -> Self {
1147 self.readiness_probe = Some(probe);
1148 self
1149 }
1150
1151 #[must_use]
1152 pub fn with_secret(mut self, secret: SecretMount) -> Self {
1153 self.secrets.push(secret);
1154 self
1155 }
1156
1157 #[must_use]
1158 pub fn with_volume(mut self, volume: VolumeMount) -> Self {
1159 self.volumes.push(volume);
1160 self
1161 }
1162
1163 #[must_use]
1164 pub fn with_workspace(mut self, workspace: WorkspaceConfig) -> Self {
1165 self.workspace = workspace;
1166 self
1167 }
1168
1169 #[must_use]
1170 pub fn with_home(mut self, home: PathBuf) -> Self {
1171 self.home = home;
1172 self
1173 }
1174
1175 #[must_use]
1176 pub fn with_provider_config(mut self, provider_config: ProviderConfigMount) -> Self {
1177 self.provider_configs.push(provider_config);
1178 self
1179 }
1180
1181 #[must_use]
1182 pub fn with_workdir(mut self, workdir: PathBuf) -> Self {
1183 self.workdir = workdir;
1184 self
1185 }
1186
1187 #[must_use]
1188 pub fn with_env(mut self, key: String, value: String) -> Self {
1189 if self.credential_broker_owns_env(&key) {
1190 return self;
1191 }
1192 self.environment.push((key, value));
1193 self
1194 }
1195
1196 #[must_use]
1202 pub fn with_derived_env(mut self, key: String, value: String) -> Self {
1203 self.derived_environment.push((key, value));
1204 self
1205 }
1206
1207 fn upsert_derived_env(&mut self, key: &str, value: String) {
1208 self.derived_environment
1209 .retain(|(existing_key, _)| existing_key != key);
1210 self.derived_environment.push((key.to_string(), value));
1211 }
1212
1213 #[must_use]
1214 pub fn with_credential_broker_identity_env(mut self) -> Self {
1215 if self.credential_broker.is_some() {
1216 self.environment
1221 .retain(|(key, _)| !is_credential_broker_identity_env(key));
1222 self.upsert_derived_env(CREDENTIAL_BROKER_CONTAINER_ID_ENV, self.id.clone());
1223 self.upsert_derived_env(
1224 CREDENTIAL_BROKER_TOKEN_ENV,
1225 self.credential_broker_token.clone(),
1226 );
1227 }
1228 self
1229 }
1230
1231 #[must_use]
1232 pub(crate) fn credential_broker_owns_env(&self, key: &str) -> bool {
1233 self.credential_broker.is_some() && is_credential_broker_identity_env(key)
1234 }
1235
1236 #[must_use]
1237 pub fn with_process_identity(mut self, identity: ProcessIdentity) -> Self {
1238 self.process_identity = identity;
1239 self
1240 }
1241
1242 #[must_use]
1243 pub fn with_config_hash(mut self, hash: u64) -> Self {
1244 self.config_hash = Some(hash);
1245 self
1246 }
1247
1248 #[must_use]
1249 pub fn with_sd_notify(mut self, enabled: bool) -> Self {
1250 self.sd_notify = enabled;
1251 self
1252 }
1253
1254 #[must_use]
1255 pub fn with_required_kernel_lockdown(mut self, mode: KernelLockdownMode) -> Self {
1256 self.required_kernel_lockdown = Some(mode);
1257 self
1258 }
1259
1260 #[must_use]
1261 pub fn with_verify_context_integrity(mut self, enabled: bool) -> Self {
1262 self.verify_context_integrity = enabled;
1263 self
1264 }
1265
1266 #[must_use]
1267 pub fn with_verify_rootfs_attestation(mut self, enabled: bool) -> Self {
1268 self.verify_rootfs_attestation = enabled;
1269 self
1270 }
1271
1272 #[must_use]
1273 pub fn with_seccomp_log_denied(mut self, enabled: bool) -> Self {
1274 self.seccomp_log_denied = enabled;
1275 self
1276 }
1277
1278 #[must_use]
1279 pub fn with_gvisor_platform(mut self, platform: GVisorPlatform) -> Self {
1280 self.gvisor_platform = platform;
1281 self
1282 }
1283
1284 #[must_use]
1285 pub fn with_seccomp_profile(mut self, path: PathBuf) -> Self {
1286 self.seccomp_profile = Some(path);
1287 self
1288 }
1289
1290 #[must_use]
1291 pub fn with_seccomp_profile_sha256(mut self, hash: String) -> Self {
1292 self.seccomp_profile_sha256 = Some(hash);
1293 self
1294 }
1295
1296 #[must_use]
1297 pub fn with_seccomp_mode(mut self, mode: SeccompMode) -> Self {
1298 self.seccomp_mode = mode;
1299 self
1300 }
1301
1302 #[must_use]
1303 pub fn with_seccomp_trace_log(mut self, path: PathBuf) -> Self {
1304 self.seccomp_trace_log = Some(path);
1305 self
1306 }
1307
1308 #[must_use]
1309 pub fn with_seccomp_allow_syscalls(mut self, syscalls: Vec<String>) -> Self {
1310 self.seccomp_allow_syscalls = syscalls;
1311 self
1312 }
1313
1314 #[must_use]
1315 pub fn with_caps_policy(mut self, path: PathBuf) -> Self {
1316 self.caps_policy = Some(path);
1317 self
1318 }
1319
1320 #[must_use]
1321 pub fn with_caps_policy_sha256(mut self, hash: String) -> Self {
1322 self.caps_policy_sha256 = Some(hash);
1323 self
1324 }
1325
1326 #[must_use]
1327 pub fn with_landlock_policy(mut self, path: PathBuf) -> Self {
1328 self.landlock_policy = Some(path);
1329 self
1330 }
1331
1332 #[must_use]
1333 pub fn with_landlock_policy_sha256(mut self, hash: String) -> Self {
1334 self.landlock_policy_sha256 = Some(hash);
1335 self
1336 }
1337
1338 #[must_use]
1339 pub fn with_pid_file(mut self, path: PathBuf) -> Self {
1340 self.pid_file = Some(path);
1341 self
1342 }
1343
1344 #[must_use]
1345 pub fn with_console_socket(mut self, path: PathBuf) -> Self {
1346 self.console_socket = Some(path);
1347 self.terminal = true;
1348 self
1349 }
1350
1351 #[must_use]
1352 pub fn with_terminal(mut self, size: ConsoleSize) -> Self {
1353 self.terminal = true;
1354 self.console_size = size;
1355 self
1356 }
1357
1358 #[must_use]
1359 pub fn with_bundle_dir(mut self, path: PathBuf) -> Self {
1360 self.bundle_dir = Some(path);
1361 self
1362 }
1363
1364 pub fn with_state_root(mut self, root: PathBuf) -> Self {
1365 self.state_root = Some(root);
1366 self
1367 }
1368
1369 #[must_use]
1374 pub fn with_gpu(mut self, gpu: GpuPassthroughConfig) -> Self {
1375 self.gpu = Some(gpu);
1376 self
1377 }
1378
1379 fn validate_credential_broker(&self) -> crate::error::Result<()> {
1380 let Some(broker) = &self.credential_broker else {
1381 return Ok(());
1382 };
1383
1384 let crate::network::NetworkMode::Bridge(bridge_config) = &self.network else {
1385 return Err(crate::error::NucleusError::ConfigError(
1386 "Credential broker egress requires --network bridge so Nucleus can force the \
1387 sandbox through the host-side broker endpoint"
1388 .to_string(),
1389 ));
1390 };
1391
1392 if bridge_config.nat_backend == crate::network::NatBackend::Userspace {
1393 return Err(crate::error::NucleusError::ConfigError(
1394 "Credential broker egress requires the kernel NAT backend; \
1395 slirp4netns userspace NAT cannot route to the host-side bridge broker"
1396 .to_string(),
1397 ));
1398 }
1399
1400 broker
1401 .validate_for_bridge(bridge_config)
1402 .map_err(crate::error::NucleusError::ConfigError)?;
1403
1404 let Some(policy) = &self.egress_policy else {
1405 return Err(crate::error::NucleusError::ConfigError(
1406 "Credential broker egress requires a broker-only egress policy".to_string(),
1407 ));
1408 };
1409
1410 if !policy.is_credential_broker_only(broker) {
1411 return Err(crate::error::NucleusError::ConfigError(
1412 "Credential broker egress must allow only the broker IP/port and must deny DNS"
1413 .to_string(),
1414 ));
1415 }
1416
1417 Ok(())
1418 }
1419
1420 fn validate_fail_closed_isolation(&self) -> crate::error::Result<()> {
1421 let mode = self.service_mode.label();
1422 if self.allow_degraded_security {
1423 return Err(crate::error::NucleusError::ConfigError(format!(
1424 "{} forbids --allow-degraded-security",
1425 mode
1426 )));
1427 }
1428
1429 if self.allow_chroot_fallback {
1430 return Err(crate::error::NucleusError::ConfigError(format!(
1431 "{} forbids --allow-chroot-fallback",
1432 mode
1433 )));
1434 }
1435
1436 if matches!(self.network, crate::network::NetworkMode::Host) {
1437 return Err(crate::error::NucleusError::ConfigError(format!(
1438 "{} forbids native host network mode because it collapses the \
1439 runtime boundary; use --network gvisor-host with --runtime gvisor and \
1440 --allow-host-network when hostinet is required",
1441 mode
1442 )));
1443 }
1444
1445 if matches!(self.network, crate::network::NetworkMode::GVisorHost) {
1446 if !self.use_gvisor {
1447 return Err(crate::error::NucleusError::ConfigError(format!(
1448 "{} requires --runtime gvisor for --network gvisor-host",
1449 mode
1450 )));
1451 }
1452 if !self.allow_host_network {
1453 return Err(crate::error::NucleusError::ConfigError(format!(
1454 "{} requires --allow-host-network for --network gvisor-host",
1455 mode
1456 )));
1457 }
1458 if self.egress_policy.is_some() {
1459 return Err(crate::error::NucleusError::ConfigError(format!(
1460 "{} cannot enforce egress policy with --network gvisor-host",
1461 mode
1462 )));
1463 }
1464 } else if self.allow_host_network {
1465 return Err(crate::error::NucleusError::ConfigError(format!(
1466 "{} permits --allow-host-network only with --network gvisor-host",
1467 mode
1468 )));
1469 }
1470
1471 if self.seccomp_mode == SeccompMode::Trace {
1472 return Err(crate::error::NucleusError::ConfigError(format!(
1473 "{} forbids --seccomp-mode trace",
1474 mode
1475 )));
1476 }
1477
1478 if !self.seccomp_allow_syscalls.is_empty() {
1479 let allow_network = !matches!(self.network, crate::network::NetworkMode::None);
1480 crate::security::SeccompManager::validate_extra_syscalls_for_production(
1481 allow_network,
1482 &self.seccomp_allow_syscalls,
1483 )?;
1484 }
1485
1486 Ok(())
1487 }
1488
1489 pub fn validate_strict_agent_mode(&self) -> crate::error::Result<()> {
1492 if self.service_mode != ServiceMode::StrictAgent {
1493 return Ok(());
1494 }
1495
1496 self.validate_fail_closed_isolation()?;
1497
1498 if !self.limits.has_cgroup_control() {
1499 return Err(crate::error::NucleusError::ConfigError(
1500 "Strict agent mode requires at least one cgroup control \
1501 (default --pids limit or explicit --memory/--cpus/--pids/--io-limit)"
1502 .to_string(),
1503 ));
1504 }
1505
1506 Ok(())
1507 }
1508
1509 pub fn validate_production_mode(&self) -> crate::error::Result<()> {
1512 if self.service_mode != ServiceMode::Production {
1513 return Ok(());
1514 }
1515
1516 self.validate_fail_closed_isolation()?;
1517
1518 if self.gpu.is_some() {
1519 return Err(crate::error::NucleusError::ConfigError(
1520 "Production mode forbids --gpu host device passthrough; production services must \
1521 declare GPU needs through an attested rootfs"
1522 .to_string(),
1523 ));
1524 }
1525
1526 if self.workspace.allow_execute && self.workspace.is_writable() {
1527 return Err(crate::error::NucleusError::ConfigError(
1528 "Production mode forbids writable executable workspaces; use bind-ro with \
1529 --workspace-exec or remove --workspace-exec"
1530 .to_string(),
1531 ));
1532 }
1533
1534 let Some(rootfs_path) = self.rootfs_path.as_ref() else {
1536 return Err(crate::error::NucleusError::ConfigError(
1537 "Production mode requires explicit --rootfs path (no host bind mounts)".to_string(),
1538 ));
1539 };
1540
1541 if self.rootfs_mode == RootfsMode::Overlay {
1542 return Err(crate::error::NucleusError::ConfigError(
1543 "Production mode does not yet support --rootfs-mode overlay; production mount \
1544 auditing currently requires read-only rootfs bind mounts"
1545 .to_string(),
1546 ));
1547 }
1548
1549 if self.caps_policy.is_some() && self.caps_policy_sha256.is_none() {
1551 return Err(crate::error::NucleusError::ConfigError(
1552 "Production mode requires --caps-policy-sha256 when using --caps-policy"
1553 .to_string(),
1554 ));
1555 }
1556 if self.landlock_policy.is_some() && self.landlock_policy_sha256.is_none() {
1557 return Err(crate::error::NucleusError::ConfigError(
1558 "Production mode requires --landlock-policy-sha256 when using --landlock-policy"
1559 .to_string(),
1560 ));
1561 }
1562 if self.seccomp_profile.is_some() && self.seccomp_profile_sha256.is_none() {
1563 return Err(crate::error::NucleusError::ConfigError(
1564 "Production mode requires --seccomp-profile-sha256 when using --seccomp-profile"
1565 .to_string(),
1566 ));
1567 }
1568
1569 if self.limits.memory_bytes.is_none() {
1571 return Err(crate::error::NucleusError::ConfigError(
1572 "Production mode requires explicit --memory limit".to_string(),
1573 ));
1574 }
1575
1576 if self.limits.cpu_quota_us.is_none() {
1577 return Err(crate::error::NucleusError::ConfigError(
1578 "Production mode requires explicit --cpus limit".to_string(),
1579 ));
1580 }
1581
1582 if !self.verify_rootfs_attestation {
1583 return Err(crate::error::NucleusError::ConfigError(
1584 "Production mode requires --verify-rootfs-attestation".to_string(),
1585 ));
1586 }
1587
1588 validate_production_rootfs_path(rootfs_path)?;
1589
1590 Ok(())
1591 }
1592
1593 pub fn validate_runtime_support(&self) -> crate::error::Result<()> {
1595 self.limits.validate_runtime_sanity()?;
1596
1597 if let Some(user_ns_config) = &self.user_ns_config {
1598 if !self.process_identity.additional_gids.is_empty() {
1599 return Err(crate::error::NucleusError::ConfigError(
1600 "Supplementary groups are currently unsupported with user namespaces"
1601 .to_string(),
1602 ));
1603 }
1604
1605 let uid_mapped = user_ns_config.uid_mappings.iter().any(|mapping| {
1606 self.process_identity.uid >= mapping.container_id
1607 && self.process_identity.uid
1608 < mapping.container_id.saturating_add(mapping.count)
1609 });
1610 if !uid_mapped {
1611 return Err(crate::error::NucleusError::ConfigError(format!(
1612 "Process uid {} is not mapped in the configured user namespace",
1613 self.process_identity.uid
1614 )));
1615 }
1616
1617 let gid_mapped = user_ns_config.gid_mappings.iter().any(|mapping| {
1618 self.process_identity.gid >= mapping.container_id
1619 && self.process_identity.gid
1620 < mapping.container_id.saturating_add(mapping.count)
1621 });
1622 if !gid_mapped {
1623 return Err(crate::error::NucleusError::ConfigError(format!(
1624 "Process gid {} is not mapped in the configured user namespace",
1625 self.process_identity.gid
1626 )));
1627 }
1628 }
1629
1630 if self.seccomp_mode == SeccompMode::Trace && self.seccomp_trace_log.is_none() {
1631 return Err(crate::error::NucleusError::ConfigError(
1632 "Seccomp trace mode requires --seccomp-log / seccomp_trace_log".to_string(),
1633 ));
1634 }
1635
1636 normalize_container_destination(&self.workdir)?;
1637 let workspace_path = normalize_container_destination(&self.workspace.container_path)?;
1638 if workspace_path != std::path::Path::new("/workspace") {
1639 return Err(crate::error::NucleusError::ConfigError(
1640 "Workspace destination is fixed at /workspace".to_string(),
1641 ));
1642 }
1643 let home_path = normalize_container_destination(&self.home)?;
1644 if home_path == workspace_path
1645 || home_path.starts_with(&workspace_path)
1646 || workspace_path.starts_with(&home_path)
1647 {
1648 return Err(crate::error::NucleusError::ConfigError(
1649 "--home must not overlap /workspace".to_string(),
1650 ));
1651 }
1652 if let Some(host_path) = &self.workspace.host_path {
1653 validate_workspace_host_path(host_path)?;
1654 }
1655 if self.workspace.mode == WorkspaceMode::CopyInOut && self.workspace.host_path.is_none() {
1656 return Err(crate::error::NucleusError::ConfigError(
1657 "--workspace-mode copy-in-out requires --workspace".to_string(),
1658 ));
1659 }
1660
1661 for secret in &self.secrets {
1662 normalize_container_destination(&secret.dest)?;
1663 }
1664
1665 for provider_config in &self.provider_configs {
1666 validate_provider_config_source(&provider_config.source)?;
1667 normalize_provider_config_destination(&home_path, &provider_config.dest)?;
1668 }
1669
1670 for volume in &self.volumes {
1671 let volume_dest = normalize_volume_destination(&volume.dest)?;
1672 if volume_dest == workspace_path {
1673 return Err(crate::error::NucleusError::ConfigError(
1674 "Volume destination /workspace conflicts with --workspace".to_string(),
1675 ));
1676 }
1677 if volume_dest == home_path {
1678 return Err(crate::error::NucleusError::ConfigError(
1679 "Volume destination conflicts with --home".to_string(),
1680 ));
1681 }
1682 match &volume.source {
1683 VolumeSource::Bind { source } => {
1684 if !source.is_absolute() {
1685 return Err(crate::error::NucleusError::ConfigError(format!(
1686 "Volume source must be absolute: {:?}",
1687 source
1688 )));
1689 }
1690 if !source.exists() {
1691 return Err(crate::error::NucleusError::ConfigError(format!(
1692 "Volume source does not exist: {:?}",
1693 source
1694 )));
1695 }
1696 crate::filesystem::validate_bind_mount_source(source)?;
1697 }
1698 VolumeSource::Tmpfs { .. } => {}
1699 }
1700 }
1701
1702 self.validate_credential_broker()?;
1703
1704 if self.rootfs_mode == RootfsMode::Overlay {
1705 if self.rootfs_path.is_none() {
1706 return Err(crate::error::NucleusError::ConfigError(
1707 "--rootfs-mode overlay requires --rootfs or --agent-toolchain-rootfs"
1708 .to_string(),
1709 ));
1710 }
1711 if self.use_gvisor {
1712 return Err(crate::error::NucleusError::ConfigError(
1713 "--rootfs-mode overlay is currently supported only with --runtime native"
1714 .to_string(),
1715 ));
1716 }
1717 }
1718
1719 if !self.use_gvisor {
1720 return Ok(());
1721 }
1722
1723 if self.seccomp_mode == SeccompMode::Trace {
1724 return Err(crate::error::NucleusError::ConfigError(
1725 "gVisor runtime does not support --seccomp-mode trace; use --runtime native"
1726 .to_string(),
1727 ));
1728 }
1729
1730 if self.seccomp_log_denied {
1731 return Err(crate::error::NucleusError::ConfigError(
1732 "gVisor runtime does not support seccomp deny logging; use --runtime native"
1733 .to_string(),
1734 ));
1735 }
1736
1737 if !self.seccomp_allow_syscalls.is_empty() {
1738 return Err(crate::error::NucleusError::ConfigError(
1739 "gVisor runtime does not support --seccomp-allow; use a custom --seccomp-profile or --runtime native"
1740 .to_string(),
1741 ));
1742 }
1743
1744 if self.caps_policy.is_some() {
1745 return Err(crate::error::NucleusError::ConfigError(
1746 "gVisor runtime does not support capability policy files; use --runtime native"
1747 .to_string(),
1748 ));
1749 }
1750
1751 if self.landlock_policy.is_some() {
1752 return Err(crate::error::NucleusError::ConfigError(
1753 "gVisor runtime does not support Landlock policy files; use --runtime native"
1754 .to_string(),
1755 ));
1756 }
1757
1758 if self.health_check.is_some() {
1759 return Err(crate::error::NucleusError::ConfigError(
1760 "gVisor runtime does not support exec health checks; use --runtime native or remove --health-cmd"
1761 .to_string(),
1762 ));
1763 }
1764
1765 if matches!(
1766 self.readiness_probe.as_ref(),
1767 Some(ReadinessProbe::Exec { .. }) | Some(ReadinessProbe::TcpPort(_))
1768 ) {
1769 return Err(crate::error::NucleusError::ConfigError(
1770 "gVisor runtime does not support exec/TCP readiness probes; use --runtime native or --readiness-sd-notify"
1771 .to_string(),
1772 ));
1773 }
1774
1775 if self.verify_context_integrity
1776 && self.context_dir.is_some()
1777 && matches!(self.context_mode, crate::filesystem::ContextMode::BindMount)
1778 {
1779 return Err(crate::error::NucleusError::ConfigError(
1780 "gVisor runtime cannot verify bind-mounted context integrity; use --context-mode copy or disable --verify-context-integrity"
1781 .to_string(),
1782 ));
1783 }
1784
1785 Ok(())
1786 }
1787
1788 pub fn apply_runtime_selection(
1790 mut self,
1791 runtime: RuntimeSelection,
1792 oci: bool,
1793 ) -> crate::error::Result<Self> {
1794 match runtime {
1795 RuntimeSelection::Native => {
1796 if oci {
1797 return Err(crate::error::NucleusError::ConfigError(
1798 "--bundle requires gVisor runtime; use --runtime gvisor".to_string(),
1799 ));
1800 }
1801 self = self.with_gvisor(false);
1802 }
1803 RuntimeSelection::GVisor => {
1804 self = self.with_gvisor(true);
1805 if !oci {
1806 tracing::info!(
1807 "Security hardening: enabling OCI bundle mode for gVisor runtime"
1808 );
1809 }
1810 self = self.with_oci_bundle();
1811 }
1812 }
1813 Ok(self)
1814 }
1815}
1816
1817pub fn validate_container_name(name: &str) -> crate::error::Result<()> {
1819 if name.is_empty() || name.len() > 128 {
1820 return Err(crate::error::NucleusError::ConfigError(
1821 "Invalid container name: must be 1-128 characters".to_string(),
1822 ));
1823 }
1824 if !name
1825 .chars()
1826 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
1827 {
1828 return Err(crate::error::NucleusError::ConfigError(
1829 "Invalid container name: allowed characters are a-zA-Z0-9, '-', '_', '.'".to_string(),
1830 ));
1831 }
1832 Ok(())
1833}
1834
1835pub fn validate_hostname(hostname: &str) -> crate::error::Result<()> {
1837 if hostname.is_empty() || hostname.len() > 253 {
1838 return Err(crate::error::NucleusError::ConfigError(
1839 "Invalid hostname: must be 1-253 characters".to_string(),
1840 ));
1841 }
1842
1843 for label in hostname.split('.') {
1844 if label.is_empty() || label.len() > 63 {
1845 return Err(crate::error::NucleusError::ConfigError(format!(
1846 "Invalid hostname label: '{}'",
1847 label
1848 )));
1849 }
1850 if label.starts_with('-') || label.ends_with('-') {
1851 return Err(crate::error::NucleusError::ConfigError(format!(
1852 "Invalid hostname label '{}': cannot start or end with '-'",
1853 label
1854 )));
1855 }
1856 if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
1857 return Err(crate::error::NucleusError::ConfigError(format!(
1858 "Invalid hostname label '{}': allowed characters are a-zA-Z0-9 and '-'",
1859 label
1860 )));
1861 }
1862 }
1863
1864 Ok(())
1865}
1866
1867#[cfg(test)]
1868#[allow(deprecated)]
1869mod tests {
1870 use super::*;
1871 use crate::network::NetworkMode;
1872
1873 #[test]
1874 fn test_generate_container_id_is_32_hex_chars() {
1875 let id = generate_container_id().unwrap();
1876 assert_eq!(
1877 id.len(),
1878 32,
1879 "Container ID must be full 128-bit (32 hex chars), got {}",
1880 id.len()
1881 );
1882 assert!(
1883 id.chars().all(|c| c.is_ascii_hexdigit()),
1884 "Container ID must be hex: {}",
1885 id
1886 );
1887 }
1888
1889 #[test]
1890 fn test_generate_container_id_is_unique() {
1891 let id1 = generate_container_id().unwrap();
1892 let id2 = generate_container_id().unwrap();
1893 assert_ne!(id1, id2, "Two consecutive IDs must differ");
1894 }
1895
1896 #[test]
1897 fn test_config_security_defaults_are_hardened() {
1898 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
1899 assert!(!cfg.allow_degraded_security);
1900 assert!(!cfg.allow_chroot_fallback);
1901 assert!(!cfg.allow_host_network);
1902 assert!(cfg.proc_readonly);
1903 assert_eq!(cfg.service_mode, ServiceMode::Agent);
1904 assert!(cfg.rootfs_path.is_none());
1905 assert!(cfg.egress_policy.is_none());
1906 assert!(cfg.credential_broker.is_none());
1907 assert!(cfg.secrets.is_empty());
1908 assert!(cfg.volumes.is_empty());
1909 assert_eq!(cfg.home, PathBuf::from(DEFAULT_HOME_PATH));
1910 assert!(cfg.provider_configs.is_empty());
1911 assert!(!cfg.sd_notify);
1912 assert!(cfg.required_kernel_lockdown.is_none());
1913 assert!(!cfg.verify_context_integrity);
1914 assert!(!cfg.verify_rootfs_attestation);
1915 assert!(!cfg.seccomp_log_denied);
1916 assert_eq!(cfg.gvisor_platform, GVisorPlatform::Systrap);
1917 }
1918
1919 #[test]
1920 fn test_credential_broker_validates_broker_only_bridge_egress() {
1921 let broker =
1922 crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1923 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1924 .unwrap()
1925 .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
1926 .with_credential_broker(broker.clone())
1927 .with_egress_policy(broker.egress_policy());
1928
1929 assert!(cfg.validate_runtime_support().is_ok());
1930 }
1931
1932 #[test]
1933 fn test_credential_broker_injects_per_container_identity_env() {
1934 let broker =
1935 crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1936 let cfg = ContainerConfig::try_new_with_id(
1937 Some("0123456789abcdef0123456789abcdef".to_string()),
1938 None,
1939 vec!["/bin/sh".to_string()],
1940 )
1941 .unwrap()
1942 .with_env(
1943 CREDENTIAL_BROKER_TOKEN_ENV.to_string(),
1944 "user-supplied-token".to_string(),
1945 )
1946 .with_env(
1947 CREDENTIAL_BROKER_CONTAINER_ID_ENV.to_string(),
1948 "user-supplied-container-id".to_string(),
1949 )
1950 .with_credential_broker(broker)
1951 .with_env(
1952 CREDENTIAL_BROKER_TOKEN_ENV.to_string(),
1953 "late-user-supplied-token".to_string(),
1954 )
1955 .with_env(
1956 CREDENTIAL_BROKER_CONTAINER_ID_ENV.to_string(),
1957 "late-user-supplied-container-id".to_string(),
1958 );
1959
1960 assert!(cfg.derived_environment.contains(&(
1963 CREDENTIAL_BROKER_CONTAINER_ID_ENV.to_string(),
1964 cfg.id.clone()
1965 )));
1966 assert_ne!(cfg.credential_broker_token, cfg.id);
1967 assert!(cfg.derived_environment.contains(&(
1968 CREDENTIAL_BROKER_TOKEN_ENV.to_string(),
1969 cfg.credential_broker_token.clone()
1970 )));
1971 for key in [
1974 CREDENTIAL_BROKER_TOKEN_ENV,
1975 CREDENTIAL_BROKER_CONTAINER_ID_ENV,
1976 ] {
1977 assert!(!cfg.environment.iter().any(|(env_key, _)| env_key == key));
1978 }
1979 }
1980
1981 #[test]
1982 fn test_credential_broker_rejects_non_bridge_network() {
1983 let broker =
1984 crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1985 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1986 .unwrap()
1987 .with_network(NetworkMode::None)
1988 .with_credential_broker(broker.clone())
1989 .with_egress_policy(broker.egress_policy());
1990
1991 let err = cfg.validate_runtime_support().unwrap_err();
1992 assert!(err.to_string().contains("requires --network bridge"));
1993 }
1994
1995 #[test]
1996 fn test_credential_broker_rejects_extra_egress_routes() {
1997 let broker =
1998 crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1999 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2000 .unwrap()
2001 .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
2002 .with_credential_broker(broker)
2003 .with_egress_policy(
2004 crate::network::EgressPolicy::default()
2005 .with_allowed_cidrs(vec![
2006 "10.0.42.1/32".to_string(),
2007 "203.0.113.0/24".to_string(),
2008 ])
2009 .with_allowed_tcp_ports(vec![8080]),
2010 );
2011
2012 let err = cfg.validate_runtime_support().unwrap_err();
2013 assert!(err.to_string().contains("allow only the broker"));
2014 }
2015
2016 #[test]
2017 fn test_credential_broker_rejects_userspace_nat_backend() {
2018 let broker =
2019 crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
2020 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2021 .unwrap()
2022 .with_network(NetworkMode::Bridge(
2023 crate::network::BridgeConfig::default()
2024 .with_nat_backend(crate::network::NatBackend::Userspace),
2025 ))
2026 .with_credential_broker(broker.clone())
2027 .with_egress_policy(broker.egress_policy());
2028
2029 let err = cfg.validate_runtime_support().unwrap_err();
2030 assert!(err.to_string().contains("kernel NAT backend"));
2031 }
2032
2033 #[test]
2034 fn test_credential_broker_rejects_ip_outside_bridge_gateway() {
2035 let broker =
2036 crate::network::CredentialBrokerConfig::parse_endpoint("8.8.8.8:8080").unwrap();
2037 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2038 .unwrap()
2039 .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
2040 .with_credential_broker(broker.clone())
2041 .with_egress_policy(broker.egress_policy());
2042
2043 let err = cfg.validate_runtime_support().unwrap_err();
2044 assert!(err
2045 .to_string()
2046 .contains("host-side bridge address 10.0.42.1"));
2047 }
2048
2049 #[test]
2050 fn test_credential_broker_rejects_non_gateway_bridge_ip() {
2051 let broker =
2052 crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.2:8080").unwrap();
2053 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2054 .unwrap()
2055 .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
2056 .with_credential_broker(broker.clone())
2057 .with_egress_policy(broker.egress_policy());
2058
2059 let err = cfg.validate_runtime_support().unwrap_err();
2060 assert!(err
2061 .to_string()
2062 .contains("host-side bridge address 10.0.42.1"));
2063 }
2064
2065 #[test]
2066 fn test_production_mode_rejects_degraded_flags() {
2067 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2068 .unwrap()
2069 .with_service_mode(ServiceMode::Production)
2070 .with_allow_degraded_security(true)
2071 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2072 .with_limits(
2073 crate::resources::ResourceLimits::default()
2074 .with_memory("512M")
2075 .unwrap()
2076 .with_cpu_cores(2.0)
2077 .unwrap(),
2078 );
2079 assert!(cfg.validate_production_mode().is_err());
2080 }
2081
2082 #[test]
2083 fn test_production_mode_rejects_chroot_fallback() {
2084 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2085 .unwrap()
2086 .with_service_mode(ServiceMode::Production)
2087 .with_allow_chroot_fallback(true)
2088 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2089 .with_limits(
2090 crate::resources::ResourceLimits::default()
2091 .with_memory("512M")
2092 .unwrap()
2093 .with_cpu_cores(2.0)
2094 .unwrap(),
2095 );
2096 let err = cfg.validate_production_mode().unwrap_err();
2097 assert!(
2098 err.to_string().contains("chroot"),
2099 "Production mode must reject chroot fallback"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_production_mode_requires_rootfs() {
2105 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2106 .unwrap()
2107 .with_service_mode(ServiceMode::Production)
2108 .with_limits(
2109 crate::resources::ResourceLimits::default()
2110 .with_memory("512M")
2111 .unwrap(),
2112 );
2113 let err = cfg.validate_production_mode().unwrap_err();
2114 assert!(err.to_string().contains("--rootfs"));
2115 }
2116
2117 fn test_rootfs_path() -> std::path::PathBuf {
2118 std::path::PathBuf::from("/nix/store")
2119 }
2120
2121 #[test]
2122 fn test_production_mode_requires_memory_limit() {
2123 let rootfs = test_rootfs_path();
2124 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2125 .unwrap()
2126 .with_service_mode(ServiceMode::Production)
2127 .with_rootfs_path(rootfs);
2128 let err = cfg.validate_production_mode().unwrap_err();
2129 assert!(err.to_string().contains("--memory"));
2130 }
2131
2132 #[test]
2133 fn test_production_mode_valid_config() {
2134 let rootfs = test_rootfs_path();
2135 if !rootfs.is_dir() {
2136 return;
2137 }
2138 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2139 .unwrap()
2140 .with_service_mode(ServiceMode::Production)
2141 .with_rootfs_path(rootfs.clone())
2142 .with_verify_rootfs_attestation(true)
2143 .with_limits(
2144 crate::resources::ResourceLimits::default()
2145 .with_memory("512M")
2146 .unwrap()
2147 .with_cpu_cores(2.0)
2148 .unwrap(),
2149 );
2150 let result = cfg.validate_production_mode();
2151 assert!(result.is_ok());
2152 }
2153
2154 #[test]
2155 fn test_production_mode_allows_explicit_gvisor_host_network() {
2156 let rootfs = test_rootfs_path();
2157 if !rootfs.is_dir() {
2158 return;
2159 }
2160 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2161 .unwrap()
2162 .with_gvisor(true)
2163 .with_service_mode(ServiceMode::Production)
2164 .with_network(NetworkMode::GVisorHost)
2165 .with_allow_host_network(true)
2166 .with_rootfs_path(rootfs)
2167 .with_verify_rootfs_attestation(true)
2168 .with_limits(
2169 crate::resources::ResourceLimits::default()
2170 .with_memory("512M")
2171 .unwrap()
2172 .with_cpu_cores(2.0)
2173 .unwrap(),
2174 );
2175
2176 assert!(cfg.validate_production_mode().is_ok());
2177 }
2178
2179 #[test]
2180 fn test_production_mode_rejects_gvisor_host_network_with_egress_policy() {
2181 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2182 .unwrap()
2183 .with_gvisor(true)
2184 .with_service_mode(ServiceMode::Production)
2185 .with_network(NetworkMode::GVisorHost)
2186 .with_allow_host_network(true)
2187 .with_egress_policy(crate::network::EgressPolicy::deny_all())
2188 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2189 .with_verify_rootfs_attestation(true)
2190 .with_limits(
2191 crate::resources::ResourceLimits::default()
2192 .with_memory("512M")
2193 .unwrap()
2194 .with_cpu_cores(2.0)
2195 .unwrap(),
2196 );
2197
2198 let err = cfg.validate_production_mode().unwrap_err();
2199 assert!(err.to_string().contains("egress policy"));
2200 }
2201
2202 #[test]
2203 fn test_production_mode_rejects_native_host_network() {
2204 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2205 .unwrap()
2206 .with_gvisor(false)
2207 .with_service_mode(ServiceMode::Production)
2208 .with_network(NetworkMode::Host)
2209 .with_allow_host_network(true)
2210 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2211 .with_verify_rootfs_attestation(true)
2212 .with_limits(
2213 crate::resources::ResourceLimits::default()
2214 .with_memory("512M")
2215 .unwrap()
2216 .with_cpu_cores(2.0)
2217 .unwrap(),
2218 );
2219
2220 let err = cfg.validate_production_mode().unwrap_err();
2221 assert!(err.to_string().contains("host network"));
2222 }
2223
2224 #[test]
2225 fn test_production_mode_rejects_gvisor_host_without_gvisor_runtime() {
2226 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2227 .unwrap()
2228 .with_gvisor(false)
2229 .with_service_mode(ServiceMode::Production)
2230 .with_network(NetworkMode::GVisorHost)
2231 .with_allow_host_network(true)
2232 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2233 .with_verify_rootfs_attestation(true)
2234 .with_limits(
2235 crate::resources::ResourceLimits::default()
2236 .with_memory("512M")
2237 .unwrap()
2238 .with_cpu_cores(2.0)
2239 .unwrap(),
2240 );
2241
2242 let err = cfg.validate_production_mode().unwrap_err();
2243 assert!(err.to_string().contains("--runtime gvisor"));
2244 }
2245
2246 #[test]
2247 fn test_production_mode_rejects_gvisor_host_without_explicit_opt_in() {
2248 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2249 .unwrap()
2250 .with_gvisor(true)
2251 .with_service_mode(ServiceMode::Production)
2252 .with_network(NetworkMode::GVisorHost)
2253 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2254 .with_verify_rootfs_attestation(true)
2255 .with_limits(
2256 crate::resources::ResourceLimits::default()
2257 .with_memory("512M")
2258 .unwrap()
2259 .with_cpu_cores(2.0)
2260 .unwrap(),
2261 );
2262
2263 let err = cfg.validate_production_mode().unwrap_err();
2264 assert!(err.to_string().contains("--allow-host-network"));
2265 }
2266
2267 #[test]
2268 fn test_production_mode_rejects_rootfs_parent_traversal() {
2269 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2270 .unwrap()
2271 .with_service_mode(ServiceMode::Production)
2272 .with_rootfs_path(std::path::PathBuf::from("/nix/store/../../tmp/evil-rootfs"))
2273 .with_verify_rootfs_attestation(true)
2274 .with_limits(
2275 crate::resources::ResourceLimits::default()
2276 .with_memory("512M")
2277 .unwrap()
2278 .with_cpu_cores(2.0)
2279 .unwrap(),
2280 );
2281
2282 let err = cfg.validate_production_mode().unwrap_err();
2283
2284 assert!(
2285 err.to_string().contains("parent traversal"),
2286 "Production mode must reject raw rootfs traversal before canonicalization"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_production_mode_rejects_out_of_store_rootfs() {
2292 let temp = tempfile::TempDir::new().unwrap();
2293 let rootfs = temp.path().join("rootfs");
2294 std::fs::create_dir(&rootfs).unwrap();
2295 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2296 .unwrap()
2297 .with_service_mode(ServiceMode::Production)
2298 .with_rootfs_path(rootfs)
2299 .with_verify_rootfs_attestation(true)
2300 .with_limits(
2301 crate::resources::ResourceLimits::default()
2302 .with_memory("512M")
2303 .unwrap()
2304 .with_cpu_cores(2.0)
2305 .unwrap(),
2306 );
2307
2308 let err = cfg.validate_production_mode().unwrap_err();
2309
2310 assert!(
2311 err.to_string().contains("/nix/store"),
2312 "Production mode must reject rootfs paths that resolve outside /nix/store"
2313 );
2314 }
2315
2316 #[test]
2317 fn test_production_mode_requires_rootfs_attestation() {
2318 let rootfs = test_rootfs_path();
2319 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2320 .unwrap()
2321 .with_service_mode(ServiceMode::Production)
2322 .with_rootfs_path(rootfs.clone())
2323 .with_limits(
2324 crate::resources::ResourceLimits::default()
2325 .with_memory("512M")
2326 .unwrap()
2327 .with_cpu_cores(2.0)
2328 .unwrap(),
2329 );
2330 let err = cfg.validate_production_mode().unwrap_err();
2331 assert!(err.to_string().contains("attestation"));
2332 }
2333
2334 #[test]
2335 fn test_production_mode_rejects_seccomp_trace() {
2336 let rootfs = test_rootfs_path();
2337 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2338 .unwrap()
2339 .with_service_mode(ServiceMode::Production)
2340 .with_rootfs_path(rootfs.clone())
2341 .with_seccomp_mode(SeccompMode::Trace)
2342 .with_limits(
2343 crate::resources::ResourceLimits::default()
2344 .with_memory("512M")
2345 .unwrap()
2346 .with_cpu_cores(2.0)
2347 .unwrap(),
2348 );
2349 let err = cfg.validate_production_mode().unwrap_err();
2350 assert!(
2351 err.to_string().contains("trace"),
2352 "Production mode must reject seccomp trace mode"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_production_mode_rejects_security_critical_seccomp_allow() {
2358 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2359 .unwrap()
2360 .with_service_mode(ServiceMode::Production)
2361 .with_rootfs_path(test_rootfs_path())
2362 .with_verify_rootfs_attestation(true)
2363 .with_seccomp_allow_syscalls(vec!["keyctl".to_string()])
2364 .with_limits(
2365 crate::resources::ResourceLimits::default()
2366 .with_memory("512M")
2367 .unwrap()
2368 .with_cpu_cores(2.0)
2369 .unwrap(),
2370 );
2371
2372 let err = cfg.validate_production_mode().unwrap_err();
2373 assert!(err.to_string().contains("seccomp-allow"));
2374 assert!(err.to_string().contains("keyctl"));
2375 }
2376
2377 #[test]
2378 fn test_strict_agent_mode_valid_without_production_rootfs() {
2379 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2380 .unwrap()
2381 .with_service_mode(ServiceMode::StrictAgent);
2382
2383 assert!(cfg.validate_strict_agent_mode().is_ok());
2384 assert!(cfg.rootfs_path.is_none());
2385 assert!(!cfg.verify_rootfs_attestation);
2386 }
2387
2388 #[test]
2389 fn test_strict_agent_mode_rejects_degraded_security() {
2390 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2391 .unwrap()
2392 .with_service_mode(ServiceMode::StrictAgent)
2393 .with_allow_degraded_security(true);
2394
2395 let err = cfg.validate_strict_agent_mode().unwrap_err();
2396 assert!(err.to_string().contains("degraded"));
2397 }
2398
2399 #[test]
2400 fn test_strict_agent_mode_rejects_chroot_fallback() {
2401 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2402 .unwrap()
2403 .with_service_mode(ServiceMode::StrictAgent)
2404 .with_allow_chroot_fallback(true);
2405
2406 let err = cfg.validate_strict_agent_mode().unwrap_err();
2407 assert!(err.to_string().contains("chroot"));
2408 }
2409
2410 #[test]
2411 fn test_strict_agent_mode_rejects_seccomp_trace() {
2412 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2413 .unwrap()
2414 .with_service_mode(ServiceMode::StrictAgent)
2415 .with_seccomp_mode(SeccompMode::Trace);
2416
2417 let err = cfg.validate_strict_agent_mode().unwrap_err();
2418 assert!(err.to_string().contains("trace"));
2419 }
2420
2421 #[test]
2422 fn test_strict_agent_mode_rejects_native_host_network() {
2423 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2424 .unwrap()
2425 .with_gvisor(false)
2426 .with_service_mode(ServiceMode::StrictAgent)
2427 .with_network(NetworkMode::Host)
2428 .with_allow_host_network(true);
2429
2430 let err = cfg.validate_strict_agent_mode().unwrap_err();
2431 assert!(err.to_string().contains("host network"));
2432 }
2433
2434 #[test]
2435 fn test_strict_agent_mode_requires_some_cgroup_control() {
2436 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2437 .unwrap()
2438 .with_service_mode(ServiceMode::StrictAgent)
2439 .with_limits(crate::resources::ResourceLimits::unlimited());
2440
2441 let err = cfg.validate_strict_agent_mode().unwrap_err();
2442 assert!(err.to_string().contains("cgroup"));
2443 }
2444
2445 #[test]
2446 fn test_strict_agent_mode_allows_explicit_gvisor_host_network() {
2447 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2448 .unwrap()
2449 .with_gvisor(true)
2450 .with_service_mode(ServiceMode::StrictAgent)
2451 .with_network(NetworkMode::GVisorHost)
2452 .with_allow_host_network(true);
2453
2454 assert!(cfg.validate_strict_agent_mode().is_ok());
2455 }
2456
2457 #[test]
2458 fn test_production_mode_requires_cpu_limit() {
2459 let rootfs = test_rootfs_path();
2460 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2461 .unwrap()
2462 .with_service_mode(ServiceMode::Production)
2463 .with_rootfs_path(rootfs.clone())
2464 .with_limits(
2465 crate::resources::ResourceLimits::default()
2466 .with_memory("512M")
2467 .unwrap(),
2468 );
2469 let err = cfg.validate_production_mode().unwrap_err();
2470 assert!(err.to_string().contains("--cpus"));
2471 }
2472
2473 #[test]
2474 fn test_config_security_builders_override_defaults() {
2475 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2476 .unwrap()
2477 .with_allow_degraded_security(true)
2478 .with_allow_chroot_fallback(true)
2479 .with_allow_host_network(true)
2480 .with_proc_readonly(false)
2481 .with_network(NetworkMode::Host);
2482
2483 assert!(cfg.allow_degraded_security);
2484 assert!(cfg.allow_chroot_fallback);
2485 assert!(cfg.allow_host_network);
2486 assert!(!cfg.proc_readonly);
2487 assert!(matches!(cfg.network, NetworkMode::Host));
2488 }
2489
2490 #[test]
2491 fn test_hardening_builders_override_defaults() {
2492 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2493 .unwrap()
2494 .with_required_kernel_lockdown(KernelLockdownMode::Confidentiality)
2495 .with_verify_context_integrity(true)
2496 .with_verify_rootfs_attestation(true)
2497 .with_seccomp_log_denied(true)
2498 .with_gvisor_platform(GVisorPlatform::Kvm);
2499
2500 assert_eq!(
2501 cfg.required_kernel_lockdown,
2502 Some(KernelLockdownMode::Confidentiality)
2503 );
2504 assert!(cfg.verify_context_integrity);
2505 assert!(cfg.verify_rootfs_attestation);
2506 assert!(cfg.seccomp_log_denied);
2507 assert_eq!(cfg.gvisor_platform, GVisorPlatform::Kvm);
2508 }
2509
2510 #[test]
2511 fn test_seccomp_trace_requires_log_path() {
2512 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2513 .unwrap()
2514 .with_gvisor(false)
2515 .with_seccomp_mode(SeccompMode::Trace);
2516
2517 let err = cfg.validate_runtime_support().unwrap_err();
2518 assert!(err.to_string().contains("seccomp-log"));
2519 }
2520
2521 #[test]
2522 fn test_gvisor_allows_custom_seccomp_profile_but_rejects_native_policy_files() {
2523 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2524 .unwrap()
2525 .with_seccomp_profile(PathBuf::from("/tmp/seccomp.json"))
2526 .with_caps_policy(PathBuf::from("/tmp/caps.toml"));
2527
2528 let err = cfg.validate_runtime_support().unwrap_err();
2529 assert!(err.to_string().contains("capability policy"));
2530 }
2531
2532 #[test]
2533 fn test_gvisor_accepts_custom_seccomp_profile() {
2534 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2535 .unwrap()
2536 .with_seccomp_profile(PathBuf::from("/tmp/seccomp.json"));
2537
2538 cfg.validate_runtime_support().unwrap();
2539 }
2540
2541 #[test]
2542 fn test_gvisor_rejects_landlock_policy_file() {
2543 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2544 .unwrap()
2545 .with_landlock_policy(PathBuf::from("/tmp/landlock.toml"));
2546
2547 let err = cfg.validate_runtime_support().unwrap_err();
2548 assert!(err.to_string().contains("Landlock"));
2549 }
2550
2551 #[test]
2552 fn test_gvisor_rejects_trace_mode_even_with_log_path() {
2553 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2554 .unwrap()
2555 .with_seccomp_mode(SeccompMode::Trace)
2556 .with_seccomp_trace_log(PathBuf::from("/tmp/trace.ndjson"));
2557
2558 let err = cfg.validate_runtime_support().unwrap_err();
2559 assert!(err.to_string().contains("gVisor runtime"));
2560 }
2561
2562 #[test]
2563 fn test_gvisor_rejects_seccomp_allow_without_custom_profile_projection() {
2564 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2565 .unwrap()
2566 .with_seccomp_allow_syscalls(vec!["io_uring_setup".to_string()]);
2567
2568 let err = cfg.validate_runtime_support().unwrap_err();
2569 assert!(err.to_string().contains("seccomp-allow"));
2570 }
2571
2572 #[test]
2573 fn test_secret_dest_must_be_absolute() {
2574 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2575 .unwrap()
2576 .with_secret(crate::container::SecretMount {
2577 source: PathBuf::from("/run/secrets/api-key"),
2578 dest: PathBuf::from("secrets/api-key"),
2579 mode: 0o400,
2580 });
2581
2582 let err = cfg.validate_runtime_support().unwrap_err();
2583 assert!(err.to_string().contains("absolute"));
2584 }
2585
2586 #[test]
2587 fn test_secret_dest_rejects_parent_traversal() {
2588 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2589 .unwrap()
2590 .with_secret(crate::container::SecretMount {
2591 source: PathBuf::from("/run/secrets/api-key"),
2592 dest: PathBuf::from("/../../etc/passwd"),
2593 mode: 0o400,
2594 });
2595
2596 let err = cfg.validate_runtime_support().unwrap_err();
2597 assert!(err.to_string().contains("parent traversal"));
2598 }
2599
2600 #[test]
2601 fn test_bind_volume_source_must_exist() {
2602 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2603 .unwrap()
2604 .with_volume(VolumeMount {
2605 source: VolumeSource::Bind {
2606 source: PathBuf::from("/tmp/definitely-missing-nucleus-volume"),
2607 },
2608 dest: PathBuf::from("/var/lib/app"),
2609 read_only: false,
2610 });
2611
2612 let err = cfg.validate_runtime_support().unwrap_err();
2613 assert!(err.to_string().contains("Volume source does not exist"));
2614 }
2615
2616 #[test]
2617 fn test_bind_volume_source_rejects_sensitive_host_subtrees() {
2618 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2619 .unwrap()
2620 .with_volume(VolumeMount {
2621 source: VolumeSource::Bind {
2622 source: PathBuf::from("/proc/sys"),
2623 },
2624 dest: PathBuf::from("/host-proc"),
2625 read_only: true,
2626 });
2627
2628 let err = cfg.validate_runtime_support().unwrap_err();
2629 assert!(err.to_string().contains("sensitive host path"));
2630 }
2631
2632 #[test]
2633 fn test_bind_volume_dest_must_be_absolute() {
2634 let dir = tempfile::TempDir::new().unwrap();
2635 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2636 .unwrap()
2637 .with_volume(VolumeMount {
2638 source: VolumeSource::Bind {
2639 source: dir.path().to_path_buf(),
2640 },
2641 dest: PathBuf::from("var/lib/app"),
2642 read_only: false,
2643 });
2644
2645 let err = cfg.validate_runtime_support().unwrap_err();
2646 assert!(err.to_string().contains("absolute"));
2647 }
2648
2649 #[test]
2650 fn test_bind_volume_dest_rejects_reserved_container_paths() {
2651 let dir = tempfile::TempDir::new().unwrap();
2652 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2653 .unwrap()
2654 .with_volume(VolumeMount {
2655 source: VolumeSource::Bind {
2656 source: dir.path().to_path_buf(),
2657 },
2658 dest: PathBuf::from("/etc"),
2659 read_only: false,
2660 });
2661
2662 let err = cfg.validate_runtime_support().unwrap_err();
2663 assert!(err.to_string().contains("reserved"));
2664 }
2665
2666 #[test]
2667 fn test_default_workspace_and_workdir_contract() {
2668 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
2669
2670 assert_eq!(cfg.workspace.container_path, PathBuf::from("/workspace"));
2671 assert_eq!(cfg.workdir, PathBuf::from("/workspace"));
2672 assert_eq!(cfg.home, PathBuf::from(DEFAULT_HOME_PATH));
2673 assert_eq!(cfg.workspace.mode, WorkspaceMode::BindRw);
2674 assert!(!cfg.workspace.allow_execute);
2675 }
2676
2677 #[test]
2678 fn test_home_must_not_overlap_workspace() {
2679 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2680 .unwrap()
2681 .with_home(PathBuf::from("/workspace/.home"));
2682
2683 let err = cfg.validate_runtime_support().unwrap_err();
2684 assert!(err.to_string().contains("must not overlap /workspace"));
2685 }
2686
2687 #[test]
2688 fn test_workspace_source_validates_existing_directory() {
2689 let dir = tempfile::TempDir::new().unwrap();
2690 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2691 .unwrap()
2692 .with_workspace(WorkspaceConfig::new().with_host_path(dir.path().to_path_buf()));
2693
2694 cfg.validate_runtime_support().unwrap();
2695 }
2696
2697 #[test]
2698 fn test_copy_in_out_requires_workspace_source() {
2699 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2700 .unwrap()
2701 .with_workspace(WorkspaceConfig::new().with_mode(WorkspaceMode::CopyInOut));
2702
2703 let err = cfg.validate_runtime_support().unwrap_err();
2704 assert!(err.to_string().contains("requires --workspace"));
2705 }
2706
2707 #[test]
2708 fn test_volume_destination_cannot_replace_workspace() {
2709 let dir = tempfile::TempDir::new().unwrap();
2710 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2711 .unwrap()
2712 .with_volume(VolumeMount {
2713 source: VolumeSource::Bind {
2714 source: dir.path().to_path_buf(),
2715 },
2716 dest: PathBuf::from("/workspace"),
2717 read_only: false,
2718 });
2719
2720 let err = cfg.validate_runtime_support().unwrap_err();
2721 assert!(err.to_string().contains("conflicts with --workspace"));
2722 }
2723
2724 #[test]
2725 fn test_volume_destination_cannot_replace_home() {
2726 let dir = tempfile::TempDir::new().unwrap();
2727 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2728 .unwrap()
2729 .with_volume(VolumeMount {
2730 source: VolumeSource::Bind {
2731 source: dir.path().to_path_buf(),
2732 },
2733 dest: PathBuf::from(DEFAULT_HOME_PATH),
2734 read_only: false,
2735 });
2736
2737 let err = cfg.validate_runtime_support().unwrap_err();
2738 assert!(err.to_string().contains("conflicts with --home"));
2739 }
2740
2741 #[test]
2742 fn test_production_rejects_writable_executable_workspace() {
2743 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2744 .unwrap()
2745 .with_service_mode(ServiceMode::Production)
2746 .with_workspace(
2747 WorkspaceConfig::new()
2748 .with_mode(WorkspaceMode::BindRw)
2749 .with_allow_execute(true),
2750 );
2751
2752 let err = cfg.validate_production_mode().unwrap_err();
2753 assert!(err.to_string().contains("writable executable workspaces"));
2754 }
2755
2756 #[test]
2757 fn test_tmpfs_volume_rejects_parent_traversal() {
2758 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2759 .unwrap()
2760 .with_volume(VolumeMount {
2761 source: VolumeSource::Tmpfs {
2762 size: Some("64M".to_string()),
2763 },
2764 dest: PathBuf::from("/../../var/lib/app"),
2765 read_only: false,
2766 });
2767
2768 let err = cfg.validate_runtime_support().unwrap_err();
2769 assert!(err.to_string().contains("parent traversal"));
2770 }
2771
2772 #[test]
2773 fn test_gvisor_rejects_bind_mount_context_integrity_verification() {
2774 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2775 .unwrap()
2776 .with_context(PathBuf::from("/tmp/context"))
2777 .with_context_mode(crate::filesystem::ContextMode::BindMount)
2778 .with_verify_context_integrity(true);
2779
2780 let err = cfg.validate_runtime_support().unwrap_err();
2781 assert!(err.to_string().contains("context integrity"));
2782 }
2783
2784 #[test]
2785 fn test_gvisor_rejects_exec_health_checks() {
2786 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2787 .unwrap()
2788 .with_health_check(HealthCheck {
2789 command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
2790 interval: Duration::from_secs(30),
2791 retries: 3,
2792 start_period: Duration::from_secs(1),
2793 timeout: Duration::from_secs(5),
2794 });
2795
2796 let err = cfg.validate_runtime_support().unwrap_err();
2797 assert!(err.to_string().contains("health checks"));
2798 }
2799
2800 #[test]
2801 fn test_gvisor_rejects_exec_readiness_probes() {
2802 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2803 .unwrap()
2804 .with_readiness_probe(ReadinessProbe::Exec {
2805 command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
2806 });
2807
2808 let err = cfg.validate_runtime_support().unwrap_err();
2809 assert!(err.to_string().contains("readiness"));
2810 }
2811
2812 #[test]
2813 fn test_gvisor_allows_copy_mode_context_integrity_verification() {
2814 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2815 .unwrap()
2816 .with_context(PathBuf::from("/tmp/context"))
2817 .with_context_mode(crate::filesystem::ContextMode::Copy)
2818 .with_verify_context_integrity(true);
2819
2820 assert!(cfg.validate_runtime_support().is_ok());
2821 }
2822
2823 #[test]
2824 fn test_user_namespace_rejects_unmapped_process_identity() {
2825 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2826 .unwrap()
2827 .with_rootless()
2828 .with_process_identity(ProcessIdentity {
2829 uid: 1000,
2830 gid: 1000,
2831 additional_gids: Vec::new(),
2832 });
2833
2834 let err = cfg.validate_runtime_support().unwrap_err();
2835 assert!(err.to_string().contains("not mapped"));
2836 }
2837
2838 #[test]
2839 fn test_user_namespace_rejects_supplementary_groups() {
2840 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2841 .unwrap()
2842 .with_rootless()
2843 .with_process_identity(ProcessIdentity {
2844 uid: 0,
2845 gid: 0,
2846 additional_gids: vec![1],
2847 });
2848
2849 let err = cfg.validate_runtime_support().unwrap_err();
2850 assert!(err.to_string().contains("Supplementary groups"));
2851 }
2852
2853 #[test]
2854 fn test_native_runtime_disables_gvisor() {
2855 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2857 .unwrap()
2858 .apply_runtime_selection(RuntimeSelection::Native, false)
2859 .unwrap();
2860 assert!(!cfg.use_gvisor, "native runtime must disable gVisor");
2861 assert_eq!(
2862 cfg.trust_level,
2863 TrustLevel::Untrusted,
2864 "native runtime must preserve the default Untrusted trust level"
2865 );
2866 }
2867
2868 #[test]
2869 fn test_native_runtime_preserves_explicit_trusted_policy() {
2870 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2871 .unwrap()
2872 .with_trust_level(TrustLevel::Trusted)
2873 .apply_runtime_selection(RuntimeSelection::Native, false)
2874 .unwrap();
2875
2876 assert!(!cfg.use_gvisor, "native runtime must disable gVisor");
2877 assert_eq!(
2878 cfg.trust_level,
2879 TrustLevel::Trusted,
2880 "native runtime must preserve explicit Trusted trust level"
2881 );
2882 }
2883
2884 #[test]
2885 fn test_default_config_has_gvisor_enabled() {
2886 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
2887 assert!(cfg.use_gvisor, "default must have gVisor enabled");
2888 assert_eq!(
2889 cfg.trust_level,
2890 TrustLevel::Untrusted,
2891 "default must be Untrusted"
2892 );
2893 }
2894
2895 #[test]
2896 fn test_console_socket_implies_terminal_mode() {
2897 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2898 .unwrap()
2899 .with_console_socket(PathBuf::from("/tmp/console.sock"));
2900
2901 assert!(cfg.terminal);
2902 assert_eq!(cfg.console_socket, Some(PathBuf::from("/tmp/console.sock")));
2903 }
2904
2905 #[test]
2906 fn test_terminal_size_can_be_configured() {
2907 let size = ConsoleSize {
2908 width: 132,
2909 height: 43,
2910 };
2911 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2912 .unwrap()
2913 .with_terminal(size);
2914
2915 assert!(cfg.terminal);
2916 assert_eq!(cfg.console_size, size);
2917 }
2918
2919 #[test]
2920 fn test_generate_container_id_returns_result() {
2921 let id: crate::error::Result<String> = generate_container_id();
2924 let id = id.expect("generate_container_id must return Ok, not panic");
2925 assert_eq!(id.len(), 32, "container ID must be 32 hex chars");
2926 assert!(
2927 id.chars().all(|c| c.is_ascii_hexdigit()),
2928 "container ID must be valid hex: {}",
2929 id
2930 );
2931 }
2932
2933 #[test]
2934 fn gpu_defaults_match_nvidia_toolkit() {
2935 let cfg = GpuPassthroughConfig::default();
2936 assert_eq!(cfg.vendor, GpuVendor::Auto);
2937 assert!(cfg.devices.is_empty());
2938 assert_eq!(cfg.driver_capabilities, DEFAULT_GPU_DRIVER_CAPABILITIES);
2939 assert_eq!(cfg.visible_devices, DEFAULT_GPU_VISIBLE_DEVICES);
2940 assert!(cfg.bind_driver_libraries);
2941 }
2942
2943 #[test]
2944 fn production_mode_rejects_gpu_passthrough() {
2945 let mut cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2946 .unwrap()
2947 .with_gpu(GpuPassthroughConfig::default());
2948 cfg.service_mode = ServiceMode::Production;
2949 cfg.rootfs_path = Some(std::path::PathBuf::from("/nix/some/rootfs"));
2950 let err = cfg.validate_production_mode().unwrap_err();
2951 let msg = format!("{}", err);
2952 assert!(
2953 msg.contains("Production mode forbids --gpu"),
2954 "unexpected error: {}",
2955 msg
2956 );
2957 }
2958
2959 #[test]
2960 fn agent_mode_allows_gpu_passthrough() {
2961 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2962 .unwrap()
2963 .with_gpu(GpuPassthroughConfig::default());
2964 assert_eq!(cfg.service_mode, ServiceMode::Agent);
2966 assert!(cfg.gpu.is_some());
2967 cfg.validate_production_mode().expect("agent mode permits gpu");
2968 }
2969
2970 #[test]
2971 fn gpu_environment_includes_nvidia_vars() {
2972 let gpu = GpuPassthroughConfig {
2973 vendor: GpuVendor::Nvidia,
2974 ..GpuPassthroughConfig::default()
2975 };
2976 let env = gpu_environment(&gpu);
2977 let keys: Vec<&str> = env.iter().map(|(k, _)| *k).collect();
2978 assert!(keys.contains(&"NVIDIA_VISIBLE_DEVICES"));
2979 assert!(keys.contains(&"NVIDIA_DRIVER_CAPABILITIES"));
2980 assert!(keys.contains(&"__EGL_VENDOR_LIBRARY_FILENAMES"));
2981 }
2982
2983 #[test]
2984 fn gpu_environment_omits_egl_when_not_nvidia() {
2985 let gpu = GpuPassthroughConfig {
2986 vendor: GpuVendor::Amd,
2987 ..GpuPassthroughConfig::default()
2988 };
2989 let env = gpu_environment(&gpu);
2990 assert!(!env.iter().any(|(k, _)| *k == "__EGL_VENDOR_LIBRARY_FILENAMES"));
2991 }
2992}