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