Skip to main content

nucleus/container/
config.rs

1use crate::filesystem::{
2    normalize_container_destination, normalize_provider_config_destination,
3    normalize_volume_destination, validate_production_rootfs_path, validate_provider_config_source,
4    validate_workspace_host_path,
5};
6use crate::isolation::{NamespaceConfig, UserNamespaceConfig};
7use crate::network::{CredentialBrokerConfig, EgressPolicy};
8use crate::resources::ResourceLimits;
9use crate::security::GVisorPlatform;
10use std::fs::OpenOptions;
11use std::os::unix::fs::FileTypeExt;
12use std::os::unix::fs::OpenOptionsExt;
13use std::os::unix::io::RawFd;
14use std::path::PathBuf;
15use std::time::Duration;
16
17pub const DEFAULT_HOME_PATH: &str = "/home/agent";
18pub const CREDENTIAL_BROKER_CONTAINER_ID_ENV: &str = "NUCLEUS_CONTAINER_ID";
19pub const CREDENTIAL_BROKER_TOKEN_ENV: &str = "NUCLEUS_CREDENTIAL_BROKER_TOKEN";
20
21#[must_use]
22fn is_credential_broker_identity_env(key: &str) -> bool {
23    key == CREDENTIAL_BROKER_CONTAINER_ID_ENV || key == CREDENTIAL_BROKER_TOKEN_ENV
24}
25
26fn open_dev_urandom() -> crate::error::Result<std::fs::File> {
27    let file = OpenOptions::new()
28        .read(true)
29        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
30        .open("/dev/urandom")
31        .map_err(|e| {
32            crate::error::NucleusError::ConfigError(format!(
33                "Failed to open /dev/urandom for container ID generation: {}",
34                e
35            ))
36        })?;
37
38    let metadata = file.metadata().map_err(|e| {
39        crate::error::NucleusError::ConfigError(format!("Failed to stat /dev/urandom: {}", e))
40    })?;
41    if !metadata.file_type().is_char_device() {
42        return Err(crate::error::NucleusError::ConfigError(
43            "/dev/urandom is not a character device".to_string(),
44        ));
45    }
46
47    Ok(file)
48}
49
50/// Generate a unique 32-hex-char container ID (128-bit) using /dev/urandom.
51pub fn generate_container_id() -> crate::error::Result<String> {
52    use std::io::Read;
53
54    let mut buf = [0u8; 16];
55    let mut file = open_dev_urandom()?;
56    file.read_exact(&mut buf).map_err(|e| {
57        crate::error::NucleusError::ConfigError(format!(
58            "Failed to read secure random bytes for container ID generation: {}",
59            e
60        ))
61    })?;
62    Ok(hex::encode(buf))
63}
64
65/// Trust level for a container workload.
66///
67/// Determines the minimum isolation guarantees the runtime must enforce.
68#[derive(
69    Debug,
70    Clone,
71    Copy,
72    PartialEq,
73    Eq,
74    Default,
75    clap::ValueEnum,
76    serde::Serialize,
77    serde::Deserialize,
78)]
79#[serde(rename_all = "kebab-case")]
80pub enum TrustLevel {
81    /// Native kernel isolation (namespaces + seccomp + Landlock) is acceptable.
82    Trusted,
83    /// Requires gVisor; refuses to start without it unless degraded mode is allowed.
84    #[default]
85    Untrusted,
86}
87
88/// Service mode for the container.
89///
90/// Determines whether the container runs as an ephemeral agent sandbox,
91/// a fail-closed agent sandbox, or a long-running production service.
92#[derive(
93    Debug,
94    Clone,
95    Copy,
96    PartialEq,
97    Eq,
98    Default,
99    clap::ValueEnum,
100    serde::Serialize,
101    serde::Deserialize,
102)]
103#[serde(rename_all = "kebab-case")]
104pub enum ServiceMode {
105    /// Ephemeral agent workload (default). Allows degraded fallbacks.
106    #[default]
107    Agent,
108    /// Ephemeral agent workload with fail-closed isolation, but without
109    /// production service rootfs, health, sd_notify, or NixOS semantics.
110    #[value(name = "strict-agent", alias = "mitos-agent")]
111    #[serde(alias = "mitos-agent")]
112    StrictAgent,
113    /// Long-running production service. Enforces strict security invariants:
114    /// - Forbids degraded security, chroot fallback, and native host network mode
115    /// - Allows gvisor-host only with explicit gVisor runtime and hostinet opt-in
116    /// - Requires cgroup resource limits
117    /// - Requires pivot_root (no chroot fallback)
118    /// - Requires explicit rootfs path (no host bind mounts)
119    Production,
120}
121
122impl ServiceMode {
123    pub fn label(self) -> &'static str {
124        match self {
125            Self::Agent => "Agent mode",
126            Self::StrictAgent => "Strict agent mode",
127            Self::Production => "Production mode",
128        }
129    }
130
131    pub fn enforces_strict_isolation(self) -> bool {
132        matches!(self, Self::StrictAgent | Self::Production)
133    }
134
135    pub fn requires_user_namespace_mapping(self) -> bool {
136        self.enforces_strict_isolation()
137    }
138
139    pub fn requires_cgroup_enforcement(self) -> bool {
140        self.enforces_strict_isolation()
141    }
142
143    pub fn requires_explicit_bridge_dns(self) -> bool {
144        self.enforces_strict_isolation()
145    }
146}
147
148/// CLI-level runtime selection.
149///
150/// Parsed by clap at argument time – invalid values are caught immediately.
151/// The variant triggers additional logic in `apply_runtime_selection`.
152#[derive(
153    Debug,
154    Clone,
155    Copy,
156    PartialEq,
157    Eq,
158    Default,
159    clap::ValueEnum,
160    serde::Serialize,
161    serde::Deserialize,
162)]
163pub enum RuntimeSelection {
164    /// gVisor sandbox runtime (default). Provides kernel-level isolation.
165    #[default]
166    #[value(name = "gvisor")]
167    #[serde(rename = "gvisor")]
168    GVisor,
169    /// Native kernel isolation (namespaces + seccomp + Landlock).
170    #[value(name = "native")]
171    #[serde(rename = "native")]
172    Native,
173}
174
175/// CLI-level network mode selection.
176///
177/// Parsed by clap at argument time. The `bridge` variant carries additional
178/// configuration that is attached after parsing.
179#[derive(
180    Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize,
181)]
182pub enum NetworkModeArg {
183    /// No network (default).
184    #[value(name = "none")]
185    #[serde(rename = "none")]
186    None,
187    /// Native host network namespace sharing (dangerous).
188    #[value(name = "host")]
189    #[serde(rename = "host")]
190    Host,
191    /// gVisor hostinet mode; requires --runtime gvisor and --allow-host-network.
192    #[value(name = "gvisor-host")]
193    #[serde(rename = "gvisor-host")]
194    GVisorHost,
195    /// Virtual bridge with veth pair.
196    #[value(name = "bridge")]
197    #[serde(rename = "bridge")]
198    Bridge,
199}
200
201#[allow(clippy::derivable_impls)]
202impl Default for NetworkModeArg {
203    fn default() -> Self {
204        Self::None
205    }
206}
207
208/// Rootless user-namespace mapping strategy (`--userns`).
209///
210/// Docker/Podman-compatible names for the mapping written via `/etc/subuid`.
211#[derive(
212    Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize,
213)]
214pub enum UsernsModeArg {
215    /// Map container root (0) to the calling user only. Historic Nucleus
216    /// behavior; workloads that refuse euid 0 (e.g. PostgreSQL) cannot run.
217    #[value(name = "nomap")]
218    #[serde(rename = "nomap")]
219    NoMap,
220    /// Map the calling user's uid/gid to itself; container root maps into the
221    /// delegated subuid range (Podman `--userns=keep-id`). Recommended for
222    /// bind-mounted host files owned by the user.
223    #[value(name = "keep-id")]
224    #[serde(rename = "keep-id")]
225    KeepId,
226    /// Map container root to the calling user and 1..N into the subuid range
227    /// (Podman/Docker rootless default). Workloads run as their image uid.
228    #[value(name = "auto")]
229    #[serde(rename = "auto")]
230    Auto,
231}
232
233#[allow(clippy::derivable_impls)]
234impl Default for UsernsModeArg {
235    fn default() -> Self {
236        Self::NoMap
237    }
238}
239
240/// Required host kernel lockdown mode, when asserted by the runtime.
241#[derive(
242    Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Serialize, serde::Deserialize,
243)]
244#[serde(rename_all = "kebab-case")]
245pub enum KernelLockdownMode {
246    /// Integrity mode blocks kernel writes from privileged userspace.
247    Integrity,
248    /// Confidentiality mode additionally blocks kernel data disclosure paths.
249    Confidentiality,
250}
251
252impl KernelLockdownMode {
253    pub fn as_str(self) -> &'static str {
254        match self {
255            Self::Integrity => "integrity",
256            Self::Confidentiality => "confidentiality",
257        }
258    }
259
260    pub fn accepts(self, active: Self) -> bool {
261        match self {
262            Self::Integrity => matches!(active, Self::Integrity | Self::Confidentiality),
263            Self::Confidentiality => matches!(active, Self::Confidentiality),
264        }
265    }
266}
267
268/// Health check configuration for long-running services.
269#[derive(Debug, Clone)]
270pub struct HealthCheck {
271    /// Command to run inside the container to check health.
272    pub command: Vec<String>,
273    /// Interval between health checks.
274    pub interval: Duration,
275    /// Number of consecutive failures before marking unhealthy.
276    pub retries: u32,
277    /// Grace period after start before health checks begin.
278    pub start_period: Duration,
279    /// Timeout for each health check execution.
280    pub timeout: Duration,
281}
282
283impl Default for HealthCheck {
284    fn default() -> Self {
285        Self {
286            command: Vec::new(),
287            interval: Duration::from_secs(30),
288            retries: 3,
289            start_period: Duration::from_secs(5),
290            timeout: Duration::from_secs(5),
291        }
292    }
293}
294
295/// Secrets configuration for mounting secret files into the container.
296#[derive(Debug, Clone)]
297pub struct SecretMount {
298    /// Source path on the host (or Nix store path).
299    pub source: PathBuf,
300    /// Destination path inside the container.
301    pub dest: PathBuf,
302    /// File mode (default: 0o400, read-only by owner).
303    pub mode: u32,
304}
305
306/// Provider CLI credential/config bind mount under the sandbox home.
307#[derive(Debug, Clone)]
308pub struct ProviderConfigMount {
309    /// Source path on the host.
310    pub source: PathBuf,
311    /// Destination path inside the container home.
312    pub dest: PathBuf,
313    /// Whether the provider config is mounted read-only.
314    pub read_only: bool,
315}
316
317/// Runtime identity for the workload process inside the container.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct ProcessIdentity {
320    /// Primary user ID for the workload process.
321    pub uid: u32,
322    /// Primary group ID for the workload process.
323    pub gid: u32,
324    /// Supplementary group IDs for the workload process.
325    pub additional_gids: Vec<u32>,
326}
327
328impl ProcessIdentity {
329    /// Root identity (the historical default).
330    pub fn root() -> Self {
331        Self {
332            uid: 0,
333            gid: 0,
334            additional_gids: Vec::new(),
335        }
336    }
337
338    /// Returns true when the workload keeps the default root identity.
339    pub fn is_root(&self) -> bool {
340        self.uid == 0 && self.gid == 0 && self.additional_gids.is_empty()
341    }
342}
343
344impl Default for ProcessIdentity {
345    fn default() -> Self {
346        Self::root()
347    }
348}
349
350/// Terminal dimensions for PTY-backed workloads.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub struct ConsoleSize {
353    /// Width in terminal columns.
354    pub width: u16,
355    /// Height in terminal rows.
356    pub height: u16,
357}
358
359impl ConsoleSize {
360    pub const DEFAULT_WIDTH: u16 = 80;
361    pub const DEFAULT_HEIGHT: u16 = 24;
362
363    /// Detect the caller's terminal size, falling back to COLUMNS/LINES and
364    /// finally 80x24 when no terminal is attached.
365    pub fn detect() -> Self {
366        Self::from_fd(libc::STDIN_FILENO)
367            .or_else(Self::from_env)
368            .unwrap_or_default()
369    }
370
371    fn from_fd(fd: RawFd) -> Option<Self> {
372        let mut winsize = libc::winsize {
373            ws_row: 0,
374            ws_col: 0,
375            ws_xpixel: 0,
376            ws_ypixel: 0,
377        };
378        let ret = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut winsize) };
379        if ret == 0 && winsize.ws_col > 0 && winsize.ws_row > 0 {
380            Some(Self {
381                width: winsize.ws_col,
382                height: winsize.ws_row,
383            })
384        } else {
385            None
386        }
387    }
388
389    fn from_env() -> Option<Self> {
390        let width = std::env::var("COLUMNS").ok()?.parse::<u16>().ok()?;
391        let height = std::env::var("LINES").ok()?.parse::<u16>().ok()?;
392        if width > 0 && height > 0 {
393            Some(Self { width, height })
394        } else {
395            None
396        }
397    }
398}
399
400impl Default for ConsoleSize {
401    fn default() -> Self {
402        Self {
403            width: Self::DEFAULT_WIDTH,
404            height: Self::DEFAULT_HEIGHT,
405        }
406    }
407}
408
409/// Source backing for a volume mount.
410#[derive(Debug, Clone)]
411pub enum VolumeSource {
412    /// Bind mount a host path into the container.
413    Bind { source: PathBuf },
414    /// Mount a fresh tmpfs at the destination.
415    Tmpfs { size: Option<String> },
416}
417
418/// Volume configuration for mounting persistent or ephemeral storage.
419#[derive(Debug, Clone)]
420pub struct VolumeMount {
421    /// Backing storage for the volume.
422    pub source: VolumeSource,
423    /// Destination path inside the container.
424    pub dest: PathBuf,
425    /// Whether the volume is mounted read-only.
426    pub read_only: bool,
427}
428
429/// How a pre-built rootfs is exposed inside the container.
430#[derive(
431    Debug,
432    Clone,
433    Copy,
434    PartialEq,
435    Eq,
436    Default,
437    clap::ValueEnum,
438    serde::Serialize,
439    serde::Deserialize,
440)]
441#[serde(rename_all = "kebab-case")]
442pub enum RootfsMode {
443    /// Bind-mount the rootfs read-only, preserving the historical behavior.
444    #[default]
445    Bind,
446    /// Mount the rootfs through overlayfs with a persistent upperdir.
447    Overlay,
448}
449
450/// Host-side overlayfs paths for a writable rootfs.
451#[derive(Debug, Clone)]
452pub struct RootfsOverlayConfig {
453    pub upperdir: PathBuf,
454    pub workdir: PathBuf,
455}
456
457/// How a host workspace is exposed at `/workspace`.
458#[derive(
459    Debug,
460    Clone,
461    Copy,
462    PartialEq,
463    Eq,
464    Default,
465    clap::ValueEnum,
466    serde::Serialize,
467    serde::Deserialize,
468)]
469#[serde(rename_all = "kebab-case")]
470pub enum WorkspaceMode {
471    /// Bind mount the host workspace read-write.
472    #[default]
473    #[value(name = "bind-rw")]
474    BindRw,
475    /// Bind mount the host workspace read-only.
476    #[value(name = "bind-ro")]
477    BindRo,
478    /// Copy the host workspace into a private stage and sync it back after exit.
479    #[value(name = "copy-in-out")]
480    CopyInOut,
481}
482
483/// First-class workspace mount configuration.
484#[derive(Debug, Clone)]
485pub struct WorkspaceConfig {
486    /// Source path on the host. `None` means no host workspace is mounted, but
487    /// `/workspace` still exists as the default cwd.
488    pub host_path: Option<PathBuf>,
489    /// Destination inside the container. Currently fixed to `/workspace`.
490    pub container_path: PathBuf,
491    /// Exposure mode for `host_path`.
492    pub mode: WorkspaceMode,
493    /// Whether execution from the workspace is allowed.
494    pub allow_execute: bool,
495    /// Private staging directory used for copy-in-out mode.
496    pub staging_path: Option<PathBuf>,
497}
498
499impl WorkspaceConfig {
500    pub fn new() -> Self {
501        Self::default()
502    }
503
504    pub fn with_host_path(mut self, host_path: PathBuf) -> Self {
505        self.host_path = Some(host_path);
506        self
507    }
508
509    pub fn with_mode(mut self, mode: WorkspaceMode) -> Self {
510        self.mode = mode;
511        self
512    }
513
514    pub fn with_allow_execute(mut self, allow_execute: bool) -> Self {
515        self.allow_execute = allow_execute;
516        self
517    }
518
519    pub fn with_staging_path(mut self, staging_path: PathBuf) -> Self {
520        self.staging_path = Some(staging_path);
521        self
522    }
523
524    pub fn is_read_only(&self) -> bool {
525        matches!(self.mode, WorkspaceMode::BindRo)
526    }
527
528    pub fn is_writable(&self) -> bool {
529        !self.is_read_only()
530    }
531
532    pub fn effective_host_path(&self) -> Option<&PathBuf> {
533        if self.mode == WorkspaceMode::CopyInOut {
534            self.staging_path.as_ref()
535        } else {
536            self.host_path.as_ref()
537        }
538    }
539}
540
541impl Default for WorkspaceConfig {
542    fn default() -> Self {
543        Self {
544            host_path: None,
545            container_path: PathBuf::from("/workspace"),
546            mode: WorkspaceMode::default(),
547            allow_execute: false,
548            staging_path: None,
549        }
550    }
551}
552
553/// Readiness probe configuration.
554#[derive(Debug, Clone)]
555pub enum ReadinessProbe {
556    /// Run a command; ready when it exits 0.
557    Exec { command: Vec<String> },
558    /// Check TCP port connectivity.
559    TcpPort(u16),
560    /// Use sd_notify protocol (service sends READY=1).
561    SdNotify,
562}
563
564/// GPU vendor to expose to the container.
565///
566/// `Auto` scans the host and exposes whatever is present. The explicit
567/// variants restrict passthrough to a single stack. `All` binds every
568/// recognized GPU device node on the host regardless of vendor.
569#[derive(
570    Debug,
571    Clone,
572    Copy,
573    PartialEq,
574    Eq,
575    Default,
576    clap::ValueEnum,
577    serde::Serialize,
578    serde::Deserialize,
579)]#[serde(rename_all = "kebab-case")]
580pub enum GpuVendor {
581    /// Discover and bind whatever GPU devices are present on the host.
582    #[default]
583    Auto,
584    /// NVIDIA (CUDA): `/dev/nvidia*`, `/dev/nvidia-uvm*`, `/dev/nvidiactl`.
585    Nvidia,
586    /// AMD ROCm: `/dev/kfd` plus DRI render nodes.
587    Amd,
588    /// Intel / Mesa: DRI render and card nodes.
589    Intel,
590    /// Bind every recognized GPU device node on the host.
591    All,
592}
593
594impl GpuVendor {
595    /// Returns `true` when this selection can bind NVIDIA device nodes.
596    pub fn includes_nvidia(self) -> bool {
597        matches!(self, Self::Auto | Self::Nvidia | Self::All)
598    }
599
600    /// Returns `true` when this selection can bind AMD (ROCm) device nodes.
601    pub fn includes_amd(self) -> bool {
602        matches!(self, Self::Auto | Self::Amd | Self::All)
603    }
604
605    /// Returns `true` when this selection can bind Intel/Mesa DRI nodes.
606    pub fn includes_intel(self) -> bool {
607        matches!(self, Self::Auto | Self::Intel | Self::All)
608    }
609}
610
611/// Default NVIDIA driver capability set exposed to the workload.
612///
613/// Matches the NVIDIA Container Toolkit default: compute + utility is enough
614/// for `nvidia-smi` and CUDA compute workloads without pulling in display,
615/// video, or graphics stacks that widen the device surface.
616pub const DEFAULT_GPU_DRIVER_CAPABILITIES: &str = "compute,utility";
617
618/// Default value for `NVIDIA_VISIBLE_DEVICES` when passthrough is enabled.
619pub const DEFAULT_GPU_VISIBLE_DEVICES: &str = "all";
620
621/// GPU passthrough configuration.
622///
623/// When present on a `ContainerConfig`, the runtime binds the selected GPU
624/// device nodes (and the minimal driver support files they need) into the
625/// container, installs a cgroup device allowlist, and relaxes the seccomp
626/// `ioctl` filter so vendor driver ioctls reach the hardware.
627///
628/// See `spec/gpu-passthrough.md` for the full design.
629#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
630pub struct GpuPassthroughConfig {
631    /// Vendor selection controlling device discovery.
632    #[serde(default)]
633    pub vendor: GpuVendor,
634    /// Explicit device node overrides. When non-empty, discovery is skipped
635    /// and exactly these host paths are bound (after validation).
636    #[serde(default)]
637    pub devices: Vec<PathBuf>,
638    /// Value for `NVIDIA_DRIVER_CAPABILITIES`. Defaults to
639    /// [`DEFAULT_GPU_DRIVER_CAPABILITIES`].
640    pub driver_capabilities: String,
641    /// Value for `NVIDIA_VISIBLE_DEVICES`. Defaults to
642    /// [`DEFAULT_GPU_VISIBLE_DEVICES`].
643    pub visible_devices: String,
644    /// Attempt to bind host driver userspace libraries (NVIDIA toolkit /
645    /// ROCm). Set `false` when the container rootfs ships its own stack.
646    #[serde(default = "default_bind_driver_libraries")]
647    pub bind_driver_libraries: bool,
648}
649
650fn default_bind_driver_libraries() -> bool {
651    true
652}
653
654impl Default for GpuPassthroughConfig {
655    fn default() -> Self {
656        Self {
657            vendor: GpuVendor::Auto,
658            devices: Vec::new(),
659            driver_capabilities: DEFAULT_GPU_DRIVER_CAPABILITIES.to_string(),
660            visible_devices: DEFAULT_GPU_VISIBLE_DEVICES.to_string(),
661            bind_driver_libraries: true,
662        }
663    }
664}
665
666impl GpuPassthroughConfig {
667    /// Returns `true` if passthrough is active (always true for this type;
668    /// presence of the `Option<GpuPassthroughConfig>` is the real switch).
669    pub fn is_enabled(&self) -> bool {
670        true
671    }
672}
673
674/// Environment variables a GPU-enabled workload expects.
675///
676/// These are launch-derived from [`GpuPassthroughConfig`] and injected at
677/// exec time (and into the gVisor OCI process env). They are intentionally
678/// minimal: `NVIDIA_VISIBLE_DEVICES`, `NVIDIA_DRIVER_CAPABILITIES`, and the
679/// EGL vendor manifest pointer that lets Mesa find the bound host driver.
680pub fn gpu_environment(gpu: &GpuPassthroughConfig) -> Vec<(&'static str, String)> {
681    let mut env = vec![
682        ("NVIDIA_VISIBLE_DEVICES", gpu.visible_devices.clone()),
683        ("NVIDIA_DRIVER_CAPABILITIES", gpu.driver_capabilities.clone()),
684    ];
685    if gpu.vendor.includes_nvidia() {
686        env.push((
687            "__EGL_VENDOR_LIBRARY_FILENAMES",
688            "/usr/share/glvnd/egl_vendor.d/10_nvidia.json".to_string(),
689        ));
690    }
691    env
692}
693
694/// Container configuration
695#[derive(Debug, Clone)]
696pub struct ContainerConfig {
697    /// Unique container ID (auto-generated 32 hex chars, 128-bit)
698    pub id: String,
699
700    /// User-supplied container name (optional, defaults to ID)
701    pub name: String,
702
703    /// Command to execute in the container
704    pub command: Vec<String>,
705
706    /// Context directory to pre-populate (optional)
707    pub context_dir: Option<PathBuf>,
708
709    /// Resource limits
710    pub limits: ResourceLimits,
711
712    /// Namespace configuration
713    pub namespaces: NamespaceConfig,
714
715    /// User namespace configuration (for rootless mode)
716    pub user_ns_config: Option<UserNamespaceConfig>,
717
718    /// Hostname to set in UTS namespace (optional)
719    pub hostname: Option<String>,
720
721    /// Whether to use gVisor runtime
722    pub use_gvisor: bool,
723
724    /// Trust level for this workload
725    pub trust_level: TrustLevel,
726
727    /// Network mode
728    pub network: crate::network::NetworkMode,
729
730    /// Context mode (copy or bind mount)
731    pub context_mode: crate::filesystem::ContextMode,
732
733    /// Allow degraded security behavior if a hardening layer cannot be applied
734    pub allow_degraded_security: bool,
735
736    /// Allow chroot fallback when pivot_root fails (weaker isolation)
737    pub allow_chroot_fallback: bool,
738
739    /// Require explicit opt-in for host networking
740    pub allow_host_network: bool,
741
742    /// Mount /proc read-only inside the container
743    pub proc_readonly: bool,
744
745    /// Service mode (agent vs production)
746    pub service_mode: ServiceMode,
747
748    /// Pre-built rootfs path (Nix store path). When set, this is bind-mounted
749    /// as the container root instead of bind-mounting host /bin, /usr, /lib, etc.
750    pub rootfs_path: Option<PathBuf>,
751
752    /// Mount mode for `rootfs_path`.
753    pub rootfs_mode: RootfsMode,
754
755    /// Prepared overlayfs upper/work directories. Normally populated by the
756    /// runtime; image run may pre-seed it from an image diff.
757    pub rootfs_overlay: Option<RootfsOverlayConfig>,
758
759    /// Egress policy for audited outbound network access.
760    pub egress_policy: Option<EgressPolicy>,
761
762    /// Host-side credential broker that is the sandbox's only allowed
763    /// authenticated egress path.
764    pub credential_broker: Option<CredentialBrokerConfig>,
765
766    /// Random per-container token surfaced to brokered workloads so the
767    /// host-side broker can authenticate and attribute requests.
768    pub credential_broker_token: String,
769
770    /// Health check configuration for long-running services.
771    pub health_check: Option<HealthCheck>,
772
773    /// Readiness probe for service startup detection.
774    pub readiness_probe: Option<ReadinessProbe>,
775
776    /// Secret files to mount into the container.
777    pub secrets: Vec<SecretMount>,
778
779    /// Volume mounts to attach to the container filesystem.
780    pub volumes: Vec<VolumeMount>,
781
782    /// First-class workspace mount/staging configuration.
783    pub workspace: WorkspaceConfig,
784
785    /// Private home directory mounted as tmpfs for provider CLIs.
786    pub home: PathBuf,
787
788    /// Provider CLI credential/config mounts under `home`.
789    pub provider_configs: Vec<ProviderConfigMount>,
790
791    /// Working directory for the workload process.
792    pub workdir: PathBuf,
793
794    /// Environment variables to pass to the container process.
795    pub environment: Vec<(String, String)>,
796
797    /// Launch-derived environment variables applied at exec time but excluded
798    /// from `ContainerState` capture and `image commit` manifests.
799    ///
800    /// This carries values that are derived from other launch config (e.g.
801    /// credential-broker proxy env, per-container broker identity) and must
802    /// not be baked into portable artifacts. The workload still observes
803    /// them at runtime via the same exec-time env vector as `environment`.
804    pub derived_environment: Vec<(String, String)>,
805
806    /// Runtime uid/gid and supplementary groups for the workload process.
807    pub process_identity: ProcessIdentity,
808
809    /// Desired topology config hash for reconciliation change detection.
810    pub config_hash: Option<u64>,
811
812    /// Enable sd_notify integration (pass NOTIFY_SOCKET into container).
813    pub sd_notify: bool,
814
815    /// Require the host kernel to be in at least this lockdown mode.
816    pub required_kernel_lockdown: Option<KernelLockdownMode>,
817
818    /// Verify context contents before executing the workload.
819    pub verify_context_integrity: bool,
820
821    /// Verify rootfs attestation manifest before mounting it.
822    pub verify_rootfs_attestation: bool,
823
824    /// Request kernel logging for denied seccomp decisions when supported.
825    pub seccomp_log_denied: bool,
826
827    /// Select the gVisor platform backend.
828    pub gvisor_platform: GVisorPlatform,
829
830    /// Path to a per-service seccomp profile (JSON, OCI subset format).
831    /// When set, this profile is used instead of the built-in allowlist.
832    pub seccomp_profile: Option<PathBuf>,
833
834    /// Expected SHA-256 hash of the seccomp profile file for integrity verification.
835    pub seccomp_profile_sha256: Option<String>,
836
837    /// Seccomp operating mode.
838    pub seccomp_mode: SeccompMode,
839
840    /// Path to write seccomp trace log (NDJSON) when seccomp_mode == Trace.
841    pub seccomp_trace_log: Option<PathBuf>,
842
843    /// Additional syscalls to allow beyond the built-in default allowlist.
844    /// Each entry is a syscall name (e.g. "io_uring_setup", "sysinfo").
845    /// These are merged into the built-in filter; they do NOT replace it.
846    pub seccomp_allow_syscalls: Vec<String>,
847
848    /// Path to capability policy file (TOML).
849    pub caps_policy: Option<PathBuf>,
850
851    /// Expected SHA-256 hash of the capability policy file.
852    pub caps_policy_sha256: Option<String>,
853
854    /// Path to Landlock policy file (TOML).
855    pub landlock_policy: Option<PathBuf>,
856
857    /// Expected SHA-256 hash of the Landlock policy file.
858    pub landlock_policy_sha256: Option<String>,
859
860    /// OCI lifecycle hooks to execute at various container lifecycle points.
861    pub hooks: Option<crate::security::OciHooks>,
862
863    /// Path to write the container PID (OCI --pid-file).
864    pub pid_file: Option<PathBuf>,
865
866    /// Path to AF_UNIX socket for console pseudo-terminal master (OCI --console-socket).
867    pub console_socket: Option<PathBuf>,
868
869    /// Run the workload behind a PTY and make it a terminal-attached process.
870    pub terminal: bool,
871
872    /// Initial PTY window size.
873    pub console_size: ConsoleSize,
874
875    /// Override OCI bundle directory path (OCI --bundle).
876    pub bundle_dir: Option<PathBuf>,
877
878    /// Override root directory for state storage (--root).
879    /// When set, ContainerStateManager uses this instead of the default.
880    pub state_root: Option<PathBuf>,
881
882    /// GPU passthrough configuration. When set, selected host GPU device nodes
883    /// and their minimal driver support files are bound into the container,
884    /// a cgroup device allowlist is installed, and the seccomp `ioctl` filter
885    /// is relaxed for vendor driver ioctls. See `spec/gpu-passthrough.md`.
886    pub gpu: Option<GpuPassthroughConfig>,
887}
888
889/// Seccomp operating mode.
890#[derive(
891    Debug,
892    Clone,
893    Copy,
894    PartialEq,
895    Eq,
896    Default,
897    clap::ValueEnum,
898    serde::Serialize,
899    serde::Deserialize,
900)]
901#[serde(rename_all = "kebab-case")]
902pub enum SeccompMode {
903    /// Normal enforcement – deny unlisted syscalls.
904    #[default]
905    Enforce,
906    /// Trace mode – allow all syscalls but log them for profile generation.
907    /// Development only; rejected in production mode.
908    Trace,
909}
910
911impl ContainerConfig {
912    /// Create a new container config with a random ID.
913    ///
914    /// # Panics
915    /// Panics if secure random bytes cannot be read from `/dev/urandom`.
916    pub fn try_new(name: Option<String>, command: Vec<String>) -> crate::error::Result<Self> {
917        Self::try_new_with_id(None, name, command)
918    }
919
920    /// Create a new container config, optionally using a pre-generated ID.
921    ///
922    /// When `preset_id` is `Some`, it is used as the container ID instead of
923    /// generating a new one. This is used by `--detach` to ensure the outer
924    /// CLI process and the systemd-managed inner process share the same ID.
925    pub fn try_new_with_id(
926        preset_id: Option<String>,
927        name: Option<String>,
928        command: Vec<String>,
929    ) -> crate::error::Result<Self> {
930        let id = match preset_id {
931            Some(id) => {
932                // Validate preset ID: must be exactly 32 hex chars
933                if id.len() != 32 || !id.chars().all(|c| c.is_ascii_hexdigit()) {
934                    return Err(crate::error::NucleusError::ConfigError(format!(
935                        "Invalid preset container ID '{}': must be 32 hex characters",
936                        id
937                    )));
938                }
939                id
940            }
941            None => generate_container_id()?,
942        };
943        let credential_broker_token = generate_container_id()?;
944        let name = name.unwrap_or_else(|| id.clone());
945        Ok(Self {
946            id,
947            name: name.clone(),
948            command,
949            context_dir: None,
950            limits: ResourceLimits::default(),
951            namespaces: NamespaceConfig::default(),
952            user_ns_config: None,
953            hostname: Some(name),
954            use_gvisor: true,
955            trust_level: TrustLevel::default(),
956            network: crate::network::NetworkMode::None,
957            context_mode: crate::filesystem::ContextMode::Copy,
958            allow_degraded_security: false,
959            allow_chroot_fallback: false,
960            allow_host_network: false,
961            proc_readonly: true,
962            service_mode: ServiceMode::default(),
963            rootfs_path: None,
964            rootfs_mode: RootfsMode::default(),
965            rootfs_overlay: None,
966            egress_policy: None,
967            credential_broker: None,
968            credential_broker_token,
969            health_check: None,
970            readiness_probe: None,
971            secrets: Vec::new(),
972            volumes: Vec::new(),
973            workspace: WorkspaceConfig::default(),
974            home: PathBuf::from(DEFAULT_HOME_PATH),
975            provider_configs: Vec::new(),
976            workdir: PathBuf::from("/workspace"),
977            environment: Vec::new(),
978            derived_environment: Vec::new(),
979            process_identity: ProcessIdentity::default(),
980            config_hash: None,
981            sd_notify: false,
982            required_kernel_lockdown: None,
983            verify_context_integrity: false,
984            verify_rootfs_attestation: false,
985            seccomp_log_denied: false,
986            gvisor_platform: GVisorPlatform::default(),
987            seccomp_profile: None,
988            seccomp_profile_sha256: None,
989            seccomp_mode: SeccompMode::default(),
990            seccomp_trace_log: None,
991            seccomp_allow_syscalls: Vec::new(),
992            caps_policy: None,
993            caps_policy_sha256: None,
994            landlock_policy: None,
995            landlock_policy_sha256: None,
996            hooks: None,
997            pid_file: None,
998            console_socket: None,
999            terminal: false,
1000            console_size: ConsoleSize::default(),
1001            bundle_dir: None,
1002            state_root: None,
1003            gpu: None,
1004        })
1005    }
1006
1007    /// Enable rootless mode with user namespace mapping
1008    #[must_use]
1009    pub fn with_rootless(mut self) -> Self {
1010        self.namespaces.user = true;
1011        self.user_ns_config = Some(UserNamespaceConfig::rootless());
1012        self
1013    }
1014
1015    /// Configure custom user namespace mapping
1016    #[must_use]
1017    pub fn with_user_namespace(mut self, config: UserNamespaceConfig) -> Self {
1018        self.namespaces.user = true;
1019        self.user_ns_config = Some(config);
1020        self
1021    }
1022
1023    #[must_use]
1024    pub fn with_context(mut self, dir: PathBuf) -> Self {
1025        self.context_dir = Some(dir);
1026        self
1027    }
1028
1029    #[must_use]
1030    pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
1031        self.limits = limits;
1032        self
1033    }
1034
1035    #[must_use]
1036    pub fn with_namespaces(mut self, namespaces: NamespaceConfig) -> Self {
1037        self.namespaces = namespaces;
1038        self
1039    }
1040
1041    #[must_use]
1042    pub fn with_hostname(mut self, hostname: Option<String>) -> Self {
1043        self.hostname = hostname;
1044        self
1045    }
1046
1047    #[must_use]
1048    pub fn with_gvisor(mut self, enabled: bool) -> Self {
1049        self.use_gvisor = enabled;
1050        self
1051    }
1052
1053    #[must_use]
1054    pub fn with_trust_level(mut self, level: TrustLevel) -> Self {
1055        self.trust_level = level;
1056        self
1057    }
1058
1059    /// Enable OCI bundle runtime path (always OCI for gVisor).
1060    #[must_use]
1061    pub fn with_oci_bundle(mut self) -> Self {
1062        self.use_gvisor = true;
1063        self
1064    }
1065
1066    #[must_use]
1067    pub fn with_network(mut self, mode: crate::network::NetworkMode) -> Self {
1068        self.network = mode;
1069        self
1070    }
1071
1072    #[must_use]
1073    pub fn with_context_mode(mut self, mode: crate::filesystem::ContextMode) -> Self {
1074        self.context_mode = mode;
1075        self
1076    }
1077
1078    #[must_use]
1079    pub fn with_allow_degraded_security(mut self, allow: bool) -> Self {
1080        self.allow_degraded_security = allow;
1081        self
1082    }
1083
1084    #[must_use]
1085    pub fn with_allow_chroot_fallback(mut self, allow: bool) -> Self {
1086        self.allow_chroot_fallback = allow;
1087        self
1088    }
1089
1090    #[must_use]
1091    pub fn with_allow_host_network(mut self, allow: bool) -> Self {
1092        self.allow_host_network = allow;
1093        self
1094    }
1095
1096    #[must_use]
1097    pub fn with_proc_readonly(mut self, proc_readonly: bool) -> Self {
1098        self.proc_readonly = proc_readonly;
1099        self
1100    }
1101
1102    #[must_use]
1103    pub fn with_service_mode(mut self, mode: ServiceMode) -> Self {
1104        self.service_mode = mode;
1105        self
1106    }
1107
1108    #[must_use]
1109    pub fn with_rootfs_path(mut self, path: PathBuf) -> Self {
1110        self.rootfs_path = Some(path);
1111        self
1112    }
1113
1114    #[must_use]
1115    pub fn with_rootfs_mode(mut self, mode: RootfsMode) -> Self {
1116        self.rootfs_mode = mode;
1117        self
1118    }
1119
1120    #[must_use]
1121    pub fn with_rootfs_overlay(mut self, upperdir: PathBuf, workdir: PathBuf) -> Self {
1122        self.rootfs_overlay = Some(RootfsOverlayConfig { upperdir, workdir });
1123        self
1124    }
1125
1126    #[must_use]
1127    pub fn with_egress_policy(mut self, policy: EgressPolicy) -> Self {
1128        self.egress_policy = Some(policy);
1129        self
1130    }
1131
1132    #[must_use]
1133    pub fn with_credential_broker(mut self, broker: CredentialBrokerConfig) -> Self {
1134        self.credential_broker = Some(broker);
1135        self = self.with_credential_broker_identity_env();
1136        self
1137    }
1138
1139    #[must_use]
1140    pub fn with_health_check(mut self, hc: HealthCheck) -> Self {
1141        self.health_check = Some(hc);
1142        self
1143    }
1144
1145    #[must_use]
1146    pub fn with_readiness_probe(mut self, probe: ReadinessProbe) -> Self {
1147        self.readiness_probe = Some(probe);
1148        self
1149    }
1150
1151    #[must_use]
1152    pub fn with_secret(mut self, secret: SecretMount) -> Self {
1153        self.secrets.push(secret);
1154        self
1155    }
1156
1157    #[must_use]
1158    pub fn with_volume(mut self, volume: VolumeMount) -> Self {
1159        self.volumes.push(volume);
1160        self
1161    }
1162
1163    #[must_use]
1164    pub fn with_workspace(mut self, workspace: WorkspaceConfig) -> Self {
1165        self.workspace = workspace;
1166        self
1167    }
1168
1169    #[must_use]
1170    pub fn with_home(mut self, home: PathBuf) -> Self {
1171        self.home = home;
1172        self
1173    }
1174
1175    #[must_use]
1176    pub fn with_provider_config(mut self, provider_config: ProviderConfigMount) -> Self {
1177        self.provider_configs.push(provider_config);
1178        self
1179    }
1180
1181    #[must_use]
1182    pub fn with_workdir(mut self, workdir: PathBuf) -> Self {
1183        self.workdir = workdir;
1184        self
1185    }
1186
1187    #[must_use]
1188    pub fn with_env(mut self, key: String, value: String) -> Self {
1189        if self.credential_broker_owns_env(&key) {
1190            return self;
1191        }
1192        self.environment.push((key, value));
1193        self
1194    }
1195
1196    /// Append a launch-derived env var. Derived env is applied to the workload
1197    /// at exec time but excluded from `ContainerState` capture and image
1198    /// commit manifests. Use this for values computed from other launch state
1199    /// (broker endpoints, per-container tokens) that must not be baked into
1200    /// portable artifacts.
1201    #[must_use]
1202    pub fn with_derived_env(mut self, key: String, value: String) -> Self {
1203        self.derived_environment.push((key, value));
1204        self
1205    }
1206
1207    fn upsert_derived_env(&mut self, key: &str, value: String) {
1208        self.derived_environment
1209            .retain(|(existing_key, _)| existing_key != key);
1210        self.derived_environment.push((key.to_string(), value));
1211    }
1212
1213    #[must_use]
1214    pub fn with_credential_broker_identity_env(mut self) -> Self {
1215        if self.credential_broker.is_some() {
1216            // These values are per-container and must not be committed into
1217            // image manifests. Route them through `derived_environment` so
1218            // `state.environment` capture and `ImageConfig::from_state` stay
1219            // clean.
1220            self.environment
1221                .retain(|(key, _)| !is_credential_broker_identity_env(key));
1222            self.upsert_derived_env(CREDENTIAL_BROKER_CONTAINER_ID_ENV, self.id.clone());
1223            self.upsert_derived_env(
1224                CREDENTIAL_BROKER_TOKEN_ENV,
1225                self.credential_broker_token.clone(),
1226            );
1227        }
1228        self
1229    }
1230
1231    #[must_use]
1232    pub(crate) fn credential_broker_owns_env(&self, key: &str) -> bool {
1233        self.credential_broker.is_some() && is_credential_broker_identity_env(key)
1234    }
1235
1236    #[must_use]
1237    pub fn with_process_identity(mut self, identity: ProcessIdentity) -> Self {
1238        self.process_identity = identity;
1239        self
1240    }
1241
1242    #[must_use]
1243    pub fn with_config_hash(mut self, hash: u64) -> Self {
1244        self.config_hash = Some(hash);
1245        self
1246    }
1247
1248    #[must_use]
1249    pub fn with_sd_notify(mut self, enabled: bool) -> Self {
1250        self.sd_notify = enabled;
1251        self
1252    }
1253
1254    #[must_use]
1255    pub fn with_required_kernel_lockdown(mut self, mode: KernelLockdownMode) -> Self {
1256        self.required_kernel_lockdown = Some(mode);
1257        self
1258    }
1259
1260    #[must_use]
1261    pub fn with_verify_context_integrity(mut self, enabled: bool) -> Self {
1262        self.verify_context_integrity = enabled;
1263        self
1264    }
1265
1266    #[must_use]
1267    pub fn with_verify_rootfs_attestation(mut self, enabled: bool) -> Self {
1268        self.verify_rootfs_attestation = enabled;
1269        self
1270    }
1271
1272    #[must_use]
1273    pub fn with_seccomp_log_denied(mut self, enabled: bool) -> Self {
1274        self.seccomp_log_denied = enabled;
1275        self
1276    }
1277
1278    #[must_use]
1279    pub fn with_gvisor_platform(mut self, platform: GVisorPlatform) -> Self {
1280        self.gvisor_platform = platform;
1281        self
1282    }
1283
1284    #[must_use]
1285    pub fn with_seccomp_profile(mut self, path: PathBuf) -> Self {
1286        self.seccomp_profile = Some(path);
1287        self
1288    }
1289
1290    #[must_use]
1291    pub fn with_seccomp_profile_sha256(mut self, hash: String) -> Self {
1292        self.seccomp_profile_sha256 = Some(hash);
1293        self
1294    }
1295
1296    #[must_use]
1297    pub fn with_seccomp_mode(mut self, mode: SeccompMode) -> Self {
1298        self.seccomp_mode = mode;
1299        self
1300    }
1301
1302    #[must_use]
1303    pub fn with_seccomp_trace_log(mut self, path: PathBuf) -> Self {
1304        self.seccomp_trace_log = Some(path);
1305        self
1306    }
1307
1308    #[must_use]
1309    pub fn with_seccomp_allow_syscalls(mut self, syscalls: Vec<String>) -> Self {
1310        self.seccomp_allow_syscalls = syscalls;
1311        self
1312    }
1313
1314    #[must_use]
1315    pub fn with_caps_policy(mut self, path: PathBuf) -> Self {
1316        self.caps_policy = Some(path);
1317        self
1318    }
1319
1320    #[must_use]
1321    pub fn with_caps_policy_sha256(mut self, hash: String) -> Self {
1322        self.caps_policy_sha256 = Some(hash);
1323        self
1324    }
1325
1326    #[must_use]
1327    pub fn with_landlock_policy(mut self, path: PathBuf) -> Self {
1328        self.landlock_policy = Some(path);
1329        self
1330    }
1331
1332    #[must_use]
1333    pub fn with_landlock_policy_sha256(mut self, hash: String) -> Self {
1334        self.landlock_policy_sha256 = Some(hash);
1335        self
1336    }
1337
1338    #[must_use]
1339    pub fn with_pid_file(mut self, path: PathBuf) -> Self {
1340        self.pid_file = Some(path);
1341        self
1342    }
1343
1344    #[must_use]
1345    pub fn with_console_socket(mut self, path: PathBuf) -> Self {
1346        self.console_socket = Some(path);
1347        self.terminal = true;
1348        self
1349    }
1350
1351    #[must_use]
1352    pub fn with_terminal(mut self, size: ConsoleSize) -> Self {
1353        self.terminal = true;
1354        self.console_size = size;
1355        self
1356    }
1357
1358    #[must_use]
1359    pub fn with_bundle_dir(mut self, path: PathBuf) -> Self {
1360        self.bundle_dir = Some(path);
1361        self
1362    }
1363
1364    pub fn with_state_root(mut self, root: PathBuf) -> Self {
1365        self.state_root = Some(root);
1366        self
1367    }
1368
1369    /// Enable GPU passthrough with the given configuration.
1370    ///
1371    /// This is the programmatic equivalent of `--gpu`. See
1372    /// `spec/gpu-passthrough.md` for the security model.
1373    #[must_use]
1374    pub fn with_gpu(mut self, gpu: GpuPassthroughConfig) -> Self {
1375        self.gpu = Some(gpu);
1376        self
1377    }
1378
1379    fn validate_credential_broker(&self) -> crate::error::Result<()> {
1380        let Some(broker) = &self.credential_broker else {
1381            return Ok(());
1382        };
1383
1384        let crate::network::NetworkMode::Bridge(bridge_config) = &self.network else {
1385            return Err(crate::error::NucleusError::ConfigError(
1386                "Credential broker egress requires --network bridge so Nucleus can force the \
1387                 sandbox through the host-side broker endpoint"
1388                    .to_string(),
1389            ));
1390        };
1391
1392        if bridge_config.nat_backend == crate::network::NatBackend::Userspace {
1393            return Err(crate::error::NucleusError::ConfigError(
1394                "Credential broker egress requires the kernel NAT backend; \
1395                 slirp4netns userspace NAT cannot route to the host-side bridge broker"
1396                    .to_string(),
1397            ));
1398        }
1399
1400        broker
1401            .validate_for_bridge(bridge_config)
1402            .map_err(crate::error::NucleusError::ConfigError)?;
1403
1404        let Some(policy) = &self.egress_policy else {
1405            return Err(crate::error::NucleusError::ConfigError(
1406                "Credential broker egress requires a broker-only egress policy".to_string(),
1407            ));
1408        };
1409
1410        if !policy.is_credential_broker_only(broker) {
1411            return Err(crate::error::NucleusError::ConfigError(
1412                "Credential broker egress must allow only the broker IP/port and must deny DNS"
1413                    .to_string(),
1414            ));
1415        }
1416
1417        Ok(())
1418    }
1419
1420    fn validate_fail_closed_isolation(&self) -> crate::error::Result<()> {
1421        let mode = self.service_mode.label();
1422        if self.allow_degraded_security {
1423            return Err(crate::error::NucleusError::ConfigError(format!(
1424                "{} forbids --allow-degraded-security",
1425                mode
1426            )));
1427        }
1428
1429        if self.allow_chroot_fallback {
1430            return Err(crate::error::NucleusError::ConfigError(format!(
1431                "{} forbids --allow-chroot-fallback",
1432                mode
1433            )));
1434        }
1435
1436        if matches!(self.network, crate::network::NetworkMode::Host) {
1437            return Err(crate::error::NucleusError::ConfigError(format!(
1438                "{} forbids native host network mode because it collapses the \
1439                 runtime boundary; use --network gvisor-host with --runtime gvisor and \
1440                 --allow-host-network when hostinet is required",
1441                mode
1442            )));
1443        }
1444
1445        if matches!(self.network, crate::network::NetworkMode::GVisorHost) {
1446            if !self.use_gvisor {
1447                return Err(crate::error::NucleusError::ConfigError(format!(
1448                    "{} requires --runtime gvisor for --network gvisor-host",
1449                    mode
1450                )));
1451            }
1452            if !self.allow_host_network {
1453                return Err(crate::error::NucleusError::ConfigError(format!(
1454                    "{} requires --allow-host-network for --network gvisor-host",
1455                    mode
1456                )));
1457            }
1458            if self.egress_policy.is_some() {
1459                return Err(crate::error::NucleusError::ConfigError(format!(
1460                    "{} cannot enforce egress policy with --network gvisor-host",
1461                    mode
1462                )));
1463            }
1464        } else if self.allow_host_network {
1465            return Err(crate::error::NucleusError::ConfigError(format!(
1466                "{} permits --allow-host-network only with --network gvisor-host",
1467                mode
1468            )));
1469        }
1470
1471        if self.seccomp_mode == SeccompMode::Trace {
1472            return Err(crate::error::NucleusError::ConfigError(format!(
1473                "{} forbids --seccomp-mode trace",
1474                mode
1475            )));
1476        }
1477
1478        if !self.seccomp_allow_syscalls.is_empty() {
1479            let allow_network = !matches!(self.network, crate::network::NetworkMode::None);
1480            crate::security::SeccompManager::validate_extra_syscalls_for_production(
1481                allow_network,
1482                &self.seccomp_allow_syscalls,
1483            )?;
1484        }
1485
1486        Ok(())
1487    }
1488
1489    /// Validate that strict agent mode invariants are satisfied.
1490    /// Called before container startup when service_mode == StrictAgent.
1491    pub fn validate_strict_agent_mode(&self) -> crate::error::Result<()> {
1492        if self.service_mode != ServiceMode::StrictAgent {
1493            return Ok(());
1494        }
1495
1496        self.validate_fail_closed_isolation()?;
1497
1498        if !self.limits.has_cgroup_control() {
1499            return Err(crate::error::NucleusError::ConfigError(
1500                "Strict agent mode requires at least one cgroup control \
1501                 (default --pids limit or explicit --memory/--cpus/--pids/--io-limit)"
1502                    .to_string(),
1503            ));
1504        }
1505
1506        Ok(())
1507    }
1508
1509    /// Validate that production mode invariants are satisfied.
1510    /// Called before container startup when service_mode == Production.
1511    pub fn validate_production_mode(&self) -> crate::error::Result<()> {
1512        if self.service_mode != ServiceMode::Production {
1513            return Ok(());
1514        }
1515
1516        self.validate_fail_closed_isolation()?;
1517
1518        if self.gpu.is_some() {
1519            return Err(crate::error::NucleusError::ConfigError(
1520                "Production mode forbids --gpu host device passthrough; production services must \
1521                 declare GPU needs through an attested rootfs"
1522                    .to_string(),
1523            ));
1524        }
1525
1526        if self.workspace.allow_execute && self.workspace.is_writable() {
1527            return Err(crate::error::NucleusError::ConfigError(
1528                "Production mode forbids writable executable workspaces; use bind-ro with \
1529                 --workspace-exec or remove --workspace-exec"
1530                    .to_string(),
1531            ));
1532        }
1533
1534        // Production mode requires explicit rootfs (no host bind mount fallback)
1535        let Some(rootfs_path) = self.rootfs_path.as_ref() else {
1536            return Err(crate::error::NucleusError::ConfigError(
1537                "Production mode requires explicit --rootfs path (no host bind mounts)".to_string(),
1538            ));
1539        };
1540
1541        if self.rootfs_mode == RootfsMode::Overlay {
1542            return Err(crate::error::NucleusError::ConfigError(
1543                "Production mode does not yet support --rootfs-mode overlay; production mount \
1544                 auditing currently requires read-only rootfs bind mounts"
1545                    .to_string(),
1546            ));
1547        }
1548
1549        // L6: Policy files must have SHA-256 verification in production
1550        if self.caps_policy.is_some() && self.caps_policy_sha256.is_none() {
1551            return Err(crate::error::NucleusError::ConfigError(
1552                "Production mode requires --caps-policy-sha256 when using --caps-policy"
1553                    .to_string(),
1554            ));
1555        }
1556        if self.landlock_policy.is_some() && self.landlock_policy_sha256.is_none() {
1557            return Err(crate::error::NucleusError::ConfigError(
1558                "Production mode requires --landlock-policy-sha256 when using --landlock-policy"
1559                    .to_string(),
1560            ));
1561        }
1562        if self.seccomp_profile.is_some() && self.seccomp_profile_sha256.is_none() {
1563            return Err(crate::error::NucleusError::ConfigError(
1564                "Production mode requires --seccomp-profile-sha256 when using --seccomp-profile"
1565                    .to_string(),
1566            ));
1567        }
1568
1569        // Production mode requires explicit resource limits
1570        if self.limits.memory_bytes.is_none() {
1571            return Err(crate::error::NucleusError::ConfigError(
1572                "Production mode requires explicit --memory limit".to_string(),
1573            ));
1574        }
1575
1576        if self.limits.cpu_quota_us.is_none() {
1577            return Err(crate::error::NucleusError::ConfigError(
1578                "Production mode requires explicit --cpus limit".to_string(),
1579            ));
1580        }
1581
1582        if !self.verify_rootfs_attestation {
1583            return Err(crate::error::NucleusError::ConfigError(
1584                "Production mode requires --verify-rootfs-attestation".to_string(),
1585            ));
1586        }
1587
1588        validate_production_rootfs_path(rootfs_path)?;
1589
1590        Ok(())
1591    }
1592
1593    /// Validate runtime-specific feature support.
1594    pub fn validate_runtime_support(&self) -> crate::error::Result<()> {
1595        self.limits.validate_runtime_sanity()?;
1596
1597        if let Some(user_ns_config) = &self.user_ns_config {
1598            if !self.process_identity.additional_gids.is_empty() {
1599                return Err(crate::error::NucleusError::ConfigError(
1600                    "Supplementary groups are currently unsupported with user namespaces"
1601                        .to_string(),
1602                ));
1603            }
1604
1605            let uid_mapped = user_ns_config.uid_mappings.iter().any(|mapping| {
1606                self.process_identity.uid >= mapping.container_id
1607                    && self.process_identity.uid
1608                        < mapping.container_id.saturating_add(mapping.count)
1609            });
1610            if !uid_mapped {
1611                return Err(crate::error::NucleusError::ConfigError(format!(
1612                    "Process uid {} is not mapped in the configured user namespace",
1613                    self.process_identity.uid
1614                )));
1615            }
1616
1617            let gid_mapped = user_ns_config.gid_mappings.iter().any(|mapping| {
1618                self.process_identity.gid >= mapping.container_id
1619                    && self.process_identity.gid
1620                        < mapping.container_id.saturating_add(mapping.count)
1621            });
1622            if !gid_mapped {
1623                return Err(crate::error::NucleusError::ConfigError(format!(
1624                    "Process gid {} is not mapped in the configured user namespace",
1625                    self.process_identity.gid
1626                )));
1627            }
1628        }
1629
1630        if self.seccomp_mode == SeccompMode::Trace && self.seccomp_trace_log.is_none() {
1631            return Err(crate::error::NucleusError::ConfigError(
1632                "Seccomp trace mode requires --seccomp-log / seccomp_trace_log".to_string(),
1633            ));
1634        }
1635
1636        normalize_container_destination(&self.workdir)?;
1637        let workspace_path = normalize_container_destination(&self.workspace.container_path)?;
1638        if workspace_path != std::path::Path::new("/workspace") {
1639            return Err(crate::error::NucleusError::ConfigError(
1640                "Workspace destination is fixed at /workspace".to_string(),
1641            ));
1642        }
1643        let home_path = normalize_container_destination(&self.home)?;
1644        if home_path == workspace_path
1645            || home_path.starts_with(&workspace_path)
1646            || workspace_path.starts_with(&home_path)
1647        {
1648            return Err(crate::error::NucleusError::ConfigError(
1649                "--home must not overlap /workspace".to_string(),
1650            ));
1651        }
1652        if let Some(host_path) = &self.workspace.host_path {
1653            validate_workspace_host_path(host_path)?;
1654        }
1655        if self.workspace.mode == WorkspaceMode::CopyInOut && self.workspace.host_path.is_none() {
1656            return Err(crate::error::NucleusError::ConfigError(
1657                "--workspace-mode copy-in-out requires --workspace".to_string(),
1658            ));
1659        }
1660
1661        for secret in &self.secrets {
1662            normalize_container_destination(&secret.dest)?;
1663        }
1664
1665        for provider_config in &self.provider_configs {
1666            validate_provider_config_source(&provider_config.source)?;
1667            normalize_provider_config_destination(&home_path, &provider_config.dest)?;
1668        }
1669
1670        for volume in &self.volumes {
1671            let volume_dest = normalize_volume_destination(&volume.dest)?;
1672            if volume_dest == workspace_path {
1673                return Err(crate::error::NucleusError::ConfigError(
1674                    "Volume destination /workspace conflicts with --workspace".to_string(),
1675                ));
1676            }
1677            if volume_dest == home_path {
1678                return Err(crate::error::NucleusError::ConfigError(
1679                    "Volume destination conflicts with --home".to_string(),
1680                ));
1681            }
1682            match &volume.source {
1683                VolumeSource::Bind { source } => {
1684                    if !source.is_absolute() {
1685                        return Err(crate::error::NucleusError::ConfigError(format!(
1686                            "Volume source must be absolute: {:?}",
1687                            source
1688                        )));
1689                    }
1690                    if !source.exists() {
1691                        return Err(crate::error::NucleusError::ConfigError(format!(
1692                            "Volume source does not exist: {:?}",
1693                            source
1694                        )));
1695                    }
1696                    crate::filesystem::validate_bind_mount_source(source)?;
1697                }
1698                VolumeSource::Tmpfs { .. } => {}
1699            }
1700        }
1701
1702        self.validate_credential_broker()?;
1703
1704        if self.rootfs_mode == RootfsMode::Overlay {
1705            if self.rootfs_path.is_none() {
1706                return Err(crate::error::NucleusError::ConfigError(
1707                    "--rootfs-mode overlay requires --rootfs or --agent-toolchain-rootfs"
1708                        .to_string(),
1709                ));
1710            }
1711            if self.use_gvisor {
1712                return Err(crate::error::NucleusError::ConfigError(
1713                    "--rootfs-mode overlay is currently supported only with --runtime native"
1714                        .to_string(),
1715                ));
1716            }
1717        }
1718
1719        if !self.use_gvisor {
1720            return Ok(());
1721        }
1722
1723        if self.seccomp_mode == SeccompMode::Trace {
1724            return Err(crate::error::NucleusError::ConfigError(
1725                "gVisor runtime does not support --seccomp-mode trace; use --runtime native"
1726                    .to_string(),
1727            ));
1728        }
1729
1730        if self.seccomp_log_denied {
1731            return Err(crate::error::NucleusError::ConfigError(
1732                "gVisor runtime does not support seccomp deny logging; use --runtime native"
1733                    .to_string(),
1734            ));
1735        }
1736
1737        if !self.seccomp_allow_syscalls.is_empty() {
1738            return Err(crate::error::NucleusError::ConfigError(
1739                "gVisor runtime does not support --seccomp-allow; use a custom --seccomp-profile or --runtime native"
1740                    .to_string(),
1741            ));
1742        }
1743
1744        if self.caps_policy.is_some() {
1745            return Err(crate::error::NucleusError::ConfigError(
1746                "gVisor runtime does not support capability policy files; use --runtime native"
1747                    .to_string(),
1748            ));
1749        }
1750
1751        if self.landlock_policy.is_some() {
1752            return Err(crate::error::NucleusError::ConfigError(
1753                "gVisor runtime does not support Landlock policy files; use --runtime native"
1754                    .to_string(),
1755            ));
1756        }
1757
1758        if self.health_check.is_some() {
1759            return Err(crate::error::NucleusError::ConfigError(
1760                "gVisor runtime does not support exec health checks; use --runtime native or remove --health-cmd"
1761                    .to_string(),
1762            ));
1763        }
1764
1765        if matches!(
1766            self.readiness_probe.as_ref(),
1767            Some(ReadinessProbe::Exec { .. }) | Some(ReadinessProbe::TcpPort(_))
1768        ) {
1769            return Err(crate::error::NucleusError::ConfigError(
1770                "gVisor runtime does not support exec/TCP readiness probes; use --runtime native or --readiness-sd-notify"
1771                    .to_string(),
1772            ));
1773        }
1774
1775        if self.verify_context_integrity
1776            && self.context_dir.is_some()
1777            && matches!(self.context_mode, crate::filesystem::ContextMode::BindMount)
1778        {
1779            return Err(crate::error::NucleusError::ConfigError(
1780                "gVisor runtime cannot verify bind-mounted context integrity; use --context-mode copy or disable --verify-context-integrity"
1781                    .to_string(),
1782            ));
1783        }
1784
1785        Ok(())
1786    }
1787
1788    /// Apply runtime selection (native vs gVisor) and OCI bundle mode.
1789    pub fn apply_runtime_selection(
1790        mut self,
1791        runtime: RuntimeSelection,
1792        oci: bool,
1793    ) -> crate::error::Result<Self> {
1794        match runtime {
1795            RuntimeSelection::Native => {
1796                if oci {
1797                    return Err(crate::error::NucleusError::ConfigError(
1798                        "--bundle requires gVisor runtime; use --runtime gvisor".to_string(),
1799                    ));
1800                }
1801                self = self.with_gvisor(false);
1802            }
1803            RuntimeSelection::GVisor => {
1804                self = self.with_gvisor(true);
1805                if !oci {
1806                    tracing::info!(
1807                        "Security hardening: enabling OCI bundle mode for gVisor runtime"
1808                    );
1809                }
1810                self = self.with_oci_bundle();
1811            }
1812        }
1813        Ok(self)
1814    }
1815}
1816
1817/// Validate a container name for safe use.
1818pub fn validate_container_name(name: &str) -> crate::error::Result<()> {
1819    if name.is_empty() || name.len() > 128 {
1820        return Err(crate::error::NucleusError::ConfigError(
1821            "Invalid container name: must be 1-128 characters".to_string(),
1822        ));
1823    }
1824    if !name
1825        .chars()
1826        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
1827    {
1828        return Err(crate::error::NucleusError::ConfigError(
1829            "Invalid container name: allowed characters are a-zA-Z0-9, '-', '_', '.'".to_string(),
1830        ));
1831    }
1832    Ok(())
1833}
1834
1835/// Validate a hostname according to RFC 1123.
1836pub fn validate_hostname(hostname: &str) -> crate::error::Result<()> {
1837    if hostname.is_empty() || hostname.len() > 253 {
1838        return Err(crate::error::NucleusError::ConfigError(
1839            "Invalid hostname: must be 1-253 characters".to_string(),
1840        ));
1841    }
1842
1843    for label in hostname.split('.') {
1844        if label.is_empty() || label.len() > 63 {
1845            return Err(crate::error::NucleusError::ConfigError(format!(
1846                "Invalid hostname label: '{}'",
1847                label
1848            )));
1849        }
1850        if label.starts_with('-') || label.ends_with('-') {
1851            return Err(crate::error::NucleusError::ConfigError(format!(
1852                "Invalid hostname label '{}': cannot start or end with '-'",
1853                label
1854            )));
1855        }
1856        if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
1857            return Err(crate::error::NucleusError::ConfigError(format!(
1858                "Invalid hostname label '{}': allowed characters are a-zA-Z0-9 and '-'",
1859                label
1860            )));
1861        }
1862    }
1863
1864    Ok(())
1865}
1866
1867#[cfg(test)]
1868#[allow(deprecated)]
1869mod tests {
1870    use super::*;
1871    use crate::network::NetworkMode;
1872
1873    #[test]
1874    fn test_generate_container_id_is_32_hex_chars() {
1875        let id = generate_container_id().unwrap();
1876        assert_eq!(
1877            id.len(),
1878            32,
1879            "Container ID must be full 128-bit (32 hex chars), got {}",
1880            id.len()
1881        );
1882        assert!(
1883            id.chars().all(|c| c.is_ascii_hexdigit()),
1884            "Container ID must be hex: {}",
1885            id
1886        );
1887    }
1888
1889    #[test]
1890    fn test_generate_container_id_is_unique() {
1891        let id1 = generate_container_id().unwrap();
1892        let id2 = generate_container_id().unwrap();
1893        assert_ne!(id1, id2, "Two consecutive IDs must differ");
1894    }
1895
1896    #[test]
1897    fn test_config_security_defaults_are_hardened() {
1898        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
1899        assert!(!cfg.allow_degraded_security);
1900        assert!(!cfg.allow_chroot_fallback);
1901        assert!(!cfg.allow_host_network);
1902        assert!(cfg.proc_readonly);
1903        assert_eq!(cfg.service_mode, ServiceMode::Agent);
1904        assert!(cfg.rootfs_path.is_none());
1905        assert!(cfg.egress_policy.is_none());
1906        assert!(cfg.credential_broker.is_none());
1907        assert!(cfg.secrets.is_empty());
1908        assert!(cfg.volumes.is_empty());
1909        assert_eq!(cfg.home, PathBuf::from(DEFAULT_HOME_PATH));
1910        assert!(cfg.provider_configs.is_empty());
1911        assert!(!cfg.sd_notify);
1912        assert!(cfg.required_kernel_lockdown.is_none());
1913        assert!(!cfg.verify_context_integrity);
1914        assert!(!cfg.verify_rootfs_attestation);
1915        assert!(!cfg.seccomp_log_denied);
1916        assert_eq!(cfg.gvisor_platform, GVisorPlatform::Systrap);
1917    }
1918
1919    #[test]
1920    fn test_credential_broker_validates_broker_only_bridge_egress() {
1921        let broker =
1922            crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1923        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1924            .unwrap()
1925            .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
1926            .with_credential_broker(broker.clone())
1927            .with_egress_policy(broker.egress_policy());
1928
1929        assert!(cfg.validate_runtime_support().is_ok());
1930    }
1931
1932    #[test]
1933    fn test_credential_broker_injects_per_container_identity_env() {
1934        let broker =
1935            crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1936        let cfg = ContainerConfig::try_new_with_id(
1937            Some("0123456789abcdef0123456789abcdef".to_string()),
1938            None,
1939            vec!["/bin/sh".to_string()],
1940        )
1941        .unwrap()
1942        .with_env(
1943            CREDENTIAL_BROKER_TOKEN_ENV.to_string(),
1944            "user-supplied-token".to_string(),
1945        )
1946        .with_env(
1947            CREDENTIAL_BROKER_CONTAINER_ID_ENV.to_string(),
1948            "user-supplied-container-id".to_string(),
1949        )
1950        .with_credential_broker(broker)
1951        .with_env(
1952            CREDENTIAL_BROKER_TOKEN_ENV.to_string(),
1953            "late-user-supplied-token".to_string(),
1954        )
1955        .with_env(
1956            CREDENTIAL_BROKER_CONTAINER_ID_ENV.to_string(),
1957            "late-user-supplied-container-id".to_string(),
1958        );
1959
1960        // Per-container identity env is launch-derived: it must live in
1961        // `derived_environment` so committed image manifests stay portable.
1962        assert!(cfg.derived_environment.contains(&(
1963            CREDENTIAL_BROKER_CONTAINER_ID_ENV.to_string(),
1964            cfg.id.clone()
1965        )));
1966        assert_ne!(cfg.credential_broker_token, cfg.id);
1967        assert!(cfg.derived_environment.contains(&(
1968            CREDENTIAL_BROKER_TOKEN_ENV.to_string(),
1969            cfg.credential_broker_token.clone()
1970        )));
1971        // User-supplied broker identity env is overwritten in broker mode,
1972        // regardless of whether it was supplied before or after broker setup.
1973        for key in [
1974            CREDENTIAL_BROKER_TOKEN_ENV,
1975            CREDENTIAL_BROKER_CONTAINER_ID_ENV,
1976        ] {
1977            assert!(!cfg.environment.iter().any(|(env_key, _)| env_key == key));
1978        }
1979    }
1980
1981    #[test]
1982    fn test_credential_broker_rejects_non_bridge_network() {
1983        let broker =
1984            crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1985        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
1986            .unwrap()
1987            .with_network(NetworkMode::None)
1988            .with_credential_broker(broker.clone())
1989            .with_egress_policy(broker.egress_policy());
1990
1991        let err = cfg.validate_runtime_support().unwrap_err();
1992        assert!(err.to_string().contains("requires --network bridge"));
1993    }
1994
1995    #[test]
1996    fn test_credential_broker_rejects_extra_egress_routes() {
1997        let broker =
1998            crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
1999        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2000            .unwrap()
2001            .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
2002            .with_credential_broker(broker)
2003            .with_egress_policy(
2004                crate::network::EgressPolicy::default()
2005                    .with_allowed_cidrs(vec![
2006                        "10.0.42.1/32".to_string(),
2007                        "203.0.113.0/24".to_string(),
2008                    ])
2009                    .with_allowed_tcp_ports(vec![8080]),
2010            );
2011
2012        let err = cfg.validate_runtime_support().unwrap_err();
2013        assert!(err.to_string().contains("allow only the broker"));
2014    }
2015
2016    #[test]
2017    fn test_credential_broker_rejects_userspace_nat_backend() {
2018        let broker =
2019            crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.1:8080").unwrap();
2020        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2021            .unwrap()
2022            .with_network(NetworkMode::Bridge(
2023                crate::network::BridgeConfig::default()
2024                    .with_nat_backend(crate::network::NatBackend::Userspace),
2025            ))
2026            .with_credential_broker(broker.clone())
2027            .with_egress_policy(broker.egress_policy());
2028
2029        let err = cfg.validate_runtime_support().unwrap_err();
2030        assert!(err.to_string().contains("kernel NAT backend"));
2031    }
2032
2033    #[test]
2034    fn test_credential_broker_rejects_ip_outside_bridge_gateway() {
2035        let broker =
2036            crate::network::CredentialBrokerConfig::parse_endpoint("8.8.8.8:8080").unwrap();
2037        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2038            .unwrap()
2039            .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
2040            .with_credential_broker(broker.clone())
2041            .with_egress_policy(broker.egress_policy());
2042
2043        let err = cfg.validate_runtime_support().unwrap_err();
2044        assert!(err
2045            .to_string()
2046            .contains("host-side bridge address 10.0.42.1"));
2047    }
2048
2049    #[test]
2050    fn test_credential_broker_rejects_non_gateway_bridge_ip() {
2051        let broker =
2052            crate::network::CredentialBrokerConfig::parse_endpoint("10.0.42.2:8080").unwrap();
2053        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2054            .unwrap()
2055            .with_network(NetworkMode::Bridge(crate::network::BridgeConfig::default()))
2056            .with_credential_broker(broker.clone())
2057            .with_egress_policy(broker.egress_policy());
2058
2059        let err = cfg.validate_runtime_support().unwrap_err();
2060        assert!(err
2061            .to_string()
2062            .contains("host-side bridge address 10.0.42.1"));
2063    }
2064
2065    #[test]
2066    fn test_production_mode_rejects_degraded_flags() {
2067        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2068            .unwrap()
2069            .with_service_mode(ServiceMode::Production)
2070            .with_allow_degraded_security(true)
2071            .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2072            .with_limits(
2073                crate::resources::ResourceLimits::default()
2074                    .with_memory("512M")
2075                    .unwrap()
2076                    .with_cpu_cores(2.0)
2077                    .unwrap(),
2078            );
2079        assert!(cfg.validate_production_mode().is_err());
2080    }
2081
2082    #[test]
2083    fn test_production_mode_rejects_chroot_fallback() {
2084        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2085            .unwrap()
2086            .with_service_mode(ServiceMode::Production)
2087            .with_allow_chroot_fallback(true)
2088            .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2089            .with_limits(
2090                crate::resources::ResourceLimits::default()
2091                    .with_memory("512M")
2092                    .unwrap()
2093                    .with_cpu_cores(2.0)
2094                    .unwrap(),
2095            );
2096        let err = cfg.validate_production_mode().unwrap_err();
2097        assert!(
2098            err.to_string().contains("chroot"),
2099            "Production mode must reject chroot fallback"
2100        );
2101    }
2102
2103    #[test]
2104    fn test_production_mode_requires_rootfs() {
2105        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2106            .unwrap()
2107            .with_service_mode(ServiceMode::Production)
2108            .with_limits(
2109                crate::resources::ResourceLimits::default()
2110                    .with_memory("512M")
2111                    .unwrap(),
2112            );
2113        let err = cfg.validate_production_mode().unwrap_err();
2114        assert!(err.to_string().contains("--rootfs"));
2115    }
2116
2117    fn test_rootfs_path() -> std::path::PathBuf {
2118        std::path::PathBuf::from("/nix/store")
2119    }
2120
2121    #[test]
2122    fn test_production_mode_requires_memory_limit() {
2123        let rootfs = test_rootfs_path();
2124        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2125            .unwrap()
2126            .with_service_mode(ServiceMode::Production)
2127            .with_rootfs_path(rootfs);
2128        let err = cfg.validate_production_mode().unwrap_err();
2129        assert!(err.to_string().contains("--memory"));
2130    }
2131
2132    #[test]
2133    fn test_production_mode_valid_config() {
2134        let rootfs = test_rootfs_path();
2135        if !rootfs.is_dir() {
2136            return;
2137        }
2138        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2139            .unwrap()
2140            .with_service_mode(ServiceMode::Production)
2141            .with_rootfs_path(rootfs.clone())
2142            .with_verify_rootfs_attestation(true)
2143            .with_limits(
2144                crate::resources::ResourceLimits::default()
2145                    .with_memory("512M")
2146                    .unwrap()
2147                    .with_cpu_cores(2.0)
2148                    .unwrap(),
2149            );
2150        let result = cfg.validate_production_mode();
2151        assert!(result.is_ok());
2152    }
2153
2154    #[test]
2155    fn test_production_mode_allows_explicit_gvisor_host_network() {
2156        let rootfs = test_rootfs_path();
2157        if !rootfs.is_dir() {
2158            return;
2159        }
2160        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2161            .unwrap()
2162            .with_gvisor(true)
2163            .with_service_mode(ServiceMode::Production)
2164            .with_network(NetworkMode::GVisorHost)
2165            .with_allow_host_network(true)
2166            .with_rootfs_path(rootfs)
2167            .with_verify_rootfs_attestation(true)
2168            .with_limits(
2169                crate::resources::ResourceLimits::default()
2170                    .with_memory("512M")
2171                    .unwrap()
2172                    .with_cpu_cores(2.0)
2173                    .unwrap(),
2174            );
2175
2176        assert!(cfg.validate_production_mode().is_ok());
2177    }
2178
2179    #[test]
2180    fn test_production_mode_rejects_gvisor_host_network_with_egress_policy() {
2181        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2182            .unwrap()
2183            .with_gvisor(true)
2184            .with_service_mode(ServiceMode::Production)
2185            .with_network(NetworkMode::GVisorHost)
2186            .with_allow_host_network(true)
2187            .with_egress_policy(crate::network::EgressPolicy::deny_all())
2188            .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2189            .with_verify_rootfs_attestation(true)
2190            .with_limits(
2191                crate::resources::ResourceLimits::default()
2192                    .with_memory("512M")
2193                    .unwrap()
2194                    .with_cpu_cores(2.0)
2195                    .unwrap(),
2196            );
2197
2198        let err = cfg.validate_production_mode().unwrap_err();
2199        assert!(err.to_string().contains("egress policy"));
2200    }
2201
2202    #[test]
2203    fn test_production_mode_rejects_native_host_network() {
2204        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2205            .unwrap()
2206            .with_gvisor(false)
2207            .with_service_mode(ServiceMode::Production)
2208            .with_network(NetworkMode::Host)
2209            .with_allow_host_network(true)
2210            .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2211            .with_verify_rootfs_attestation(true)
2212            .with_limits(
2213                crate::resources::ResourceLimits::default()
2214                    .with_memory("512M")
2215                    .unwrap()
2216                    .with_cpu_cores(2.0)
2217                    .unwrap(),
2218            );
2219
2220        let err = cfg.validate_production_mode().unwrap_err();
2221        assert!(err.to_string().contains("host network"));
2222    }
2223
2224    #[test]
2225    fn test_production_mode_rejects_gvisor_host_without_gvisor_runtime() {
2226        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2227            .unwrap()
2228            .with_gvisor(false)
2229            .with_service_mode(ServiceMode::Production)
2230            .with_network(NetworkMode::GVisorHost)
2231            .with_allow_host_network(true)
2232            .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2233            .with_verify_rootfs_attestation(true)
2234            .with_limits(
2235                crate::resources::ResourceLimits::default()
2236                    .with_memory("512M")
2237                    .unwrap()
2238                    .with_cpu_cores(2.0)
2239                    .unwrap(),
2240            );
2241
2242        let err = cfg.validate_production_mode().unwrap_err();
2243        assert!(err.to_string().contains("--runtime gvisor"));
2244    }
2245
2246    #[test]
2247    fn test_production_mode_rejects_gvisor_host_without_explicit_opt_in() {
2248        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2249            .unwrap()
2250            .with_gvisor(true)
2251            .with_service_mode(ServiceMode::Production)
2252            .with_network(NetworkMode::GVisorHost)
2253            .with_rootfs_path(std::path::PathBuf::from("/nix/store/fake-rootfs"))
2254            .with_verify_rootfs_attestation(true)
2255            .with_limits(
2256                crate::resources::ResourceLimits::default()
2257                    .with_memory("512M")
2258                    .unwrap()
2259                    .with_cpu_cores(2.0)
2260                    .unwrap(),
2261            );
2262
2263        let err = cfg.validate_production_mode().unwrap_err();
2264        assert!(err.to_string().contains("--allow-host-network"));
2265    }
2266
2267    #[test]
2268    fn test_production_mode_rejects_rootfs_parent_traversal() {
2269        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2270            .unwrap()
2271            .with_service_mode(ServiceMode::Production)
2272            .with_rootfs_path(std::path::PathBuf::from("/nix/store/../../tmp/evil-rootfs"))
2273            .with_verify_rootfs_attestation(true)
2274            .with_limits(
2275                crate::resources::ResourceLimits::default()
2276                    .with_memory("512M")
2277                    .unwrap()
2278                    .with_cpu_cores(2.0)
2279                    .unwrap(),
2280            );
2281
2282        let err = cfg.validate_production_mode().unwrap_err();
2283
2284        assert!(
2285            err.to_string().contains("parent traversal"),
2286            "Production mode must reject raw rootfs traversal before canonicalization"
2287        );
2288    }
2289
2290    #[test]
2291    fn test_production_mode_rejects_out_of_store_rootfs() {
2292        let temp = tempfile::TempDir::new().unwrap();
2293        let rootfs = temp.path().join("rootfs");
2294        std::fs::create_dir(&rootfs).unwrap();
2295        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2296            .unwrap()
2297            .with_service_mode(ServiceMode::Production)
2298            .with_rootfs_path(rootfs)
2299            .with_verify_rootfs_attestation(true)
2300            .with_limits(
2301                crate::resources::ResourceLimits::default()
2302                    .with_memory("512M")
2303                    .unwrap()
2304                    .with_cpu_cores(2.0)
2305                    .unwrap(),
2306            );
2307
2308        let err = cfg.validate_production_mode().unwrap_err();
2309
2310        assert!(
2311            err.to_string().contains("/nix/store"),
2312            "Production mode must reject rootfs paths that resolve outside /nix/store"
2313        );
2314    }
2315
2316    #[test]
2317    fn test_production_mode_requires_rootfs_attestation() {
2318        let rootfs = test_rootfs_path();
2319        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2320            .unwrap()
2321            .with_service_mode(ServiceMode::Production)
2322            .with_rootfs_path(rootfs.clone())
2323            .with_limits(
2324                crate::resources::ResourceLimits::default()
2325                    .with_memory("512M")
2326                    .unwrap()
2327                    .with_cpu_cores(2.0)
2328                    .unwrap(),
2329            );
2330        let err = cfg.validate_production_mode().unwrap_err();
2331        assert!(err.to_string().contains("attestation"));
2332    }
2333
2334    #[test]
2335    fn test_production_mode_rejects_seccomp_trace() {
2336        let rootfs = test_rootfs_path();
2337        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2338            .unwrap()
2339            .with_service_mode(ServiceMode::Production)
2340            .with_rootfs_path(rootfs.clone())
2341            .with_seccomp_mode(SeccompMode::Trace)
2342            .with_limits(
2343                crate::resources::ResourceLimits::default()
2344                    .with_memory("512M")
2345                    .unwrap()
2346                    .with_cpu_cores(2.0)
2347                    .unwrap(),
2348            );
2349        let err = cfg.validate_production_mode().unwrap_err();
2350        assert!(
2351            err.to_string().contains("trace"),
2352            "Production mode must reject seccomp trace mode"
2353        );
2354    }
2355
2356    #[test]
2357    fn test_production_mode_rejects_security_critical_seccomp_allow() {
2358        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2359            .unwrap()
2360            .with_service_mode(ServiceMode::Production)
2361            .with_rootfs_path(test_rootfs_path())
2362            .with_verify_rootfs_attestation(true)
2363            .with_seccomp_allow_syscalls(vec!["keyctl".to_string()])
2364            .with_limits(
2365                crate::resources::ResourceLimits::default()
2366                    .with_memory("512M")
2367                    .unwrap()
2368                    .with_cpu_cores(2.0)
2369                    .unwrap(),
2370            );
2371
2372        let err = cfg.validate_production_mode().unwrap_err();
2373        assert!(err.to_string().contains("seccomp-allow"));
2374        assert!(err.to_string().contains("keyctl"));
2375    }
2376
2377    #[test]
2378    fn test_strict_agent_mode_valid_without_production_rootfs() {
2379        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2380            .unwrap()
2381            .with_service_mode(ServiceMode::StrictAgent);
2382
2383        assert!(cfg.validate_strict_agent_mode().is_ok());
2384        assert!(cfg.rootfs_path.is_none());
2385        assert!(!cfg.verify_rootfs_attestation);
2386    }
2387
2388    #[test]
2389    fn test_strict_agent_mode_rejects_degraded_security() {
2390        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2391            .unwrap()
2392            .with_service_mode(ServiceMode::StrictAgent)
2393            .with_allow_degraded_security(true);
2394
2395        let err = cfg.validate_strict_agent_mode().unwrap_err();
2396        assert!(err.to_string().contains("degraded"));
2397    }
2398
2399    #[test]
2400    fn test_strict_agent_mode_rejects_chroot_fallback() {
2401        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2402            .unwrap()
2403            .with_service_mode(ServiceMode::StrictAgent)
2404            .with_allow_chroot_fallback(true);
2405
2406        let err = cfg.validate_strict_agent_mode().unwrap_err();
2407        assert!(err.to_string().contains("chroot"));
2408    }
2409
2410    #[test]
2411    fn test_strict_agent_mode_rejects_seccomp_trace() {
2412        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2413            .unwrap()
2414            .with_service_mode(ServiceMode::StrictAgent)
2415            .with_seccomp_mode(SeccompMode::Trace);
2416
2417        let err = cfg.validate_strict_agent_mode().unwrap_err();
2418        assert!(err.to_string().contains("trace"));
2419    }
2420
2421    #[test]
2422    fn test_strict_agent_mode_rejects_native_host_network() {
2423        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2424            .unwrap()
2425            .with_gvisor(false)
2426            .with_service_mode(ServiceMode::StrictAgent)
2427            .with_network(NetworkMode::Host)
2428            .with_allow_host_network(true);
2429
2430        let err = cfg.validate_strict_agent_mode().unwrap_err();
2431        assert!(err.to_string().contains("host network"));
2432    }
2433
2434    #[test]
2435    fn test_strict_agent_mode_requires_some_cgroup_control() {
2436        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2437            .unwrap()
2438            .with_service_mode(ServiceMode::StrictAgent)
2439            .with_limits(crate::resources::ResourceLimits::unlimited());
2440
2441        let err = cfg.validate_strict_agent_mode().unwrap_err();
2442        assert!(err.to_string().contains("cgroup"));
2443    }
2444
2445    #[test]
2446    fn test_strict_agent_mode_allows_explicit_gvisor_host_network() {
2447        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2448            .unwrap()
2449            .with_gvisor(true)
2450            .with_service_mode(ServiceMode::StrictAgent)
2451            .with_network(NetworkMode::GVisorHost)
2452            .with_allow_host_network(true);
2453
2454        assert!(cfg.validate_strict_agent_mode().is_ok());
2455    }
2456
2457    #[test]
2458    fn test_production_mode_requires_cpu_limit() {
2459        let rootfs = test_rootfs_path();
2460        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2461            .unwrap()
2462            .with_service_mode(ServiceMode::Production)
2463            .with_rootfs_path(rootfs.clone())
2464            .with_limits(
2465                crate::resources::ResourceLimits::default()
2466                    .with_memory("512M")
2467                    .unwrap(),
2468            );
2469        let err = cfg.validate_production_mode().unwrap_err();
2470        assert!(err.to_string().contains("--cpus"));
2471    }
2472
2473    #[test]
2474    fn test_config_security_builders_override_defaults() {
2475        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2476            .unwrap()
2477            .with_allow_degraded_security(true)
2478            .with_allow_chroot_fallback(true)
2479            .with_allow_host_network(true)
2480            .with_proc_readonly(false)
2481            .with_network(NetworkMode::Host);
2482
2483        assert!(cfg.allow_degraded_security);
2484        assert!(cfg.allow_chroot_fallback);
2485        assert!(cfg.allow_host_network);
2486        assert!(!cfg.proc_readonly);
2487        assert!(matches!(cfg.network, NetworkMode::Host));
2488    }
2489
2490    #[test]
2491    fn test_hardening_builders_override_defaults() {
2492        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2493            .unwrap()
2494            .with_required_kernel_lockdown(KernelLockdownMode::Confidentiality)
2495            .with_verify_context_integrity(true)
2496            .with_verify_rootfs_attestation(true)
2497            .with_seccomp_log_denied(true)
2498            .with_gvisor_platform(GVisorPlatform::Kvm);
2499
2500        assert_eq!(
2501            cfg.required_kernel_lockdown,
2502            Some(KernelLockdownMode::Confidentiality)
2503        );
2504        assert!(cfg.verify_context_integrity);
2505        assert!(cfg.verify_rootfs_attestation);
2506        assert!(cfg.seccomp_log_denied);
2507        assert_eq!(cfg.gvisor_platform, GVisorPlatform::Kvm);
2508    }
2509
2510    #[test]
2511    fn test_seccomp_trace_requires_log_path() {
2512        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2513            .unwrap()
2514            .with_gvisor(false)
2515            .with_seccomp_mode(SeccompMode::Trace);
2516
2517        let err = cfg.validate_runtime_support().unwrap_err();
2518        assert!(err.to_string().contains("seccomp-log"));
2519    }
2520
2521    #[test]
2522    fn test_gvisor_allows_custom_seccomp_profile_but_rejects_native_policy_files() {
2523        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2524            .unwrap()
2525            .with_seccomp_profile(PathBuf::from("/tmp/seccomp.json"))
2526            .with_caps_policy(PathBuf::from("/tmp/caps.toml"));
2527
2528        let err = cfg.validate_runtime_support().unwrap_err();
2529        assert!(err.to_string().contains("capability policy"));
2530    }
2531
2532    #[test]
2533    fn test_gvisor_accepts_custom_seccomp_profile() {
2534        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2535            .unwrap()
2536            .with_seccomp_profile(PathBuf::from("/tmp/seccomp.json"));
2537
2538        cfg.validate_runtime_support().unwrap();
2539    }
2540
2541    #[test]
2542    fn test_gvisor_rejects_landlock_policy_file() {
2543        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2544            .unwrap()
2545            .with_landlock_policy(PathBuf::from("/tmp/landlock.toml"));
2546
2547        let err = cfg.validate_runtime_support().unwrap_err();
2548        assert!(err.to_string().contains("Landlock"));
2549    }
2550
2551    #[test]
2552    fn test_gvisor_rejects_trace_mode_even_with_log_path() {
2553        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2554            .unwrap()
2555            .with_seccomp_mode(SeccompMode::Trace)
2556            .with_seccomp_trace_log(PathBuf::from("/tmp/trace.ndjson"));
2557
2558        let err = cfg.validate_runtime_support().unwrap_err();
2559        assert!(err.to_string().contains("gVisor runtime"));
2560    }
2561
2562    #[test]
2563    fn test_gvisor_rejects_seccomp_allow_without_custom_profile_projection() {
2564        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2565            .unwrap()
2566            .with_seccomp_allow_syscalls(vec!["io_uring_setup".to_string()]);
2567
2568        let err = cfg.validate_runtime_support().unwrap_err();
2569        assert!(err.to_string().contains("seccomp-allow"));
2570    }
2571
2572    #[test]
2573    fn test_secret_dest_must_be_absolute() {
2574        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2575            .unwrap()
2576            .with_secret(crate::container::SecretMount {
2577                source: PathBuf::from("/run/secrets/api-key"),
2578                dest: PathBuf::from("secrets/api-key"),
2579                mode: 0o400,
2580            });
2581
2582        let err = cfg.validate_runtime_support().unwrap_err();
2583        assert!(err.to_string().contains("absolute"));
2584    }
2585
2586    #[test]
2587    fn test_secret_dest_rejects_parent_traversal() {
2588        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2589            .unwrap()
2590            .with_secret(crate::container::SecretMount {
2591                source: PathBuf::from("/run/secrets/api-key"),
2592                dest: PathBuf::from("/../../etc/passwd"),
2593                mode: 0o400,
2594            });
2595
2596        let err = cfg.validate_runtime_support().unwrap_err();
2597        assert!(err.to_string().contains("parent traversal"));
2598    }
2599
2600    #[test]
2601    fn test_bind_volume_source_must_exist() {
2602        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2603            .unwrap()
2604            .with_volume(VolumeMount {
2605                source: VolumeSource::Bind {
2606                    source: PathBuf::from("/tmp/definitely-missing-nucleus-volume"),
2607                },
2608                dest: PathBuf::from("/var/lib/app"),
2609                read_only: false,
2610            });
2611
2612        let err = cfg.validate_runtime_support().unwrap_err();
2613        assert!(err.to_string().contains("Volume source does not exist"));
2614    }
2615
2616    #[test]
2617    fn test_bind_volume_source_rejects_sensitive_host_subtrees() {
2618        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2619            .unwrap()
2620            .with_volume(VolumeMount {
2621                source: VolumeSource::Bind {
2622                    source: PathBuf::from("/proc/sys"),
2623                },
2624                dest: PathBuf::from("/host-proc"),
2625                read_only: true,
2626            });
2627
2628        let err = cfg.validate_runtime_support().unwrap_err();
2629        assert!(err.to_string().contains("sensitive host path"));
2630    }
2631
2632    #[test]
2633    fn test_bind_volume_dest_must_be_absolute() {
2634        let dir = tempfile::TempDir::new().unwrap();
2635        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2636            .unwrap()
2637            .with_volume(VolumeMount {
2638                source: VolumeSource::Bind {
2639                    source: dir.path().to_path_buf(),
2640                },
2641                dest: PathBuf::from("var/lib/app"),
2642                read_only: false,
2643            });
2644
2645        let err = cfg.validate_runtime_support().unwrap_err();
2646        assert!(err.to_string().contains("absolute"));
2647    }
2648
2649    #[test]
2650    fn test_bind_volume_dest_rejects_reserved_container_paths() {
2651        let dir = tempfile::TempDir::new().unwrap();
2652        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2653            .unwrap()
2654            .with_volume(VolumeMount {
2655                source: VolumeSource::Bind {
2656                    source: dir.path().to_path_buf(),
2657                },
2658                dest: PathBuf::from("/etc"),
2659                read_only: false,
2660            });
2661
2662        let err = cfg.validate_runtime_support().unwrap_err();
2663        assert!(err.to_string().contains("reserved"));
2664    }
2665
2666    #[test]
2667    fn test_default_workspace_and_workdir_contract() {
2668        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
2669
2670        assert_eq!(cfg.workspace.container_path, PathBuf::from("/workspace"));
2671        assert_eq!(cfg.workdir, PathBuf::from("/workspace"));
2672        assert_eq!(cfg.home, PathBuf::from(DEFAULT_HOME_PATH));
2673        assert_eq!(cfg.workspace.mode, WorkspaceMode::BindRw);
2674        assert!(!cfg.workspace.allow_execute);
2675    }
2676
2677    #[test]
2678    fn test_home_must_not_overlap_workspace() {
2679        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2680            .unwrap()
2681            .with_home(PathBuf::from("/workspace/.home"));
2682
2683        let err = cfg.validate_runtime_support().unwrap_err();
2684        assert!(err.to_string().contains("must not overlap /workspace"));
2685    }
2686
2687    #[test]
2688    fn test_workspace_source_validates_existing_directory() {
2689        let dir = tempfile::TempDir::new().unwrap();
2690        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2691            .unwrap()
2692            .with_workspace(WorkspaceConfig::new().with_host_path(dir.path().to_path_buf()));
2693
2694        cfg.validate_runtime_support().unwrap();
2695    }
2696
2697    #[test]
2698    fn test_copy_in_out_requires_workspace_source() {
2699        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2700            .unwrap()
2701            .with_workspace(WorkspaceConfig::new().with_mode(WorkspaceMode::CopyInOut));
2702
2703        let err = cfg.validate_runtime_support().unwrap_err();
2704        assert!(err.to_string().contains("requires --workspace"));
2705    }
2706
2707    #[test]
2708    fn test_volume_destination_cannot_replace_workspace() {
2709        let dir = tempfile::TempDir::new().unwrap();
2710        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2711            .unwrap()
2712            .with_volume(VolumeMount {
2713                source: VolumeSource::Bind {
2714                    source: dir.path().to_path_buf(),
2715                },
2716                dest: PathBuf::from("/workspace"),
2717                read_only: false,
2718            });
2719
2720        let err = cfg.validate_runtime_support().unwrap_err();
2721        assert!(err.to_string().contains("conflicts with --workspace"));
2722    }
2723
2724    #[test]
2725    fn test_volume_destination_cannot_replace_home() {
2726        let dir = tempfile::TempDir::new().unwrap();
2727        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2728            .unwrap()
2729            .with_volume(VolumeMount {
2730                source: VolumeSource::Bind {
2731                    source: dir.path().to_path_buf(),
2732                },
2733                dest: PathBuf::from(DEFAULT_HOME_PATH),
2734                read_only: false,
2735            });
2736
2737        let err = cfg.validate_runtime_support().unwrap_err();
2738        assert!(err.to_string().contains("conflicts with --home"));
2739    }
2740
2741    #[test]
2742    fn test_production_rejects_writable_executable_workspace() {
2743        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2744            .unwrap()
2745            .with_service_mode(ServiceMode::Production)
2746            .with_workspace(
2747                WorkspaceConfig::new()
2748                    .with_mode(WorkspaceMode::BindRw)
2749                    .with_allow_execute(true),
2750            );
2751
2752        let err = cfg.validate_production_mode().unwrap_err();
2753        assert!(err.to_string().contains("writable executable workspaces"));
2754    }
2755
2756    #[test]
2757    fn test_tmpfs_volume_rejects_parent_traversal() {
2758        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2759            .unwrap()
2760            .with_volume(VolumeMount {
2761                source: VolumeSource::Tmpfs {
2762                    size: Some("64M".to_string()),
2763                },
2764                dest: PathBuf::from("/../../var/lib/app"),
2765                read_only: false,
2766            });
2767
2768        let err = cfg.validate_runtime_support().unwrap_err();
2769        assert!(err.to_string().contains("parent traversal"));
2770    }
2771
2772    #[test]
2773    fn test_gvisor_rejects_bind_mount_context_integrity_verification() {
2774        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2775            .unwrap()
2776            .with_context(PathBuf::from("/tmp/context"))
2777            .with_context_mode(crate::filesystem::ContextMode::BindMount)
2778            .with_verify_context_integrity(true);
2779
2780        let err = cfg.validate_runtime_support().unwrap_err();
2781        assert!(err.to_string().contains("context integrity"));
2782    }
2783
2784    #[test]
2785    fn test_gvisor_rejects_exec_health_checks() {
2786        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2787            .unwrap()
2788            .with_health_check(HealthCheck {
2789                command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
2790                interval: Duration::from_secs(30),
2791                retries: 3,
2792                start_period: Duration::from_secs(1),
2793                timeout: Duration::from_secs(5),
2794            });
2795
2796        let err = cfg.validate_runtime_support().unwrap_err();
2797        assert!(err.to_string().contains("health checks"));
2798    }
2799
2800    #[test]
2801    fn test_gvisor_rejects_exec_readiness_probes() {
2802        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2803            .unwrap()
2804            .with_readiness_probe(ReadinessProbe::Exec {
2805                command: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()],
2806            });
2807
2808        let err = cfg.validate_runtime_support().unwrap_err();
2809        assert!(err.to_string().contains("readiness"));
2810    }
2811
2812    #[test]
2813    fn test_gvisor_allows_copy_mode_context_integrity_verification() {
2814        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2815            .unwrap()
2816            .with_context(PathBuf::from("/tmp/context"))
2817            .with_context_mode(crate::filesystem::ContextMode::Copy)
2818            .with_verify_context_integrity(true);
2819
2820        assert!(cfg.validate_runtime_support().is_ok());
2821    }
2822
2823    #[test]
2824    fn test_user_namespace_rejects_unmapped_process_identity() {
2825        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2826            .unwrap()
2827            .with_rootless()
2828            .with_process_identity(ProcessIdentity {
2829                uid: 1000,
2830                gid: 1000,
2831                additional_gids: Vec::new(),
2832            });
2833
2834        let err = cfg.validate_runtime_support().unwrap_err();
2835        assert!(err.to_string().contains("not mapped"));
2836    }
2837
2838    #[test]
2839    fn test_user_namespace_rejects_supplementary_groups() {
2840        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2841            .unwrap()
2842            .with_rootless()
2843            .with_process_identity(ProcessIdentity {
2844                uid: 0,
2845                gid: 0,
2846                additional_gids: vec![1],
2847            });
2848
2849        let err = cfg.validate_runtime_support().unwrap_err();
2850        assert!(err.to_string().contains("Supplementary groups"));
2851    }
2852
2853    #[test]
2854    fn test_native_runtime_disables_gvisor() {
2855        // --runtime native selects the native runtime without changing trust policy.
2856        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2857            .unwrap()
2858            .apply_runtime_selection(RuntimeSelection::Native, false)
2859            .unwrap();
2860        assert!(!cfg.use_gvisor, "native runtime must disable gVisor");
2861        assert_eq!(
2862            cfg.trust_level,
2863            TrustLevel::Untrusted,
2864            "native runtime must preserve the default Untrusted trust level"
2865        );
2866    }
2867
2868    #[test]
2869    fn test_native_runtime_preserves_explicit_trusted_policy() {
2870        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2871            .unwrap()
2872            .with_trust_level(TrustLevel::Trusted)
2873            .apply_runtime_selection(RuntimeSelection::Native, false)
2874            .unwrap();
2875
2876        assert!(!cfg.use_gvisor, "native runtime must disable gVisor");
2877        assert_eq!(
2878            cfg.trust_level,
2879            TrustLevel::Trusted,
2880            "native runtime must preserve explicit Trusted trust level"
2881        );
2882    }
2883
2884    #[test]
2885    fn test_default_config_has_gvisor_enabled() {
2886        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()]).unwrap();
2887        assert!(cfg.use_gvisor, "default must have gVisor enabled");
2888        assert_eq!(
2889            cfg.trust_level,
2890            TrustLevel::Untrusted,
2891            "default must be Untrusted"
2892        );
2893    }
2894
2895    #[test]
2896    fn test_console_socket_implies_terminal_mode() {
2897        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2898            .unwrap()
2899            .with_console_socket(PathBuf::from("/tmp/console.sock"));
2900
2901        assert!(cfg.terminal);
2902        assert_eq!(cfg.console_socket, Some(PathBuf::from("/tmp/console.sock")));
2903    }
2904
2905    #[test]
2906    fn test_terminal_size_can_be_configured() {
2907        let size = ConsoleSize {
2908            width: 132,
2909            height: 43,
2910        };
2911        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2912            .unwrap()
2913            .with_terminal(size);
2914
2915        assert!(cfg.terminal);
2916        assert_eq!(cfg.console_size, size);
2917    }
2918
2919    #[test]
2920    fn test_generate_container_id_returns_result() {
2921        // BUG-07: generate_container_id must return Result, not panic.
2922        // Verify by calling it and checking the Ok value is valid hex.
2923        let id: crate::error::Result<String> = generate_container_id();
2924        let id = id.expect("generate_container_id must return Ok, not panic");
2925        assert_eq!(id.len(), 32, "container ID must be 32 hex chars");
2926        assert!(
2927            id.chars().all(|c| c.is_ascii_hexdigit()),
2928            "container ID must be valid hex: {}",
2929            id
2930        );
2931    }
2932
2933    #[test]
2934    fn gpu_defaults_match_nvidia_toolkit() {
2935        let cfg = GpuPassthroughConfig::default();
2936        assert_eq!(cfg.vendor, GpuVendor::Auto);
2937        assert!(cfg.devices.is_empty());
2938        assert_eq!(cfg.driver_capabilities, DEFAULT_GPU_DRIVER_CAPABILITIES);
2939        assert_eq!(cfg.visible_devices, DEFAULT_GPU_VISIBLE_DEVICES);
2940        assert!(cfg.bind_driver_libraries);
2941    }
2942
2943    #[test]
2944    fn production_mode_rejects_gpu_passthrough() {
2945        let mut cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2946            .unwrap()
2947            .with_gpu(GpuPassthroughConfig::default());
2948        cfg.service_mode = ServiceMode::Production;
2949        cfg.rootfs_path = Some(std::path::PathBuf::from("/nix/some/rootfs"));
2950        let err = cfg.validate_production_mode().unwrap_err();
2951        let msg = format!("{}", err);
2952        assert!(
2953            msg.contains("Production mode forbids --gpu"),
2954            "unexpected error: {}",
2955            msg
2956        );
2957    }
2958
2959    #[test]
2960    fn agent_mode_allows_gpu_passthrough() {
2961        let cfg = ContainerConfig::try_new(None, vec!["/bin/sh".to_string()])
2962            .unwrap()
2963            .with_gpu(GpuPassthroughConfig::default());
2964        // Agent is the default service mode and must permit GPU.
2965        assert_eq!(cfg.service_mode, ServiceMode::Agent);
2966        assert!(cfg.gpu.is_some());
2967        cfg.validate_production_mode().expect("agent mode permits gpu");
2968    }
2969
2970    #[test]
2971    fn gpu_environment_includes_nvidia_vars() {
2972        let gpu = GpuPassthroughConfig {
2973            vendor: GpuVendor::Nvidia,
2974            ..GpuPassthroughConfig::default()
2975        };
2976        let env = gpu_environment(&gpu);
2977        let keys: Vec<&str> = env.iter().map(|(k, _)| *k).collect();
2978        assert!(keys.contains(&"NVIDIA_VISIBLE_DEVICES"));
2979        assert!(keys.contains(&"NVIDIA_DRIVER_CAPABILITIES"));
2980        assert!(keys.contains(&"__EGL_VENDOR_LIBRARY_FILENAMES"));
2981    }
2982
2983    #[test]
2984    fn gpu_environment_omits_egl_when_not_nvidia() {
2985        let gpu = GpuPassthroughConfig {
2986            vendor: GpuVendor::Amd,
2987            ..GpuPassthroughConfig::default()
2988        };
2989        let env = gpu_environment(&gpu);
2990        assert!(!env.iter().any(|(k, _)| *k == "__EGL_VENDOR_LIBRARY_FILENAMES"));
2991    }
2992}