Skip to main content

nucleus/security/
gvisor.rs

1use super::landlock::LandlockManager;
2use crate::error::{NucleusError, Result};
3use crate::oci::OciBundle;
4use nix::unistd::{Gid, Uid};
5use sha2::{Digest, Sha256};
6use std::ffi::CString;
7use std::fs::{self, DirBuilder, OpenOptions};
8use std::io;
9use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
10use std::path::{Component, Path, PathBuf};
11use std::process::Command;
12use tracing::{debug, info, warn};
13
14const NIX_STORE_EXEC_ROOT: &str = "/nix/store";
15const NIXOS_ROOTLESS_USERNS_WRAPPER_DIR: &str = "/run/wrappers/bin";
16const ROOTLESS_USERNS_HELPERS: &[&str] = &["newuidmap", "newgidmap"];
17const ROOTLESS_USERNS_HELPER_EXEC_ROOTS_ENV: &str = "NUCLEUS_ROOTLESS_USERNS_HELPER_EXEC_ROOTS";
18const RUNSC_DEBUG_DIR_ENV: &str = "NUCLEUS_RUNSC_DEBUG_DIR";
19const RUNSC_REEXEC_VIA_PROC_SELF_EXE_ENV: &str = "NUCLEUS_RUNSC_REEXEC_VIA_PROC_SELF_EXE";
20const RUNSC_SUPERVISOR_PATH: &str =
21    "/run/wrappers/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
22
23/// Network mode for gVisor runtime.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum GVisorNetworkMode {
26    /// No networking (fully isolated). Default for agent workloads.
27    None,
28    /// gVisor user-space network stack. Suitable for networked production services
29    /// that need gVisor isolation with network access.
30    Sandbox,
31    /// Share host network namespace. Use with caution.
32    Host,
33}
34
35/// Platform backend for gVisor's Sentry.
36#[derive(
37    Debug,
38    Clone,
39    Copy,
40    PartialEq,
41    Eq,
42    Default,
43    clap::ValueEnum,
44    serde::Serialize,
45    serde::Deserialize,
46)]
47#[serde(rename_all = "kebab-case")]
48pub enum GVisorPlatform {
49    /// systrap backend, the current default and most broadly compatible option.
50    #[default]
51    Systrap,
52    /// KVM-backed sandboxing for the Sentry itself.
53    Kvm,
54    /// ptrace backend for maximal compatibility where systrap/KVM are unavailable.
55    Ptrace,
56}
57
58impl GVisorPlatform {
59    pub fn as_flag(self) -> &'static str {
60        match self {
61            Self::Systrap => "systrap",
62            Self::Kvm => "kvm",
63            Self::Ptrace => "ptrace",
64        }
65    }
66}
67
68/// Options for running an OCI bundle with gVisor.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct GVisorOciRunOptions {
71    /// gVisor networking mode passed to runsc.
72    pub network_mode: GVisorNetworkMode,
73    /// Skip runsc's cgroup setup when Nucleus manages cgroups externally.
74    pub ignore_cgroups: bool,
75    /// Tell runsc to use its rootless launcher and helper handoff semantics.
76    ///
77    /// This is independent from `require_supervisor_exec_policy`: production
78    /// callers decide separately whether the host-side execute allowlist is
79    /// compatible with the selected handoff path.
80    pub runsc_rootless: bool,
81    /// Stage runsc into the per-container runtime directory before exec.
82    ///
83    /// This is for non-store runsc binaries and host-side supervisor execute
84    /// policy isolation. Rootless bridge mode should normally leave runsc on
85    /// its immutable package path so gVisor helpers can re-exec a globally
86    /// traversable binary after they adjust credentials.
87    pub stage_runsc_binary: bool,
88    /// Fail if the host-side supervisor execute allowlist cannot be installed.
89    pub require_supervisor_exec_policy: bool,
90    /// gVisor Sentry platform backend.
91    pub platform: GVisorPlatform,
92    /// Optional OCI console socket that receives the terminal PTY master.
93    pub console_socket: Option<PathBuf>,
94}
95
96impl Default for GVisorOciRunOptions {
97    fn default() -> Self {
98        Self {
99            network_mode: GVisorNetworkMode::None,
100            ignore_cgroups: false,
101            runsc_rootless: false,
102            stage_runsc_binary: false,
103            require_supervisor_exec_policy: false,
104            platform: GVisorPlatform::default(),
105            console_socket: None,
106        }
107    }
108}
109
110impl GVisorOciRunOptions {
111    fn network_flag(&self) -> &'static str {
112        match self.network_mode {
113            GVisorNetworkMode::None => "none",
114            GVisorNetworkMode::Sandbox => "sandbox",
115            GVisorNetworkMode::Host => "host",
116        }
117    }
118}
119
120/// GVisor runtime manager
121///
122/// Implements the gVisor state machine from
123/// NucleusSecurity_GVisor_GVisorRuntime.tla
124pub struct GVisorRuntime {
125    runsc_path: String,
126}
127
128impl GVisorRuntime {
129    /// Create a new GVisor runtime manager
130    ///
131    /// This checks for runsc binary availability
132    pub fn new() -> Result<Self> {
133        let runsc_path = Self::find_runsc()?;
134        info!("Found runsc at: {}", runsc_path);
135        Ok(Self { runsc_path })
136    }
137
138    /// Create a GVisor runtime with a pre-resolved runsc path.
139    ///
140    /// Use this when the path was resolved before privilege changes
141    /// (e.g. before entering a user namespace where UID 0 would block
142    /// PATH-based lookup).
143    pub fn with_path(runsc_path: String) -> Self {
144        Self { runsc_path }
145    }
146
147    /// Resolve the runsc path without constructing a full runtime.
148    /// Call this before fork/unshare so the path is resolved while
149    /// still unprivileged.
150    pub fn resolve_path() -> Result<String> {
151        Self::find_runsc()
152    }
153
154    /// Find the runsc binary
155    fn find_runsc() -> Result<String> {
156        // Try common locations
157        let paths = vec![
158            "/usr/local/bin/runsc",
159            "/usr/bin/runsc",
160            "/opt/gvisor/runsc",
161        ];
162
163        for path in &paths {
164            if let Some(validated) = Self::validate_runsc_path(Path::new(path))? {
165                return Ok(validated);
166            }
167        }
168
169        // For privileged execution, do not resolve runtime binaries via PATH.
170        // This avoids environment-based binary hijacking when running as root.
171        if Uid::effective().is_root() {
172            return Err(NucleusError::GVisorError(
173                "runsc binary not found in trusted system paths".to_string(),
174            ));
175        }
176
177        // Try to find in PATH without invoking a shell command.
178        if let Some(path_var) = std::env::var_os("PATH") {
179            for dir in std::env::split_paths(&path_var) {
180                let candidate = dir.join("runsc");
181                if let Some(validated) = Self::validate_runsc_path(&candidate)? {
182                    return Ok(validated);
183                }
184            }
185        }
186
187        Err(NucleusError::GVisorError(
188            "runsc binary not found. Please install gVisor.".to_string(),
189        ))
190    }
191
192    fn validate_runsc_path(path: &Path) -> Result<Option<String>> {
193        if !path.exists() {
194            return Ok(None);
195        }
196        if !path.is_file() {
197            return Ok(None);
198        }
199
200        let canonical = std::fs::canonicalize(path).map_err(|e| {
201            NucleusError::GVisorError(format!(
202                "Failed to canonicalize runsc path {:?}: {}",
203                path, e
204            ))
205        })?;
206
207        // If the candidate is a shell wrapper script (common on NixOS where
208        // nix wraps binaries to inject PATH), look for the real ELF binary
209        // next to it. The gVisor helper re-exec path must stay on the real
210        // binary, not a bash wrapper.
211        let resolved = Self::unwrap_nix_wrapper(&canonical).unwrap_or_else(|| canonical.clone());
212
213        let metadata = std::fs::metadata(&resolved).map_err(|e| {
214            NucleusError::GVisorError(format!("Failed to stat runsc path {:?}: {}", resolved, e))
215        })?;
216
217        let mode = metadata.permissions().mode();
218        if mode & 0o022 != 0 {
219            return Err(NucleusError::GVisorError(format!(
220                "Refusing insecure runsc binary permissions at {:?} (mode {:o})",
221                resolved, mode
222            )));
223        }
224        if mode & 0o111 == 0 {
225            return Ok(None);
226        }
227
228        // Reject binaries owned by other non-root users – a malicious user
229        // could place a trojan runsc earlier in PATH.
230        use std::os::unix::fs::MetadataExt;
231        let owner = metadata.uid();
232        let current_uid = nix::unistd::Uid::effective().as_raw();
233        if !Self::is_trusted_runsc_owner(&resolved, owner, current_uid) {
234            return Err(NucleusError::GVisorError(format!(
235                "Refusing runsc binary at {:?} owned by uid {} (expected root, current user {}, or immutable /nix/store artifact)",
236                resolved, owner, current_uid
237            )));
238        }
239
240        Ok(Some(resolved.to_string_lossy().to_string()))
241    }
242
243    fn is_trusted_runsc_owner(path: &Path, owner: u32, current_uid: u32) -> bool {
244        if owner == 0 || owner == current_uid {
245            return true;
246        }
247
248        // Nix store artifacts are immutable content-addressed paths and are
249        // commonly owned by `nobody` rather than root/current user.
250        // Extra hardening: verify the binary is not writable by *anyone* and
251        // the parent directory is also not writable, to guard against a
252        // compromised or mutable store.
253        if path.starts_with("/nix/store") {
254            if let Ok(meta) = std::fs::metadata(path) {
255                let mode = meta.permissions().mode();
256                // Reject if owner-writable (group/other already checked by caller)
257                if mode & 0o200 != 0 {
258                    return false;
259                }
260            } else {
261                return false;
262            }
263            // Verify the immediate parent directory is not writable
264            if let Some(parent) = path.parent() {
265                if let Ok(parent_meta) = std::fs::metadata(parent) {
266                    let parent_mode = parent_meta.permissions().mode();
267                    if parent_mode & 0o222 != 0 {
268                        return false;
269                    }
270                } else {
271                    return false;
272                }
273            }
274            return true;
275        }
276
277        false
278    }
279
280    /// If `path` is a Nix wrapper script, extract the real binary path.
281    ///
282    /// Nix wrapper scripts end with a line like:
283    ///   exec -a "$0" "/nix/store/…/.runsc-wrapped"  "$@"
284    /// We parse that to find the actual ELF binary.
285    fn unwrap_nix_wrapper(path: &Path) -> Option<std::path::PathBuf> {
286        let content = std::fs::read_to_string(path).ok()?;
287        // Only process short scripts (wrapper scripts are small)
288        if content.len() > 4096 || !content.starts_with("#!") {
289            return None;
290        }
291        // Look for the exec line that references the wrapped binary
292        for line in content.lines().rev() {
293            let trimmed = line.trim();
294            if trimmed.starts_with("exec ") {
295                // Parse: exec -a "$0" "/nix/store/.../bin/.runsc-wrapped"  "$@"
296                // or:    exec "/nix/store/.../bin/.runsc-wrapped"  "$@"
297                for token in trimmed.split_whitespace() {
298                    let unquoted = token.trim_matches('"');
299                    if unquoted.starts_with('/') && unquoted.contains("runsc") {
300                        let candidate = std::path::PathBuf::from(unquoted);
301                        if candidate.exists() && candidate.is_file() {
302                            debug!("Resolved Nix wrapper {:?} → {:?}", path, candidate);
303                            return Some(candidate);
304                        }
305                    }
306                }
307            }
308        }
309        None
310    }
311
312    /// Execute using gVisor with an OCI bundle
313    ///
314    /// This is the OCI-compliant way to run containers with gVisor using
315    /// default options: no networking, systrap platform, no rootless flag,
316    /// and no internal cgroup setup override.
317    pub fn exec_with_oci_bundle(&self, container_id: &str, bundle: &OciBundle) -> Result<()> {
318        self.exec_with_oci_bundle_options(container_id, bundle, GVisorOciRunOptions::default())
319    }
320
321    /// Execute using gVisor with an OCI bundle and explicit run options.
322    ///
323    /// `ignore_cgroups` skips runsc's internal cgroup configuration because
324    /// Nucleus already manages cgroups externally and unprivileged callers
325    /// cannot configure them directly. `runsc_rootless` tells runsc that its
326    /// supervisor is not running with host-root privileges, so helpers must
327    /// keep caller privileges during handoff. `stage_runsc_binary` forces a
328    /// private runsc copy for non-store binaries or host-side supervisor
329    /// execute policy isolation.
330    /// `require_supervisor_exec_policy` fail-closes if Nucleus cannot install
331    /// the host-side execute allowlist before handing control to runsc.
332    pub fn exec_with_oci_bundle_options(
333        &self,
334        container_id: &str,
335        bundle: &OciBundle,
336        options: GVisorOciRunOptions,
337    ) -> Result<()> {
338        info!(
339            "Executing with gVisor using OCI bundle at {:?} (network: {:?}, platform: {:?})",
340            bundle.bundle_path(),
341            options.network_mode,
342            options.platform,
343        );
344
345        // Create a per-container root directory for runsc state. Do not derive
346        // this from the OCI bundle parent: --bundle may be operator-provided,
347        // shared, or attacker-writable, while runsc state includes a staged
348        // executable used by the supervisor process.
349        let runsc_root = Self::secure_runsc_root(container_id)?;
350
351        let runsc_runtime_dir = runsc_root.join("runtime");
352        Self::ensure_secure_runsc_dir(&runsc_runtime_dir, "runsc runtime directory")?;
353
354        let (program_path, mut exec_allow_roots) =
355            self.prepare_supervisor_runsc_program(&runsc_root, options.stage_runsc_binary)?;
356        if options.runsc_rootless {
357            exec_allow_roots.extend(Self::rootless_userns_helper_exec_roots());
358        }
359
360        // Build runsc command with OCI bundle.
361        // Global flags (--root, --network, --platform) must come BEFORE the subcommand.
362        // runsc --root <dir> --network <mode> --platform <plat> run --bundle <path> <id>
363        let debug_dir = Self::runsc_debug_dir()?;
364        let mut args = self.build_oci_run_args(
365            container_id,
366            bundle,
367            &runsc_root,
368            &options,
369            debug_dir.as_deref(),
370        );
371        args[0] = program_path.to_string_lossy().to_string();
372
373        debug!("runsc OCI args: {:?}", args);
374
375        // Convert to CStrings for exec
376        let program = CString::new(program_path.to_string_lossy().as_ref())
377            .map_err(|e| NucleusError::GVisorError(format!("Invalid runsc path: {}", e)))?;
378
379        let c_args: Result<Vec<CString>> = args
380            .iter()
381            .map(|arg| {
382                CString::new(arg.as_str())
383                    .map_err(|e| NucleusError::GVisorError(format!("Invalid argument: {}", e)))
384            })
385            .collect();
386        let c_args = c_args?;
387
388        // Rootless runsc helper processes may re-exec after switching into a
389        // user namespace where the concrete Nix store path is no longer
390        // executable. Use upstream's /proc/self/exe handoff for rootless
391        // launches when no host-side execute allowlist is active. Rootful
392        // production still keeps helpers anchored on the concrete path so the
393        // supervisor Landlock policy can fail closed.
394        let reexec_via_proc_self_exe =
395            options.runsc_rootless && !options.require_supervisor_exec_policy;
396        let c_env = self.exec_environment(&runsc_runtime_dir, reexec_via_proc_self_exe)?;
397
398        // Install an execute-only Landlock allowlist before handing control to
399        // runsc when the caller requested a fail-closed supervisor policy.
400        // Rootless runsc flag selection remains independent from policy
401        // selection; production callers request this policy explicitly except
402        // for the pre-created bridge userns handoff.
403        if options.require_supervisor_exec_policy {
404            self.apply_supervisor_exec_policy(
405                &exec_allow_roots,
406                options.require_supervisor_exec_policy,
407            )?;
408        }
409
410        // execve - this replaces the current process with runsc
411        nix::unistd::execve::<std::ffi::CString, std::ffi::CString>(&program, &c_args, &c_env)?;
412
413        // Should never reach here
414        Ok(())
415    }
416
417    /// Execute using gVisor with an OCI bundle and explicit network mode.
418    ///
419    /// Prefer [`Self::exec_with_oci_bundle_options`] for new call sites.
420    #[allow(clippy::too_many_arguments)]
421    pub fn exec_with_oci_bundle_network(
422        &self,
423        container_id: &str,
424        bundle: &OciBundle,
425        network_mode: GVisorNetworkMode,
426        ignore_cgroups: bool,
427        runsc_rootless: bool,
428        stage_runsc_binary: bool,
429        require_supervisor_exec_policy: bool,
430        platform: GVisorPlatform,
431    ) -> Result<()> {
432        self.exec_with_oci_bundle_options(
433            container_id,
434            bundle,
435            GVisorOciRunOptions {
436                network_mode,
437                ignore_cgroups,
438                runsc_rootless,
439                stage_runsc_binary,
440                require_supervisor_exec_policy,
441                platform,
442                console_socket: None,
443            },
444        )
445    }
446
447    /// Check if gVisor is available on this system
448    pub fn is_available() -> bool {
449        Self::find_runsc().is_ok()
450    }
451
452    /// Get runsc version
453    pub fn version(&self) -> Result<String> {
454        let output = Command::new(&self.runsc_path)
455            .arg("--version")
456            .output()
457            .map_err(|e| NucleusError::GVisorError(format!("Failed to get version: {}", e)))?;
458
459        if !output.status.success() {
460            return Err(NucleusError::GVisorError(
461                "Failed to get runsc version".to_string(),
462            ));
463        }
464
465        let version = String::from_utf8_lossy(&output.stdout).to_string();
466        Ok(version.trim().to_string())
467    }
468
469    fn exec_environment(
470        &self,
471        runtime_dir: &Path,
472        reexec_via_proc_self_exe: bool,
473    ) -> Result<Vec<CString>> {
474        let mut env = Vec::new();
475        let mut push = |key: &str, value: String| -> Result<()> {
476            env.push(
477                CString::new(format!("{}={}", key, value))
478                    .map_err(|e| NucleusError::GVisorError(format!("Invalid {}: {}", key, e)))?,
479            );
480            Ok(())
481        };
482
483        // Use a hardcoded PATH for the runsc supervisor process to prevent
484        // host PATH from leaking into the gVisor environment. NixOS exposes
485        // privileged rootless-userns helpers through /run/wrappers/bin; runsc
486        // needs newuidmap/newgidmap there when it creates an OCI user namespace.
487        push("PATH", RUNSC_SUPERVISOR_PATH.to_string())?;
488        let runtime_dir = runtime_dir.to_string_lossy().to_string();
489        push("TMPDIR", runtime_dir.clone())?;
490        push("XDG_RUNTIME_DIR", runtime_dir)?;
491
492        // Hardcode safe values instead of leaking host identity/paths.
493        // HOME could point to an attacker-controlled directory; USER/LOGNAME
494        // leak host identity information – none of which gVisor needs.
495        push("HOME", "/root".to_string())?;
496        push("USER", "root".to_string())?;
497        push("LOGNAME", "root".to_string())?;
498        if reexec_via_proc_self_exe {
499            push(RUNSC_REEXEC_VIA_PROC_SELF_EXE_ENV, "1".to_string())?;
500        }
501
502        Ok(env)
503    }
504
505    fn runsc_debug_dir() -> Result<Option<PathBuf>> {
506        let Some(raw) = std::env::var_os(RUNSC_DEBUG_DIR_ENV).filter(|value| !value.is_empty())
507        else {
508            return Ok(None);
509        };
510
511        let path = Self::absolute_path(Path::new(&raw), "runsc debug directory")?;
512        Self::ensure_secure_runsc_dir(&path, "runsc debug directory")?;
513        Ok(Some(path))
514    }
515
516    fn prepare_supervisor_runsc_program(
517        &self,
518        runsc_root: &Path,
519        force_private_stage: bool,
520    ) -> Result<(PathBuf, Vec<PathBuf>)> {
521        let canonical = fs::canonicalize(&self.runsc_path).map_err(|e| {
522            NucleusError::GVisorError(format!(
523                "Failed to canonicalize runsc path {:?}: {}",
524                self.runsc_path, e
525            ))
526        })?;
527        let canonical = Self::unwrap_nix_wrapper(&canonical).unwrap_or(canonical);
528
529        Self::ensure_secure_runsc_dir(runsc_root, "runsc root directory")?;
530
531        if !force_private_stage {
532            if let Some(exec_root) = Self::nix_store_runsc_exec_root(&canonical) {
533                // Nix store paths are immutable and already validated as trusted
534                // runsc artifacts. Use the store binary directly so gVisor helper
535                // re-execs do not depend on private runtime-directory ownership.
536                return Ok((canonical, Self::supervisor_exec_allow_roots(exec_root)));
537            }
538        }
539
540        let private_dir = runsc_root.join("exec-allow");
541        Self::ensure_secure_runsc_dir(&private_dir, "private runsc exec directory")?;
542
543        if force_private_stage {
544            // Force a private copy only when the caller explicitly wants a
545            // per-container executable root. Rootless bridge mode should not
546            // set this: gVisor helpers may re-exec after changing credentials,
547            // and the Nix store path remains easier to traverse safely.
548            debug!(
549                source = ?canonical,
550                target_root = ?private_dir,
551                "Staging runsc binary for private supervisor exec root"
552            );
553        }
554
555        // Stage runsc when it is not an immutable Nix store artifact, or when
556        // the caller explicitly requests a private executable root. Keeping the
557        // executable under a per-container allowlist avoids granting execute
558        // over broader host paths when a supervisor policy is installed.
559        let stage_dir = Self::create_unique_runsc_stage_dir(&private_dir)?;
560        let staged = stage_dir.join("runsc");
561        Self::copy_runsc_nofollow(&canonical, &staged)?;
562
563        Ok((staged, Self::supervisor_exec_allow_roots(private_dir)))
564    }
565
566    fn supervisor_exec_allow_roots(program_root: PathBuf) -> Vec<PathBuf> {
567        // The patched runsc resolves helper re-execs to its concrete binary
568        // path. Keep the supervisor policy scoped to that executable root.
569        vec![program_root]
570    }
571
572    fn rootless_userns_helper_exec_roots() -> Vec<PathBuf> {
573        // NixOS exposes newuidmap/newgidmap as setuid wrappers below
574        // /run/wrappers/bin. Rootless runsc invokes them while creating the
575        // OCI user namespace, after the supervisor execute allowlist is active.
576        let mut roots: Vec<PathBuf> = ROOTLESS_USERNS_HELPERS
577            .iter()
578            .map(|helper| PathBuf::from(NIXOS_ROOTLESS_USERNS_WRAPPER_DIR).join(helper))
579            .collect();
580
581        // The NixOS wrappers exec immutable store binaries, but the wrapper
582        // executables are intentionally unreadable so Nucleus cannot discover
583        // those targets by inspecting the wrapper. Callers that install a
584        // fail-closed supervisor policy can provide the concrete helper paths.
585        if let Some(raw_roots) = std::env::var_os(ROOTLESS_USERNS_HELPER_EXEC_ROOTS_ENV)
586            .filter(|value| !value.is_empty())
587        {
588            roots.extend(std::env::split_paths(&raw_roots).filter(|path| path.is_absolute()));
589        }
590
591        roots
592    }
593
594    fn nix_store_runsc_exec_root(program: &Path) -> Option<PathBuf> {
595        if !program.starts_with(NIX_STORE_EXEC_ROOT) {
596            return None;
597        }
598
599        program.parent().map(Path::to_path_buf)
600    }
601
602    fn secure_runsc_root(container_id: &str) -> Result<PathBuf> {
603        let artifact_base = Self::gvisor_artifact_base()?;
604        let artifact_dir = artifact_base.join(Self::runsc_state_component(container_id));
605
606        if Self::host_root_requires_trusted_runsc_ancestry() {
607            Self::ensure_trusted_host_root_runsc_ancestry(
608                &artifact_base,
609                "gVisor runsc artifact base",
610            )?;
611        }
612
613        Self::ensure_secure_runsc_dir(&artifact_base, "gVisor runsc artifact base")?;
614        Self::ensure_secure_runsc_dir(&artifact_dir, "gVisor runsc artifact directory")?;
615
616        let runsc_root = artifact_dir.join("runsc-root");
617        Self::ensure_secure_runsc_dir(&runsc_root, "runsc root directory")?;
618        Ok(runsc_root)
619    }
620
621    fn gvisor_artifact_base() -> Result<PathBuf> {
622        if let Some(path) =
623            std::env::var_os("NUCLEUS_GVISOR_ARTIFACT_BASE").filter(|path| !path.is_empty())
624        {
625            return Self::absolute_path(Path::new(&path), "gVisor artifact base");
626        }
627
628        if !Uid::effective().is_root() || Self::root_uid_maps_to_unprivileged_host_uid_from_proc() {
629            if let Some(dir) = dirs::runtime_dir() {
630                return Ok(dir.join("nucleus-gvisor"));
631            }
632        }
633
634        if Uid::effective().is_root() {
635            Ok(PathBuf::from("/run/nucleus-gvisor"))
636        } else {
637            Ok(std::env::temp_dir().join(format!("nucleus-gvisor-{}", Uid::effective().as_raw())))
638        }
639    }
640
641    fn absolute_path(path: &Path, label: &str) -> Result<PathBuf> {
642        if path.is_absolute() {
643            return Ok(path.to_path_buf());
644        }
645
646        std::env::current_dir()
647            .map(|cwd| cwd.join(path))
648            .map_err(|e| {
649                NucleusError::GVisorError(format!(
650                    "Failed to resolve current directory for {} {:?}: {}",
651                    label, path, e
652                ))
653            })
654    }
655
656    fn runsc_state_component(container_id: &str) -> String {
657        if container_id.len() == 32 && container_id.chars().all(|c| c.is_ascii_hexdigit()) {
658            return container_id.to_string();
659        }
660
661        let digest = Sha256::digest(container_id.as_bytes());
662        format!("id-{}", hex::encode(&digest[..16]))
663    }
664
665    fn root_uid_maps_to_unprivileged_host_uid_from_proc() -> bool {
666        fs::read_to_string("/proc/self/uid_map")
667            .map(|uid_map| Self::root_uid_maps_to_unprivileged_host_uid(&uid_map))
668            .unwrap_or(false)
669    }
670
671    fn root_uid_maps_to_unprivileged_host_uid(uid_map: &str) -> bool {
672        for line in uid_map.lines() {
673            let mut fields = line.split_whitespace();
674            let Some(namespace_start) = fields.next() else {
675                continue;
676            };
677            let Some(host_start) = fields.next() else {
678                continue;
679            };
680            let Some(length) = fields.next() else {
681                continue;
682            };
683            if fields.next().is_some() {
684                continue;
685            }
686
687            let Ok(namespace_start) = namespace_start.parse::<u64>() else {
688                continue;
689            };
690            let Ok(host_start) = host_start.parse::<u64>() else {
691                continue;
692            };
693            let Ok(length) = length.parse::<u64>() else {
694                continue;
695            };
696
697            if namespace_start == 0 && length > 0 {
698                return host_start != 0;
699            }
700        }
701
702        false
703    }
704
705    fn host_root_requires_trusted_runsc_ancestry() -> bool {
706        Uid::effective().is_root() && !Self::root_uid_maps_to_unprivileged_host_uid_from_proc()
707    }
708
709    fn ensure_trusted_host_root_runsc_ancestry(path: &Path, label: &str) -> Result<()> {
710        let path = Self::absolute_path(path, label)?;
711
712        let mut current = PathBuf::new();
713        for component in path.components() {
714            match component {
715                Component::Prefix(prefix) => current.push(prefix.as_os_str()),
716                Component::RootDir => current.push(component.as_os_str()),
717                Component::CurDir => {}
718                Component::ParentDir => {
719                    return Err(NucleusError::GVisorError(format!(
720                        "{} {:?} contains a parent-directory component",
721                        label, path
722                    )));
723                }
724                Component::Normal(name) => {
725                    current.push(name);
726                    match fs::symlink_metadata(&current) {
727                        Ok(metadata) => Self::ensure_trusted_host_root_runsc_ancestor_component(
728                            &current, metadata, label,
729                        )?,
730                        Err(e) if e.kind() == io::ErrorKind::NotFound => break,
731                        Err(e) => {
732                            return Err(NucleusError::GVisorError(format!(
733                                "Failed to stat {} ancestor {:?}: {}",
734                                label, current, e
735                            )));
736                        }
737                    }
738                }
739            }
740        }
741
742        Ok(())
743    }
744
745    fn ensure_trusted_host_root_runsc_ancestor_component(
746        path: &Path,
747        metadata: fs::Metadata,
748        label: &str,
749    ) -> Result<()> {
750        if metadata.file_type().is_symlink() {
751            return Err(NucleusError::GVisorError(format!(
752                "Refusing symlink {} ancestor {:?}",
753                label, path
754            )));
755        }
756        if !metadata.file_type().is_dir() {
757            return Err(NucleusError::GVisorError(format!(
758                "{} ancestor {:?} is not a directory",
759                label, path
760            )));
761        }
762
763        let owner = metadata.uid();
764        if owner != 0 {
765            return Err(NucleusError::GVisorError(format!(
766                "{} ancestor {:?} is owned by uid {} (expected root)",
767                label, path, owner
768            )));
769        }
770
771        let mode = metadata.permissions().mode();
772        if mode & 0o022 != 0 && mode & 0o1000 == 0 {
773            return Err(NucleusError::GVisorError(format!(
774                "{} ancestor {:?} has unsafe permissions {:o}",
775                label,
776                path,
777                mode & 0o7777
778            )));
779        }
780
781        Ok(())
782    }
783
784    fn ensure_secure_runsc_dir(path: &Path, label: &str) -> Result<()> {
785        if let Some(parent) = path
786            .parent()
787            .filter(|parent| !parent.as_os_str().is_empty())
788        {
789            Self::ensure_trusted_runsc_parent(parent, label)?;
790        }
791
792        let mut created = false;
793        match fs::symlink_metadata(path) {
794            Ok(metadata) if metadata.file_type().is_symlink() => {
795                return Err(NucleusError::GVisorError(format!(
796                    "Refusing symlink {} {:?}",
797                    label, path
798                )));
799            }
800            Ok(metadata) if !metadata.file_type().is_dir() => {
801                return Err(NucleusError::GVisorError(format!(
802                    "{} {:?} is not a directory",
803                    label, path
804                )));
805            }
806            Ok(_) => {}
807            Err(e) if e.kind() == io::ErrorKind::NotFound => {
808                match DirBuilder::new().mode(0o700).create(path) {
809                    Ok(()) => {
810                        created = true;
811                    }
812                    Err(create_err) if create_err.kind() == io::ErrorKind::AlreadyExists => {}
813                    Err(create_err) => {
814                        return Err(NucleusError::GVisorError(format!(
815                            "Failed to create {} {:?}: {}",
816                            label, path, create_err
817                        )));
818                    }
819                }
820            }
821            Err(e) => {
822                return Err(NucleusError::GVisorError(format!(
823                    "Failed to stat {} {:?}: {}",
824                    label, path, e
825                )));
826            }
827        }
828
829        if created {
830            fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|e| {
831                NucleusError::GVisorError(format!(
832                    "Failed to secure newly-created {} permissions {:?}: {}",
833                    label, path, e
834                ))
835            })?;
836        }
837
838        let dir = OpenOptions::new()
839            .read(true)
840            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_DIRECTORY)
841            .open(path)
842            .map_err(|e| {
843                NucleusError::GVisorError(format!(
844                    "Failed to open {} {:?} without following symlinks: {}",
845                    label, path, e
846                ))
847            })?;
848
849        let metadata = dir.metadata().map_err(|e| {
850            NucleusError::GVisorError(format!("Failed to stat {} {:?}: {}", label, path, e))
851        })?;
852        if !metadata.file_type().is_dir() {
853            return Err(NucleusError::GVisorError(format!(
854                "{} {:?} is not a directory",
855                label, path
856            )));
857        }
858
859        let owner = metadata.uid();
860        let expected = Uid::effective().as_raw();
861        if owner != expected {
862            // Under a non-trivial user namespace (keep-id / auto), files we
863            // legitimately own can appear under a non-root container uid
864            // (e.g. host uid 1000 -> container uid 1000). If the owner is still
865            // a uid mapped into our namespace, reclaim the directory by
866            // chowning it to container root (the trusted owner) rather than
867            // failing. This handles stale dirs left by a prior rootless mode.
868            if crate::isolation::uid_is_mapped_in_current_userns(owner)
869                && Uid::effective().is_root()
870            {
871                debug!(
872                    "Reclaiming {} {:?} from mapped uid {} to container root",
873                    label, path, owner
874                );
875                nix::unistd::chown(path, Some(Uid::from_raw(0)), Some(Gid::from_raw(0)))
876                    .map_err(|e| {
877                        NucleusError::GVisorError(format!(
878                            "Failed to reclaim {} {:?}: {}",
879                            label, path, e
880                        ))
881                    })?;
882            } else {
883                return Err(NucleusError::GVisorError(format!(
884                    "{} {:?} is owned by uid {} (expected {})",
885                    label, path, owner, expected
886                )));
887            }
888        }
889
890        let mode = metadata.permissions().mode() & 0o777;
891        if mode != 0o700 {
892            dir.set_permissions(fs::Permissions::from_mode(0o700))
893                .map_err(|e| {
894                    NucleusError::GVisorError(format!(
895                        "Failed to secure {} permissions {:?}: {}",
896                        label, path, e
897                    ))
898                })?;
899        }
900
901        Ok(())
902    }
903
904    fn ensure_trusted_runsc_parent(parent: &Path, label: &str) -> Result<()> {
905        let metadata = fs::symlink_metadata(parent).map_err(|e| {
906            NucleusError::GVisorError(format!(
907                "Failed to stat parent for {} {:?}: {}",
908                label, parent, e
909            ))
910        })?;
911        if metadata.file_type().is_symlink() {
912            return Err(NucleusError::GVisorError(format!(
913                "Refusing symlink parent for {} {:?}",
914                label, parent
915            )));
916        }
917        if !metadata.file_type().is_dir() {
918            return Err(NucleusError::GVisorError(format!(
919                "Parent for {} {:?} is not a directory",
920                label, parent
921            )));
922        }
923
924        let owner = metadata.uid();
925        let current = Uid::effective().as_raw();
926        // A parent is trusted if it is owned by us, by container root, or by any
927        // uid mapped into our (non-trivial) user namespace. The last clause is
928        // required for keep-id / auto rootless, where our own runtime dir
929        // (e.g. $XDG_RUNTIME_DIR) appears under a non-root container uid.
930        let owner_trusted = owner == current
931            || owner == 0
932            || crate::isolation::uid_is_mapped_in_current_userns(owner);
933        let mode = metadata.permissions().mode();
934        let unsafe_writable = mode & 0o022 != 0 && mode & 0o1000 == 0;
935        if !owner_trusted || unsafe_writable {
936            return Err(NucleusError::GVisorError(format!(
937                "Parent for {} {:?} is not trusted (owner uid {}, mode {:o})",
938                label,
939                parent,
940                owner,
941                mode & 0o7777
942            )));
943        }
944
945        Ok(())
946    }
947
948    fn create_unique_runsc_stage_dir(private_dir: &Path) -> Result<PathBuf> {
949        let nonce = std::time::SystemTime::now()
950            .duration_since(std::time::UNIX_EPOCH)
951            .map(|duration| duration.as_nanos())
952            .unwrap_or_default();
953
954        for attempt in 0..100u32 {
955            let stage_dir = private_dir.join(format!(
956                "stage-{}-{}-{}",
957                std::process::id(),
958                nonce,
959                attempt
960            ));
961            match DirBuilder::new().mode(0o700).create(&stage_dir) {
962                Ok(()) => {
963                    Self::ensure_secure_runsc_dir(&stage_dir, "runsc stage directory")?;
964                    return Ok(stage_dir);
965                }
966                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
967                Err(e) => {
968                    return Err(NucleusError::GVisorError(format!(
969                        "Failed to create runsc stage directory {:?}: {}",
970                        stage_dir, e
971                    )));
972                }
973            }
974        }
975
976        Err(NucleusError::GVisorError(format!(
977            "Failed to create unique runsc stage directory under {:?}",
978            private_dir
979        )))
980    }
981
982    fn copy_runsc_nofollow(source: &Path, staged: &Path) -> Result<()> {
983        let mut source_file = OpenOptions::new()
984            .read(true)
985            .custom_flags(libc::O_CLOEXEC)
986            .open(source)
987            .map_err(|e| {
988                NucleusError::GVisorError(format!(
989                    "Failed to open runsc source {:?}: {}",
990                    source, e
991                ))
992            })?;
993
994        let source_meta = source_file.metadata().map_err(|e| {
995            NucleusError::GVisorError(format!("Failed to stat runsc source {:?}: {}", source, e))
996        })?;
997        if !source_meta.file_type().is_file() {
998            return Err(NucleusError::GVisorError(format!(
999                "runsc source {:?} is not a regular file",
1000                source
1001            )));
1002        }
1003
1004        let mut staged_file = OpenOptions::new()
1005            .write(true)
1006            .create_new(true)
1007            .mode(0o500)
1008            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
1009            .open(staged)
1010            .map_err(|e| {
1011                NucleusError::GVisorError(format!(
1012                    "Failed to create staged runsc binary {:?}: {}",
1013                    staged, e
1014                ))
1015            })?;
1016
1017        io::copy(&mut source_file, &mut staged_file).map_err(|e| {
1018            NucleusError::GVisorError(format!(
1019                "Failed to stage runsc binary from {:?} to {:?}: {}",
1020                source, staged, e
1021            ))
1022        })?;
1023        staged_file
1024            .set_permissions(fs::Permissions::from_mode(0o500))
1025            .map_err(|e| {
1026                NucleusError::GVisorError(format!(
1027                    "Failed to secure staged runsc binary {:?}: {}",
1028                    staged, e
1029                ))
1030            })?;
1031        staged_file.sync_all().map_err(|e| {
1032            NucleusError::GVisorError(format!(
1033                "Failed to sync staged runsc binary {:?}: {}",
1034                staged, e
1035            ))
1036        })?;
1037
1038        Ok(())
1039    }
1040
1041    fn apply_supervisor_exec_policy(
1042        &self,
1043        allowed_roots: &[PathBuf],
1044        required: bool,
1045    ) -> Result<()> {
1046        let mut landlock = LandlockManager::new();
1047        let applied = landlock.apply_execute_allowlist_policy(allowed_roots, !required)?;
1048        if applied {
1049            info!(
1050                allowed_roots = ?allowed_roots,
1051                "Applied gVisor supervisor execute allowlist"
1052            );
1053        } else if required {
1054            return Err(NucleusError::LandlockError(
1055                "Required gVisor supervisor execute allowlist was not applied".to_string(),
1056            ));
1057        } else {
1058            warn!(
1059                allowed_roots = ?allowed_roots,
1060                "gVisor supervisor execute allowlist unavailable"
1061            );
1062        }
1063        Ok(())
1064    }
1065
1066    fn build_oci_run_args(
1067        &self,
1068        container_id: &str,
1069        bundle: &OciBundle,
1070        runsc_root: &Path,
1071        options: &GVisorOciRunOptions,
1072        debug_dir: Option<&Path>,
1073    ) -> Vec<String> {
1074        let mut args = vec![
1075            self.runsc_path.clone(),
1076            "--root".to_string(),
1077            runsc_root.to_string_lossy().to_string(),
1078        ];
1079
1080        if options.runsc_rootless {
1081            args.push("--rootless".to_string());
1082        }
1083
1084        if options.ignore_cgroups {
1085            args.push("--ignore-cgroups".to_string());
1086        }
1087
1088        if let Some(debug_dir) = debug_dir {
1089            let debug_pattern = debug_dir
1090                .join("%TIMESTAMP%.%COMMAND%.log")
1091                .to_string_lossy()
1092                .to_string();
1093            let panic_log = debug_dir
1094                .join("panic.%TIMESTAMP%.log")
1095                .to_string_lossy()
1096                .to_string();
1097            args.extend([
1098                "--debug".to_string(),
1099                "--alsologtostderr".to_string(),
1100                "--debug-to-user-log".to_string(),
1101                "--debug-log".to_string(),
1102                debug_pattern,
1103                "--panic-log".to_string(),
1104                panic_log,
1105            ]);
1106        }
1107
1108        args.extend([
1109            "--network".to_string(),
1110            options.network_flag().to_string(),
1111            "--platform".to_string(),
1112            options.platform.as_flag().to_string(),
1113            "run".to_string(),
1114        ]);
1115
1116        if let Some(console_socket) = &options.console_socket {
1117            args.extend([
1118                "--console-socket".to_string(),
1119                console_socket.to_string_lossy().to_string(),
1120            ]);
1121        }
1122
1123        args.extend([
1124            "--bundle".to_string(),
1125            bundle.bundle_path().to_string_lossy().to_string(),
1126            container_id.to_string(),
1127        ]);
1128
1129        args
1130    }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136    use crate::oci::OciConfig;
1137    use std::path::{Path, PathBuf};
1138    use std::sync::{Mutex, MutexGuard};
1139
1140    static ENV_LOCK: Mutex<()> = Mutex::new(());
1141
1142    struct EnvLock {
1143        _guard: MutexGuard<'static, ()>,
1144    }
1145
1146    impl EnvLock {
1147        fn acquire() -> Self {
1148            Self {
1149                _guard: ENV_LOCK.lock().unwrap(),
1150            }
1151        }
1152    }
1153
1154    struct EnvVarGuard {
1155        key: &'static str,
1156        previous: Option<std::ffi::OsString>,
1157    }
1158
1159    impl EnvVarGuard {
1160        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
1161            let previous = std::env::var_os(key);
1162            std::env::set_var(key, value);
1163            Self { key, previous }
1164        }
1165
1166        fn remove(key: &'static str) -> Self {
1167            let previous = std::env::var_os(key);
1168            std::env::remove_var(key);
1169            Self { key, previous }
1170        }
1171    }
1172
1173    impl Drop for EnvVarGuard {
1174        fn drop(&mut self) {
1175            match &self.previous {
1176                Some(value) => std::env::set_var(self.key, value),
1177                None => std::env::remove_var(self.key),
1178            }
1179        }
1180    }
1181
1182    #[test]
1183    fn test_gvisor_availability() {
1184        // This test just checks if we can determine availability
1185        // It may pass or fail depending on whether gVisor is installed
1186        let available = GVisorRuntime::is_available();
1187        println!("gVisor available: {}", available);
1188    }
1189
1190    #[test]
1191    fn test_gvisor_new() {
1192        let runtime = GVisorRuntime::new();
1193        if let Ok(rt) = runtime {
1194            println!("Found runsc at: {}", rt.runsc_path);
1195            if let Ok(version) = rt.version() {
1196                println!("runsc version: {}", version);
1197            }
1198        }
1199    }
1200
1201    #[test]
1202    fn test_find_runsc() {
1203        // Test that find_runsc either succeeds or returns appropriate error
1204        match GVisorRuntime::find_runsc() {
1205            Ok(path) => {
1206                println!("Found runsc at: {}", path);
1207                assert!(!path.is_empty());
1208            }
1209            Err(e) => {
1210                println!("runsc not found (expected if gVisor not installed): {}", e);
1211            }
1212        }
1213    }
1214
1215    #[test]
1216    fn test_validate_runsc_rejects_world_writable() {
1217        let dir = tempfile::tempdir().unwrap();
1218        let fake_runsc = dir.path().join("runsc");
1219        std::fs::write(&fake_runsc, "#!/bin/sh\necho fake").unwrap();
1220        // Make world-writable
1221        std::fs::set_permissions(&fake_runsc, std::fs::Permissions::from_mode(0o777)).unwrap();
1222
1223        let result = GVisorRuntime::validate_runsc_path(&fake_runsc);
1224        assert!(
1225            result.is_err(),
1226            "validate_runsc_path must reject world-writable binaries"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_validate_runsc_rejects_group_writable() {
1232        let dir = tempfile::tempdir().unwrap();
1233        let fake_runsc = dir.path().join("runsc");
1234        std::fs::write(&fake_runsc, "#!/bin/sh\necho fake").unwrap();
1235        // Make group-writable
1236        std::fs::set_permissions(&fake_runsc, std::fs::Permissions::from_mode(0o775)).unwrap();
1237
1238        let result = GVisorRuntime::validate_runsc_path(&fake_runsc);
1239        assert!(
1240            result.is_err(),
1241            "validate_runsc_path must reject group-writable binaries"
1242        );
1243    }
1244
1245    #[test]
1246    fn test_runsc_owner_accepts_nix_store_artifact_owner() {
1247        // Use a real Nix store binary so the metadata/permission checks pass.
1248        // The /nix/store contents are read-only and content-addressed, so any
1249        // existing file with mode 555 works.
1250        let nix_binary = std::fs::read_dir("/nix/store")
1251            .ok()
1252            .and_then(|mut entries| {
1253                entries.find_map(|e| {
1254                    let dir = e.ok()?.path();
1255                    let candidate = dir.join("bin/runsc");
1256                    if candidate.exists() {
1257                        Some(candidate)
1258                    } else {
1259                        None
1260                    }
1261                })
1262            });
1263
1264        let path = match nix_binary {
1265            Some(p) => p,
1266            None => {
1267                eprintln!("skipping: no runsc binary found in /nix/store");
1268                return;
1269            }
1270        };
1271
1272        assert!(GVisorRuntime::is_trusted_runsc_owner(&path, 65534, 1000));
1273    }
1274
1275    #[test]
1276    fn test_exec_environment_uses_hardcoded_path() {
1277        // The gVisor supervisor must NOT inherit the host PATH, to prevent
1278        // host filesystem layout leaking into the container environment.
1279        // Verify by setting a distinctive PATH and checking exec_environment
1280        // returns a hardcoded value instead.
1281        std::env::set_var("PATH", "/tmp/evil-inject/bin:/opt/attacker/sbin");
1282        let rt = GVisorRuntime::with_path("/fake/runsc".to_string());
1283        let tmp = tempfile::tempdir().unwrap();
1284        let env = rt.exec_environment(tmp.path(), false).unwrap();
1285        let path_entry = env
1286            .iter()
1287            .find(|e| e.to_str().is_ok_and(|s| s.starts_with("PATH=")))
1288            .expect("exec_environment must set PATH");
1289        let path_val = path_entry.to_str().unwrap();
1290        assert!(
1291            !path_val.contains("evil-inject") && !path_val.contains("attacker"),
1292            "exec_environment must use hardcoded PATH, not host PATH. Got: {}",
1293            path_val
1294        );
1295        assert_eq!(
1296            path_val,
1297            format!("PATH={RUNSC_SUPERVISOR_PATH}"),
1298            "exec_environment PATH must be the standard hardcoded value"
1299        );
1300    }
1301
1302    #[test]
1303    fn test_exec_environment_can_request_proc_self_exe_reexec() {
1304        let rt = GVisorRuntime::with_path("/fake/runsc".to_string());
1305        let tmp = tempfile::tempdir().unwrap();
1306        let env = rt.exec_environment(tmp.path(), true).unwrap();
1307        let expected = format!("{}=1", RUNSC_REEXEC_VIA_PROC_SELF_EXE_ENV);
1308
1309        assert!(
1310            env.iter()
1311                .any(|entry| entry.to_str().is_ok_and(|entry| entry == expected)),
1312            "runsc env must request /proc/self/exe re-exec when explicitly requested"
1313        );
1314    }
1315
1316    #[test]
1317    fn test_exec_environment_keeps_real_exe_reexec_for_supervisor_policy() {
1318        let rt = GVisorRuntime::with_path("/fake/runsc".to_string());
1319        let tmp = tempfile::tempdir().unwrap();
1320        let env = rt.exec_environment(tmp.path(), false).unwrap();
1321
1322        assert!(
1323            env.iter().all(|entry| entry.to_str().map_or(true, |entry| {
1324                !entry.starts_with(RUNSC_REEXEC_VIA_PROC_SELF_EXE_ENV)
1325            })),
1326            "runsc env must not enable /proc/self/exe re-exec when supervisor policy is active"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_builtin_rootless_args_pass_runsc_rootless() {
1332        let rt = GVisorRuntime::with_path("/nix/store/fake-runsc/bin/runsc".to_string());
1333        let tmp = tempfile::tempdir().unwrap();
1334        let bundle = OciBundle::new(
1335            tmp.path().join("bundle"),
1336            OciConfig::new(vec!["/bin/true".to_string()], None),
1337        );
1338
1339        let args = rt.build_oci_run_args(
1340            "container-id",
1341            &bundle,
1342            tmp.path(),
1343            &GVisorOciRunOptions {
1344                network_mode: GVisorNetworkMode::Host,
1345                ignore_cgroups: true,
1346                runsc_rootless: true,
1347                stage_runsc_binary: false,
1348                require_supervisor_exec_policy: false,
1349                platform: GVisorPlatform::Systrap,
1350                console_socket: None,
1351            },
1352            None,
1353        );
1354
1355        assert!(args.iter().any(|arg| arg == "--rootless"));
1356        assert!(args.iter().any(|arg| arg == "--ignore-cgroups"));
1357    }
1358
1359    #[test]
1360    fn test_runsc_rootless_does_not_force_supervisor_exec_policy() {
1361        let source = include_str!("gvisor.rs");
1362        let start = source.find("pub fn exec_with_oci_bundle_options").unwrap();
1363        let end = source[start..]
1364            .find("pub fn exec_with_oci_bundle_network")
1365            .map(|offset| start + offset)
1366            .unwrap();
1367        let fn_body = &source[start..end];
1368
1369        assert!(
1370            fn_body.contains("if options.require_supervisor_exec_policy"),
1371            "supervisor execute policy must be selected explicitly"
1372        );
1373        assert!(
1374            !fn_body.contains("|| options.runsc_rootless"),
1375            "runsc --rootless must not force the host-side supervisor execute policy"
1376        );
1377        assert!(
1378            fn_body.contains("options.runsc_rootless && !options.require_supervisor_exec_policy"),
1379            "rootless runsc must use /proc/self/exe helper re-exec only without supervisor policy"
1380        );
1381    }
1382
1383    #[test]
1384    fn test_rootless_oci_args_do_not_pass_runsc_rootless() {
1385        let rt = GVisorRuntime::with_path("/nix/store/fake-runsc/bin/runsc".to_string());
1386        let tmp = tempfile::tempdir().unwrap();
1387        let bundle = OciBundle::new(
1388            tmp.path().join("bundle"),
1389            OciConfig::new(vec!["/bin/true".to_string()], None),
1390        );
1391
1392        let args = rt.build_oci_run_args(
1393            "container-id",
1394            &bundle,
1395            tmp.path(),
1396            &GVisorOciRunOptions {
1397                network_mode: GVisorNetworkMode::Host,
1398                ignore_cgroups: true,
1399                runsc_rootless: false,
1400                stage_runsc_binary: false,
1401                require_supervisor_exec_policy: false,
1402                platform: GVisorPlatform::Systrap,
1403                console_socket: None,
1404            },
1405            None,
1406        );
1407
1408        assert!(!args.iter().any(|arg| arg == "--rootless"));
1409        assert!(args.iter().any(|arg| arg == "--ignore-cgroups"));
1410    }
1411
1412    #[test]
1413    fn test_runsc_args_pass_console_socket_after_run_subcommand() {
1414        let rt = GVisorRuntime::with_path("/nix/store/fake-runsc/bin/runsc".to_string());
1415        let tmp = tempfile::tempdir().unwrap();
1416        let console_socket = tmp.path().join("console.sock");
1417        let bundle = OciBundle::new(
1418            tmp.path().join("bundle"),
1419            OciConfig::new(vec!["/bin/true".to_string()], None),
1420        );
1421
1422        let args = rt.build_oci_run_args(
1423            "container-id",
1424            &bundle,
1425            tmp.path(),
1426            &GVisorOciRunOptions {
1427                network_mode: GVisorNetworkMode::Host,
1428                ignore_cgroups: true,
1429                runsc_rootless: false,
1430                stage_runsc_binary: false,
1431                require_supervisor_exec_policy: false,
1432                platform: GVisorPlatform::Systrap,
1433                console_socket: Some(console_socket.clone()),
1434            },
1435            None,
1436        );
1437
1438        let run_index = args.iter().position(|arg| arg == "run").unwrap();
1439        let socket_index = args
1440            .iter()
1441            .position(|arg| arg == "--console-socket")
1442            .unwrap();
1443        assert!(socket_index > run_index);
1444        assert_eq!(
1445            args.get(socket_index + 1),
1446            Some(&console_socket.to_string_lossy().to_string())
1447        );
1448    }
1449
1450    #[test]
1451    fn test_runsc_debug_dir_adds_debug_flags_before_subcommand() {
1452        let rt = GVisorRuntime::with_path("/nix/store/fake-runsc/bin/runsc".to_string());
1453        let tmp = tempfile::tempdir().unwrap();
1454        let debug_dir = tmp.path().join("runsc-debug");
1455        let bundle = OciBundle::new(
1456            tmp.path().join("bundle"),
1457            OciConfig::new(vec!["/bin/true".to_string()], None),
1458        );
1459
1460        let args = rt.build_oci_run_args(
1461            "container-id",
1462            &bundle,
1463            tmp.path(),
1464            &GVisorOciRunOptions {
1465                network_mode: GVisorNetworkMode::Host,
1466                ignore_cgroups: true,
1467                runsc_rootless: true,
1468                stage_runsc_binary: false,
1469                require_supervisor_exec_policy: false,
1470                platform: GVisorPlatform::Ptrace,
1471                console_socket: None,
1472            },
1473            Some(&debug_dir),
1474        );
1475
1476        let run_index = args.iter().position(|arg| arg == "run").unwrap();
1477        let debug_index = args.iter().position(|arg| arg == "--debug").unwrap();
1478        assert!(
1479            debug_index < run_index,
1480            "runsc debug flags must be top-level flags before the subcommand"
1481        );
1482        assert!(args.iter().any(|arg| arg == "--alsologtostderr"));
1483        assert!(args.iter().any(|arg| arg == "--debug-to-user-log"));
1484        assert!(args.iter().any(|arg| arg == "--debug-log"));
1485        assert!(args.iter().any(|arg| arg.contains("%COMMAND%.log")));
1486        assert!(args.iter().any(|arg| arg.contains("panic.%TIMESTAMP%.log")));
1487    }
1488
1489    #[test]
1490    fn test_non_nix_runsc_is_staged_for_supervisor_exec_policy() {
1491        let tmp = tempfile::tempdir().unwrap();
1492        let fake_runsc = tmp.path().join("runsc-source");
1493        std::fs::write(&fake_runsc, b"fake-runsc").unwrap();
1494        std::fs::set_permissions(&fake_runsc, std::fs::Permissions::from_mode(0o500)).unwrap();
1495
1496        let rt = GVisorRuntime::with_path(fake_runsc.to_string_lossy().to_string());
1497        let runsc_root = tmp.path().join("runsc-root");
1498        let (program, allow_roots) = rt
1499            .prepare_supervisor_runsc_program(&runsc_root, false)
1500            .unwrap();
1501
1502        assert!(program.starts_with(runsc_root.join("exec-allow")));
1503        assert_eq!(allow_roots, vec![runsc_root.join("exec-allow")]);
1504        assert_eq!(std::fs::read(&program).unwrap(), b"fake-runsc");
1505        let mode = std::fs::metadata(&program).unwrap().permissions().mode() & 0o777;
1506        assert_eq!(mode, 0o500);
1507    }
1508
1509    #[test]
1510    fn test_nix_store_runsc_uses_package_bin_exec_root() {
1511        let program = Path::new("/nix/store/hash-gvisor-20251110.0/bin/.runsc-wrapped");
1512
1513        assert_eq!(
1514            GVisorRuntime::nix_store_runsc_exec_root(program),
1515            Some(PathBuf::from("/nix/store/hash-gvisor-20251110.0/bin"))
1516        );
1517    }
1518
1519    #[test]
1520    fn test_non_nix_runsc_has_no_store_exec_root() {
1521        let program = Path::new("/tmp/nucleus/runsc");
1522
1523        assert_eq!(GVisorRuntime::nix_store_runsc_exec_root(program), None);
1524    }
1525
1526    #[test]
1527    fn test_supervisor_exec_allow_roots_use_only_runsc_exec_root() {
1528        let roots = GVisorRuntime::supervisor_exec_allow_roots(PathBuf::from(NIX_STORE_EXEC_ROOT));
1529
1530        assert_eq!(roots, vec![PathBuf::from(NIX_STORE_EXEC_ROOT)]);
1531    }
1532
1533    #[test]
1534    fn test_rootless_userns_helper_exec_roots_include_default_wrappers() {
1535        let _env_lock = EnvLock::acquire();
1536        let _helper_roots = EnvVarGuard::remove(ROOTLESS_USERNS_HELPER_EXEC_ROOTS_ENV);
1537        let roots = GVisorRuntime::rootless_userns_helper_exec_roots();
1538
1539        assert_eq!(
1540            roots,
1541            vec![
1542                PathBuf::from("/run/wrappers/bin/newuidmap"),
1543                PathBuf::from("/run/wrappers/bin/newgidmap"),
1544            ]
1545        );
1546    }
1547
1548    #[test]
1549    fn test_rootless_userns_helper_exec_roots_include_configured_absolute_roots() {
1550        let _env_lock = EnvLock::acquire();
1551        let configured_roots = std::env::join_paths([
1552            PathBuf::from("/nix/store/shadow/bin/newuidmap"),
1553            PathBuf::from("relative/newgidmap"),
1554            PathBuf::from("/nix/store/shadow/bin/newgidmap"),
1555        ])
1556        .unwrap();
1557        let _helper_roots =
1558            EnvVarGuard::set(ROOTLESS_USERNS_HELPER_EXEC_ROOTS_ENV, configured_roots);
1559
1560        let roots = GVisorRuntime::rootless_userns_helper_exec_roots();
1561
1562        assert_eq!(
1563            roots,
1564            vec![
1565                PathBuf::from("/run/wrappers/bin/newuidmap"),
1566                PathBuf::from("/run/wrappers/bin/newgidmap"),
1567                PathBuf::from("/nix/store/shadow/bin/newuidmap"),
1568                PathBuf::from("/nix/store/shadow/bin/newgidmap"),
1569            ]
1570        );
1571    }
1572
1573    #[test]
1574    fn test_rootless_supervisor_policy_allows_userns_helpers() {
1575        let source = include_str!("gvisor.rs");
1576        let start = source.find("pub fn exec_with_oci_bundle_options").unwrap();
1577        let end = source[start..]
1578            .find("pub fn exec_with_oci_bundle_network")
1579            .map(|offset| start + offset)
1580            .unwrap();
1581        let fn_body = &source[start..end];
1582        let helper_roots = fn_body
1583            .find("exec_allow_roots.extend(Self::rootless_userns_helper_exec_roots())")
1584            .unwrap();
1585        let policy = fn_body.find("self.apply_supervisor_exec_policy").unwrap();
1586
1587        assert!(
1588            helper_roots < policy,
1589            "rootless runsc must allow newuidmap/newgidmap before installing Landlock"
1590        );
1591    }
1592
1593    #[test]
1594    fn test_runsc_root_uses_hardened_artifact_dir_not_bundle_parent() {
1595        let _env_lock = EnvLock::acquire();
1596        let tmp = tempfile::tempdir().unwrap();
1597        let artifact_base = tmp.path().join("gvisor-artifacts");
1598        let _artifact_base = EnvVarGuard::set("NUCLEUS_GVISOR_ARTIFACT_BASE", &artifact_base);
1599        let _runtime = EnvVarGuard::remove("XDG_RUNTIME_DIR");
1600
1601        let bundle_parent = tmp.path().join("shared");
1602        std::fs::create_dir_all(&bundle_parent).unwrap();
1603        std::fs::set_permissions(&bundle_parent, std::fs::Permissions::from_mode(0o777)).unwrap();
1604        let bundle = OciBundle::new(
1605            bundle_parent.join("bundle"),
1606            OciConfig::new(vec!["/bin/true".to_string()], None),
1607        );
1608
1609        let runsc_root = GVisorRuntime::secure_runsc_root("container-id").unwrap();
1610
1611        assert!(runsc_root
1612            .starts_with(artifact_base.join(GVisorRuntime::runsc_state_component("container-id"))));
1613        assert!(
1614            !runsc_root.starts_with(bundle.bundle_path().parent().unwrap()),
1615            "runsc root must not be derived from a custom bundle parent"
1616        );
1617    }
1618
1619    #[test]
1620    fn test_runsc_staging_rejects_symlink_exec_allow_dir() {
1621        let tmp = tempfile::tempdir().unwrap();
1622        let fake_runsc = tmp.path().join("runsc-source");
1623        std::fs::write(&fake_runsc, b"fake-runsc").unwrap();
1624        std::fs::set_permissions(&fake_runsc, std::fs::Permissions::from_mode(0o500)).unwrap();
1625
1626        let runsc_root = tmp.path().join("runsc-root");
1627        std::fs::create_dir(&runsc_root).unwrap();
1628        std::fs::set_permissions(&runsc_root, std::fs::Permissions::from_mode(0o700)).unwrap();
1629        let victim_dir = tmp.path().join("victim");
1630        std::fs::create_dir(&victim_dir).unwrap();
1631        std::os::unix::fs::symlink(&victim_dir, runsc_root.join("exec-allow")).unwrap();
1632
1633        let rt = GVisorRuntime::with_path(fake_runsc.to_string_lossy().to_string());
1634        let err = rt
1635            .prepare_supervisor_runsc_program(&runsc_root, false)
1636            .unwrap_err()
1637            .to_string();
1638
1639        assert!(
1640            err.contains("Refusing symlink private runsc exec directory"),
1641            "unexpected error: {}",
1642            err
1643        );
1644        assert!(
1645            !victim_dir.join("runsc").exists(),
1646            "staging must not follow the exec-allow symlink"
1647        );
1648    }
1649
1650    #[test]
1651    fn test_runsc_owner_rejects_untrusted_non_store_owner() {
1652        assert!(!GVisorRuntime::is_trusted_runsc_owner(
1653            Path::new("/tmp/runsc"),
1654            4242,
1655            1000
1656        ));
1657    }
1658}