Skip to main content

nucleus/container/
config.rs

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
9/// Generate a unique 32-hex-char container ID (128-bit) using /dev/urandom.
10pub 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/// Trust level for a container workload.
30///
31/// Determines the minimum isolation guarantees the runtime must enforce.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
33pub enum TrustLevel {
34    /// Native kernel isolation (namespaces + seccomp + Landlock) is acceptable.
35    Trusted,
36    /// Requires gVisor; refuses to start without it unless degraded mode is allowed.
37    #[default]
38    Untrusted,
39}
40
41/// Service mode for the container.
42///
43/// Determines whether the container runs as an ephemeral agent sandbox
44/// or a long-running production service with stricter requirements.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
46pub enum ServiceMode {
47    /// Ephemeral agent workload (default). Allows degraded fallbacks.
48    #[default]
49    Agent,
50    /// Long-running production service. Enforces strict security invariants:
51    /// - Forbids degraded security, chroot fallback, and host networking
52    /// - Requires cgroup resource limits
53    /// - Requires pivot_root (no chroot fallback)
54    /// - Requires explicit rootfs path (no host bind mounts)
55    Production,
56}
57
58/// Required host kernel lockdown mode, when asserted by the runtime.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
60pub enum KernelLockdownMode {
61    /// Integrity mode blocks kernel writes from privileged userspace.
62    Integrity,
63    /// Confidentiality mode additionally blocks kernel data disclosure paths.
64    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/// Health check configuration for long-running services.
84#[derive(Debug, Clone)]
85pub struct HealthCheck {
86    /// Command to run inside the container to check health.
87    pub command: Vec<String>,
88    /// Interval between health checks.
89    pub interval: Duration,
90    /// Number of consecutive failures before marking unhealthy.
91    pub retries: u32,
92    /// Grace period after start before health checks begin.
93    pub start_period: Duration,
94    /// Timeout for each health check execution.
95    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/// Secrets configuration for mounting secret files into the container.
111#[derive(Debug, Clone)]
112pub struct SecretMount {
113    /// Source path on the host (or Nix store path).
114    pub source: PathBuf,
115    /// Destination path inside the container.
116    pub dest: PathBuf,
117    /// File mode (default: 0o400, read-only by owner).
118    pub mode: u32,
119}
120
121/// Runtime identity for the workload process inside the container.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct ProcessIdentity {
124    /// Primary user ID for the workload process.
125    pub uid: u32,
126    /// Primary group ID for the workload process.
127    pub gid: u32,
128    /// Supplementary group IDs for the workload process.
129    pub additional_gids: Vec<u32>,
130}
131
132impl ProcessIdentity {
133    /// Root identity (the historical default).
134    pub fn root() -> Self {
135        Self {
136            uid: 0,
137            gid: 0,
138            additional_gids: Vec::new(),
139        }
140    }
141
142    /// Returns true when the workload keeps the default root identity.
143    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/// Source backing for a volume mount.
155#[derive(Debug, Clone)]
156pub enum VolumeSource {
157    /// Bind mount a host path into the container.
158    Bind { source: PathBuf },
159    /// Mount a fresh tmpfs at the destination.
160    Tmpfs { size: Option<String> },
161}
162
163/// Volume configuration for mounting persistent or ephemeral storage.
164#[derive(Debug, Clone)]
165pub struct VolumeMount {
166    /// Backing storage for the volume.
167    pub source: VolumeSource,
168    /// Destination path inside the container.
169    pub dest: PathBuf,
170    /// Whether the volume is mounted read-only.
171    pub read_only: bool,
172}
173
174/// Readiness probe configuration.
175#[derive(Debug, Clone)]
176pub enum ReadinessProbe {
177    /// Run a command; ready when it exits 0.
178    Exec { command: Vec<String> },
179    /// Check TCP port connectivity.
180    TcpPort(u16),
181    /// Use sd_notify protocol (service sends READY=1).
182    SdNotify,
183}
184
185/// Container configuration
186#[derive(Debug, Clone)]
187pub struct ContainerConfig {
188    /// Unique container ID (auto-generated 32 hex chars, 128-bit)
189    pub id: String,
190
191    /// User-supplied container name (optional, defaults to ID)
192    pub name: String,
193
194    /// Command to execute in the container
195    pub command: Vec<String>,
196
197    /// Context directory to pre-populate (optional)
198    pub context_dir: Option<PathBuf>,
199
200    /// Resource limits
201    pub limits: ResourceLimits,
202
203    /// Namespace configuration
204    pub namespaces: NamespaceConfig,
205
206    /// User namespace configuration (for rootless mode)
207    pub user_ns_config: Option<UserNamespaceConfig>,
208
209    /// Hostname to set in UTS namespace (optional)
210    pub hostname: Option<String>,
211
212    /// Whether to use gVisor runtime
213    pub use_gvisor: bool,
214
215    /// Trust level for this workload
216    pub trust_level: TrustLevel,
217
218    /// Network mode
219    pub network: crate::network::NetworkMode,
220
221    /// Context mode (copy or bind mount)
222    pub context_mode: crate::filesystem::ContextMode,
223
224    /// Allow degraded security behavior if a hardening layer cannot be applied
225    pub allow_degraded_security: bool,
226
227    /// Allow chroot fallback when pivot_root fails (weaker isolation)
228    pub allow_chroot_fallback: bool,
229
230    /// Require explicit opt-in for host networking
231    pub allow_host_network: bool,
232
233    /// Mount /proc read-only inside the container
234    pub proc_readonly: bool,
235
236    /// Service mode (agent vs production)
237    pub service_mode: ServiceMode,
238
239    /// Pre-built rootfs path (Nix store path). When set, this is bind-mounted
240    /// as the container root instead of bind-mounting host /bin, /usr, /lib, etc.
241    pub rootfs_path: Option<PathBuf>,
242
243    /// Egress policy for audited outbound network access.
244    pub egress_policy: Option<EgressPolicy>,
245
246    /// Health check configuration for long-running services.
247    pub health_check: Option<HealthCheck>,
248
249    /// Readiness probe for service startup detection.
250    pub readiness_probe: Option<ReadinessProbe>,
251
252    /// Secret files to mount into the container.
253    pub secrets: Vec<SecretMount>,
254
255    /// Volume mounts to attach to the container filesystem.
256    pub volumes: Vec<VolumeMount>,
257
258    /// Environment variables to pass to the container process.
259    pub environment: Vec<(String, String)>,
260
261    /// Runtime uid/gid and supplementary groups for the workload process.
262    pub process_identity: ProcessIdentity,
263
264    /// Desired topology config hash for reconciliation change detection.
265    pub config_hash: Option<u64>,
266
267    /// Enable sd_notify integration (pass NOTIFY_SOCKET into container).
268    pub sd_notify: bool,
269
270    /// Require the host kernel to be in at least this lockdown mode.
271    pub required_kernel_lockdown: Option<KernelLockdownMode>,
272
273    /// Verify context contents before executing the workload.
274    pub verify_context_integrity: bool,
275
276    /// Verify rootfs attestation manifest before mounting it.
277    pub verify_rootfs_attestation: bool,
278
279    /// Request kernel logging for denied seccomp decisions when supported.
280    pub seccomp_log_denied: bool,
281
282    /// Select the gVisor platform backend.
283    pub gvisor_platform: GVisorPlatform,
284
285    /// Path to a per-service seccomp profile (JSON, OCI subset format).
286    /// When set, this profile is used instead of the built-in allowlist.
287    pub seccomp_profile: Option<PathBuf>,
288
289    /// Expected SHA-256 hash of the seccomp profile file for integrity verification.
290    pub seccomp_profile_sha256: Option<String>,
291
292    /// Seccomp operating mode.
293    pub seccomp_mode: SeccompMode,
294
295    /// Path to write seccomp trace log (NDJSON) when seccomp_mode == Trace.
296    pub seccomp_trace_log: Option<PathBuf>,
297
298    /// Path to capability policy file (TOML).
299    pub caps_policy: Option<PathBuf>,
300
301    /// Expected SHA-256 hash of the capability policy file.
302    pub caps_policy_sha256: Option<String>,
303
304    /// Path to Landlock policy file (TOML).
305    pub landlock_policy: Option<PathBuf>,
306
307    /// Expected SHA-256 hash of the Landlock policy file.
308    pub landlock_policy_sha256: Option<String>,
309
310    /// OCI lifecycle hooks to execute at various container lifecycle points.
311    pub hooks: Option<crate::security::OciHooks>,
312
313    /// Path to write the container PID (OCI --pid-file).
314    pub pid_file: Option<PathBuf>,
315
316    /// Path to AF_UNIX socket for console pseudo-terminal master (OCI --console-socket).
317    pub console_socket: Option<PathBuf>,
318
319    /// Override OCI bundle directory path (OCI --bundle).
320    pub bundle_dir: Option<PathBuf>,
321}
322
323/// Seccomp operating mode.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
325pub enum SeccompMode {
326    /// Normal enforcement — deny unlisted syscalls.
327    #[default]
328    Enforce,
329    /// Trace mode — allow all syscalls but log them for profile generation.
330    /// Development only; rejected in production mode.
331    Trace,
332}
333
334impl ContainerConfig {
335    /// Create a new container config with a random ID.
336    ///
337    /// # Panics
338    /// Panics if secure random bytes cannot be read from `/dev/urandom`.
339    /// Prefer [`Self::try_new`] for production code.
340    #[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    /// Enable rootless mode with user namespace mapping
400    #[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    /// Configure custom user namespace mapping
408    #[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    /// Enable OCI bundle runtime path (always OCI for gVisor).
452    #[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    /// Validate that production mode invariants are satisfied.
657    /// Called before container startup when service_mode == Production.
658    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        // Production mode requires explicit rootfs (no host bind mount fallback)
688        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        // Allow test rootfs paths under /tmp that simulate /nix/store structure
695        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        // Production mode requires explicit resource limits
711        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        // Verify rootfs exists (checked last, after config invariants)
730        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    /// Validate runtime-specific feature support.
741    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    /// Apply runtime selection (native vs gVisor) and OCI bundle mode.
870    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
906/// Validate a container name for safe use.
907pub 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
924/// Validate a hostname according to RFC 1123.
925pub 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        // --runtime native must explicitly disable gVisor and set Trusted trust level
1392        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        // BUG-07: generate_container_id must return Result, not panic.
1417        // Verify by calling it and checking the Ok value is valid hex.
1418        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}