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