1use crate::filesystem::normalize_container_destination;
2use crate::isolation::{NamespaceConfig, UserNamespaceConfig};
3use crate::network::EgressPolicy;
4use crate::resources::ResourceLimits;
5use crate::security::GVisorPlatform;
6use std::fs::OpenOptions;
7use std::os::unix::fs::FileTypeExt;
8use std::os::unix::fs::OpenOptionsExt;
9use std::path::PathBuf;
10use std::time::Duration;
11
12fn open_dev_urandom() -> crate::error::Result<std::fs::File> {
13 let file = OpenOptions::new()
14 .read(true)
15 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
16 .open("/dev/urandom")
17 .map_err(|e| {
18 crate::error::NucleusError::ConfigError(format!(
19 "Failed to open /dev/urandom for container ID generation: {}",
20 e
21 ))
22 })?;
23
24 let metadata = file.metadata().map_err(|e| {
25 crate::error::NucleusError::ConfigError(format!("Failed to stat /dev/urandom: {}", e))
26 })?;
27 if !metadata.file_type().is_char_device() {
28 return Err(crate::error::NucleusError::ConfigError(
29 "/dev/urandom is not a character device".to_string(),
30 ));
31 }
32
33 Ok(file)
34}
35
36pub fn generate_container_id() -> crate::error::Result<String> {
38 use std::io::Read;
39
40 let mut buf = [0u8; 16];
41 let mut file = open_dev_urandom()?;
42 file.read_exact(&mut buf).map_err(|e| {
43 crate::error::NucleusError::ConfigError(format!(
44 "Failed to read secure random bytes for container ID generation: {}",
45 e
46 ))
47 })?;
48 Ok(hex::encode(buf))
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
55pub enum TrustLevel {
56 Trusted,
58 #[default]
60 Untrusted,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
68pub enum ServiceMode {
69 #[default]
71 Agent,
72 Production,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
85pub enum RuntimeSelection {
86 #[value(name = "gvisor")]
88 GVisor,
89 #[value(name = "native")]
91 Native,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
99pub enum NetworkModeArg {
100 #[value(name = "none")]
102 None,
103 #[value(name = "host")]
105 Host,
106 #[value(name = "bridge")]
108 Bridge,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
113pub enum KernelLockdownMode {
114 Integrity,
116 Confidentiality,
118}
119
120impl KernelLockdownMode {
121 pub fn as_str(self) -> &'static str {
122 match self {
123 Self::Integrity => "integrity",
124 Self::Confidentiality => "confidentiality",
125 }
126 }
127
128 pub fn accepts(self, active: Self) -> bool {
129 match self {
130 Self::Integrity => matches!(active, Self::Integrity | Self::Confidentiality),
131 Self::Confidentiality => matches!(active, Self::Confidentiality),
132 }
133 }
134}
135
136#[derive(Debug, Clone)]
138pub struct HealthCheck {
139 pub command: Vec<String>,
141 pub interval: Duration,
143 pub retries: u32,
145 pub start_period: Duration,
147 pub timeout: Duration,
149}
150
151impl Default for HealthCheck {
152 fn default() -> Self {
153 Self {
154 command: Vec::new(),
155 interval: Duration::from_secs(30),
156 retries: 3,
157 start_period: Duration::from_secs(5),
158 timeout: Duration::from_secs(5),
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct SecretMount {
166 pub source: PathBuf,
168 pub dest: PathBuf,
170 pub mode: u32,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ProcessIdentity {
177 pub uid: u32,
179 pub gid: u32,
181 pub additional_gids: Vec<u32>,
183}
184
185impl ProcessIdentity {
186 pub fn root() -> Self {
188 Self {
189 uid: 0,
190 gid: 0,
191 additional_gids: Vec::new(),
192 }
193 }
194
195 pub fn is_root(&self) -> bool {
197 self.uid == 0 && self.gid == 0 && self.additional_gids.is_empty()
198 }
199}
200
201impl Default for ProcessIdentity {
202 fn default() -> Self {
203 Self::root()
204 }
205}
206
207#[derive(Debug, Clone)]
209pub enum VolumeSource {
210 Bind { source: PathBuf },
212 Tmpfs { size: Option<String> },
214}
215
216#[derive(Debug, Clone)]
218pub struct VolumeMount {
219 pub source: VolumeSource,
221 pub dest: PathBuf,
223 pub read_only: bool,
225}
226
227#[derive(Debug, Clone)]
229pub enum ReadinessProbe {
230 Exec { command: Vec<String> },
232 TcpPort(u16),
234 SdNotify,
236}
237
238#[derive(Debug, Clone)]
240pub struct ContainerConfig {
241 pub id: String,
243
244 pub name: String,
246
247 pub command: Vec<String>,
249
250 pub context_dir: Option<PathBuf>,
252
253 pub limits: ResourceLimits,
255
256 pub namespaces: NamespaceConfig,
258
259 pub user_ns_config: Option<UserNamespaceConfig>,
261
262 pub hostname: Option<String>,
264
265 pub use_gvisor: bool,
267
268 pub trust_level: TrustLevel,
270
271 pub network: crate::network::NetworkMode,
273
274 pub context_mode: crate::filesystem::ContextMode,
276
277 pub allow_degraded_security: bool,
279
280 pub allow_chroot_fallback: bool,
282
283 pub allow_host_network: bool,
285
286 pub proc_readonly: bool,
288
289 pub service_mode: ServiceMode,
291
292 pub rootfs_path: Option<PathBuf>,
295
296 pub egress_policy: Option<EgressPolicy>,
298
299 pub health_check: Option<HealthCheck>,
301
302 pub readiness_probe: Option<ReadinessProbe>,
304
305 pub secrets: Vec<SecretMount>,
307
308 pub volumes: Vec<VolumeMount>,
310
311 pub environment: Vec<(String, String)>,
313
314 pub process_identity: ProcessIdentity,
316
317 pub config_hash: Option<u64>,
319
320 pub sd_notify: bool,
322
323 pub required_kernel_lockdown: Option<KernelLockdownMode>,
325
326 pub verify_context_integrity: bool,
328
329 pub verify_rootfs_attestation: bool,
331
332 pub seccomp_log_denied: bool,
334
335 pub gvisor_platform: GVisorPlatform,
337
338 pub seccomp_profile: Option<PathBuf>,
341
342 pub seccomp_profile_sha256: Option<String>,
344
345 pub seccomp_mode: SeccompMode,
347
348 pub seccomp_trace_log: Option<PathBuf>,
350
351 pub seccomp_allow_syscalls: Vec<String>,
355
356 pub caps_policy: Option<PathBuf>,
358
359 pub caps_policy_sha256: Option<String>,
361
362 pub landlock_policy: Option<PathBuf>,
364
365 pub landlock_policy_sha256: Option<String>,
367
368 pub hooks: Option<crate::security::OciHooks>,
370
371 pub pid_file: Option<PathBuf>,
373
374 pub console_socket: Option<PathBuf>,
376
377 pub bundle_dir: Option<PathBuf>,
379
380 pub state_root: Option<PathBuf>,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
387pub enum SeccompMode {
388 #[default]
390 Enforce,
391 Trace,
394}
395
396impl ContainerConfig {
397 pub fn try_new(name: Option<String>, command: Vec<String>) -> crate::error::Result<Self> {
402 Self::try_new_with_id(None, name, command)
403 }
404
405 pub fn try_new_with_id(
411 preset_id: Option<String>,
412 name: Option<String>,
413 command: Vec<String>,
414 ) -> crate::error::Result<Self> {
415 let id = match preset_id {
416 Some(id) => {
417 if id.len() != 32 || !id.chars().all(|c| c.is_ascii_hexdigit()) {
419 return Err(crate::error::NucleusError::ConfigError(format!(
420 "Invalid preset container ID '{}': must be 32 hex characters",
421 id
422 )));
423 }
424 id
425 }
426 None => generate_container_id()?,
427 };
428 let name = name.unwrap_or_else(|| id.clone());
429 Ok(Self {
430 id,
431 name: name.clone(),
432 command,
433 context_dir: None,
434 limits: ResourceLimits::default(),
435 namespaces: NamespaceConfig::default(),
436 user_ns_config: None,
437 hostname: Some(name),
438 use_gvisor: true,
439 trust_level: TrustLevel::default(),
440 network: crate::network::NetworkMode::None,
441 context_mode: crate::filesystem::ContextMode::Copy,
442 allow_degraded_security: false,
443 allow_chroot_fallback: false,
444 allow_host_network: false,
445 proc_readonly: true,
446 service_mode: ServiceMode::default(),
447 rootfs_path: None,
448 egress_policy: None,
449 health_check: None,
450 readiness_probe: None,
451 secrets: Vec::new(),
452 volumes: Vec::new(),
453 environment: Vec::new(),
454 process_identity: ProcessIdentity::default(),
455 config_hash: None,
456 sd_notify: false,
457 required_kernel_lockdown: None,
458 verify_context_integrity: false,
459 verify_rootfs_attestation: false,
460 seccomp_log_denied: false,
461 gvisor_platform: GVisorPlatform::default(),
462 seccomp_profile: None,
463 seccomp_profile_sha256: None,
464 seccomp_mode: SeccompMode::default(),
465 seccomp_trace_log: None,
466 seccomp_allow_syscalls: Vec::new(),
467 caps_policy: None,
468 caps_policy_sha256: None,
469 landlock_policy: None,
470 landlock_policy_sha256: None,
471 hooks: None,
472 pid_file: None,
473 console_socket: None,
474 bundle_dir: None,
475 state_root: None,
476 })
477 }
478
479 #[must_use]
481 pub fn with_rootless(mut self) -> Self {
482 self.namespaces.user = true;
483 self.user_ns_config = Some(UserNamespaceConfig::rootless());
484 self
485 }
486
487 #[must_use]
489 pub fn with_user_namespace(mut self, config: UserNamespaceConfig) -> Self {
490 self.namespaces.user = true;
491 self.user_ns_config = Some(config);
492 self
493 }
494
495 #[must_use]
496 pub fn with_context(mut self, dir: PathBuf) -> Self {
497 self.context_dir = Some(dir);
498 self
499 }
500
501 #[must_use]
502 pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
503 self.limits = limits;
504 self
505 }
506
507 #[must_use]
508 pub fn with_namespaces(mut self, namespaces: NamespaceConfig) -> Self {
509 self.namespaces = namespaces;
510 self
511 }
512
513 #[must_use]
514 pub fn with_hostname(mut self, hostname: Option<String>) -> Self {
515 self.hostname = hostname;
516 self
517 }
518
519 #[must_use]
520 pub fn with_gvisor(mut self, enabled: bool) -> Self {
521 self.use_gvisor = enabled;
522 self
523 }
524
525 #[must_use]
526 pub fn with_trust_level(mut self, level: TrustLevel) -> Self {
527 self.trust_level = level;
528 self
529 }
530
531 #[must_use]
533 pub fn with_oci_bundle(mut self) -> Self {
534 self.use_gvisor = true;
535 self
536 }
537
538 #[must_use]
539 pub fn with_network(mut self, mode: crate::network::NetworkMode) -> Self {
540 self.network = mode;
541 self
542 }
543
544 #[must_use]
545 pub fn with_context_mode(mut self, mode: crate::filesystem::ContextMode) -> Self {
546 self.context_mode = mode;
547 self
548 }
549
550 #[must_use]
551 pub fn with_allow_degraded_security(mut self, allow: bool) -> Self {
552 self.allow_degraded_security = allow;
553 self
554 }
555
556 #[must_use]
557 pub fn with_allow_chroot_fallback(mut self, allow: bool) -> Self {
558 self.allow_chroot_fallback = allow;
559 self
560 }
561
562 #[must_use]
563 pub fn with_allow_host_network(mut self, allow: bool) -> Self {
564 self.allow_host_network = allow;
565 self
566 }
567
568 #[must_use]
569 pub fn with_proc_readonly(mut self, proc_readonly: bool) -> Self {
570 self.proc_readonly = proc_readonly;
571 self
572 }
573
574 #[must_use]
575 pub fn with_service_mode(mut self, mode: ServiceMode) -> Self {
576 self.service_mode = mode;
577 self
578 }
579
580 #[must_use]
581 pub fn with_rootfs_path(mut self, path: PathBuf) -> Self {
582 self.rootfs_path = Some(path);
583 self
584 }
585
586 #[must_use]
587 pub fn with_egress_policy(mut self, policy: EgressPolicy) -> Self {
588 self.egress_policy = Some(policy);
589 self
590 }
591
592 #[must_use]
593 pub fn with_health_check(mut self, hc: HealthCheck) -> Self {
594 self.health_check = Some(hc);
595 self
596 }
597
598 #[must_use]
599 pub fn with_readiness_probe(mut self, probe: ReadinessProbe) -> Self {
600 self.readiness_probe = Some(probe);
601 self
602 }
603
604 #[must_use]
605 pub fn with_secret(mut self, secret: SecretMount) -> Self {
606 self.secrets.push(secret);
607 self
608 }
609
610 #[must_use]
611 pub fn with_volume(mut self, volume: VolumeMount) -> Self {
612 self.volumes.push(volume);
613 self
614 }
615
616 #[must_use]
617 pub fn with_env(mut self, key: String, value: String) -> Self {
618 self.environment.push((key, value));
619 self
620 }
621
622 #[must_use]
623 pub fn with_process_identity(mut self, identity: ProcessIdentity) -> Self {
624 self.process_identity = identity;
625 self
626 }
627
628 #[must_use]
629 pub fn with_config_hash(mut self, hash: u64) -> Self {
630 self.config_hash = Some(hash);
631 self
632 }
633
634 #[must_use]
635 pub fn with_sd_notify(mut self, enabled: bool) -> Self {
636 self.sd_notify = enabled;
637 self
638 }
639
640 #[must_use]
641 pub fn with_required_kernel_lockdown(mut self, mode: KernelLockdownMode) -> Self {
642 self.required_kernel_lockdown = Some(mode);
643 self
644 }
645
646 #[must_use]
647 pub fn with_verify_context_integrity(mut self, enabled: bool) -> Self {
648 self.verify_context_integrity = enabled;
649 self
650 }
651
652 #[must_use]
653 pub fn with_verify_rootfs_attestation(mut self, enabled: bool) -> Self {
654 self.verify_rootfs_attestation = enabled;
655 self
656 }
657
658 #[must_use]
659 pub fn with_seccomp_log_denied(mut self, enabled: bool) -> Self {
660 self.seccomp_log_denied = enabled;
661 self
662 }
663
664 #[must_use]
665 pub fn with_gvisor_platform(mut self, platform: GVisorPlatform) -> Self {
666 self.gvisor_platform = platform;
667 self
668 }
669
670 #[must_use]
671 pub fn with_seccomp_profile(mut self, path: PathBuf) -> Self {
672 self.seccomp_profile = Some(path);
673 self
674 }
675
676 #[must_use]
677 pub fn with_seccomp_profile_sha256(mut self, hash: String) -> Self {
678 self.seccomp_profile_sha256 = Some(hash);
679 self
680 }
681
682 #[must_use]
683 pub fn with_seccomp_mode(mut self, mode: SeccompMode) -> Self {
684 self.seccomp_mode = mode;
685 self
686 }
687
688 #[must_use]
689 pub fn with_seccomp_trace_log(mut self, path: PathBuf) -> Self {
690 self.seccomp_trace_log = Some(path);
691 self
692 }
693
694 #[must_use]
695 pub fn with_seccomp_allow_syscalls(mut self, syscalls: Vec<String>) -> Self {
696 self.seccomp_allow_syscalls = syscalls;
697 self
698 }
699
700 #[must_use]
701 pub fn with_caps_policy(mut self, path: PathBuf) -> Self {
702 self.caps_policy = Some(path);
703 self
704 }
705
706 #[must_use]
707 pub fn with_caps_policy_sha256(mut self, hash: String) -> Self {
708 self.caps_policy_sha256 = Some(hash);
709 self
710 }
711
712 #[must_use]
713 pub fn with_landlock_policy(mut self, path: PathBuf) -> Self {
714 self.landlock_policy = Some(path);
715 self
716 }
717
718 #[must_use]
719 pub fn with_landlock_policy_sha256(mut self, hash: String) -> Self {
720 self.landlock_policy_sha256 = Some(hash);
721 self
722 }
723
724 #[must_use]
725 pub fn with_pid_file(mut self, path: PathBuf) -> Self {
726 self.pid_file = Some(path);
727 self
728 }
729
730 #[must_use]
731 pub fn with_console_socket(mut self, path: PathBuf) -> Self {
732 self.console_socket = Some(path);
733 self
734 }
735
736 #[must_use]
737 pub fn with_bundle_dir(mut self, path: PathBuf) -> Self {
738 self.bundle_dir = Some(path);
739 self
740 }
741
742 pub fn with_state_root(mut self, root: PathBuf) -> Self {
743 self.state_root = Some(root);
744 self
745 }
746
747 pub fn validate_production_mode(&self) -> crate::error::Result<()> {
750 if self.service_mode != ServiceMode::Production {
751 return Ok(());
752 }
753
754 if self.allow_degraded_security {
755 return Err(crate::error::NucleusError::ConfigError(
756 "Production mode forbids --allow-degraded-security".to_string(),
757 ));
758 }
759
760 if self.allow_chroot_fallback {
761 return Err(crate::error::NucleusError::ConfigError(
762 "Production mode forbids --allow-chroot-fallback".to_string(),
763 ));
764 }
765
766 if self.allow_host_network {
767 return Err(crate::error::NucleusError::ConfigError(
768 "Production mode forbids --allow-host-network".to_string(),
769 ));
770 }
771
772 if matches!(self.network, crate::network::NetworkMode::Host) {
773 return Err(crate::error::NucleusError::ConfigError(
774 "Production mode forbids host network mode".to_string(),
775 ));
776 }
777
778 let Some(rootfs_path) = self.rootfs_path.as_ref() else {
780 return Err(crate::error::NucleusError::ConfigError(
781 "Production mode requires explicit --rootfs path (no host bind mounts)".to_string(),
782 ));
783 };
784
785 let rootfs_path = std::fs::canonicalize(rootfs_path).map_err(|e| {
788 crate::error::NucleusError::ConfigError(format!(
789 "Failed to canonicalize rootfs path '{}': {}",
790 rootfs_path.display(),
791 e
792 ))
793 })?;
794
795 let is_test_rootfs = rootfs_path
797 .to_string_lossy()
798 .contains("nucleus-test-nix-store");
799 if !rootfs_path.starts_with("/nix/store") && !is_test_rootfs {
800 return Err(crate::error::NucleusError::ConfigError(
801 "Production mode requires a /nix/store rootfs path".to_string(),
802 ));
803 }
804
805 if self.seccomp_mode == SeccompMode::Trace {
806 return Err(crate::error::NucleusError::ConfigError(
807 "Production mode forbids --seccomp-mode trace".to_string(),
808 ));
809 }
810
811 if self.caps_policy.is_some() && self.caps_policy_sha256.is_none() {
813 return Err(crate::error::NucleusError::ConfigError(
814 "Production mode requires --caps-policy-sha256 when using --caps-policy"
815 .to_string(),
816 ));
817 }
818 if self.landlock_policy.is_some() && self.landlock_policy_sha256.is_none() {
819 return Err(crate::error::NucleusError::ConfigError(
820 "Production mode requires --landlock-policy-sha256 when using --landlock-policy"
821 .to_string(),
822 ));
823 }
824 if self.seccomp_profile.is_some() && self.seccomp_profile_sha256.is_none() {
825 return Err(crate::error::NucleusError::ConfigError(
826 "Production mode requires --seccomp-profile-sha256 when using --seccomp-profile"
827 .to_string(),
828 ));
829 }
830
831 if self.limits.memory_bytes.is_none() {
833 return Err(crate::error::NucleusError::ConfigError(
834 "Production mode requires explicit --memory limit".to_string(),
835 ));
836 }
837
838 if self.limits.cpu_quota_us.is_none() {
839 return Err(crate::error::NucleusError::ConfigError(
840 "Production mode requires explicit --cpus limit".to_string(),
841 ));
842 }
843
844 if !self.verify_rootfs_attestation {
845 return Err(crate::error::NucleusError::ConfigError(
846 "Production mode requires --verify-rootfs-attestation".to_string(),
847 ));
848 }
849
850 if !rootfs_path.exists() {
852 return Err(crate::error::NucleusError::ConfigError(format!(
853 "Production mode rootfs path does not exist: {:?}",
854 rootfs_path
855 )));
856 }
857
858 Ok(())
859 }
860
861 pub fn validate_runtime_support(&self) -> crate::error::Result<()> {
863 if let Some(user_ns_config) = &self.user_ns_config {
864 if !self.process_identity.additional_gids.is_empty() {
865 return Err(crate::error::NucleusError::ConfigError(
866 "Supplementary groups are unsupported with user namespaces because \
867 /proc/self/setgroups is denied"
868 .to_string(),
869 ));
870 }
871
872 let uid_mapped = user_ns_config.uid_mappings.iter().any(|mapping| {
873 self.process_identity.uid >= mapping.container_id
874 && self.process_identity.uid
875 < mapping.container_id.saturating_add(mapping.count)
876 });
877 if !uid_mapped {
878 return Err(crate::error::NucleusError::ConfigError(format!(
879 "Process uid {} is not mapped in the configured user namespace",
880 self.process_identity.uid
881 )));
882 }
883
884 let gid_mapped = user_ns_config.gid_mappings.iter().any(|mapping| {
885 self.process_identity.gid >= mapping.container_id
886 && self.process_identity.gid
887 < mapping.container_id.saturating_add(mapping.count)
888 });
889 if !gid_mapped {
890 return Err(crate::error::NucleusError::ConfigError(format!(
891 "Process gid {} is not mapped in the configured user namespace",
892 self.process_identity.gid
893 )));
894 }
895 }
896
897 if self.seccomp_mode == SeccompMode::Trace && self.seccomp_trace_log.is_none() {
898 return Err(crate::error::NucleusError::ConfigError(
899 "Seccomp trace mode requires --seccomp-log / seccomp_trace_log".to_string(),
900 ));
901 }
902
903 for secret in &self.secrets {
904 normalize_container_destination(&secret.dest)?;
905 }
906
907 for volume in &self.volumes {
908 normalize_container_destination(&volume.dest)?;
909 match &volume.source {
910 VolumeSource::Bind { source } => {
911 if !source.is_absolute() {
912 return Err(crate::error::NucleusError::ConfigError(format!(
913 "Volume source must be absolute: {:?}",
914 source
915 )));
916 }
917 if !source.exists() {
918 return Err(crate::error::NucleusError::ConfigError(format!(
919 "Volume source does not exist: {:?}",
920 source
921 )));
922 }
923 crate::filesystem::validate_bind_mount_source(source)?;
924 }
925 VolumeSource::Tmpfs { .. } => {}
926 }
927 }
928
929 if !self.use_gvisor {
930 return Ok(());
931 }
932
933 if self.seccomp_mode == SeccompMode::Trace {
934 return Err(crate::error::NucleusError::ConfigError(
935 "gVisor runtime does not support --seccomp-mode trace; use --runtime native"
936 .to_string(),
937 ));
938 }
939
940 if self.seccomp_profile.is_some() || self.seccomp_log_denied {
941 return Err(crate::error::NucleusError::ConfigError(
942 "gVisor runtime does not support custom seccomp profiles or seccomp deny logging; use --runtime native"
943 .to_string(),
944 ));
945 }
946
947 if self.caps_policy.is_some() {
948 return Err(crate::error::NucleusError::ConfigError(
949 "gVisor runtime does not support capability policy files; use --runtime native"
950 .to_string(),
951 ));
952 }
953
954 if self.landlock_policy.is_some() {
955 return Err(crate::error::NucleusError::ConfigError(
956 "gVisor runtime does not support Landlock policy files; use --runtime native"
957 .to_string(),
958 ));
959 }
960
961 if self.health_check.is_some() {
962 return Err(crate::error::NucleusError::ConfigError(
963 "gVisor runtime does not support exec health checks; use --runtime native or remove --health-cmd"
964 .to_string(),
965 ));
966 }
967
968 if matches!(
969 self.readiness_probe.as_ref(),
970 Some(ReadinessProbe::Exec { .. }) | Some(ReadinessProbe::TcpPort(_))
971 ) {
972 return Err(crate::error::NucleusError::ConfigError(
973 "gVisor runtime does not support exec/TCP readiness probes; use --runtime native or --readiness-sd-notify"
974 .to_string(),
975 ));
976 }
977
978 if self.verify_context_integrity
979 && self.context_dir.is_some()
980 && matches!(self.context_mode, crate::filesystem::ContextMode::BindMount)
981 {
982 return Err(crate::error::NucleusError::ConfigError(
983 "gVisor runtime cannot verify bind-mounted context integrity; use --context-mode copy or disable --verify-context-integrity"
984 .to_string(),
985 ));
986 }
987
988 Ok(())
989 }
990
991 pub fn apply_runtime_selection(
993 mut self,
994 runtime: RuntimeSelection,
995 oci: bool,
996 ) -> crate::error::Result<Self> {
997 match runtime {
998 RuntimeSelection::Native => {
999 if oci {
1000 return Err(crate::error::NucleusError::ConfigError(
1001 "--bundle requires gVisor runtime; use --runtime gvisor".to_string(),
1002 ));
1003 }
1004 self = self
1005 .with_gvisor(false)
1006 .with_trust_level(TrustLevel::Trusted);
1007 }
1008 RuntimeSelection::GVisor => {
1009 self = self.with_gvisor(true);
1010 if !oci {
1011 tracing::info!(
1012 "Security hardening: enabling OCI bundle mode for gVisor runtime"
1013 );
1014 }
1015 self = self.with_oci_bundle();
1016 }
1017 }
1018 Ok(self)
1019 }
1020}
1021
1022pub fn validate_container_name(name: &str) -> crate::error::Result<()> {
1024 if name.is_empty() || name.len() > 128 {
1025 return Err(crate::error::NucleusError::ConfigError(
1026 "Invalid container name: must be 1-128 characters".to_string(),
1027 ));
1028 }
1029 if !name
1030 .chars()
1031 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
1032 {
1033 return Err(crate::error::NucleusError::ConfigError(
1034 "Invalid container name: allowed characters are a-zA-Z0-9, '-', '_', '.'".to_string(),
1035 ));
1036 }
1037 Ok(())
1038}
1039
1040pub fn validate_hostname(hostname: &str) -> crate::error::Result<()> {
1042 if hostname.is_empty() || hostname.len() > 253 {
1043 return Err(crate::error::NucleusError::ConfigError(
1044 "Invalid hostname: must be 1-253 characters".to_string(),
1045 ));
1046 }
1047
1048 for label in hostname.split('.') {
1049 if label.is_empty() || label.len() > 63 {
1050 return Err(crate::error::NucleusError::ConfigError(format!(
1051 "Invalid hostname label: '{}'",
1052 label
1053 )));
1054 }
1055 if label.starts_with('-') || label.ends_with('-') {
1056 return Err(crate::error::NucleusError::ConfigError(format!(
1057 "Invalid hostname label '{}': cannot start or end with '-'",
1058 label
1059 )));
1060 }
1061 if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
1062 return Err(crate::error::NucleusError::ConfigError(format!(
1063 "Invalid hostname label '{}': allowed characters are a-zA-Z0-9 and '-'",
1064 label
1065 )));
1066 }
1067 }
1068
1069 Ok(())
1070}
1071
1072#[cfg(test)]
1073#[allow(deprecated)]
1074mod tests {
1075 use super::*;
1076 use crate::network::NetworkMode;
1077
1078 #[test]
1079 fn test_generate_container_id_is_32_hex_chars() {
1080 let id = generate_container_id().unwrap();
1081 assert_eq!(
1082 id.len(),
1083 32,
1084 "Container ID must be full 128-bit (32 hex chars), got {}",
1085 id.len()
1086 );
1087 assert!(
1088 id.chars().all(|c| c.is_ascii_hexdigit()),
1089 "Container ID must be hex: {}",
1090 id
1091 );
1092 }
1093
1094 #[test]
1095 fn test_generate_container_id_is_unique() {
1096 let id1 = generate_container_id().unwrap();
1097 let id2 = generate_container_id().unwrap();
1098 assert_ne!(id1, id2, "Two consecutive IDs must differ");
1099 }
1100
1101 #[test]
1102 fn test_config_security_defaults_are_hardened() {
1103 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
1104 assert!(!cfg.allow_degraded_security);
1105 assert!(!cfg.allow_chroot_fallback);
1106 assert!(!cfg.allow_host_network);
1107 assert!(cfg.proc_readonly);
1108 assert_eq!(cfg.service_mode, ServiceMode::Agent);
1109 assert!(cfg.rootfs_path.is_none());
1110 assert!(cfg.egress_policy.is_none());
1111 assert!(cfg.secrets.is_empty());
1112 assert!(cfg.volumes.is_empty());
1113 assert!(!cfg.sd_notify);
1114 assert!(cfg.required_kernel_lockdown.is_none());
1115 assert!(!cfg.verify_context_integrity);
1116 assert!(!cfg.verify_rootfs_attestation);
1117 assert!(!cfg.seccomp_log_denied);
1118 assert_eq!(cfg.gvisor_platform, GVisorPlatform::Systrap);
1119 }
1120
1121 #[test]
1122 fn test_production_mode_rejects_degraded_flags() {
1123 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1124 .unwrap()
1125 .with_service_mode(ServiceMode::Production)
1126 .with_allow_degraded_security(true)
1127 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
1128 .with_limits(
1129 crate::resources::ResourceLimits::default()
1130 .with_memory("512M")
1131 .unwrap()
1132 .with_cpu_cores(2.0)
1133 .unwrap(),
1134 );
1135 assert!(cfg.validate_production_mode().is_err());
1136 }
1137
1138 #[test]
1139 fn test_production_mode_rejects_chroot_fallback() {
1140 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1141 .unwrap()
1142 .with_service_mode(ServiceMode::Production)
1143 .with_allow_chroot_fallback(true)
1144 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
1145 .with_limits(
1146 crate::resources::ResourceLimits::default()
1147 .with_memory("512M")
1148 .unwrap()
1149 .with_cpu_cores(2.0)
1150 .unwrap(),
1151 );
1152 let err = cfg.validate_production_mode().unwrap_err();
1153 assert!(
1154 err.to_string().contains("chroot"),
1155 "Production mode must reject chroot fallback"
1156 );
1157 }
1158
1159 #[test]
1160 fn test_production_mode_requires_rootfs() {
1161 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1162 .unwrap()
1163 .with_service_mode(ServiceMode::Production)
1164 .with_limits(
1165 crate::resources::ResourceLimits::default()
1166 .with_memory("512M")
1167 .unwrap(),
1168 );
1169 let err = cfg.validate_production_mode().unwrap_err();
1170 assert!(err.to_string().contains("--rootfs"));
1171 }
1172
1173 fn test_rootfs_path() -> std::path::PathBuf {
1174 use std::sync::atomic::{AtomicU64, Ordering};
1175 static COUNTER: AtomicU64 = AtomicU64::new(0);
1176 let id = COUNTER.fetch_add(1, Ordering::SeqCst);
1177
1178 let rootfs = std::env::temp_dir().join(format!(
1181 "nucleus-test-nix-store-{}-{}/rootfs",
1182 std::process::id(),
1183 id
1184 ));
1185 std::fs::create_dir_all(&rootfs).unwrap();
1186
1187 rootfs
1188 }
1189
1190 #[test]
1191 fn test_production_mode_requires_memory_limit() {
1192 let rootfs = test_rootfs_path();
1193 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1194 .unwrap()
1195 .with_service_mode(ServiceMode::Production)
1196 .with_rootfs_path(rootfs);
1197 let err = cfg.validate_production_mode().unwrap_err();
1198 let _ = std::fs::remove_dir_all(&cfg.rootfs_path.as_ref().unwrap());
1199 assert!(err.to_string().contains("--memory"));
1200 }
1201
1202 #[test]
1203 fn test_production_mode_valid_config() {
1204 let rootfs = test_rootfs_path();
1205 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1206 .unwrap()
1207 .with_service_mode(ServiceMode::Production)
1208 .with_rootfs_path(rootfs.clone())
1209 .with_verify_rootfs_attestation(true)
1210 .with_limits(
1211 crate::resources::ResourceLimits::default()
1212 .with_memory("512M")
1213 .unwrap()
1214 .with_cpu_cores(2.0)
1215 .unwrap(),
1216 );
1217 let result = cfg.validate_production_mode();
1218 let _ = std::fs::remove_dir_all(&rootfs);
1219 assert!(result.is_ok());
1220 }
1221
1222 #[test]
1223 fn test_production_mode_requires_rootfs_attestation() {
1224 let rootfs = test_rootfs_path();
1225 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1226 .unwrap()
1227 .with_service_mode(ServiceMode::Production)
1228 .with_rootfs_path(rootfs.clone())
1229 .with_limits(
1230 crate::resources::ResourceLimits::default()
1231 .with_memory("512M")
1232 .unwrap()
1233 .with_cpu_cores(2.0)
1234 .unwrap(),
1235 );
1236 let err = cfg.validate_production_mode().unwrap_err();
1237 let _ = std::fs::remove_dir_all(&rootfs);
1238 assert!(err.to_string().contains("attestation"));
1239 }
1240
1241 #[test]
1242 fn test_production_mode_rejects_seccomp_trace() {
1243 let rootfs = test_rootfs_path();
1244 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1245 .unwrap()
1246 .with_service_mode(ServiceMode::Production)
1247 .with_rootfs_path(rootfs.clone())
1248 .with_seccomp_mode(SeccompMode::Trace)
1249 .with_limits(
1250 crate::resources::ResourceLimits::default()
1251 .with_memory("512M")
1252 .unwrap()
1253 .with_cpu_cores(2.0)
1254 .unwrap(),
1255 );
1256 let err = cfg.validate_production_mode().unwrap_err();
1257 let _ = std::fs::remove_dir_all(&rootfs);
1258 assert!(
1259 err.to_string().contains("trace"),
1260 "Production mode must reject seccomp trace mode"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_production_mode_requires_cpu_limit() {
1266 let rootfs = test_rootfs_path();
1267 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1268 .unwrap()
1269 .with_service_mode(ServiceMode::Production)
1270 .with_rootfs_path(rootfs.clone())
1271 .with_limits(
1272 crate::resources::ResourceLimits::default()
1273 .with_memory("512M")
1274 .unwrap(),
1275 );
1276 let err = cfg.validate_production_mode().unwrap_err();
1277 let _ = std::fs::remove_dir_all(&rootfs);
1278 assert!(err.to_string().contains("--cpus"));
1279 }
1280
1281 #[test]
1282 fn test_config_security_builders_override_defaults() {
1283 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1284 .unwrap()
1285 .with_allow_degraded_security(true)
1286 .with_allow_chroot_fallback(true)
1287 .with_allow_host_network(true)
1288 .with_proc_readonly(false)
1289 .with_network(NetworkMode::Host);
1290
1291 assert!(cfg.allow_degraded_security);
1292 assert!(cfg.allow_chroot_fallback);
1293 assert!(cfg.allow_host_network);
1294 assert!(!cfg.proc_readonly);
1295 assert!(matches!(cfg.network, NetworkMode::Host));
1296 }
1297
1298 #[test]
1299 fn test_hardening_builders_override_defaults() {
1300 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1301 .unwrap()
1302 .with_required_kernel_lockdown(KernelLockdownMode::Confidentiality)
1303 .with_verify_context_integrity(true)
1304 .with_verify_rootfs_attestation(true)
1305 .with_seccomp_log_denied(true)
1306 .with_gvisor_platform(GVisorPlatform::Kvm);
1307
1308 assert_eq!(
1309 cfg.required_kernel_lockdown,
1310 Some(KernelLockdownMode::Confidentiality)
1311 );
1312 assert!(cfg.verify_context_integrity);
1313 assert!(cfg.verify_rootfs_attestation);
1314 assert!(cfg.seccomp_log_denied);
1315 assert_eq!(cfg.gvisor_platform, GVisorPlatform::Kvm);
1316 }
1317
1318 #[test]
1319 fn test_seccomp_trace_requires_log_path() {
1320 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1321 .unwrap()
1322 .with_gvisor(false)
1323 .with_seccomp_mode(SeccompMode::Trace);
1324
1325 let err = cfg.validate_runtime_support().unwrap_err();
1326 assert!(err.to_string().contains("seccomp-log"));
1327 }
1328
1329 #[test]
1330 fn test_gvisor_rejects_native_security_policy_files() {
1331 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1332 .unwrap()
1333 .with_seccomp_profile(PathBuf::from("/tmp/seccomp.json"))
1334 .with_caps_policy(PathBuf::from("/tmp/caps.toml"));
1335
1336 let err = cfg.validate_runtime_support().unwrap_err();
1337 assert!(err.to_string().contains("gVisor runtime"));
1338 }
1339
1340 #[test]
1341 fn test_gvisor_rejects_landlock_policy_file() {
1342 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1343 .unwrap()
1344 .with_landlock_policy(PathBuf::from("/tmp/landlock.toml"));
1345
1346 let err = cfg.validate_runtime_support().unwrap_err();
1347 assert!(err.to_string().contains("Landlock"));
1348 }
1349
1350 #[test]
1351 fn test_gvisor_rejects_trace_mode_even_with_log_path() {
1352 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1353 .unwrap()
1354 .with_seccomp_mode(SeccompMode::Trace)
1355 .with_seccomp_trace_log(PathBuf::from("/tmp/trace.ndjson"));
1356
1357 let err = cfg.validate_runtime_support().unwrap_err();
1358 assert!(err.to_string().contains("gVisor runtime"));
1359 }
1360
1361 #[test]
1362 fn test_secret_dest_must_be_absolute() {
1363 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1364 .unwrap()
1365 .with_secret(crate::container::SecretMount {
1366 source: PathBuf::from("/run/secrets/api-key"),
1367 dest: PathBuf::from("secrets/api-key"),
1368 mode: 0o400,
1369 });
1370
1371 let err = cfg.validate_runtime_support().unwrap_err();
1372 assert!(err.to_string().contains("absolute"));
1373 }
1374
1375 #[test]
1376 fn test_secret_dest_rejects_parent_traversal() {
1377 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1378 .unwrap()
1379 .with_secret(crate::container::SecretMount {
1380 source: PathBuf::from("/run/secrets/api-key"),
1381 dest: PathBuf::from("/../../etc/passwd"),
1382 mode: 0o400,
1383 });
1384
1385 let err = cfg.validate_runtime_support().unwrap_err();
1386 assert!(err.to_string().contains("parent traversal"));
1387 }
1388
1389 #[test]
1390 fn test_bind_volume_source_must_exist() {
1391 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1392 .unwrap()
1393 .with_volume(VolumeMount {
1394 source: VolumeSource::Bind {
1395 source: PathBuf::from("/tmp/definitely-missing-nucleus-volume"),
1396 },
1397 dest: PathBuf::from("/var/lib/app"),
1398 read_only: false,
1399 });
1400
1401 let err = cfg.validate_runtime_support().unwrap_err();
1402 assert!(err.to_string().contains("Volume source does not exist"));
1403 }
1404
1405 #[test]
1406 fn test_bind_volume_source_rejects_sensitive_host_subtrees() {
1407 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1408 .unwrap()
1409 .with_volume(VolumeMount {
1410 source: VolumeSource::Bind {
1411 source: PathBuf::from("/proc/sys"),
1412 },
1413 dest: PathBuf::from("/host-proc"),
1414 read_only: true,
1415 });
1416
1417 let err = cfg.validate_runtime_support().unwrap_err();
1418 assert!(err.to_string().contains("sensitive host path"));
1419 }
1420
1421 #[test]
1422 fn test_bind_volume_dest_must_be_absolute() {
1423 let dir = tempfile::TempDir::new().unwrap();
1424 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1425 .unwrap()
1426 .with_volume(VolumeMount {
1427 source: VolumeSource::Bind {
1428 source: dir.path().to_path_buf(),
1429 },
1430 dest: PathBuf::from("var/lib/app"),
1431 read_only: false,
1432 });
1433
1434 let err = cfg.validate_runtime_support().unwrap_err();
1435 assert!(err.to_string().contains("absolute"));
1436 }
1437
1438 #[test]
1439 fn test_tmpfs_volume_rejects_parent_traversal() {
1440 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1441 .unwrap()
1442 .with_volume(VolumeMount {
1443 source: VolumeSource::Tmpfs {
1444 size: Some("64M".to_string()),
1445 },
1446 dest: PathBuf::from("/../../var/lib/app"),
1447 read_only: false,
1448 });
1449
1450 let err = cfg.validate_runtime_support().unwrap_err();
1451 assert!(err.to_string().contains("parent traversal"));
1452 }
1453
1454 #[test]
1455 fn test_gvisor_rejects_bind_mount_context_integrity_verification() {
1456 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1457 .unwrap()
1458 .with_context(PathBuf::from("/tmp/context"))
1459 .with_context_mode(crate::filesystem::ContextMode::BindMount)
1460 .with_verify_context_integrity(true);
1461
1462 let err = cfg.validate_runtime_support().unwrap_err();
1463 assert!(err.to_string().contains("context integrity"));
1464 }
1465
1466 #[test]
1467 fn test_gvisor_rejects_exec_health_checks() {
1468 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1469 .unwrap()
1470 .with_health_check(HealthCheck {
1471 command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
1472 interval: Duration::from_secs(30),
1473 retries: 3,
1474 start_period: Duration::from_secs(1),
1475 timeout: Duration::from_secs(5),
1476 });
1477
1478 let err = cfg.validate_runtime_support().unwrap_err();
1479 assert!(err.to_string().contains("health checks"));
1480 }
1481
1482 #[test]
1483 fn test_gvisor_rejects_exec_readiness_probes() {
1484 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1485 .unwrap()
1486 .with_readiness_probe(ReadinessProbe::Exec {
1487 command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
1488 });
1489
1490 let err = cfg.validate_runtime_support().unwrap_err();
1491 assert!(err.to_string().contains("readiness"));
1492 }
1493
1494 #[test]
1495 fn test_gvisor_allows_copy_mode_context_integrity_verification() {
1496 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1497 .unwrap()
1498 .with_context(PathBuf::from("/tmp/context"))
1499 .with_context_mode(crate::filesystem::ContextMode::Copy)
1500 .with_verify_context_integrity(true);
1501
1502 assert!(cfg.validate_runtime_support().is_ok());
1503 }
1504
1505 #[test]
1506 fn test_user_namespace_rejects_unmapped_process_identity() {
1507 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1508 .unwrap()
1509 .with_rootless()
1510 .with_process_identity(ProcessIdentity {
1511 uid: 1000,
1512 gid: 1000,
1513 additional_gids: Vec::new(),
1514 });
1515
1516 let err = cfg.validate_runtime_support().unwrap_err();
1517 assert!(err.to_string().contains("not mapped"));
1518 }
1519
1520 #[test]
1521 fn test_user_namespace_rejects_supplementary_groups() {
1522 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1523 .unwrap()
1524 .with_rootless()
1525 .with_process_identity(ProcessIdentity {
1526 uid: 0,
1527 gid: 0,
1528 additional_gids: vec![1],
1529 });
1530
1531 let err = cfg.validate_runtime_support().unwrap_err();
1532 assert!(err.to_string().contains("Supplementary groups"));
1533 }
1534
1535 #[test]
1536 fn test_native_runtime_disables_gvisor() {
1537 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1539 .unwrap()
1540 .with_gvisor(false)
1541 .with_trust_level(TrustLevel::Trusted);
1542 assert!(!cfg.use_gvisor, "native runtime must disable gVisor");
1543 assert_eq!(
1544 cfg.trust_level,
1545 TrustLevel::Trusted,
1546 "native runtime must set Trusted trust level"
1547 );
1548 }
1549
1550 #[test]
1551 fn test_default_config_has_gvisor_enabled() {
1552 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
1553 assert!(cfg.use_gvisor, "default must have gVisor enabled");
1554 assert_eq!(
1555 cfg.trust_level,
1556 TrustLevel::Untrusted,
1557 "default must be Untrusted"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_generate_container_id_returns_result() {
1563 let id: crate::error::Result<String> = generate_container_id();
1566 let id = id.expect("generate_container_id must return Ok, not panic");
1567 assert_eq!(id.len(), 32, "container ID must be 32 hex chars");
1568 assert!(
1569 id.chars().all(|c| c.is_ascii_hexdigit()),
1570 "container ID must be valid hex: {}",
1571 id
1572 );
1573 }
1574}