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