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