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