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 caps_policy: Option<PathBuf>,
353
354 pub caps_policy_sha256: Option<String>,
356
357 pub landlock_policy: Option<PathBuf>,
359
360 pub landlock_policy_sha256: Option<String>,
362
363 pub hooks: Option<crate::security::OciHooks>,
365
366 pub pid_file: Option<PathBuf>,
368
369 pub console_socket: Option<PathBuf>,
371
372 pub bundle_dir: Option<PathBuf>,
374
375 pub state_root: Option<PathBuf>,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
382pub enum SeccompMode {
383 #[default]
385 Enforce,
386 Trace,
389}
390
391impl ContainerConfig {
392 pub fn try_new(name: Option<String>, command: Vec<String>) -> crate::error::Result<Self> {
397 let id = generate_container_id()?;
398 let name = name.unwrap_or_else(|| id.clone());
399 Ok(Self {
400 id,
401 name: name.clone(),
402 command,
403 context_dir: None,
404 limits: ResourceLimits::default(),
405 namespaces: NamespaceConfig::default(),
406 user_ns_config: None,
407 hostname: Some(name),
408 use_gvisor: true,
409 trust_level: TrustLevel::default(),
410 network: crate::network::NetworkMode::None,
411 context_mode: crate::filesystem::ContextMode::Copy,
412 allow_degraded_security: false,
413 allow_chroot_fallback: false,
414 allow_host_network: false,
415 proc_readonly: true,
416 service_mode: ServiceMode::default(),
417 rootfs_path: None,
418 egress_policy: None,
419 health_check: None,
420 readiness_probe: None,
421 secrets: Vec::new(),
422 volumes: Vec::new(),
423 environment: Vec::new(),
424 process_identity: ProcessIdentity::default(),
425 config_hash: None,
426 sd_notify: false,
427 required_kernel_lockdown: None,
428 verify_context_integrity: false,
429 verify_rootfs_attestation: false,
430 seccomp_log_denied: false,
431 gvisor_platform: GVisorPlatform::default(),
432 seccomp_profile: None,
433 seccomp_profile_sha256: None,
434 seccomp_mode: SeccompMode::default(),
435 seccomp_trace_log: None,
436 caps_policy: None,
437 caps_policy_sha256: None,
438 landlock_policy: None,
439 landlock_policy_sha256: None,
440 hooks: None,
441 pid_file: None,
442 console_socket: None,
443 bundle_dir: None,
444 state_root: None,
445 })
446 }
447
448 #[must_use]
450 pub fn with_rootless(mut self) -> Self {
451 self.namespaces.user = true;
452 self.user_ns_config = Some(UserNamespaceConfig::rootless());
453 self
454 }
455
456 #[must_use]
458 pub fn with_user_namespace(mut self, config: UserNamespaceConfig) -> Self {
459 self.namespaces.user = true;
460 self.user_ns_config = Some(config);
461 self
462 }
463
464 #[must_use]
465 pub fn with_context(mut self, dir: PathBuf) -> Self {
466 self.context_dir = Some(dir);
467 self
468 }
469
470 #[must_use]
471 pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
472 self.limits = limits;
473 self
474 }
475
476 #[must_use]
477 pub fn with_namespaces(mut self, namespaces: NamespaceConfig) -> Self {
478 self.namespaces = namespaces;
479 self
480 }
481
482 #[must_use]
483 pub fn with_hostname(mut self, hostname: Option<String>) -> Self {
484 self.hostname = hostname;
485 self
486 }
487
488 #[must_use]
489 pub fn with_gvisor(mut self, enabled: bool) -> Self {
490 self.use_gvisor = enabled;
491 self
492 }
493
494 #[must_use]
495 pub fn with_trust_level(mut self, level: TrustLevel) -> Self {
496 self.trust_level = level;
497 self
498 }
499
500 #[must_use]
502 pub fn with_oci_bundle(mut self) -> Self {
503 self.use_gvisor = true;
504 self
505 }
506
507 #[must_use]
508 pub fn with_network(mut self, mode: crate::network::NetworkMode) -> Self {
509 self.network = mode;
510 self
511 }
512
513 #[must_use]
514 pub fn with_context_mode(mut self, mode: crate::filesystem::ContextMode) -> Self {
515 self.context_mode = mode;
516 self
517 }
518
519 #[must_use]
520 pub fn with_allow_degraded_security(mut self, allow: bool) -> Self {
521 self.allow_degraded_security = allow;
522 self
523 }
524
525 #[must_use]
526 pub fn with_allow_chroot_fallback(mut self, allow: bool) -> Self {
527 self.allow_chroot_fallback = allow;
528 self
529 }
530
531 #[must_use]
532 pub fn with_allow_host_network(mut self, allow: bool) -> Self {
533 self.allow_host_network = allow;
534 self
535 }
536
537 #[must_use]
538 pub fn with_proc_readonly(mut self, proc_readonly: bool) -> Self {
539 self.proc_readonly = proc_readonly;
540 self
541 }
542
543 #[must_use]
544 pub fn with_service_mode(mut self, mode: ServiceMode) -> Self {
545 self.service_mode = mode;
546 self
547 }
548
549 #[must_use]
550 pub fn with_rootfs_path(mut self, path: PathBuf) -> Self {
551 self.rootfs_path = Some(path);
552 self
553 }
554
555 #[must_use]
556 pub fn with_egress_policy(mut self, policy: EgressPolicy) -> Self {
557 self.egress_policy = Some(policy);
558 self
559 }
560
561 #[must_use]
562 pub fn with_health_check(mut self, hc: HealthCheck) -> Self {
563 self.health_check = Some(hc);
564 self
565 }
566
567 #[must_use]
568 pub fn with_readiness_probe(mut self, probe: ReadinessProbe) -> Self {
569 self.readiness_probe = Some(probe);
570 self
571 }
572
573 #[must_use]
574 pub fn with_secret(mut self, secret: SecretMount) -> Self {
575 self.secrets.push(secret);
576 self
577 }
578
579 #[must_use]
580 pub fn with_volume(mut self, volume: VolumeMount) -> Self {
581 self.volumes.push(volume);
582 self
583 }
584
585 #[must_use]
586 pub fn with_env(mut self, key: String, value: String) -> Self {
587 self.environment.push((key, value));
588 self
589 }
590
591 #[must_use]
592 pub fn with_process_identity(mut self, identity: ProcessIdentity) -> Self {
593 self.process_identity = identity;
594 self
595 }
596
597 #[must_use]
598 pub fn with_config_hash(mut self, hash: u64) -> Self {
599 self.config_hash = Some(hash);
600 self
601 }
602
603 #[must_use]
604 pub fn with_sd_notify(mut self, enabled: bool) -> Self {
605 self.sd_notify = enabled;
606 self
607 }
608
609 #[must_use]
610 pub fn with_required_kernel_lockdown(mut self, mode: KernelLockdownMode) -> Self {
611 self.required_kernel_lockdown = Some(mode);
612 self
613 }
614
615 #[must_use]
616 pub fn with_verify_context_integrity(mut self, enabled: bool) -> Self {
617 self.verify_context_integrity = enabled;
618 self
619 }
620
621 #[must_use]
622 pub fn with_verify_rootfs_attestation(mut self, enabled: bool) -> Self {
623 self.verify_rootfs_attestation = enabled;
624 self
625 }
626
627 #[must_use]
628 pub fn with_seccomp_log_denied(mut self, enabled: bool) -> Self {
629 self.seccomp_log_denied = enabled;
630 self
631 }
632
633 #[must_use]
634 pub fn with_gvisor_platform(mut self, platform: GVisorPlatform) -> Self {
635 self.gvisor_platform = platform;
636 self
637 }
638
639 #[must_use]
640 pub fn with_seccomp_profile(mut self, path: PathBuf) -> Self {
641 self.seccomp_profile = Some(path);
642 self
643 }
644
645 #[must_use]
646 pub fn with_seccomp_profile_sha256(mut self, hash: String) -> Self {
647 self.seccomp_profile_sha256 = Some(hash);
648 self
649 }
650
651 #[must_use]
652 pub fn with_seccomp_mode(mut self, mode: SeccompMode) -> Self {
653 self.seccomp_mode = mode;
654 self
655 }
656
657 #[must_use]
658 pub fn with_seccomp_trace_log(mut self, path: PathBuf) -> Self {
659 self.seccomp_trace_log = Some(path);
660 self
661 }
662
663 #[must_use]
664 pub fn with_caps_policy(mut self, path: PathBuf) -> Self {
665 self.caps_policy = Some(path);
666 self
667 }
668
669 #[must_use]
670 pub fn with_caps_policy_sha256(mut self, hash: String) -> Self {
671 self.caps_policy_sha256 = Some(hash);
672 self
673 }
674
675 #[must_use]
676 pub fn with_landlock_policy(mut self, path: PathBuf) -> Self {
677 self.landlock_policy = Some(path);
678 self
679 }
680
681 #[must_use]
682 pub fn with_landlock_policy_sha256(mut self, hash: String) -> Self {
683 self.landlock_policy_sha256 = Some(hash);
684 self
685 }
686
687 #[must_use]
688 pub fn with_pid_file(mut self, path: PathBuf) -> Self {
689 self.pid_file = Some(path);
690 self
691 }
692
693 #[must_use]
694 pub fn with_console_socket(mut self, path: PathBuf) -> Self {
695 self.console_socket = Some(path);
696 self
697 }
698
699 #[must_use]
700 pub fn with_bundle_dir(mut self, path: PathBuf) -> Self {
701 self.bundle_dir = Some(path);
702 self
703 }
704
705 pub fn with_state_root(mut self, root: PathBuf) -> Self {
706 self.state_root = Some(root);
707 self
708 }
709
710 pub fn validate_production_mode(&self) -> crate::error::Result<()> {
713 if self.service_mode != ServiceMode::Production {
714 return Ok(());
715 }
716
717 if self.allow_degraded_security {
718 return Err(crate::error::NucleusError::ConfigError(
719 "Production mode forbids --allow-degraded-security".to_string(),
720 ));
721 }
722
723 if self.allow_chroot_fallback {
724 return Err(crate::error::NucleusError::ConfigError(
725 "Production mode forbids --allow-chroot-fallback".to_string(),
726 ));
727 }
728
729 if self.allow_host_network {
730 return Err(crate::error::NucleusError::ConfigError(
731 "Production mode forbids --allow-host-network".to_string(),
732 ));
733 }
734
735 if matches!(self.network, crate::network::NetworkMode::Host) {
736 return Err(crate::error::NucleusError::ConfigError(
737 "Production mode forbids host network mode".to_string(),
738 ));
739 }
740
741 let Some(rootfs_path) = self.rootfs_path.as_ref() else {
743 return Err(crate::error::NucleusError::ConfigError(
744 "Production mode requires explicit --rootfs path (no host bind mounts)".to_string(),
745 ));
746 };
747
748 let rootfs_path = std::fs::canonicalize(rootfs_path).map_err(|e| {
751 crate::error::NucleusError::ConfigError(format!(
752 "Failed to canonicalize rootfs path '{}': {}",
753 rootfs_path.display(),
754 e
755 ))
756 })?;
757
758 let is_test_rootfs = rootfs_path
760 .to_string_lossy()
761 .contains("nucleus-test-nix-store");
762 if !rootfs_path.starts_with("/nix/store") && !is_test_rootfs {
763 return Err(crate::error::NucleusError::ConfigError(
764 "Production mode requires a /nix/store rootfs path".to_string(),
765 ));
766 }
767
768 if self.seccomp_mode == SeccompMode::Trace {
769 return Err(crate::error::NucleusError::ConfigError(
770 "Production mode forbids --seccomp-mode trace".to_string(),
771 ));
772 }
773
774 if self.caps_policy.is_some() && self.caps_policy_sha256.is_none() {
776 return Err(crate::error::NucleusError::ConfigError(
777 "Production mode requires --caps-policy-sha256 when using --caps-policy"
778 .to_string(),
779 ));
780 }
781 if self.landlock_policy.is_some() && self.landlock_policy_sha256.is_none() {
782 return Err(crate::error::NucleusError::ConfigError(
783 "Production mode requires --landlock-policy-sha256 when using --landlock-policy"
784 .to_string(),
785 ));
786 }
787 if self.seccomp_profile.is_some() && self.seccomp_profile_sha256.is_none() {
788 return Err(crate::error::NucleusError::ConfigError(
789 "Production mode requires --seccomp-profile-sha256 when using --seccomp-profile"
790 .to_string(),
791 ));
792 }
793
794 if self.limits.memory_bytes.is_none() {
796 return Err(crate::error::NucleusError::ConfigError(
797 "Production mode requires explicit --memory limit".to_string(),
798 ));
799 }
800
801 if self.limits.cpu_quota_us.is_none() {
802 return Err(crate::error::NucleusError::ConfigError(
803 "Production mode requires explicit --cpus limit".to_string(),
804 ));
805 }
806
807 if !self.verify_rootfs_attestation {
808 return Err(crate::error::NucleusError::ConfigError(
809 "Production mode requires --verify-rootfs-attestation".to_string(),
810 ));
811 }
812
813 if !rootfs_path.exists() {
815 return Err(crate::error::NucleusError::ConfigError(format!(
816 "Production mode rootfs path does not exist: {:?}",
817 rootfs_path
818 )));
819 }
820
821 Ok(())
822 }
823
824 pub fn validate_runtime_support(&self) -> crate::error::Result<()> {
826 if let Some(user_ns_config) = &self.user_ns_config {
827 if !self.process_identity.additional_gids.is_empty() {
828 return Err(crate::error::NucleusError::ConfigError(
829 "Supplementary groups are unsupported with user namespaces because \
830 /proc/self/setgroups is denied"
831 .to_string(),
832 ));
833 }
834
835 let uid_mapped = user_ns_config.uid_mappings.iter().any(|mapping| {
836 self.process_identity.uid >= mapping.container_id
837 && self.process_identity.uid
838 < mapping.container_id.saturating_add(mapping.count)
839 });
840 if !uid_mapped {
841 return Err(crate::error::NucleusError::ConfigError(format!(
842 "Process uid {} is not mapped in the configured user namespace",
843 self.process_identity.uid
844 )));
845 }
846
847 let gid_mapped = user_ns_config.gid_mappings.iter().any(|mapping| {
848 self.process_identity.gid >= mapping.container_id
849 && self.process_identity.gid
850 < mapping.container_id.saturating_add(mapping.count)
851 });
852 if !gid_mapped {
853 return Err(crate::error::NucleusError::ConfigError(format!(
854 "Process gid {} is not mapped in the configured user namespace",
855 self.process_identity.gid
856 )));
857 }
858 }
859
860 if self.seccomp_mode == SeccompMode::Trace && self.seccomp_trace_log.is_none() {
861 return Err(crate::error::NucleusError::ConfigError(
862 "Seccomp trace mode requires --seccomp-log / seccomp_trace_log".to_string(),
863 ));
864 }
865
866 for secret in &self.secrets {
867 normalize_container_destination(&secret.dest)?;
868 }
869
870 for volume in &self.volumes {
871 normalize_container_destination(&volume.dest)?;
872 match &volume.source {
873 VolumeSource::Bind { source } => {
874 if !source.is_absolute() {
875 return Err(crate::error::NucleusError::ConfigError(format!(
876 "Volume source must be absolute: {:?}",
877 source
878 )));
879 }
880 if !source.exists() {
881 return Err(crate::error::NucleusError::ConfigError(format!(
882 "Volume source does not exist: {:?}",
883 source
884 )));
885 }
886 }
887 VolumeSource::Tmpfs { .. } => {}
888 }
889 }
890
891 if !self.use_gvisor {
892 return Ok(());
893 }
894
895 if self.seccomp_mode == SeccompMode::Trace {
896 return Err(crate::error::NucleusError::ConfigError(
897 "gVisor runtime does not support --seccomp-mode trace; use --runtime native"
898 .to_string(),
899 ));
900 }
901
902 if self.seccomp_profile.is_some() || self.seccomp_log_denied {
903 return Err(crate::error::NucleusError::ConfigError(
904 "gVisor runtime does not support custom seccomp profiles or seccomp deny logging; use --runtime native"
905 .to_string(),
906 ));
907 }
908
909 if self.caps_policy.is_some() {
910 return Err(crate::error::NucleusError::ConfigError(
911 "gVisor runtime does not support capability policy files; use --runtime native"
912 .to_string(),
913 ));
914 }
915
916 if self.landlock_policy.is_some() {
917 return Err(crate::error::NucleusError::ConfigError(
918 "gVisor runtime does not support Landlock policy files; use --runtime native"
919 .to_string(),
920 ));
921 }
922
923 if self.health_check.is_some() {
924 return Err(crate::error::NucleusError::ConfigError(
925 "gVisor runtime does not support exec health checks; use --runtime native or remove --health-cmd"
926 .to_string(),
927 ));
928 }
929
930 if matches!(
931 self.readiness_probe.as_ref(),
932 Some(ReadinessProbe::Exec { .. }) | Some(ReadinessProbe::TcpPort(_))
933 ) {
934 return Err(crate::error::NucleusError::ConfigError(
935 "gVisor runtime does not support exec/TCP readiness probes; use --runtime native or --readiness-sd-notify"
936 .to_string(),
937 ));
938 }
939
940 if self.verify_context_integrity
941 && self.context_dir.is_some()
942 && matches!(self.context_mode, crate::filesystem::ContextMode::BindMount)
943 {
944 return Err(crate::error::NucleusError::ConfigError(
945 "gVisor runtime cannot verify bind-mounted context integrity; use --context-mode copy or disable --verify-context-integrity"
946 .to_string(),
947 ));
948 }
949
950 Ok(())
951 }
952
953 pub fn apply_runtime_selection(
955 mut self,
956 runtime: RuntimeSelection,
957 oci: bool,
958 ) -> crate::error::Result<Self> {
959 match runtime {
960 RuntimeSelection::Native => {
961 if oci {
962 return Err(crate::error::NucleusError::ConfigError(
963 "--bundle requires gVisor runtime; use --runtime gvisor".to_string(),
964 ));
965 }
966 self = self
967 .with_gvisor(false)
968 .with_trust_level(TrustLevel::Trusted);
969 }
970 RuntimeSelection::GVisor => {
971 self = self.with_gvisor(true);
972 if !oci {
973 tracing::info!(
974 "Security hardening: enabling OCI bundle mode for gVisor runtime"
975 );
976 }
977 self = self.with_oci_bundle();
978 }
979 }
980 Ok(self)
981 }
982}
983
984pub fn validate_container_name(name: &str) -> crate::error::Result<()> {
986 if name.is_empty() || name.len() > 128 {
987 return Err(crate::error::NucleusError::ConfigError(
988 "Invalid container name: must be 1-128 characters".to_string(),
989 ));
990 }
991 if !name
992 .chars()
993 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
994 {
995 return Err(crate::error::NucleusError::ConfigError(
996 "Invalid container name: allowed characters are a-zA-Z0-9, '-', '_', '.'".to_string(),
997 ));
998 }
999 Ok(())
1000}
1001
1002pub fn validate_hostname(hostname: &str) -> crate::error::Result<()> {
1004 if hostname.is_empty() || hostname.len() > 253 {
1005 return Err(crate::error::NucleusError::ConfigError(
1006 "Invalid hostname: must be 1-253 characters".to_string(),
1007 ));
1008 }
1009
1010 for label in hostname.split('.') {
1011 if label.is_empty() || label.len() > 63 {
1012 return Err(crate::error::NucleusError::ConfigError(format!(
1013 "Invalid hostname label: '{}'",
1014 label
1015 )));
1016 }
1017 if label.starts_with('-') || label.ends_with('-') {
1018 return Err(crate::error::NucleusError::ConfigError(format!(
1019 "Invalid hostname label '{}': cannot start or end with '-'",
1020 label
1021 )));
1022 }
1023 if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
1024 return Err(crate::error::NucleusError::ConfigError(format!(
1025 "Invalid hostname label '{}': allowed characters are a-zA-Z0-9 and '-'",
1026 label
1027 )));
1028 }
1029 }
1030
1031 Ok(())
1032}
1033
1034#[cfg(test)]
1035#[allow(deprecated)]
1036mod tests {
1037 use super::*;
1038 use crate::network::NetworkMode;
1039
1040 #[test]
1041 fn test_generate_container_id_is_32_hex_chars() {
1042 let id = generate_container_id().unwrap();
1043 assert_eq!(
1044 id.len(),
1045 32,
1046 "Container ID must be full 128-bit (32 hex chars), got {}",
1047 id.len()
1048 );
1049 assert!(
1050 id.chars().all(|c| c.is_ascii_hexdigit()),
1051 "Container ID must be hex: {}",
1052 id
1053 );
1054 }
1055
1056 #[test]
1057 fn test_generate_container_id_is_unique() {
1058 let id1 = generate_container_id().unwrap();
1059 let id2 = generate_container_id().unwrap();
1060 assert_ne!(id1, id2, "Two consecutive IDs must differ");
1061 }
1062
1063 #[test]
1064 fn test_config_security_defaults_are_hardened() {
1065 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
1066 assert!(!cfg.allow_degraded_security);
1067 assert!(!cfg.allow_chroot_fallback);
1068 assert!(!cfg.allow_host_network);
1069 assert!(cfg.proc_readonly);
1070 assert_eq!(cfg.service_mode, ServiceMode::Agent);
1071 assert!(cfg.rootfs_path.is_none());
1072 assert!(cfg.egress_policy.is_none());
1073 assert!(cfg.secrets.is_empty());
1074 assert!(cfg.volumes.is_empty());
1075 assert!(!cfg.sd_notify);
1076 assert!(cfg.required_kernel_lockdown.is_none());
1077 assert!(!cfg.verify_context_integrity);
1078 assert!(!cfg.verify_rootfs_attestation);
1079 assert!(!cfg.seccomp_log_denied);
1080 assert_eq!(cfg.gvisor_platform, GVisorPlatform::Systrap);
1081 }
1082
1083 #[test]
1084 fn test_production_mode_rejects_degraded_flags() {
1085 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1086 .unwrap()
1087 .with_service_mode(ServiceMode::Production)
1088 .with_allow_degraded_security(true)
1089 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
1090 .with_limits(
1091 crate::resources::ResourceLimits::default()
1092 .with_memory("512M")
1093 .unwrap()
1094 .with_cpu_cores(2.0)
1095 .unwrap(),
1096 );
1097 assert!(cfg.validate_production_mode().is_err());
1098 }
1099
1100 #[test]
1101 fn test_production_mode_rejects_chroot_fallback() {
1102 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1103 .unwrap()
1104 .with_service_mode(ServiceMode::Production)
1105 .with_allow_chroot_fallback(true)
1106 .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
1107 .with_limits(
1108 crate::resources::ResourceLimits::default()
1109 .with_memory("512M")
1110 .unwrap()
1111 .with_cpu_cores(2.0)
1112 .unwrap(),
1113 );
1114 let err = cfg.validate_production_mode().unwrap_err();
1115 assert!(
1116 err.to_string().contains("chroot"),
1117 "Production mode must reject chroot fallback"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_production_mode_requires_rootfs() {
1123 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1124 .unwrap()
1125 .with_service_mode(ServiceMode::Production)
1126 .with_limits(
1127 crate::resources::ResourceLimits::default()
1128 .with_memory("512M")
1129 .unwrap(),
1130 );
1131 let err = cfg.validate_production_mode().unwrap_err();
1132 assert!(err.to_string().contains("--rootfs"));
1133 }
1134
1135 fn test_rootfs_path() -> std::path::PathBuf {
1136 use std::sync::atomic::{AtomicU64, Ordering};
1137 static COUNTER: AtomicU64 = AtomicU64::new(0);
1138 let id = COUNTER.fetch_add(1, Ordering::SeqCst);
1139
1140 let rootfs = std::env::temp_dir().join(format!(
1143 "nucleus-test-nix-store-{}-{}/rootfs",
1144 std::process::id(),
1145 id
1146 ));
1147 std::fs::create_dir_all(&rootfs).unwrap();
1148
1149 rootfs
1150 }
1151
1152 #[test]
1153 fn test_production_mode_requires_memory_limit() {
1154 let rootfs = test_rootfs_path();
1155 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1156 .unwrap()
1157 .with_service_mode(ServiceMode::Production)
1158 .with_rootfs_path(rootfs);
1159 let err = cfg.validate_production_mode().unwrap_err();
1160 let _ = std::fs::remove_dir_all(&cfg.rootfs_path.as_ref().unwrap());
1161 assert!(err.to_string().contains("--memory"));
1162 }
1163
1164 #[test]
1165 fn test_production_mode_valid_config() {
1166 let rootfs = test_rootfs_path();
1167 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1168 .unwrap()
1169 .with_service_mode(ServiceMode::Production)
1170 .with_rootfs_path(rootfs.clone())
1171 .with_verify_rootfs_attestation(true)
1172 .with_limits(
1173 crate::resources::ResourceLimits::default()
1174 .with_memory("512M")
1175 .unwrap()
1176 .with_cpu_cores(2.0)
1177 .unwrap(),
1178 );
1179 let result = cfg.validate_production_mode();
1180 let _ = std::fs::remove_dir_all(&rootfs);
1181 assert!(result.is_ok());
1182 }
1183
1184 #[test]
1185 fn test_production_mode_requires_rootfs_attestation() {
1186 let rootfs = test_rootfs_path();
1187 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1188 .unwrap()
1189 .with_service_mode(ServiceMode::Production)
1190 .with_rootfs_path(rootfs.clone())
1191 .with_limits(
1192 crate::resources::ResourceLimits::default()
1193 .with_memory("512M")
1194 .unwrap()
1195 .with_cpu_cores(2.0)
1196 .unwrap(),
1197 );
1198 let err = cfg.validate_production_mode().unwrap_err();
1199 let _ = std::fs::remove_dir_all(&rootfs);
1200 assert!(err.to_string().contains("attestation"));
1201 }
1202
1203 #[test]
1204 fn test_production_mode_rejects_seccomp_trace() {
1205 let rootfs = test_rootfs_path();
1206 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1207 .unwrap()
1208 .with_service_mode(ServiceMode::Production)
1209 .with_rootfs_path(rootfs.clone())
1210 .with_seccomp_mode(SeccompMode::Trace)
1211 .with_limits(
1212 crate::resources::ResourceLimits::default()
1213 .with_memory("512M")
1214 .unwrap()
1215 .with_cpu_cores(2.0)
1216 .unwrap(),
1217 );
1218 let err = cfg.validate_production_mode().unwrap_err();
1219 let _ = std::fs::remove_dir_all(&rootfs);
1220 assert!(
1221 err.to_string().contains("trace"),
1222 "Production mode must reject seccomp trace mode"
1223 );
1224 }
1225
1226 #[test]
1227 fn test_production_mode_requires_cpu_limit() {
1228 let rootfs = test_rootfs_path();
1229 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1230 .unwrap()
1231 .with_service_mode(ServiceMode::Production)
1232 .with_rootfs_path(rootfs.clone())
1233 .with_limits(
1234 crate::resources::ResourceLimits::default()
1235 .with_memory("512M")
1236 .unwrap(),
1237 );
1238 let err = cfg.validate_production_mode().unwrap_err();
1239 let _ = std::fs::remove_dir_all(&rootfs);
1240 assert!(err.to_string().contains("--cpus"));
1241 }
1242
1243 #[test]
1244 fn test_config_security_builders_override_defaults() {
1245 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1246 .unwrap()
1247 .with_allow_degraded_security(true)
1248 .with_allow_chroot_fallback(true)
1249 .with_allow_host_network(true)
1250 .with_proc_readonly(false)
1251 .with_network(NetworkMode::Host);
1252
1253 assert!(cfg.allow_degraded_security);
1254 assert!(cfg.allow_chroot_fallback);
1255 assert!(cfg.allow_host_network);
1256 assert!(!cfg.proc_readonly);
1257 assert!(matches!(cfg.network, NetworkMode::Host));
1258 }
1259
1260 #[test]
1261 fn test_hardening_builders_override_defaults() {
1262 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1263 .unwrap()
1264 .with_required_kernel_lockdown(KernelLockdownMode::Confidentiality)
1265 .with_verify_context_integrity(true)
1266 .with_verify_rootfs_attestation(true)
1267 .with_seccomp_log_denied(true)
1268 .with_gvisor_platform(GVisorPlatform::Kvm);
1269
1270 assert_eq!(
1271 cfg.required_kernel_lockdown,
1272 Some(KernelLockdownMode::Confidentiality)
1273 );
1274 assert!(cfg.verify_context_integrity);
1275 assert!(cfg.verify_rootfs_attestation);
1276 assert!(cfg.seccomp_log_denied);
1277 assert_eq!(cfg.gvisor_platform, GVisorPlatform::Kvm);
1278 }
1279
1280 #[test]
1281 fn test_seccomp_trace_requires_log_path() {
1282 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1283 .unwrap()
1284 .with_gvisor(false)
1285 .with_seccomp_mode(SeccompMode::Trace);
1286
1287 let err = cfg.validate_runtime_support().unwrap_err();
1288 assert!(err.to_string().contains("seccomp-log"));
1289 }
1290
1291 #[test]
1292 fn test_gvisor_rejects_native_security_policy_files() {
1293 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1294 .unwrap()
1295 .with_seccomp_profile(PathBuf::from("/tmp/seccomp.json"))
1296 .with_caps_policy(PathBuf::from("/tmp/caps.toml"));
1297
1298 let err = cfg.validate_runtime_support().unwrap_err();
1299 assert!(err.to_string().contains("gVisor runtime"));
1300 }
1301
1302 #[test]
1303 fn test_gvisor_rejects_landlock_policy_file() {
1304 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1305 .unwrap()
1306 .with_landlock_policy(PathBuf::from("/tmp/landlock.toml"));
1307
1308 let err = cfg.validate_runtime_support().unwrap_err();
1309 assert!(err.to_string().contains("Landlock"));
1310 }
1311
1312 #[test]
1313 fn test_gvisor_rejects_trace_mode_even_with_log_path() {
1314 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1315 .unwrap()
1316 .with_seccomp_mode(SeccompMode::Trace)
1317 .with_seccomp_trace_log(PathBuf::from("/tmp/trace.ndjson"));
1318
1319 let err = cfg.validate_runtime_support().unwrap_err();
1320 assert!(err.to_string().contains("gVisor runtime"));
1321 }
1322
1323 #[test]
1324 fn test_secret_dest_must_be_absolute() {
1325 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1326 .unwrap()
1327 .with_secret(crate::container::SecretMount {
1328 source: PathBuf::from("/run/secrets/api-key"),
1329 dest: PathBuf::from("secrets/api-key"),
1330 mode: 0o400,
1331 });
1332
1333 let err = cfg.validate_runtime_support().unwrap_err();
1334 assert!(err.to_string().contains("absolute"));
1335 }
1336
1337 #[test]
1338 fn test_secret_dest_rejects_parent_traversal() {
1339 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1340 .unwrap()
1341 .with_secret(crate::container::SecretMount {
1342 source: PathBuf::from("/run/secrets/api-key"),
1343 dest: PathBuf::from("/../../etc/passwd"),
1344 mode: 0o400,
1345 });
1346
1347 let err = cfg.validate_runtime_support().unwrap_err();
1348 assert!(err.to_string().contains("parent traversal"));
1349 }
1350
1351 #[test]
1352 fn test_bind_volume_source_must_exist() {
1353 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1354 .unwrap()
1355 .with_volume(VolumeMount {
1356 source: VolumeSource::Bind {
1357 source: PathBuf::from("/tmp/definitely-missing-nucleus-volume"),
1358 },
1359 dest: PathBuf::from("/var/lib/app"),
1360 read_only: false,
1361 });
1362
1363 let err = cfg.validate_runtime_support().unwrap_err();
1364 assert!(err.to_string().contains("Volume source does not exist"));
1365 }
1366
1367 #[test]
1368 fn test_bind_volume_dest_must_be_absolute() {
1369 let dir = tempfile::TempDir::new().unwrap();
1370 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1371 .unwrap()
1372 .with_volume(VolumeMount {
1373 source: VolumeSource::Bind {
1374 source: dir.path().to_path_buf(),
1375 },
1376 dest: PathBuf::from("var/lib/app"),
1377 read_only: false,
1378 });
1379
1380 let err = cfg.validate_runtime_support().unwrap_err();
1381 assert!(err.to_string().contains("absolute"));
1382 }
1383
1384 #[test]
1385 fn test_tmpfs_volume_rejects_parent_traversal() {
1386 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1387 .unwrap()
1388 .with_volume(VolumeMount {
1389 source: VolumeSource::Tmpfs {
1390 size: Some("64M".to_string()),
1391 },
1392 dest: PathBuf::from("/../../var/lib/app"),
1393 read_only: false,
1394 });
1395
1396 let err = cfg.validate_runtime_support().unwrap_err();
1397 assert!(err.to_string().contains("parent traversal"));
1398 }
1399
1400 #[test]
1401 fn test_gvisor_rejects_bind_mount_context_integrity_verification() {
1402 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1403 .unwrap()
1404 .with_context(PathBuf::from("/tmp/context"))
1405 .with_context_mode(crate::filesystem::ContextMode::BindMount)
1406 .with_verify_context_integrity(true);
1407
1408 let err = cfg.validate_runtime_support().unwrap_err();
1409 assert!(err.to_string().contains("context integrity"));
1410 }
1411
1412 #[test]
1413 fn test_gvisor_rejects_exec_health_checks() {
1414 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1415 .unwrap()
1416 .with_health_check(HealthCheck {
1417 command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
1418 interval: Duration::from_secs(30),
1419 retries: 3,
1420 start_period: Duration::from_secs(1),
1421 timeout: Duration::from_secs(5),
1422 });
1423
1424 let err = cfg.validate_runtime_support().unwrap_err();
1425 assert!(err.to_string().contains("health checks"));
1426 }
1427
1428 #[test]
1429 fn test_gvisor_rejects_exec_readiness_probes() {
1430 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1431 .unwrap()
1432 .with_readiness_probe(ReadinessProbe::Exec {
1433 command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
1434 });
1435
1436 let err = cfg.validate_runtime_support().unwrap_err();
1437 assert!(err.to_string().contains("readiness"));
1438 }
1439
1440 #[test]
1441 fn test_gvisor_allows_copy_mode_context_integrity_verification() {
1442 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1443 .unwrap()
1444 .with_context(PathBuf::from("/tmp/context"))
1445 .with_context_mode(crate::filesystem::ContextMode::Copy)
1446 .with_verify_context_integrity(true);
1447
1448 assert!(cfg.validate_runtime_support().is_ok());
1449 }
1450
1451 #[test]
1452 fn test_user_namespace_rejects_unmapped_process_identity() {
1453 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1454 .unwrap()
1455 .with_rootless()
1456 .with_process_identity(ProcessIdentity {
1457 uid: 1000,
1458 gid: 1000,
1459 additional_gids: Vec::new(),
1460 });
1461
1462 let err = cfg.validate_runtime_support().unwrap_err();
1463 assert!(err.to_string().contains("not mapped"));
1464 }
1465
1466 #[test]
1467 fn test_user_namespace_rejects_supplementary_groups() {
1468 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1469 .unwrap()
1470 .with_rootless()
1471 .with_process_identity(ProcessIdentity {
1472 uid: 0,
1473 gid: 0,
1474 additional_gids: vec![1],
1475 });
1476
1477 let err = cfg.validate_runtime_support().unwrap_err();
1478 assert!(err.to_string().contains("Supplementary groups"));
1479 }
1480
1481 #[test]
1482 fn test_native_runtime_disables_gvisor() {
1483 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1485 .unwrap()
1486 .with_gvisor(false)
1487 .with_trust_level(TrustLevel::Trusted);
1488 assert!(!cfg.use_gvisor, "native runtime must disable gVisor");
1489 assert_eq!(
1490 cfg.trust_level,
1491 TrustLevel::Trusted,
1492 "native runtime must set Trusted trust level"
1493 );
1494 }
1495
1496 #[test]
1497 fn test_default_config_has_gvisor_enabled() {
1498 let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
1499 assert!(cfg.use_gvisor, "default must have gVisor enabled");
1500 assert_eq!(
1501 cfg.trust_level,
1502 TrustLevel::Untrusted,
1503 "default must be Untrusted"
1504 );
1505 }
1506
1507 #[test]
1508 fn test_generate_container_id_returns_result() {
1509 let id: crate::error::Result<String> = generate_container_id();
1512 let id = id.expect("generate_container_id must return Ok, not panic");
1513 assert_eq!(id.len(), 32, "container ID must be 32 hex chars");
1514 assert!(
1515 id.chars().all(|c| c.is_ascii_hexdigit()),
1516 "container ID must be valid hex: {}",
1517 id
1518 );
1519 }
1520}