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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct OciRlimit {
193 #[serde(rename = "type")]
195 pub limit_type: String,
196 pub hard: u64,
198 pub soft: u64,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct OciConsoleSize {
205 pub height: u32,
206 pub width: u32,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct OciDevice {
214 #[serde(rename = "type")]
216 pub device_type: String,
217 pub path: String,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub major: Option<i64>,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub minor: Option<i64>,
225 #[serde(rename = "fileMode", skip_serializing_if = "Option::is_none")]
227 pub file_mode: Option<u32>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub uid: Option<u32>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub gid: Option<u32>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct OciSeccomp {
241 #[serde(rename = "defaultAction")]
243 pub default_action: String,
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub architectures: Vec<String>,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub syscalls: Vec<OciSeccompSyscall>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct OciSeccompSyscall {
255 pub names: Vec<String>,
257 pub action: String,
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub args: Vec<OciSeccompArg>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct OciSeccompArg {
267 pub index: u32,
269 pub value: u64,
271 #[serde(rename = "valueTwo", default, skip_serializing_if = "is_zero")]
273 pub value_two: u64,
274 pub op: String,
276}
277
278fn is_zero(v: &u64) -> bool {
279 *v == 0
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct OciIntelRdt {
287 #[serde(rename = "closID", default, skip_serializing_if = "Option::is_none")]
289 pub clos_id: Option<String>,
290 #[serde(
292 rename = "l3CacheSchema",
293 default,
294 skip_serializing_if = "Option::is_none"
295 )]
296 pub l3_cache_schema: Option<String>,
297 #[serde(
299 rename = "memBwSchema",
300 default,
301 skip_serializing_if = "Option::is_none"
302 )]
303 pub mem_bw_schema: Option<String>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct OciHook {
311 pub path: String,
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 pub args: Vec<String>,
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
318 pub env: Vec<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub timeout: Option<u32>,
322}
323
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328pub struct OciHooks {
329 #[serde(
331 rename = "createRuntime",
332 default,
333 skip_serializing_if = "Vec::is_empty"
334 )]
335 pub create_runtime: Vec<OciHook>,
336 #[serde(
338 rename = "createContainer",
339 default,
340 skip_serializing_if = "Vec::is_empty"
341 )]
342 pub create_container: Vec<OciHook>,
343 #[serde(
345 rename = "startContainer",
346 default,
347 skip_serializing_if = "Vec::is_empty"
348 )]
349 pub start_container: Vec<OciHook>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub poststart: Vec<OciHook>,
353 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 pub poststop: Vec<OciHook>,
356}
357
358#[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 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 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 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 #[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 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 Self::validate_hook_binary(hook_path, phase)?;
502
503 let mut cmd = Command::new(&hook.path);
504 if !hook.args.is_empty() {
505 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 cmd.stdin(Stdio::piped());
522 cmd.stdout(Stdio::piped());
523 cmd.stderr(Stdio::piped());
524
525 #[cfg(not(test))]
529 unsafe {
530 cmd.pre_exec(|| {
531 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 fn validate_hook_binary(hook_path: &Path, phase: &str) -> Result<()> {
651 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 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 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 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 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 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 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(), "/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 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 pub fn with_no_new_privileges(mut self, enabled: bool) -> Self {
973 self.process.no_new_privileges = enabled;
974 self
975 }
976
977 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn with_host_runtime_binds(mut self) -> Self {
1480 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 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 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 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 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 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 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 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 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 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 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
1652pub 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 pub fn new(bundle_path: PathBuf, config: OciConfig) -> Self {
1741 Self {
1742 bundle_path,
1743 config,
1744 }
1745 }
1746
1747 pub fn create(&self) -> Result<()> {
1749 info!("Creating OCI bundle at {:?}", self.bundle_path);
1750
1751 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 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 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 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 pub fn rootfs_path(&self) -> PathBuf {
1832 self.bundle_path.join("rootfs")
1833 }
1834
1835 pub fn bundle_path(&self) -> &Path {
1837 &self.bundle_path
1838 }
1839
1840 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 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 assert!(!config.linux.as_ref().unwrap().devices.is_empty());
1876 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 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 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 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 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 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")); 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()); 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 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 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 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 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 OciHooks::run_hooks_best_effort(&hooks, &state, "poststop");
2721 assert!(marker.exists(), "second hook should run after first fails");
2723 }
2724}