Skip to main content

nucleus/container/
config.rs

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