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