Skip to main content

nucleus/oci/
mod.rs

1use crate::container::{ConsoleSize, OciStatus};
2use crate::error::{NucleusError, Result};
3use crate::filesystem::{
4    is_immediate_nix_store_object_path, normalize_container_destination,
5    normalize_provider_config_destination, normalize_volume_destination, ROOTFS_STORE_PATHS_FILE,
6};
7use crate::isolation::{IdMapping, NamespaceConfig, UserNamespaceConfig};
8use crate::resources::ResourceLimits;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeSet, HashMap};
11use std::ffi::CString;
12use std::fs;
13use std::fs::OpenOptions;
14use std::io::Write;
15use std::os::fd::{AsRawFd, FromRawFd};
16use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
17use std::path::{Path, PathBuf};
18use tracing::{debug, info, warn};
19
20/// OCI Runtime Specification configuration
21///
22/// This implements a subset of the OCI runtime spec for gVisor compatibility
23/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/config.md>
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct OciConfig {
26    #[serde(rename = "ociVersion")]
27    pub oci_version: String,
28
29    pub root: OciRoot,
30    pub process: OciProcess,
31    pub hostname: Option<String>,
32    pub mounts: Vec<OciMount>,
33    pub linux: Option<OciLinux>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub hooks: Option<OciHooks>,
36    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
37    pub annotations: HashMap<String, String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct OciRoot {
42    pub path: String,
43    pub readonly: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct OciProcess {
48    pub terminal: bool,
49    pub user: OciUser,
50    pub args: Vec<String>,
51    pub env: Vec<String>,
52    pub cwd: String,
53    #[serde(rename = "noNewPrivileges")]
54    pub no_new_privileges: bool,
55    pub capabilities: Option<OciCapabilities>,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub rlimits: Vec<OciRlimit>,
58    #[serde(
59        rename = "consoleSize",
60        default,
61        skip_serializing_if = "Option::is_none"
62    )]
63    pub console_size: Option<OciConsoleSize>,
64    #[serde(
65        rename = "apparmorProfile",
66        default,
67        skip_serializing_if = "Option::is_none"
68    )]
69    pub apparmor_profile: Option<String>,
70    #[serde(
71        rename = "selinuxLabel",
72        default,
73        skip_serializing_if = "Option::is_none"
74    )]
75    pub selinux_label: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct OciUser {
80    pub uid: u32,
81    pub gid: u32,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub additional_gids: Option<Vec<u32>>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct OciCapabilities {
88    pub bounding: Vec<String>,
89    pub effective: Vec<String>,
90    pub inheritable: Vec<String>,
91    pub permitted: Vec<String>,
92    pub ambient: Vec<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct OciMount {
97    pub destination: String,
98    pub source: String,
99    #[serde(rename = "type")]
100    pub mount_type: String,
101    pub options: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct OciLinux {
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub namespaces: Option<Vec<OciNamespace>>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub resources: Option<OciResources>,
110    #[serde(rename = "uidMappings", skip_serializing_if = "Vec::is_empty", default)]
111    pub uid_mappings: Vec<OciIdMapping>,
112    #[serde(rename = "gidMappings", skip_serializing_if = "Vec::is_empty", default)]
113    pub gid_mappings: Vec<OciIdMapping>,
114    #[serde(rename = "maskedPaths", skip_serializing_if = "Vec::is_empty", default)]
115    pub masked_paths: Vec<String>,
116    #[serde(
117        rename = "readonlyPaths",
118        skip_serializing_if = "Vec::is_empty",
119        default
120    )]
121    pub readonly_paths: Vec<String>,
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub devices: Vec<OciDevice>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub seccomp: Option<OciSeccomp>,
126    #[serde(
127        rename = "rootfsPropagation",
128        default,
129        skip_serializing_if = "Option::is_none"
130    )]
131    pub rootfs_propagation: Option<String>,
132    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
133    pub sysctl: HashMap<String, String>,
134    #[serde(
135        rename = "cgroupsPath",
136        default,
137        skip_serializing_if = "Option::is_none"
138    )]
139    pub cgroups_path: Option<String>,
140    #[serde(rename = "intelRdt", default, skip_serializing_if = "Option::is_none")]
141    pub intel_rdt: Option<OciIntelRdt>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct OciNamespace {
146    #[serde(rename = "type")]
147    pub namespace_type: String,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151pub struct OciIdMapping {
152    #[serde(rename = "containerID")]
153    pub container_id: u32,
154    #[serde(rename = "hostID")]
155    pub host_id: u32,
156    pub size: u32,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct OciResources {
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub memory: Option<OciMemory>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub cpu: Option<OciCpu>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub pids: Option<OciPids>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct OciMemory {
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub limit: Option<i64>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct OciCpu {
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub quota: Option<i64>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub period: Option<u64>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct OciPids {
185    pub limit: i64,
186}
187
188/// OCI process resource limit.
189///
190/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/config.md#posix-process>
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct OciRlimit {
193    /// Resource type (e.g. "RLIMIT_NOFILE", "RLIMIT_NPROC")
194    #[serde(rename = "type")]
195    pub limit_type: String,
196    /// Hard limit
197    pub hard: u64,
198    /// Soft limit
199    pub soft: u64,
200}
201
202/// OCI console size for terminal-attached processes.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct OciConsoleSize {
205    pub height: u32,
206    pub width: u32,
207}
208
209/// OCI linux device entry.
210///
211/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/config-linux.md#devices>
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct OciDevice {
214    /// Device type: "c" (char), "b" (block), "u" (unbuffered), "p" (FIFO)
215    #[serde(rename = "type")]
216    pub device_type: String,
217    /// Device path inside the container
218    pub path: String,
219    /// Major number
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub major: Option<i64>,
222    /// Minor number
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub minor: Option<i64>,
225    /// File mode (permissions)
226    #[serde(rename = "fileMode", skip_serializing_if = "Option::is_none")]
227    pub file_mode: Option<u32>,
228    /// UID of the device owner
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub uid: Option<u32>,
231    /// GID of the device owner
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub gid: Option<u32>,
234}
235
236/// OCI seccomp configuration.
237///
238/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/config-linux.md#seccomp>
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct OciSeccomp {
241    /// Default action when no rule matches (e.g. "SCMP_ACT_ERRNO", "SCMP_ACT_ALLOW")
242    #[serde(rename = "defaultAction")]
243    pub default_action: String,
244    /// Target architectures
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub architectures: Vec<String>,
247    /// Syscall rules
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub syscalls: Vec<OciSeccompSyscall>,
250}
251
252/// A single seccomp syscall rule.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct OciSeccompSyscall {
255    /// Syscall names this rule applies to
256    pub names: Vec<String>,
257    /// Action to take (e.g. "SCMP_ACT_ALLOW")
258    pub action: String,
259    /// Optional argument conditions
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub args: Vec<OciSeccompArg>,
262}
263
264/// Seccomp syscall argument filter.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct OciSeccompArg {
267    /// Argument index (0-based)
268    pub index: u32,
269    /// Value to compare against
270    pub value: u64,
271    /// Second value for masked operations
272    #[serde(rename = "valueTwo", default, skip_serializing_if = "is_zero")]
273    pub value_two: u64,
274    /// Comparison operator (e.g. "SCMP_CMP_EQ", "SCMP_CMP_MASKED_EQ")
275    pub op: String,
276}
277
278fn is_zero(v: &u64) -> bool {
279    *v == 0
280}
281
282/// OCI Intel RDT (Resource Director Technology) configuration.
283///
284/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/config-linux.md#intel-rdt>
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct OciIntelRdt {
287    /// Unique identity for the container's cache and memory bandwidth allocation
288    #[serde(rename = "closID", default, skip_serializing_if = "Option::is_none")]
289    pub clos_id: Option<String>,
290    /// Schema for L3 cache allocation
291    #[serde(
292        rename = "l3CacheSchema",
293        default,
294        skip_serializing_if = "Option::is_none"
295    )]
296    pub l3_cache_schema: Option<String>,
297    /// Schema for memory bandwidth allocation
298    #[serde(
299        rename = "memBwSchema",
300        default,
301        skip_serializing_if = "Option::is_none"
302    )]
303    pub mem_bw_schema: Option<String>,
304}
305
306/// A single OCI lifecycle hook entry.
307///
308/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/config.md#posix-platform-hooks>
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct OciHook {
311    /// Absolute path to the hook binary.
312    pub path: String,
313    /// Arguments passed to the hook (argv\[0\] should be the binary name).
314    #[serde(default, skip_serializing_if = "Vec::is_empty")]
315    pub args: Vec<String>,
316    /// Environment variables for the hook process.
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub env: Vec<String>,
319    /// Timeout in seconds. If the hook does not exit within this duration it is killed.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub timeout: Option<u32>,
322}
323
324/// OCI lifecycle hooks.
325///
326/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/config.md#posix-platform-hooks>
327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328pub struct OciHooks {
329    /// Called after the runtime environment has been created but before pivot_root.
330    #[serde(
331        rename = "createRuntime",
332        default,
333        skip_serializing_if = "Vec::is_empty"
334    )]
335    pub create_runtime: Vec<OciHook>,
336    /// Called after pivot_root but before the start operation.
337    #[serde(
338        rename = "createContainer",
339        default,
340        skip_serializing_if = "Vec::is_empty"
341    )]
342    pub create_container: Vec<OciHook>,
343    /// Called after the start operation but before the user process executes.
344    #[serde(
345        rename = "startContainer",
346        default,
347        skip_serializing_if = "Vec::is_empty"
348    )]
349    pub start_container: Vec<OciHook>,
350    /// Called after the user-specified process has started.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub poststart: Vec<OciHook>,
353    /// Called after the container has been stopped.
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub poststop: Vec<OciHook>,
356}
357
358/// Container state JSON passed to OCI hooks on stdin.
359///
360/// Spec: <https://github.com/opencontainers/runtime-spec/blob/main/runtime.md#state>
361#[derive(Debug, Clone, Serialize)]
362pub struct OciContainerState {
363    #[serde(rename = "ociVersion")]
364    pub oci_version: String,
365    pub id: String,
366    pub status: OciStatus,
367    pub pid: u32,
368    pub bundle: String,
369}
370
371impl OciHooks {
372    /// Returns true if there are no hooks configured.
373    pub fn is_empty(&self) -> bool {
374        self.create_runtime.is_empty()
375            && self.create_container.is_empty()
376            && self.start_container.is_empty()
377            && self.poststart.is_empty()
378            && self.poststop.is_empty()
379    }
380
381    /// Execute a list of hooks in order, passing container state JSON on stdin.
382    ///
383    /// If any hook exits non-zero, an error is returned immediately (remaining hooks are skipped).
384    pub fn run_hooks(hooks: &[OciHook], state: &OciContainerState, phase: &str) -> Result<()> {
385        let state_json = serde_json::to_string(state).map_err(|e| {
386            NucleusError::HookError(format!(
387                "Failed to serialize container state for hook: {}",
388                e
389            ))
390        })?;
391
392        for (i, hook) in hooks.iter().enumerate() {
393            info!(
394                "Running {} hook [{}/{}]: {}",
395                phase,
396                i + 1,
397                hooks.len(),
398                hook.path
399            );
400            Self::execute_hook(hook, &state_json, phase)?;
401        }
402
403        Ok(())
404    }
405
406    /// Execute a list of hooks best-effort (log errors but don't fail).
407    ///
408    /// Used for poststop hooks per the OCI spec: errors MUST be logged but MUST NOT
409    /// prevent cleanup.
410    pub fn run_hooks_best_effort(hooks: &[OciHook], state: &OciContainerState, phase: &str) {
411        let state_json = match serde_json::to_string(state) {
412            Ok(json) => json,
413            Err(e) => {
414                warn!(
415                    "Failed to serialize container state for {} hooks: {}",
416                    phase, e
417                );
418                return;
419            }
420        };
421
422        for (i, hook) in hooks.iter().enumerate() {
423            info!(
424                "Running {} hook [{}/{}]: {}",
425                phase,
426                i + 1,
427                hooks.len(),
428                hook.path
429            );
430            if let Err(e) = Self::execute_hook(hook, &state_json, phase) {
431                warn!("{} hook [{}] failed (continuing): {}", phase, i + 1, e);
432            }
433        }
434    }
435
436    fn execute_hook(hook: &OciHook, state_json: &str, phase: &str) -> Result<()> {
437        #[cfg(not(test))]
438        use std::os::unix::process::CommandExt;
439        use std::process::{Command, Stdio};
440
441        let hook_path = Path::new(&hook.path);
442        if !hook_path.is_absolute() {
443            return Err(NucleusError::HookError(format!(
444                "{} hook path must be absolute: {}",
445                phase, hook.path
446            )));
447        }
448
449        // Restrict hooks to trusted system directories. Hooks execute in
450        // the parent process before security hardening (by OCI spec), so
451        // they must come from locations that unprivileged users cannot write to.
452        #[cfg(not(test))]
453        {
454            const TRUSTED_HOOK_PREFIXES: &[&str] = &[
455                "/usr/bin/",
456                "/usr/sbin/",
457                "/usr/lib/",
458                "/usr/libexec/",
459                "/usr/local/bin/",
460                "/usr/local/sbin/",
461                "/usr/local/libexec/",
462                "/bin/",
463                "/sbin/",
464                "/nix/store/",
465                "/opt/",
466            ];
467            if !TRUSTED_HOOK_PREFIXES
468                .iter()
469                .any(|prefix| hook.path.starts_with(prefix))
470            {
471                return Err(NucleusError::HookError(format!(
472                    "{} hook path '{}' is not under a trusted directory ({:?})",
473                    phase, hook.path, TRUSTED_HOOK_PREFIXES
474                )));
475            }
476        }
477
478        // Use symlink_metadata (lstat) instead of .exists() to avoid
479        // following symlinks in the existence check. Reject symlinked hooks
480        // to prevent a TOCTOU swap between the check and exec.
481        match std::fs::symlink_metadata(hook_path) {
482            Ok(meta) if meta.file_type().is_symlink() => {
483                return Err(NucleusError::HookError(format!(
484                    "{} hook path is a symlink (refusing to follow): {}",
485                    phase, hook.path
486                )));
487            }
488            Err(_) => {
489                return Err(NucleusError::HookError(format!(
490                    "{} hook binary not found: {}",
491                    phase, hook.path
492                )));
493            }
494            Ok(_) => {}
495        }
496
497        // C-1: Validate hook binary ownership and permissions to prevent
498        // execution of world-writable or unexpectedly-owned binaries.
499        // Similar to runsc's hook validation – reject hooks that could be
500        // tampered with by unprivileged users.
501        Self::validate_hook_binary(hook_path, phase)?;
502
503        let mut cmd = Command::new(&hook.path);
504        if !hook.args.is_empty() {
505            // OCI spec: args[0] is the binary name (like execve argv); pass rest as arguments
506            cmd.args(&hook.args[1..]);
507        }
508
509        if !hook.env.is_empty() {
510            cmd.env_clear();
511            for entry in &hook.env {
512                if let Some((key, value)) = entry.split_once('=') {
513                    cmd.env(key, value);
514                }
515            }
516        }
517
518        // C-1: Drop all capabilities and set restrictive resource limits
519        // for hook execution. Hooks run in the parent process before security
520        // hardening, so we sandbox them defensively.
521        cmd.stdin(Stdio::piped());
522        cmd.stdout(Stdio::piped());
523        cmd.stderr(Stdio::piped());
524
525        // C-1: Apply RLIMIT backstops only in the spawned child process
526        // via pre_exec, so the parent process is not affected.
527        // Note: pre_exec runs after fork but before exec, in the child process.
528        #[cfg(not(test))]
529        unsafe {
530            cmd.pre_exec(|| {
531                // Prevent the hook from gaining privileges via setuid/setgid
532                // binaries or file capabilities. This must be set before exec.
533                if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
534                    return Err(std::io::Error::last_os_error());
535                }
536
537                let rlim_nproc = libc::rlimit {
538                    rlim_cur: 1024,
539                    rlim_max: 1024,
540                };
541                if libc::setrlimit(libc::RLIMIT_NPROC, &rlim_nproc) != 0 {
542                    return Err(std::io::Error::last_os_error());
543                }
544
545                let rlim_nofile = libc::rlimit {
546                    rlim_cur: 1024,
547                    rlim_max: 1024,
548                };
549                if libc::setrlimit(libc::RLIMIT_NOFILE, &rlim_nofile) != 0 {
550                    return Err(std::io::Error::last_os_error());
551                }
552
553                Ok(())
554            });
555        }
556
557        const TEXT_FILE_BUSY_SPAWN_RETRIES: usize = 100;
558        const TEXT_FILE_BUSY_RETRY_DELAY: std::time::Duration =
559            std::time::Duration::from_millis(10);
560
561        let mut text_file_busy_retries = 0;
562        let mut child = loop {
563            match cmd.spawn() {
564                Ok(child) => break child,
565                Err(e)
566                    if e.raw_os_error() == Some(libc::ETXTBSY)
567                        && text_file_busy_retries < TEXT_FILE_BUSY_SPAWN_RETRIES =>
568                {
569                    text_file_busy_retries += 1;
570                    debug!(
571                        "{} hook {} was busy during spawn; retrying ({}/{})",
572                        phase, hook.path, text_file_busy_retries, TEXT_FILE_BUSY_SPAWN_RETRIES
573                    );
574                    std::thread::sleep(TEXT_FILE_BUSY_RETRY_DELAY);
575                }
576                Err(e) => {
577                    return Err(NucleusError::HookError(format!(
578                        "Failed to spawn {} hook {}: {}",
579                        phase, hook.path, e
580                    )));
581                }
582            }
583        };
584
585        if let Some(mut stdin) = child.stdin.take() {
586            use std::io::Write as IoWrite;
587            let _ = stdin.write_all(state_json.as_bytes());
588        }
589
590        let timeout_secs = hook.timeout.unwrap_or(30) as u64;
591        let start = std::time::Instant::now();
592        let timeout = std::time::Duration::from_secs(timeout_secs);
593
594        loop {
595            match child.try_wait() {
596                Ok(Some(status)) => {
597                    if status.success() {
598                        debug!("{} hook {} completed successfully", phase, hook.path);
599                        return Ok(());
600                    } else {
601                        let stderr = child
602                            .stderr
603                            .take()
604                            .map(|mut e| {
605                                let mut buf = String::new();
606                                use std::io::Read;
607                                let _ = e.read_to_string(&mut buf);
608                                buf
609                            })
610                            .unwrap_or_default();
611                        return Err(NucleusError::HookError(format!(
612                            "{} hook {} exited with status: {}{}",
613                            phase,
614                            hook.path,
615                            status,
616                            if stderr.is_empty() {
617                                String::new()
618                            } else {
619                                format!(" (stderr: {})", stderr.trim())
620                            }
621                        )));
622                    }
623                }
624                Ok(None) => {
625                    if start.elapsed() >= timeout {
626                        let _ = child.kill();
627                        let _ = child.wait();
628                        return Err(NucleusError::HookError(format!(
629                            "{} hook {} timed out after {}s",
630                            phase, hook.path, timeout_secs
631                        )));
632                    }
633                    std::thread::sleep(std::time::Duration::from_millis(50));
634                }
635                Err(e) => {
636                    return Err(NucleusError::HookError(format!(
637                        "Failed to wait for {} hook {}: {}",
638                        phase, hook.path, e
639                    )));
640                }
641            }
642        }
643    }
644
645    /// Validate hook binary ownership and permissions.
646    ///
647    /// Rejects hooks that are world-writable or group-writable, or owned by
648    /// a UID that doesn't match the effective UID or root. This prevents
649    /// privilege escalation via tampered hook binaries.
650    fn validate_hook_binary(hook_path: &Path, phase: &str) -> Result<()> {
651        // Use symlink_metadata (lstat) to inspect the hook path itself
652        // rather than following symlinks, consistent with the rejection
653        // of symlinked hooks above.
654        let metadata = std::fs::symlink_metadata(hook_path).map_err(|e| {
655            NucleusError::HookError(format!(
656                "Failed to stat {} hook {}: {}",
657                phase,
658                hook_path.display(),
659                e
660            ))
661        })?;
662
663        use std::os::unix::fs::MetadataExt;
664        let mode = metadata.mode();
665        let uid = metadata.uid();
666        let gid = metadata.gid();
667        let effective_uid = nix::unistd::Uid::effective().as_raw();
668
669        // Reject world-writable hooks
670        if mode & 0o002 != 0 {
671            return Err(NucleusError::HookError(format!(
672                "{} hook {} is world-writable (mode {:04o}) – refusing to execute",
673                phase,
674                hook_path.display(),
675                mode & 0o7777
676            )));
677        }
678
679        // Reject group-writable hooks unless owned by root
680        if mode & 0o020 != 0 && uid != 0 {
681            return Err(NucleusError::HookError(format!(
682                "{} hook {} is group-writable and not owned by root (mode {:04o}, uid {}) – refusing to execute",
683                phase,
684                hook_path.display(),
685                mode & 0o7777,
686                uid
687            )));
688        }
689
690        // Reject hooks owned by arbitrary UIDs – must be root or effective UID
691        if uid != 0 && uid != effective_uid {
692            return Err(NucleusError::HookError(format!(
693                "{} hook {} is owned by UID {} (expected 0 or {}) – refusing to execute",
694                phase,
695                hook_path.display(),
696                uid,
697                effective_uid
698            )));
699        }
700
701        // Reject hooks with setuid/setgid bits
702        if mode & 0o6000 != 0 {
703            return Err(NucleusError::HookError(format!(
704                "{} hook {} has setuid/setgid bits (mode {:04o}) – refusing to execute",
705                phase,
706                hook_path.display(),
707                mode & 0o7777
708            )));
709        }
710
711        debug!(
712            "{} hook {} validation passed (uid={}, gid={}, mode={:04o})",
713            phase,
714            hook_path.display(),
715            uid,
716            gid,
717            mode & 0o7777
718        );
719
720        Ok(())
721    }
722}
723
724impl OciConfig {
725    /// Create a minimal OCI config for Nucleus containers
726    pub fn new(command: Vec<String>, hostname: Option<String>) -> Self {
727        Self {
728            oci_version: "1.0.2".to_string(),
729            root: OciRoot {
730                path: "rootfs".to_string(),
731                readonly: true,
732            },
733            process: OciProcess {
734                terminal: false,
735                user: OciUser {
736                    uid: 0,
737                    gid: 0,
738                    additional_gids: None,
739                },
740                args: command,
741                env: vec![
742                    "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
743                    "HOME=/home/agent".to_string(),
744                ],
745                cwd: "/workspace".to_string(),
746                no_new_privileges: true,
747                capabilities: Some(OciCapabilities {
748                    bounding: vec![],
749                    effective: vec![],
750                    inheritable: vec![],
751                    permitted: vec![],
752                    ambient: vec![],
753                }),
754                rlimits: vec![],
755                console_size: None,
756                apparmor_profile: None,
757                selinux_label: None,
758            },
759            hostname,
760            mounts: vec![
761                OciMount {
762                    destination: "/proc".to_string(),
763                    source: "proc".to_string(),
764                    mount_type: "proc".to_string(),
765                    options: vec![
766                        "nosuid".to_string(),
767                        "noexec".to_string(),
768                        "nodev".to_string(),
769                    ],
770                },
771                OciMount {
772                    destination: "/dev".to_string(),
773                    source: "tmpfs".to_string(),
774                    mount_type: "tmpfs".to_string(),
775                    options: vec![
776                        "nosuid".to_string(),
777                        "noexec".to_string(),
778                        "strictatime".to_string(),
779                        "mode=755".to_string(),
780                        "size=65536k".to_string(),
781                    ],
782                },
783                OciMount {
784                    destination: "/dev/shm".to_string(),
785                    source: "shm".to_string(),
786                    mount_type: "tmpfs".to_string(),
787                    options: vec![
788                        "nosuid".to_string(),
789                        "noexec".to_string(),
790                        "nodev".to_string(),
791                        "mode=1777".to_string(),
792                        "size=65536k".to_string(),
793                    ],
794                },
795                OciMount {
796                    destination: "/tmp".to_string(),
797                    source: "tmpfs".to_string(),
798                    mount_type: "tmpfs".to_string(),
799                    options: vec![
800                        "nosuid".to_string(),
801                        "nodev".to_string(),
802                        "noexec".to_string(),
803                        "mode=1777".to_string(),
804                        "size=65536k".to_string(),
805                    ],
806                },
807                OciMount {
808                    destination: "/home/agent".to_string(),
809                    source: "tmpfs".to_string(),
810                    mount_type: "tmpfs".to_string(),
811                    options: vec![
812                        "nosuid".to_string(),
813                        "nodev".to_string(),
814                        "noexec".to_string(),
815                        "mode=0700".to_string(),
816                        "size=262144k".to_string(),
817                        "uid=0".to_string(),
818                        "gid=0".to_string(),
819                    ],
820                },
821                OciMount {
822                    destination: "/sys".to_string(),
823                    source: "sysfs".to_string(),
824                    mount_type: "sysfs".to_string(),
825                    options: vec![
826                        "nosuid".to_string(),
827                        "noexec".to_string(),
828                        "nodev".to_string(),
829                        "ro".to_string(),
830                    ],
831                },
832            ],
833            hooks: None,
834            annotations: HashMap::new(),
835            linux: Some(OciLinux {
836                namespaces: Some(vec![
837                    OciNamespace {
838                        namespace_type: "pid".to_string(),
839                    },
840                    OciNamespace {
841                        namespace_type: "network".to_string(),
842                    },
843                    OciNamespace {
844                        namespace_type: "ipc".to_string(),
845                    },
846                    OciNamespace {
847                        namespace_type: "uts".to_string(),
848                    },
849                    OciNamespace {
850                        namespace_type: "mount".to_string(),
851                    },
852                ]),
853                resources: None,
854                uid_mappings: vec![],
855                gid_mappings: vec![],
856                // M14: Aligned with native masked paths in mount.rs (PROC_NULL_MASKED)
857                masked_paths: vec![
858                    "/proc/acpi".to_string(),
859                    "/proc/asound".to_string(),
860                    "/proc/kcore".to_string(),
861                    "/proc/keys".to_string(),
862                    "/proc/latency_stats".to_string(),
863                    "/proc/sched_debug".to_string(),
864                    "/proc/scsi".to_string(),
865                    "/proc/timer_list".to_string(),
866                    "/proc/timer_stats".to_string(),
867                    "/proc/sysrq-trigger".to_string(), // M14: null-mask, not read-only
868                    "/proc/kpagecount".to_string(),
869                    "/proc/kpageflags".to_string(),
870                    "/proc/kpagecgroup".to_string(),
871                    "/proc/config.gz".to_string(),
872                    "/proc/kallsyms".to_string(),
873                    "/sys/firmware".to_string(),
874                ],
875                readonly_paths: vec![
876                    "/proc/bus".to_string(),
877                    "/proc/fs".to_string(),
878                    "/proc/irq".to_string(),
879                    "/proc/sys".to_string(),
880                ],
881                devices: vec![
882                    OciDevice {
883                        device_type: "c".to_string(),
884                        path: "/dev/null".to_string(),
885                        major: Some(1),
886                        minor: Some(3),
887                        file_mode: Some(0o666),
888                        uid: Some(0),
889                        gid: Some(0),
890                    },
891                    OciDevice {
892                        device_type: "c".to_string(),
893                        path: "/dev/zero".to_string(),
894                        major: Some(1),
895                        minor: Some(5),
896                        file_mode: Some(0o666),
897                        uid: Some(0),
898                        gid: Some(0),
899                    },
900                    OciDevice {
901                        device_type: "c".to_string(),
902                        path: "/dev/full".to_string(),
903                        major: Some(1),
904                        minor: Some(7),
905                        file_mode: Some(0o666),
906                        uid: Some(0),
907                        gid: Some(0),
908                    },
909                    OciDevice {
910                        device_type: "c".to_string(),
911                        path: "/dev/random".to_string(),
912                        major: Some(1),
913                        minor: Some(8),
914                        file_mode: Some(0o666),
915                        uid: Some(0),
916                        gid: Some(0),
917                    },
918                    OciDevice {
919                        device_type: "c".to_string(),
920                        path: "/dev/urandom".to_string(),
921                        major: Some(1),
922                        minor: Some(9),
923                        file_mode: Some(0o666),
924                        uid: Some(0),
925                        gid: Some(0),
926                    },
927                ],
928                seccomp: None,
929                rootfs_propagation: Some("rprivate".to_string()),
930                sysctl: HashMap::new(),
931                cgroups_path: None,
932                intel_rdt: None,
933            }),
934        }
935    }
936
937    /// Add resource limits to the config
938    pub fn with_resources(mut self, limits: &ResourceLimits) -> Self {
939        let mut resources = OciResources {
940            memory: None,
941            cpu: None,
942            pids: None,
943        };
944
945        if let Some(memory_bytes) = limits.memory_bytes {
946            resources.memory = Some(OciMemory {
947                limit: Some(memory_bytes as i64),
948            });
949        }
950
951        if let Some(quota_us) = limits.cpu_quota_us {
952            resources.cpu = Some(OciCpu {
953                quota: Some(quota_us as i64),
954                period: Some(limits.cpu_period_us),
955            });
956        }
957
958        if let Some(pids_max) = limits.pids_max {
959            resources.pids = Some(OciPids {
960                limit: pids_max as i64,
961            });
962        }
963
964        if let Some(linux) = &mut self.linux {
965            linux.resources = Some(resources);
966        }
967
968        self
969    }
970
971    /// Configure the OCI noNewPrivileges process flag.
972    pub fn with_no_new_privileges(mut self, enabled: bool) -> Self {
973        self.process.no_new_privileges = enabled;
974        self
975    }
976
977    /// Configure the process as terminal-attached with an initial console size.
978    pub fn with_terminal(mut self, size: ConsoleSize) -> Self {
979        self.process.terminal = true;
980        self.process.console_size = Some(OciConsoleSize {
981            height: size.height as u32,
982            width: size.width as u32,
983        });
984        self
985    }
986
987    /// Add environment variables to the OCI process config.
988    pub fn with_env(mut self, vars: &[(String, String)]) -> Self {
989        if vars.iter().any(|(key, _)| key == "HOME") {
990            self.process.env.retain(|entry| !entry.starts_with("HOME="));
991        }
992        for (key, value) in vars {
993            self.process.env.push(format!("{}={}", key, value));
994        }
995        self
996    }
997
998    /// Add or replace the private sandbox home tmpfs mount.
999    pub fn with_home_tmpfs(
1000        mut self,
1001        home: &Path,
1002        identity: &crate::container::ProcessIdentity,
1003    ) -> Result<Self> {
1004        let dest = normalize_container_destination(home)?;
1005        let dest_str = dest.to_string_lossy().to_string();
1006
1007        self.process.env.retain(|entry| !entry.starts_with("HOME="));
1008        self.process.env.push(format!("HOME={}", dest_str));
1009
1010        self.mounts
1011            .retain(|mount| mount.destination != "/home/agent");
1012        self.mounts.push(OciMount {
1013            destination: dest_str,
1014            source: "tmpfs".to_string(),
1015            mount_type: "tmpfs".to_string(),
1016            options: vec![
1017                "nosuid".to_string(),
1018                "nodev".to_string(),
1019                "noexec".to_string(),
1020                "mode=0700".to_string(),
1021                "size=262144k".to_string(),
1022                format!("uid={}", identity.uid),
1023                format!("gid={}", identity.gid),
1024            ],
1025        });
1026
1027        Ok(self)
1028    }
1029
1030    /// Set the OCI process working directory.
1031    pub fn with_workdir(mut self, workdir: &Path) -> Result<Self> {
1032        let normalized = normalize_container_destination(workdir)?;
1033        self.process.cwd = normalized.to_string_lossy().to_string();
1034        Ok(self)
1035    }
1036
1037    /// Add sd_notify socket passthrough.
1038    pub fn with_sd_notify(mut self) -> Self {
1039        if let Ok(notify_socket) = std::env::var("NOTIFY_SOCKET") {
1040            self.process
1041                .env
1042                .push(format!("NOTIFY_SOCKET={}", notify_socket));
1043        }
1044        self
1045    }
1046
1047    /// Add bind mounts for secrets.
1048    pub fn with_secret_mounts(mut self, secrets: &[crate::container::SecretMount]) -> Self {
1049        for secret in secrets {
1050            self.mounts.push(OciMount {
1051                destination: secret.dest.to_string_lossy().to_string(),
1052                source: secret.source.to_string_lossy().to_string(),
1053                mount_type: "bind".to_string(),
1054                options: vec![
1055                    "bind".to_string(),
1056                    "ro".to_string(),
1057                    "nosuid".to_string(),
1058                    "nodev".to_string(),
1059                    "noexec".to_string(),
1060                ],
1061            });
1062        }
1063        self
1064    }
1065
1066    /// Set the process identity for the OCI workload.
1067    pub fn with_process_identity(mut self, identity: &crate::container::ProcessIdentity) -> Self {
1068        self.process.user.uid = identity.uid;
1069        self.process.user.gid = identity.gid;
1070        self.process.user.additional_gids = if identity.additional_gids.is_empty() {
1071            None
1072        } else {
1073            Some(identity.additional_gids.clone())
1074        };
1075        self
1076    }
1077
1078    /// Add a read-only bind mount of an in-memory secret staging directory at
1079    /// `/run/secrets`, plus compatibility bind mounts for each staged secret to
1080    /// its requested container destination.
1081    pub fn with_inmemory_secret_mounts(
1082        mut self,
1083        stage_dir: &Path,
1084        secrets: &[crate::container::SecretMount],
1085    ) -> Result<Self> {
1086        self.mounts.push(OciMount {
1087            destination: "/run/secrets".to_string(),
1088            source: stage_dir.to_string_lossy().to_string(),
1089            mount_type: "bind".to_string(),
1090            options: vec![
1091                "bind".to_string(),
1092                "ro".to_string(),
1093                "nosuid".to_string(),
1094                "nodev".to_string(),
1095                "noexec".to_string(),
1096            ],
1097        });
1098
1099        for secret in secrets {
1100            let dest = normalize_container_destination(&secret.dest)?;
1101            if !secret.source.starts_with(stage_dir) {
1102                return Err(NucleusError::ConfigError(format!(
1103                    "Staged secret source {:?} must live under {:?}",
1104                    secret.source, stage_dir
1105                )));
1106            }
1107            self.mounts.push(OciMount {
1108                destination: dest.to_string_lossy().to_string(),
1109                source: secret.source.to_string_lossy().to_string(),
1110                mount_type: "bind".to_string(),
1111                options: vec![
1112                    "bind".to_string(),
1113                    "ro".to_string(),
1114                    "nosuid".to_string(),
1115                    "nodev".to_string(),
1116                    "noexec".to_string(),
1117                ],
1118            });
1119        }
1120
1121        Ok(self)
1122    }
1123
1124    /// Add bind or tmpfs volume mounts.
1125    pub fn with_volume_mounts(mut self, volumes: &[crate::container::VolumeMount]) -> Result<Self> {
1126        use crate::container::VolumeSource;
1127
1128        for volume in volumes {
1129            let dest = normalize_volume_destination(&volume.dest)?;
1130            match &volume.source {
1131                VolumeSource::Bind { source } => {
1132                    crate::filesystem::validate_bind_mount_source(source)?;
1133                    let mut options = vec![
1134                        "bind".to_string(),
1135                        "nosuid".to_string(),
1136                        "nodev".to_string(),
1137                    ];
1138                    if volume.read_only {
1139                        options.push("ro".to_string());
1140                    }
1141                    self.mounts.push(OciMount {
1142                        destination: dest.to_string_lossy().to_string(),
1143                        source: source.to_string_lossy().to_string(),
1144                        mount_type: "bind".to_string(),
1145                        options,
1146                    });
1147                }
1148                VolumeSource::Tmpfs { size } => {
1149                    let mut options = vec![
1150                        "nosuid".to_string(),
1151                        "nodev".to_string(),
1152                        "mode=0755".to_string(),
1153                    ];
1154                    if volume.read_only {
1155                        options.push("ro".to_string());
1156                    }
1157                    if let Some(size) = size {
1158                        options.push(format!("size={}", size));
1159                    }
1160                    self.mounts.push(OciMount {
1161                        destination: dest.to_string_lossy().to_string(),
1162                        source: "tmpfs".to_string(),
1163                        mount_type: "tmpfs".to_string(),
1164                        options,
1165                    });
1166                }
1167            }
1168        }
1169
1170        Ok(self)
1171    }
1172
1173    /// Add GPU passthrough: OCI device entries for each GPU device plus
1174    /// read-only bind mounts for driver support files, and inject the NVIDIA
1175    /// environment variables. runsc creates the device nodes and installs the
1176    /// matching cgroup device rules inside its sandbox.
1177    pub fn with_gpu_passthrough(
1178        mut self,
1179        gpu: &crate::container::GpuPassthroughConfig,
1180        set: &crate::filesystem::GpuDeviceSet,
1181        identity: &crate::container::ProcessIdentity,
1182    ) -> Result<Self> {
1183        use crate::container::gpu_environment;
1184
1185        // Device entries: runsc mknod's these and applies cgroup device rules.
1186        if let Some(linux) = self.linux.as_mut() {
1187            for (host_node, spec) in set.node_specs_with_paths() {
1188                let rel = host_node.strip_prefix("/").unwrap_or(host_node.as_path());
1189                let container_path = format!("/dev/{}", rel.to_string_lossy());
1190                linux.devices.push(OciDevice {
1191                    device_type: if spec.is_block { "b" } else { "c" }.to_string(),
1192                    path: container_path,
1193                    major: Some(spec.major as i64),
1194                    minor: Some(spec.minor as i64),
1195                    file_mode: Some(0o660),
1196                    uid: Some(identity.uid),
1197                    gid: Some(identity.gid),
1198                });
1199            }
1200        }
1201
1202        // Driver support files (NVIDIA /proc, lib dirs, ICD JSON; ROCm /opt).
1203        for support in crate::filesystem::support_paths(set, gpu.bind_driver_libraries) {
1204            let dest = support.to_string_lossy().to_string();
1205            self.mounts.push(OciMount {
1206                destination: dest,
1207                source: support.to_string_lossy().to_string(),
1208                mount_type: "bind".to_string(),
1209                options: vec![
1210                    "bind".to_string(),
1211                    "ro".to_string(),
1212                    "nosuid".to_string(),
1213                    "nodev".to_string(),
1214                ],
1215            });
1216        }
1217
1218        // NVIDIA environment variables.
1219        let env: Vec<(String, String)> =
1220            gpu_environment(gpu).into_iter().map(|(k, v)| (k.to_string(), v)).collect();
1221        self = self.with_env(&env);
1222        Ok(self)
1223    }
1224
1225    /// Add explicit provider CLI config bind mounts under the sandbox home.
1226    pub fn with_provider_config_mounts(
1227        mut self,
1228        home: &Path,
1229        provider_configs: &[crate::container::ProviderConfigMount],
1230    ) -> Result<Self> {
1231        for provider_config in provider_configs {
1232            crate::filesystem::validate_provider_config_source(&provider_config.source)?;
1233            let source = fs::canonicalize(&provider_config.source).map_err(|e| {
1234                NucleusError::FilesystemError(format!(
1235                    "Failed to canonicalize provider config source {:?}: {}",
1236                    provider_config.source, e
1237                ))
1238            })?;
1239            let dest = normalize_provider_config_destination(home, &provider_config.dest)?;
1240
1241            let mut options = vec![
1242                "bind".to_string(),
1243                "nosuid".to_string(),
1244                "nodev".to_string(),
1245                "noexec".to_string(),
1246            ];
1247            if provider_config.read_only {
1248                options.push("ro".to_string());
1249            }
1250
1251            self.mounts.push(OciMount {
1252                destination: dest.to_string_lossy().to_string(),
1253                source: source.to_string_lossy().to_string(),
1254                mount_type: "bind".to_string(),
1255                options,
1256            });
1257        }
1258
1259        Ok(self)
1260    }
1261
1262    /// Add the first-class workspace bind mount.
1263    pub fn with_workspace_mount(
1264        mut self,
1265        workspace: &crate::container::WorkspaceConfig,
1266    ) -> Result<Self> {
1267        let Some(source) = workspace.effective_host_path() else {
1268            return Ok(self);
1269        };
1270        if workspace.mode == crate::container::WorkspaceMode::CopyInOut {
1271            let metadata = fs::symlink_metadata(source).map_err(|e| {
1272                NucleusError::FilesystemError(format!(
1273                    "Failed to stat workspace staging path {:?}: {}",
1274                    source, e
1275                ))
1276            })?;
1277            if metadata.file_type().is_symlink() || !metadata.is_dir() {
1278                return Err(NucleusError::FilesystemError(format!(
1279                    "Workspace staging path must be a real directory: {:?}",
1280                    source
1281                )));
1282            }
1283        } else {
1284            crate::filesystem::validate_workspace_host_path(source)?;
1285        }
1286        let dest = normalize_container_destination(&workspace.container_path)?;
1287
1288        let mut options = vec![
1289            "bind".to_string(),
1290            "nosuid".to_string(),
1291            "nodev".to_string(),
1292        ];
1293        if workspace.is_read_only() {
1294            options.push("ro".to_string());
1295        }
1296        if !workspace.allow_execute {
1297            options.push("noexec".to_string());
1298        }
1299
1300        self.mounts.push(OciMount {
1301            destination: dest.to_string_lossy().to_string(),
1302            source: source.to_string_lossy().to_string(),
1303            mount_type: "bind".to_string(),
1304            options,
1305        });
1306
1307        Ok(self)
1308    }
1309
1310    /// Bind mount the host context directory into the container.
1311    ///
1312    /// The gVisor integration path expects `/context` to be writable so test
1313    /// workloads can write results back to the host.
1314    pub fn with_context_bind(mut self, context_dir: &std::path::Path) -> Self {
1315        self.mounts.push(OciMount {
1316            destination: "/context".to_string(),
1317            source: context_dir.to_string_lossy().to_string(),
1318            mount_type: "bind".to_string(),
1319            options: vec![
1320                "bind".to_string(),
1321                "ro".to_string(),
1322                "nosuid".to_string(),
1323                "nodev".to_string(),
1324            ],
1325        });
1326        self
1327    }
1328
1329    /// Add rootfs bind mounts from a pre-built rootfs path.
1330    pub fn with_rootfs_binds(mut self, rootfs_path: &std::path::Path) -> Result<Self> {
1331        let subdirs = ["bin", "sbin", "lib", "lib64", "usr", "etc", "nix"];
1332        for subdir in &subdirs {
1333            let source = rootfs_path.join(subdir);
1334            if source.exists() {
1335                let source = fs::canonicalize(&source).map_err(|e| {
1336                    NucleusError::FilesystemError(format!(
1337                        "Failed to canonicalize rootfs mount source {:?}: {}",
1338                        source, e
1339                    ))
1340                })?;
1341                self.mounts
1342                    .push(Self::readonly_bind_mount(format!("/{}", subdir), source));
1343            }
1344        }
1345        self.add_rootfs_store_path_binds(rootfs_path)?;
1346        Ok(self)
1347    }
1348
1349    fn readonly_bind_mount(destination: String, source: PathBuf) -> OciMount {
1350        OciMount {
1351            destination,
1352            source: source.to_string_lossy().to_string(),
1353            mount_type: "bind".to_string(),
1354            options: vec![
1355                "bind".to_string(),
1356                "ro".to_string(),
1357                "nosuid".to_string(),
1358                "nodev".to_string(),
1359            ],
1360        }
1361    }
1362
1363    fn add_rootfs_store_path_binds(&mut self, rootfs_path: &Path) -> Result<()> {
1364        let store_paths_file = rootfs_path.join(ROOTFS_STORE_PATHS_FILE);
1365        if !store_paths_file.exists() {
1366            return Ok(());
1367        }
1368
1369        let content = fs::read_to_string(&store_paths_file).map_err(|e| {
1370            NucleusError::FilesystemError(format!(
1371                "Failed to read rootfs store paths {:?}: {}",
1372                store_paths_file, e
1373            ))
1374        })?;
1375
1376        for (line_no, line) in content.lines().enumerate() {
1377            let source = line.trim();
1378            if source.is_empty() {
1379                continue;
1380            }
1381            let source = Path::new(source);
1382            if !is_immediate_nix_store_object_path(source) {
1383                return Err(NucleusError::FilesystemError(format!(
1384                    "Invalid rootfs store path on line {} in {:?}: {} (expected immediate /nix/store/<32-char-hash>-<name> object)",
1385                    line_no + 1,
1386                    store_paths_file,
1387                    source.display()
1388                )));
1389            }
1390            let store_name = source.file_name().ok_or_else(|| {
1391                NucleusError::FilesystemError(format!(
1392                    "Invalid rootfs store path on line {} in {:?}: {}",
1393                    line_no + 1,
1394                    store_paths_file,
1395                    source.display()
1396                ))
1397            })?;
1398            let canonical = fs::canonicalize(source).map_err(|e| {
1399                NucleusError::FilesystemError(format!(
1400                    "Failed to canonicalize rootfs store path {:?}: {}",
1401                    source, e
1402                ))
1403            })?;
1404            if !is_immediate_nix_store_object_path(&canonical) {
1405                return Err(NucleusError::FilesystemError(format!(
1406                    "Rootfs store path must resolve to an immediate /nix/store/<32-char-hash>-<name> object: {} -> {}",
1407                    source.display(),
1408                    canonical.display()
1409                )));
1410            }
1411            let destination = Path::new("/nix/store")
1412                .join(store_name)
1413                .to_string_lossy()
1414                .to_string();
1415            self.mounts
1416                .push(Self::readonly_bind_mount(destination, canonical));
1417        }
1418
1419        Ok(())
1420    }
1421
1422    /// Replace the default namespace list with an explicit configuration.
1423    pub fn with_namespace_config(mut self, config: &NamespaceConfig) -> Self {
1424        let mut namespaces = Vec::new();
1425
1426        if config.pid {
1427            namespaces.push(OciNamespace {
1428                namespace_type: "pid".to_string(),
1429            });
1430        }
1431        if config.net {
1432            namespaces.push(OciNamespace {
1433                namespace_type: "network".to_string(),
1434            });
1435        }
1436        if config.ipc {
1437            namespaces.push(OciNamespace {
1438                namespace_type: "ipc".to_string(),
1439            });
1440        }
1441        if config.uts {
1442            namespaces.push(OciNamespace {
1443                namespace_type: "uts".to_string(),
1444            });
1445        }
1446        if config.mnt {
1447            namespaces.push(OciNamespace {
1448                namespace_type: "mount".to_string(),
1449            });
1450        }
1451        if config.cgroup {
1452            namespaces.push(OciNamespace {
1453                namespace_type: "cgroup".to_string(),
1454            });
1455        }
1456        if config.time {
1457            namespaces.push(OciNamespace {
1458                namespace_type: "time".to_string(),
1459            });
1460        }
1461        if config.user {
1462            namespaces.push(OciNamespace {
1463                namespace_type: "user".to_string(),
1464            });
1465        }
1466
1467        if let Some(linux) = &mut self.linux {
1468            linux.namespaces = Some(namespaces);
1469        }
1470
1471        self
1472    }
1473
1474    /// Add read-only bind mounts for host runtime paths.
1475    ///
1476    /// This mirrors the native fallback path for non-production containers so
1477    /// common executables such as `/bin/sh` remain available inside the OCI
1478    /// rootfs when no explicit rootfs is configured.
1479    pub fn with_host_runtime_binds(mut self) -> Self {
1480        // Use a fixed set of standard FHS paths only. Do NOT scan host $PATH,
1481        // which would expose arbitrary host directories inside the container.
1482        let host_paths: BTreeSet<String> =
1483            ["/bin", "/sbin", "/usr", "/lib", "/lib64", "/nix/store"]
1484                .iter()
1485                .map(|s| s.to_string())
1486                .collect();
1487
1488        for host_path in host_paths {
1489            let source = Path::new(&host_path);
1490            if !source.exists() {
1491                continue;
1492            }
1493
1494            self.mounts.push(OciMount {
1495                destination: host_path.clone(),
1496                source: source.to_string_lossy().to_string(),
1497                mount_type: "bind".to_string(),
1498                options: vec![
1499                    "bind".to_string(),
1500                    "ro".to_string(),
1501                    "nosuid".to_string(),
1502                    "nodev".to_string(),
1503                ],
1504            });
1505        }
1506        self
1507    }
1508
1509    /// Add user namespace configuration
1510    pub fn with_user_namespace(mut self) -> Self {
1511        if let Some(linux) = &mut self.linux {
1512            if let Some(namespaces) = &mut linux.namespaces {
1513                namespaces.push(OciNamespace {
1514                    namespace_type: "user".to_string(),
1515                });
1516            }
1517        }
1518        self
1519    }
1520
1521    /// Remove the OCI network namespace entry so runsc inherits the process
1522    /// network namespace that Nucleus prepared before exec.
1523    pub fn without_network_namespace(mut self) -> Self {
1524        if let Some(linux) = &mut self.linux {
1525            if let Some(namespaces) = &mut linux.namespaces {
1526                namespaces.retain(|ns| ns.namespace_type != "network");
1527            }
1528        }
1529
1530        self
1531    }
1532
1533    /// Remove the OCI user namespace entry so runsc inherits a namespace that
1534    /// Nucleus already created and mapped before exec.
1535    pub fn without_user_namespace(mut self) -> Self {
1536        if let Some(linux) = &mut self.linux {
1537            if let Some(namespaces) = &mut linux.namespaces {
1538                namespaces.retain(|ns| ns.namespace_type != "user");
1539            }
1540            linux.uid_mappings.clear();
1541            linux.gid_mappings.clear();
1542        }
1543
1544        self
1545    }
1546
1547    /// Configure gVisor's true rootless OCI path.
1548    ///
1549    /// gVisor expects UID/GID mappings in the OCI spec for this mode, and its
1550    /// rootless OCI implementation does not currently support a network
1551    /// namespace entry in the spec. We still control networking through
1552    /// runsc's top-level `--network` flag.
1553    pub fn with_rootless_user_namespace(mut self, config: &UserNamespaceConfig) -> Self {
1554        if let Some(linux) = &mut self.linux {
1555            if let Some(namespaces) = &mut linux.namespaces {
1556                namespaces.retain(|ns| ns.namespace_type != "network");
1557                if !namespaces.iter().any(|ns| ns.namespace_type == "user") {
1558                    namespaces.push(OciNamespace {
1559                        namespace_type: "user".to_string(),
1560                    });
1561                }
1562            }
1563            linux.uid_mappings = config.uid_mappings.iter().map(OciIdMapping::from).collect();
1564            linux.gid_mappings = config.gid_mappings.iter().map(OciIdMapping::from).collect();
1565        }
1566        self
1567    }
1568
1569    /// Set OCI lifecycle hooks on the config.
1570    pub fn with_hooks(mut self, hooks: OciHooks) -> Self {
1571        if hooks.is_empty() {
1572            self.hooks = None;
1573        } else {
1574            self.hooks = Some(hooks);
1575        }
1576        self
1577    }
1578
1579    /// Set process rlimits from the Nucleus runtime defaults and configured limits.
1580    ///
1581    /// Mirrors the RLIMIT backstops applied in-process for native containers
1582    /// (runtime.rs), expressed as OCI config so gVisor can enforce them.
1583    pub fn with_rlimits(mut self, limits: &ResourceLimits) -> Self {
1584        let mut rlimits = Vec::with_capacity(3);
1585
1586        if let Some(nproc_limit) = limits.pids_max {
1587            rlimits.push(OciRlimit {
1588                limit_type: "RLIMIT_NPROC".to_string(),
1589                hard: nproc_limit,
1590                soft: nproc_limit,
1591            });
1592        }
1593
1594        rlimits.push(OciRlimit {
1595            limit_type: "RLIMIT_NOFILE".to_string(),
1596            hard: 1024,
1597            soft: 1024,
1598        });
1599
1600        let memlock_limit = limits.memlock_bytes.unwrap_or(64 * 1024);
1601        rlimits.push(OciRlimit {
1602            limit_type: "RLIMIT_MEMLOCK".to_string(),
1603            hard: memlock_limit,
1604            soft: memlock_limit,
1605        });
1606
1607        self.process.rlimits = rlimits;
1608        self
1609    }
1610
1611    /// Set the linux.seccomp section from an OCI seccomp config.
1612    pub fn with_seccomp(mut self, seccomp: OciSeccomp) -> Self {
1613        if let Some(linux) = &mut self.linux {
1614            linux.seccomp = Some(seccomp);
1615        }
1616        self
1617    }
1618
1619    /// Set the linux.cgroupsPath field.
1620    pub fn with_cgroups_path(mut self, path: String) -> Self {
1621        if let Some(linux) = &mut self.linux {
1622            linux.cgroups_path = Some(path);
1623        }
1624        self
1625    }
1626
1627    /// Set sysctl key-value pairs on the linux config.
1628    pub fn with_sysctl(mut self, sysctl: HashMap<String, String>) -> Self {
1629        if let Some(linux) = &mut self.linux {
1630            linux.sysctl = sysctl;
1631        }
1632        self
1633    }
1634
1635    /// Set annotations on the OCI config.
1636    pub fn with_annotations(mut self, annotations: HashMap<String, String>) -> Self {
1637        self.annotations = annotations;
1638        self
1639    }
1640}
1641
1642impl From<&IdMapping> for OciIdMapping {
1643    fn from(mapping: &IdMapping) -> Self {
1644        Self {
1645            container_id: mapping.container_id,
1646            host_id: mapping.host_id,
1647            size: mapping.count,
1648        }
1649    }
1650}
1651
1652/// OCI Bundle manager
1653///
1654/// Creates and manages OCI-compliant bundles for gVisor
1655pub struct OciBundle {
1656    bundle_path: PathBuf,
1657    config: OciConfig,
1658}
1659
1660fn safe_child_name(name: &str) -> std::io::Result<CString> {
1661    if name.is_empty() || name == "." || name == ".." || name.contains('/') {
1662        return Err(std::io::Error::new(
1663            std::io::ErrorKind::InvalidInput,
1664            "invalid path child name",
1665        ));
1666    }
1667
1668    CString::new(name).map_err(|_| {
1669        std::io::Error::new(
1670            std::io::ErrorKind::InvalidInput,
1671            "path child name contains NUL",
1672        )
1673    })
1674}
1675
1676fn open_dir_nofollow(path: &Path) -> std::io::Result<fs::File> {
1677    OpenOptions::new()
1678        .read(true)
1679        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1680        .open(path)
1681}
1682
1683fn mkdirat_dir(parent: &fs::File, name: &str, mode: libc::mode_t) -> std::io::Result<()> {
1684    let name = safe_child_name(name)?;
1685    let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), mode) };
1686
1687    if result == 0 {
1688        return Ok(());
1689    }
1690
1691    let err = std::io::Error::last_os_error();
1692    if err.raw_os_error() == Some(libc::EEXIST) {
1693        Ok(())
1694    } else {
1695        Err(err)
1696    }
1697}
1698
1699fn openat_dir_nofollow(parent: &fs::File, name: &str) -> std::io::Result<fs::File> {
1700    let name = safe_child_name(name)?;
1701    let fd = unsafe {
1702        libc::openat(
1703            parent.as_raw_fd(),
1704            name.as_ptr(),
1705            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
1706        )
1707    };
1708
1709    if fd < 0 {
1710        Err(std::io::Error::last_os_error())
1711    } else {
1712        Ok(unsafe { fs::File::from_raw_fd(fd) })
1713    }
1714}
1715
1716fn openat_file_nofollow(
1717    parent: &fs::File,
1718    name: &str,
1719    mode: libc::mode_t,
1720) -> std::io::Result<fs::File> {
1721    let name = safe_child_name(name)?;
1722    let fd = unsafe {
1723        libc::openat(
1724            parent.as_raw_fd(),
1725            name.as_ptr(),
1726            libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW | libc::O_CLOEXEC,
1727            mode,
1728        )
1729    };
1730
1731    if fd < 0 {
1732        Err(std::io::Error::last_os_error())
1733    } else {
1734        Ok(unsafe { fs::File::from_raw_fd(fd) })
1735    }
1736}
1737
1738impl OciBundle {
1739    /// Create a new OCI bundle
1740    pub fn new(bundle_path: PathBuf, config: OciConfig) -> Self {
1741        Self {
1742            bundle_path,
1743            config,
1744        }
1745    }
1746
1747    /// Create the bundle directory structure and write config.json
1748    pub fn create(&self) -> Result<()> {
1749        info!("Creating OCI bundle at {:?}", self.bundle_path);
1750
1751        // Create the bundle directory, then reopen it without following a final
1752        // symlink before applying permissions or creating children beneath it.
1753        fs::create_dir_all(&self.bundle_path).map_err(|e| {
1754            NucleusError::GVisorError(format!(
1755                "Failed to create bundle directory {:?}: {}",
1756                self.bundle_path, e
1757            ))
1758        })?;
1759        let bundle_dir = open_dir_nofollow(&self.bundle_path).map_err(|e| {
1760            NucleusError::GVisorError(format!(
1761                "Failed to open bundle directory safely {:?}: {}",
1762                self.bundle_path, e
1763            ))
1764        })?;
1765        bundle_dir
1766            .set_permissions(fs::Permissions::from_mode(0o700))
1767            .map_err(|e| {
1768                NucleusError::GVisorError(format!(
1769                    "Failed to secure bundle directory permissions {:?}: {}",
1770                    self.bundle_path, e
1771                ))
1772            })?;
1773
1774        // Create rootfs relative to the trusted bundle directory. mkdirat plus
1775        // openat(O_NOFOLLOW) prevents a pre-existing rootfs symlink from being
1776        // chmodded when rootfs needs to be traversable for non-root UIDs.
1777        let rootfs = self.bundle_path.join("rootfs");
1778        mkdirat_dir(&bundle_dir, "rootfs", 0o755).map_err(|e| {
1779            NucleusError::GVisorError(format!("Failed to create rootfs directory: {}", e))
1780        })?;
1781        let rootfs_dir = openat_dir_nofollow(&bundle_dir, "rootfs").map_err(|e| {
1782            NucleusError::GVisorError(format!(
1783                "Failed to open rootfs directory safely {:?}: {}",
1784                rootfs, e
1785            ))
1786        })?;
1787        // The rootfs is the container's "/" – it must be traversable by the
1788        // container UID which may be non-root (via --user).  Mode 0755 matches
1789        // the standard Linux root directory permission and lets gVisor's VFS
1790        // permit path traversal for any UID.
1791        rootfs_dir
1792            .set_permissions(fs::Permissions::from_mode(0o755))
1793            .map_err(|e| {
1794                NucleusError::GVisorError(format!(
1795                    "Failed to set rootfs directory permissions {:?}: {}",
1796                    rootfs, e
1797                ))
1798            })?;
1799
1800        // Write config.json
1801        let config_path = self.bundle_path.join("config.json");
1802        let config_json = serde_json::to_string_pretty(&self.config).map_err(|e| {
1803            NucleusError::GVisorError(format!("Failed to serialize OCI config: {}", e))
1804        })?;
1805
1806        let mut file = openat_file_nofollow(&bundle_dir, "config.json", 0o600).map_err(|e| {
1807            NucleusError::GVisorError(format!(
1808                "Failed to open config.json safely {:?}: {}",
1809                config_path, e
1810            ))
1811        })?;
1812        file.set_permissions(fs::Permissions::from_mode(0o600))
1813            .map_err(|e| {
1814                NucleusError::GVisorError(format!(
1815                    "Failed to set config.json permissions {:?}: {}",
1816                    config_path, e
1817                ))
1818            })?;
1819        file.write_all(config_json.as_bytes()).map_err(|e| {
1820            NucleusError::GVisorError(format!("Failed to write config.json: {}", e))
1821        })?;
1822        file.sync_all()
1823            .map_err(|e| NucleusError::GVisorError(format!("Failed to sync config.json: {}", e)))?;
1824
1825        debug!("Created OCI bundle structure at {:?}", self.bundle_path);
1826
1827        Ok(())
1828    }
1829
1830    /// Get the rootfs path
1831    pub fn rootfs_path(&self) -> PathBuf {
1832        self.bundle_path.join("rootfs")
1833    }
1834
1835    /// Get the bundle path
1836    pub fn bundle_path(&self) -> &Path {
1837        &self.bundle_path
1838    }
1839
1840    /// Clean up the bundle
1841    pub fn cleanup(&self) -> Result<()> {
1842        if self.bundle_path.exists() {
1843            fs::remove_dir_all(&self.bundle_path).map_err(|e| {
1844                NucleusError::GVisorError(format!("Failed to cleanup bundle: {}", e))
1845            })?;
1846            debug!("Cleaned up OCI bundle at {:?}", self.bundle_path);
1847        }
1848        Ok(())
1849    }
1850}
1851
1852#[cfg(test)]
1853mod tests {
1854    use super::*;
1855    use std::os::unix::fs::symlink;
1856    use tempfile::TempDir;
1857
1858    #[test]
1859    fn gpu_passthrough_injects_env_even_with_no_devices() {
1860        // An empty device set (e.g. discovery found nothing on the host) still
1861        // injects the NVIDIA environment variables; device entries and support
1862        // binds are simply absent.
1863        let gpu = crate::container::GpuPassthroughConfig {
1864            vendor: crate::container::GpuVendor::Nvidia,
1865            ..crate::container::GpuPassthroughConfig::default()
1866        };
1867        let set = crate::filesystem::GpuDeviceSet::default();
1868        let identity = crate::container::ProcessIdentity::default();
1869
1870        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
1871            .with_gpu_passthrough(&gpu, &set, &identity)
1872            .expect("gpu passthrough config builds");
1873
1874        // No devices discovered -> no device entries beyond the base set.
1875        assert!(!config.linux.as_ref().unwrap().devices.is_empty());
1876        // NVIDIA env vars are present.
1877        assert!(config
1878            .process
1879            .env
1880            .iter()
1881            .any(|e| e.starts_with("NVIDIA_VISIBLE_DEVICES=")));
1882        assert!(config
1883            .process
1884            .env
1885            .iter()
1886            .any(|e| e.starts_with("NVIDIA_DRIVER_CAPABILITIES=")));
1887    }
1888
1889    #[test]
1890    fn test_oci_config_new() {
1891        let config = OciConfig::new(vec!["/bin/sh".to_string()], Some("test".to_string()));
1892
1893        assert_eq!(config.oci_version, "1.0.2");
1894        assert_eq!(config.root.path, "rootfs");
1895        assert_eq!(config.process.args, vec!["/bin/sh"]);
1896        assert_eq!(config.hostname, Some("test".to_string()));
1897        assert!(!config.process.terminal);
1898        assert!(config.process.console_size.is_none());
1899    }
1900
1901    #[test]
1902    fn test_oci_config_with_terminal_sets_console_size() {
1903        let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_terminal(ConsoleSize {
1904            width: 120,
1905            height: 40,
1906        });
1907
1908        assert!(config.process.terminal);
1909        let size = config.process.console_size.unwrap();
1910        assert_eq!(size.width, 120);
1911        assert_eq!(size.height, 40);
1912    }
1913
1914    #[test]
1915    fn test_oci_config_with_resources() {
1916        let limits = ResourceLimits::unlimited()
1917            .with_memory("512M")
1918            .unwrap()
1919            .with_cpu_cores(2.0)
1920            .unwrap();
1921
1922        let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_resources(&limits);
1923
1924        assert!(config.linux.is_some());
1925        let linux = config.linux.unwrap();
1926        assert!(linux.resources.is_some());
1927
1928        let resources = linux.resources.unwrap();
1929        assert!(resources.memory.is_some());
1930        assert!(resources.cpu.is_some());
1931    }
1932
1933    #[test]
1934    fn test_rootfs_binds_canonicalize_top_level_sources() {
1935        let temp_dir = TempDir::new().unwrap();
1936        let rootfs = temp_dir.path().join("rootfs");
1937        let real_bin = temp_dir.path().join("real-bin");
1938        fs::create_dir_all(&rootfs).unwrap();
1939        fs::create_dir_all(&real_bin).unwrap();
1940        symlink(&real_bin, rootfs.join("bin")).unwrap();
1941
1942        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
1943            .with_rootfs_binds(&rootfs)
1944            .unwrap();
1945        let bin_mount = config
1946            .mounts
1947            .iter()
1948            .find(|mount| mount.destination == "/bin")
1949            .unwrap();
1950
1951        assert_eq!(bin_mount.source, real_bin.to_string_lossy());
1952    }
1953
1954    #[test]
1955    fn test_rootfs_binds_mount_declared_nix_store_closure_paths() {
1956        let Some(store_path) = fs::read_dir("/nix/store").ok().and_then(|entries| {
1957            entries
1958                .flatten()
1959                .map(|entry| entry.path())
1960                .find(|path| path.is_dir() && is_immediate_nix_store_object_path(path))
1961        }) else {
1962            return;
1963        };
1964        let temp_dir = TempDir::new().unwrap();
1965        let rootfs = temp_dir.path().join("rootfs");
1966        fs::create_dir_all(rootfs.join("nix/store")).unwrap();
1967        fs::write(
1968            rootfs.join(ROOTFS_STORE_PATHS_FILE),
1969            format!("{}\n", store_path.display()),
1970        )
1971        .unwrap();
1972
1973        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
1974            .with_rootfs_binds(&rootfs)
1975            .unwrap();
1976        let store_name = store_path.file_name().unwrap();
1977        let destination = Path::new("/nix/store")
1978            .join(store_name)
1979            .to_string_lossy()
1980            .to_string();
1981
1982        assert!(config
1983            .mounts
1984            .iter()
1985            .any(|mount| mount.destination == destination
1986                && mount.source == fs::canonicalize(&store_path).unwrap().to_string_lossy()));
1987    }
1988
1989    #[test]
1990    fn test_rootfs_binds_reject_store_root_manifest_entry() {
1991        let temp_dir = TempDir::new().unwrap();
1992        let rootfs = temp_dir.path().join("rootfs");
1993        fs::create_dir_all(rootfs.join("nix/store")).unwrap();
1994        fs::write(rootfs.join(ROOTFS_STORE_PATHS_FILE), "/nix/store\n").unwrap();
1995
1996        let err = OciConfig::new(vec!["/bin/sh".to_string()], None)
1997            .with_rootfs_binds(&rootfs)
1998            .unwrap_err();
1999
2000        assert!(
2001            err.to_string().contains("expected immediate"),
2002            "expected immediate-store-object rejection, got: {err}"
2003        );
2004    }
2005
2006    #[test]
2007    fn test_rootfs_binds_reject_store_subpath_manifest_entry() {
2008        let temp_dir = TempDir::new().unwrap();
2009        let rootfs = temp_dir.path().join("rootfs");
2010        fs::create_dir_all(rootfs.join("nix/store")).unwrap();
2011        fs::write(
2012            rootfs.join(ROOTFS_STORE_PATHS_FILE),
2013            "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-hello/bin\n",
2014        )
2015        .unwrap();
2016
2017        let err = OciConfig::new(vec!["/bin/sh".to_string()], None)
2018            .with_rootfs_binds(&rootfs)
2019            .unwrap_err();
2020
2021        assert!(
2022            err.to_string().contains("expected immediate"),
2023            "expected immediate-store-object rejection, got: {err}"
2024        );
2025    }
2026
2027    #[test]
2028    fn test_oci_bundle_create() {
2029        let temp_dir = TempDir::new().unwrap();
2030        let bundle_path = temp_dir.path().join("test-bundle");
2031
2032        let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
2033        let bundle = OciBundle::new(bundle_path.clone(), config);
2034
2035        bundle.create().unwrap();
2036
2037        assert!(bundle_path.exists());
2038        assert!(bundle_path.join("rootfs").exists());
2039        assert!(bundle_path.join("config.json").exists());
2040
2041        bundle.cleanup().unwrap();
2042        assert!(!bundle_path.exists());
2043    }
2044
2045    #[test]
2046    fn test_oci_bundle_rejects_bundle_symlink() {
2047        let temp_dir = TempDir::new().unwrap();
2048        let bundle_path = temp_dir.path().join("test-bundle");
2049        let protected_host_dir = temp_dir.path().join("protected-host-dir");
2050
2051        fs::create_dir_all(&protected_host_dir).unwrap();
2052        fs::set_permissions(&protected_host_dir, fs::Permissions::from_mode(0o755)).unwrap();
2053        symlink(&protected_host_dir, &bundle_path).unwrap();
2054
2055        let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
2056        let bundle = OciBundle::new(bundle_path.clone(), config);
2057
2058        let err = bundle.create().unwrap_err();
2059
2060        assert!(format!("{err}").contains("Failed to open bundle directory safely"));
2061        assert_eq!(
2062            fs::metadata(&protected_host_dir)
2063                .unwrap()
2064                .permissions()
2065                .mode()
2066                & 0o777,
2067            0o755
2068        );
2069        assert!(fs::symlink_metadata(&bundle_path)
2070            .unwrap()
2071            .file_type()
2072            .is_symlink());
2073    }
2074
2075    #[test]
2076    fn test_oci_bundle_rejects_rootfs_symlink() {
2077        let temp_dir = TempDir::new().unwrap();
2078        let bundle_path = temp_dir.path().join("test-bundle");
2079        let protected_host_dir = temp_dir.path().join("protected-host-dir");
2080
2081        fs::create_dir_all(&bundle_path).unwrap();
2082        fs::create_dir_all(&protected_host_dir).unwrap();
2083        fs::set_permissions(&protected_host_dir, fs::Permissions::from_mode(0o700)).unwrap();
2084        symlink(&protected_host_dir, bundle_path.join("rootfs")).unwrap();
2085
2086        let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
2087        let bundle = OciBundle::new(bundle_path.clone(), config);
2088
2089        let err = bundle.create().unwrap_err();
2090
2091        assert!(format!("{err}").contains("Failed to open rootfs directory safely"));
2092        assert_eq!(
2093            fs::metadata(&protected_host_dir)
2094                .unwrap()
2095                .permissions()
2096                .mode()
2097                & 0o777,
2098            0o700
2099        );
2100        assert!(fs::symlink_metadata(bundle_path.join("rootfs"))
2101            .unwrap()
2102            .file_type()
2103            .is_symlink());
2104    }
2105
2106    #[test]
2107    fn test_oci_config_serialization() {
2108        let config = OciConfig::new(vec!["/bin/sh".to_string()], Some("test".to_string()));
2109
2110        let json = serde_json::to_string_pretty(&config).unwrap();
2111        assert!(json.contains("ociVersion"));
2112        assert!(json.contains("1.0.2"));
2113        assert!(json.contains("/bin/sh"));
2114
2115        // Test deserialization
2116        let deserialized: OciConfig = serde_json::from_str(&json).unwrap();
2117        assert_eq!(deserialized.oci_version, config.oci_version);
2118        assert_eq!(deserialized.process.args, config.process.args);
2119    }
2120
2121    #[test]
2122    fn test_host_runtime_binds_uses_fixed_paths_not_host_path() {
2123        // with_host_runtime_binds must NOT scan the host $PATH. Only standard
2124        // FHS paths should be bind-mounted to prevent leaking arbitrary host
2125        // directories into the container. Verify by setting a distinctive PATH
2126        // and checking that none of its entries appear in the resulting mounts.
2127        std::env::set_var("PATH", "/tmp/evil-inject-path/bin:/opt/attacker/sbin");
2128        let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_host_runtime_binds();
2129        let mount_dests: Vec<&str> = config
2130            .mounts
2131            .iter()
2132            .map(|m| m.destination.as_str())
2133            .collect();
2134        let mount_srcs: Vec<&str> = config.mounts.iter().map(|m| m.source.as_str()).collect();
2135        // Verify no mount references the injected PATH entries
2136        for path in &["/tmp/evil-inject-path", "/opt/attacker"] {
2137            assert!(
2138                !mount_dests.iter().any(|d| d.contains(path)),
2139                "with_host_runtime_binds must not use host $PATH – found {:?} in mount destinations",
2140                path
2141            );
2142            assert!(
2143                !mount_srcs.iter().any(|s| s.contains(path)),
2144                "with_host_runtime_binds must not use host $PATH – found {:?} in mount sources",
2145                path
2146            );
2147        }
2148        // Verify only standard FHS paths are mounted
2149        let allowed_prefixes = ["/bin", "/sbin", "/usr", "/lib", "/lib64", "/nix/store"];
2150        for mount in &config.mounts {
2151            if mount.mount_type == "bind" {
2152                assert!(
2153                    allowed_prefixes
2154                        .iter()
2155                        .any(|p| mount.destination.starts_with(p)),
2156                    "unexpected bind mount destination: {} – only FHS paths allowed",
2157                    mount.destination
2158                );
2159            }
2160        }
2161    }
2162
2163    #[test]
2164    fn test_without_user_namespace_removes_namespace_and_mappings() {
2165        let user_ns = UserNamespaceConfig::rootless();
2166        let namespaces = NamespaceConfig {
2167            user: true,
2168            ..Default::default()
2169        };
2170
2171        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
2172            .with_namespace_config(&namespaces)
2173            .with_rootless_user_namespace(&user_ns)
2174            .without_user_namespace();
2175        let linux = config.linux.unwrap();
2176
2177        assert!(!linux
2178            .namespaces
2179            .unwrap()
2180            .iter()
2181            .any(|namespace| namespace.namespace_type == "user"));
2182        assert!(linux.uid_mappings.is_empty());
2183        assert!(linux.gid_mappings.is_empty());
2184    }
2185
2186    #[test]
2187    fn test_volume_mounts_include_bind_and_tmpfs_options() {
2188        let tmp = tempfile::TempDir::new().unwrap();
2189        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
2190            .with_volume_mounts(&[
2191                crate::container::VolumeMount {
2192                    source: crate::container::VolumeSource::Bind {
2193                        source: tmp.path().to_path_buf(),
2194                    },
2195                    dest: std::path::PathBuf::from("/var/lib/app"),
2196                    read_only: true,
2197                },
2198                crate::container::VolumeMount {
2199                    source: crate::container::VolumeSource::Tmpfs {
2200                        size: Some("64M".to_string()),
2201                    },
2202                    dest: std::path::PathBuf::from("/var/cache/app"),
2203                    read_only: false,
2204                },
2205            ])
2206            .unwrap();
2207
2208        assert!(config.mounts.iter().any(|mount| {
2209            mount.destination == "/var/lib/app"
2210                && mount.mount_type == "bind"
2211                && mount.options.contains(&"ro".to_string())
2212        }));
2213        assert!(config.mounts.iter().any(|mount| {
2214            mount.destination == "/var/cache/app"
2215                && mount.mount_type == "tmpfs"
2216                && mount.options.contains(&"size=64M".to_string())
2217        }));
2218    }
2219
2220    #[test]
2221    fn test_workspace_mount_defaults_noexec_and_cwd() {
2222        let tmp = tempfile::TempDir::new().unwrap();
2223        let workspace = crate::container::WorkspaceConfig::new()
2224            .with_host_path(tmp.path().to_path_buf())
2225            .with_mode(crate::container::WorkspaceMode::BindRw);
2226        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
2227            .with_workspace_mount(&workspace)
2228            .unwrap();
2229
2230        assert_eq!(config.process.cwd, "/workspace");
2231        assert!(config.process.env.contains(&"HOME=/home/agent".to_string()));
2232        assert!(config.mounts.iter().any(|mount| {
2233            mount.destination == "/workspace"
2234                && mount.mount_type == "bind"
2235                && mount.options.contains(&"noexec".to_string())
2236                && !mount.options.contains(&"ro".to_string())
2237        }));
2238    }
2239
2240    #[test]
2241    fn test_home_tmpfs_defaults_and_process_identity_options() {
2242        let identity = crate::container::ProcessIdentity {
2243            uid: 1000,
2244            gid: 1001,
2245            additional_gids: Vec::new(),
2246        };
2247        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
2248            .with_home_tmpfs(std::path::Path::new("/home/agent"), &identity)
2249            .unwrap();
2250
2251        assert!(config.process.env.contains(&"HOME=/home/agent".to_string()));
2252        let mount = config
2253            .mounts
2254            .iter()
2255            .find(|mount| mount.destination == "/home/agent")
2256            .unwrap();
2257        assert_eq!(mount.mount_type, "tmpfs");
2258        assert!(mount.options.contains(&"noexec".to_string()));
2259        assert!(mount.options.contains(&"uid=1000".to_string()));
2260        assert!(mount.options.contains(&"gid=1001".to_string()));
2261    }
2262
2263    #[test]
2264    fn test_workspace_exec_removes_noexec() {
2265        let tmp = tempfile::TempDir::new().unwrap();
2266        let workspace = crate::container::WorkspaceConfig::new()
2267            .with_host_path(tmp.path().to_path_buf())
2268            .with_allow_execute(true);
2269        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
2270            .with_workspace_mount(&workspace)
2271            .unwrap();
2272
2273        let mount = config
2274            .mounts
2275            .iter()
2276            .find(|mount| mount.destination == "/workspace")
2277            .unwrap();
2278        assert!(!mount.options.contains(&"noexec".to_string()));
2279    }
2280
2281    #[test]
2282    fn test_provider_config_mounts_are_home_relative_and_noexec() {
2283        let tmp = tempfile::TempDir::new().unwrap();
2284        let provider_dir = tmp.path().join("aws");
2285        fs::create_dir(&provider_dir).unwrap();
2286
2287        let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
2288            .with_provider_config_mounts(
2289                std::path::Path::new("/home/agent"),
2290                &[crate::container::ProviderConfigMount {
2291                    source: provider_dir.clone(),
2292                    dest: std::path::PathBuf::from(".aws"),
2293                    read_only: true,
2294                }],
2295            )
2296            .unwrap();
2297
2298        let mount = config
2299            .mounts
2300            .iter()
2301            .find(|mount| mount.destination == "/home/agent/.aws")
2302            .unwrap();
2303        assert_eq!(
2304            mount.source,
2305            fs::canonicalize(provider_dir)
2306                .unwrap()
2307                .to_string_lossy()
2308                .to_string()
2309        );
2310        assert!(mount.options.contains(&"ro".to_string()));
2311        assert!(mount.options.contains(&"noexec".to_string()));
2312    }
2313
2314    #[test]
2315    fn test_volume_mounts_reject_sensitive_host_sources() {
2316        let err = OciConfig::new(vec!["/bin/sh".to_string()], None)
2317            .with_volume_mounts(&[crate::container::VolumeMount {
2318                source: crate::container::VolumeSource::Bind {
2319                    source: std::path::PathBuf::from("/proc/sys"),
2320                },
2321                dest: std::path::PathBuf::from("/host-proc"),
2322                read_only: true,
2323            }])
2324            .unwrap_err();
2325
2326        assert!(err.to_string().contains("sensitive host path"));
2327    }
2328
2329    #[test]
2330    fn test_volume_mounts_reject_reserved_destinations() {
2331        let tmp = tempfile::TempDir::new().unwrap();
2332        let err = OciConfig::new(vec!["/bin/sh".to_string()], None)
2333            .with_volume_mounts(&[crate::container::VolumeMount {
2334                source: crate::container::VolumeSource::Bind {
2335                    source: tmp.path().to_path_buf(),
2336                },
2337                dest: std::path::PathBuf::from("/usr/bin"),
2338                read_only: true,
2339            }])
2340            .unwrap_err();
2341
2342        assert!(err.to_string().contains("reserved"));
2343    }
2344
2345    #[test]
2346    fn test_oci_config_with_process_identity() {
2347        let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_process_identity(
2348            &crate::container::ProcessIdentity {
2349                uid: 1001,
2350                gid: 1002,
2351                additional_gids: vec![1003, 1004],
2352            },
2353        );
2354
2355        assert_eq!(config.process.user.uid, 1001);
2356        assert_eq!(config.process.user.gid, 1002);
2357        assert_eq!(config.process.user.additional_gids, Some(vec![1003, 1004]));
2358    }
2359
2360    #[test]
2361    fn test_oci_config_with_rlimits_uses_configured_memlock() {
2362        let limits = ResourceLimits::default()
2363            .with_pids(99)
2364            .unwrap()
2365            .with_memlock("8M")
2366            .unwrap();
2367
2368        let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_rlimits(&limits);
2369
2370        assert!(config.process.rlimits.iter().any(|limit| {
2371            limit.limit_type == "RLIMIT_NPROC" && limit.soft == 99 && limit.hard == 99
2372        }));
2373        assert!(config.process.rlimits.iter().any(|limit| {
2374            limit.limit_type == "RLIMIT_MEMLOCK"
2375                && limit.soft == 8 * 1024 * 1024
2376                && limit.hard == 8 * 1024 * 1024
2377        }));
2378    }
2379
2380    #[test]
2381    fn test_oci_config_with_rlimits_omits_nproc_when_unlimited() {
2382        let limits = ResourceLimits {
2383            pids_max: None,
2384            ..ResourceLimits::default()
2385        };
2386
2387        let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_rlimits(&limits);
2388
2389        assert!(
2390            !config
2391                .process
2392                .rlimits
2393                .iter()
2394                .any(|limit| limit.limit_type == "RLIMIT_NPROC"),
2395            "RLIMIT_NPROC must be omitted when pids_max is unlimited"
2396        );
2397    }
2398
2399    #[test]
2400    fn test_oci_config_uses_hardcoded_path_not_host() {
2401        // C-3: PATH must be a hardcoded minimal value, never the host's PATH.
2402        // This prevents leaking host filesystem layout into the container.
2403        std::env::set_var("PATH", "/nix/store/secret-hash/bin:/home/user/.local/bin");
2404        let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
2405        let path_env = config
2406            .process
2407            .env
2408            .iter()
2409            .find(|e| e.starts_with("PATH="))
2410            .expect("PATH env must be set");
2411        assert_eq!(
2412            path_env, "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
2413            "OCI config must not leak host PATH"
2414        );
2415        assert!(
2416            !path_env.contains("/nix/store/secret"),
2417            "Host PATH must not leak into container"
2418        );
2419    }
2420
2421    #[test]
2422    fn test_oci_hooks_serialization_roundtrip() {
2423        let hooks = OciHooks {
2424            create_runtime: vec![OciHook {
2425                path: "/usr/bin/hook1".to_string(),
2426                args: vec!["hook1".to_string(), "--arg1".to_string()],
2427                env: vec!["FOO=bar".to_string()],
2428                timeout: Some(10),
2429            }],
2430            create_container: vec![],
2431            start_container: vec![],
2432            poststart: vec![OciHook {
2433                path: "/usr/bin/hook2".to_string(),
2434                args: vec![],
2435                env: vec![],
2436                timeout: None,
2437            }],
2438            poststop: vec![],
2439        };
2440
2441        let json = serde_json::to_string_pretty(&hooks).unwrap();
2442        assert!(json.contains("createRuntime"));
2443        assert!(json.contains("/usr/bin/hook1"));
2444        assert!(!json.contains("createContainer")); // empty vecs are skipped
2445
2446        let deserialized: OciHooks = serde_json::from_str(&json).unwrap();
2447        assert_eq!(deserialized.create_runtime.len(), 1);
2448        assert_eq!(deserialized.create_runtime[0].path, "/usr/bin/hook1");
2449        assert_eq!(deserialized.create_runtime[0].timeout, Some(10));
2450        assert_eq!(deserialized.poststart.len(), 1);
2451        assert!(deserialized.create_container.is_empty());
2452    }
2453
2454    #[test]
2455    fn test_oci_hooks_is_empty() {
2456        let empty = OciHooks::default();
2457        assert!(empty.is_empty());
2458
2459        let not_empty = OciHooks {
2460            poststop: vec![OciHook {
2461                path: "/bin/cleanup".to_string(),
2462                args: vec![],
2463                env: vec![],
2464                timeout: None,
2465            }],
2466            ..Default::default()
2467        };
2468        assert!(!not_empty.is_empty());
2469    }
2470
2471    #[test]
2472    fn test_oci_config_with_hooks() {
2473        let hooks = OciHooks {
2474            create_runtime: vec![OciHook {
2475                path: "/usr/bin/setup".to_string(),
2476                args: vec![],
2477                env: vec![],
2478                timeout: None,
2479            }],
2480            ..Default::default()
2481        };
2482
2483        let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_hooks(hooks);
2484        assert!(config.hooks.is_some());
2485
2486        let json = serde_json::to_string_pretty(&config).unwrap();
2487        assert!(json.contains("hooks"));
2488        assert!(json.contains("createRuntime"));
2489
2490        let deserialized: OciConfig = serde_json::from_str(&json).unwrap();
2491        assert!(deserialized.hooks.is_some());
2492        assert_eq!(deserialized.hooks.unwrap().create_runtime.len(), 1);
2493    }
2494
2495    #[test]
2496    fn test_oci_config_with_empty_hooks_serializes_without_hooks() {
2497        let config =
2498            OciConfig::new(vec!["/bin/sh".to_string()], None).with_hooks(OciHooks::default());
2499        assert!(config.hooks.is_none()); // empty hooks are set to None
2500
2501        let json = serde_json::to_string_pretty(&config).unwrap();
2502        assert!(!json.contains("hooks"));
2503    }
2504
2505    #[test]
2506    fn test_oci_hook_rejects_relative_path() {
2507        let hook = OciHook {
2508            path: "relative/path".to_string(),
2509            args: vec![],
2510            env: vec![],
2511            timeout: None,
2512        };
2513        let state = OciContainerState {
2514            oci_version: "1.0.2".to_string(),
2515            id: "test".to_string(),
2516            status: OciStatus::Creating,
2517            pid: 1234,
2518            bundle: "/tmp/bundle".to_string(),
2519        };
2520        let result = OciHooks::run_hooks(&[hook], &state, "test");
2521        assert!(result.is_err());
2522        let err_msg = result.unwrap_err().to_string();
2523        assert!(err_msg.contains("absolute"), "error: {}", err_msg);
2524    }
2525
2526    /// Read the original PATH from /proc/self/environ.
2527    ///
2528    /// Other tests in this module call `std::env::set_var("PATH", ...)` which
2529    /// corrupts the process environment. /proc/self/environ is frozen at
2530    /// process startup so it always reflects the real PATH.
2531    fn original_path() -> String {
2532        if let Ok(environ) = std::fs::read("/proc/self/environ") {
2533            for entry in environ.split(|&b| b == 0) {
2534                if let Ok(s) = std::str::from_utf8(entry) {
2535                    if let Some(val) = s.strip_prefix("PATH=") {
2536                        return val.to_string();
2537                    }
2538                }
2539            }
2540        }
2541        String::new()
2542    }
2543
2544    /// Resolve the absolute path to bash for test scripts.
2545    fn find_bash() -> String {
2546        let candidates = ["/bin/bash", "/usr/bin/bash"];
2547        for c in &candidates {
2548            if std::path::Path::new(c).exists() {
2549                return c.to_string();
2550            }
2551        }
2552        for dir in original_path().split(':') {
2553            let candidate = std::path::PathBuf::from(dir).join("bash");
2554            if candidate.exists() {
2555                return candidate.to_string_lossy().to_string();
2556            }
2557        }
2558        panic!("Cannot find bash binary for test");
2559    }
2560
2561    /// Write a script file with proper shebang and ensure it's fully flushed before execution.
2562    /// Embeds the original PATH so scripts can find utilities like `cat`/`touch`
2563    /// even when other tests have corrupted the process PATH.
2564    fn write_script(path: &std::path::Path, body: &str) {
2565        use std::io::Write as IoWrite;
2566        let bash = find_bash();
2567        let orig_path = original_path();
2568        let content = format!("#!{}\nexport PATH='{}'\n{}", bash, orig_path, body);
2569        let mut f = OpenOptions::new()
2570            .create(true)
2571            .truncate(true)
2572            .write(true)
2573            .mode(0o755)
2574            .open(path)
2575            .unwrap();
2576        f.write_all(content.as_bytes()).unwrap();
2577        f.sync_all().unwrap();
2578        drop(f);
2579    }
2580
2581    #[test]
2582    fn test_oci_hook_executes_successfully() {
2583        let temp_dir = TempDir::new().unwrap();
2584        let hook_script = temp_dir.path().join("hook.sh");
2585        let output_file = temp_dir.path().join("output.json");
2586
2587        write_script(
2588            &hook_script,
2589            &format!("cat > {}\n", output_file.to_string_lossy()),
2590        );
2591
2592        let hook = OciHook {
2593            path: hook_script.to_string_lossy().to_string(),
2594            args: vec![],
2595            env: vec![],
2596            timeout: Some(5),
2597        };
2598        let state = OciContainerState {
2599            oci_version: "1.0.2".to_string(),
2600            id: "test-container".to_string(),
2601            status: OciStatus::Creating,
2602            pid: 12345,
2603            bundle: "/tmp/test-bundle".to_string(),
2604        };
2605
2606        OciHooks::run_hooks(&[hook], &state, "createRuntime").unwrap();
2607
2608        // Verify the hook received the container state JSON on stdin
2609        let written = std::fs::read_to_string(&output_file).unwrap();
2610        let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
2611        assert_eq!(parsed["id"], "test-container");
2612        assert_eq!(parsed["pid"], 12345);
2613        assert_eq!(parsed["status"], "creating");
2614    }
2615
2616    #[test]
2617    fn test_oci_hook_retries_text_file_busy_spawn() {
2618        let temp_dir = TempDir::new().unwrap();
2619        let hook_script = temp_dir.path().join("hook.sh");
2620        let output_file = temp_dir.path().join("output.json");
2621
2622        write_script(
2623            &hook_script,
2624            &format!("cat > {}\n", output_file.to_string_lossy()),
2625        );
2626
2627        let (ready_tx, ready_rx) = std::sync::mpsc::channel();
2628        let busy_script = hook_script.clone();
2629        let busy_handle = std::thread::spawn(move || {
2630            let _busy_file = OpenOptions::new().write(true).open(&busy_script).unwrap();
2631            ready_tx.send(()).unwrap();
2632            std::thread::sleep(std::time::Duration::from_millis(100));
2633        });
2634        ready_rx.recv().unwrap();
2635
2636        let hook = OciHook {
2637            path: hook_script.to_string_lossy().to_string(),
2638            args: vec![],
2639            env: vec![],
2640            timeout: Some(5),
2641        };
2642        let state = OciContainerState {
2643            oci_version: "1.0.2".to_string(),
2644            id: "test-container".to_string(),
2645            status: OciStatus::Creating,
2646            pid: 12345,
2647            bundle: "/tmp/test-bundle".to_string(),
2648        };
2649
2650        let result = OciHooks::run_hooks(&[hook], &state, "createRuntime");
2651        busy_handle.join().unwrap();
2652        result.unwrap();
2653
2654        let written = std::fs::read_to_string(&output_file).unwrap();
2655        let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
2656        assert_eq!(parsed["id"], "test-container");
2657    }
2658
2659    #[test]
2660    fn test_oci_hook_nonzero_exit_is_error() {
2661        let temp_dir = TempDir::new().unwrap();
2662        let hook_script = temp_dir.path().join("fail.sh");
2663        write_script(&hook_script, "exit 1\n");
2664
2665        let hook = OciHook {
2666            path: hook_script.to_string_lossy().to_string(),
2667            args: vec![],
2668            env: vec![],
2669            timeout: Some(5),
2670        };
2671        let state = OciContainerState {
2672            oci_version: "1.0.2".to_string(),
2673            id: "test".to_string(),
2674            status: OciStatus::Creating,
2675            pid: 1,
2676            bundle: "".to_string(),
2677        };
2678
2679        let result = OciHooks::run_hooks(&[hook], &state, "test");
2680        assert!(result.is_err());
2681        assert!(result
2682            .unwrap_err()
2683            .to_string()
2684            .contains("exited with status"));
2685    }
2686
2687    #[test]
2688    fn test_oci_hooks_best_effort_continues_on_failure() {
2689        let temp_dir = TempDir::new().unwrap();
2690        let fail_script = temp_dir.path().join("fail.sh");
2691        write_script(&fail_script, "exit 1\n");
2692
2693        let marker = temp_dir.path().join("ran");
2694        let ok_script = temp_dir.path().join("ok.sh");
2695        write_script(&ok_script, &format!("touch {}\n", marker.to_string_lossy()));
2696
2697        let hooks = vec![
2698            OciHook {
2699                path: fail_script.to_string_lossy().to_string(),
2700                args: vec![],
2701                env: vec![],
2702                timeout: Some(5),
2703            },
2704            OciHook {
2705                path: ok_script.to_string_lossy().to_string(),
2706                args: vec![],
2707                env: vec![],
2708                timeout: Some(5),
2709            },
2710        ];
2711        let state = OciContainerState {
2712            oci_version: "1.0.2".to_string(),
2713            id: "test".to_string(),
2714            status: OciStatus::Stopped,
2715            pid: 0,
2716            bundle: "".to_string(),
2717        };
2718
2719        // best_effort should not panic or return error
2720        OciHooks::run_hooks_best_effort(&hooks, &state, "poststop");
2721        // Second hook should have run despite first failing
2722        assert!(marker.exists(), "second hook should run after first fails");
2723    }
2724}