1use crate::container::OciStatus;
2use crate::error::{NucleusError, Result};
3use crate::filesystem::normalize_container_destination;
4use crate::isolation::{IdMapping, NamespaceConfig, UserNamespaceConfig};
5use crate::resources::ResourceLimits;
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeSet, HashMap};
8use std::fs;
9use std::fs::OpenOptions;
10use std::io::Write;
11use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
12use std::path::{Path, PathBuf};
13use tracing::{debug, info, warn};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct OciConfig {
21 #[serde(rename = "ociVersion")]
22 pub oci_version: String,
23
24 pub root: OciRoot,
25 pub process: OciProcess,
26 pub hostname: Option<String>,
27 pub mounts: Vec<OciMount>,
28 pub linux: Option<OciLinux>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub hooks: Option<OciHooks>,
31 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
32 pub annotations: HashMap<String, String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct OciRoot {
37 pub path: String,
38 pub readonly: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OciProcess {
43 pub terminal: bool,
44 pub user: OciUser,
45 pub args: Vec<String>,
46 pub env: Vec<String>,
47 pub cwd: String,
48 #[serde(rename = "noNewPrivileges")]
49 pub no_new_privileges: bool,
50 pub capabilities: Option<OciCapabilities>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub rlimits: Vec<OciRlimit>,
53 #[serde(
54 rename = "consoleSize",
55 default,
56 skip_serializing_if = "Option::is_none"
57 )]
58 pub console_size: Option<OciConsoleSize>,
59 #[serde(
60 rename = "apparmorProfile",
61 default,
62 skip_serializing_if = "Option::is_none"
63 )]
64 pub apparmor_profile: Option<String>,
65 #[serde(
66 rename = "selinuxLabel",
67 default,
68 skip_serializing_if = "Option::is_none"
69 )]
70 pub selinux_label: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct OciUser {
75 pub uid: u32,
76 pub gid: u32,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub additional_gids: Option<Vec<u32>>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct OciCapabilities {
83 pub bounding: Vec<String>,
84 pub effective: Vec<String>,
85 pub inheritable: Vec<String>,
86 pub permitted: Vec<String>,
87 pub ambient: Vec<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct OciMount {
92 pub destination: String,
93 pub source: String,
94 #[serde(rename = "type")]
95 pub mount_type: String,
96 pub options: Vec<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct OciLinux {
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub namespaces: Option<Vec<OciNamespace>>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub resources: Option<OciResources>,
105 #[serde(rename = "uidMappings", skip_serializing_if = "Vec::is_empty", default)]
106 pub uid_mappings: Vec<OciIdMapping>,
107 #[serde(rename = "gidMappings", skip_serializing_if = "Vec::is_empty", default)]
108 pub gid_mappings: Vec<OciIdMapping>,
109 #[serde(rename = "maskedPaths", skip_serializing_if = "Vec::is_empty", default)]
110 pub masked_paths: Vec<String>,
111 #[serde(
112 rename = "readonlyPaths",
113 skip_serializing_if = "Vec::is_empty",
114 default
115 )]
116 pub readonly_paths: Vec<String>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub devices: Vec<OciDevice>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub seccomp: Option<OciSeccomp>,
121 #[serde(
122 rename = "rootfsPropagation",
123 default,
124 skip_serializing_if = "Option::is_none"
125 )]
126 pub rootfs_propagation: Option<String>,
127 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
128 pub sysctl: HashMap<String, String>,
129 #[serde(
130 rename = "cgroupsPath",
131 default,
132 skip_serializing_if = "Option::is_none"
133 )]
134 pub cgroups_path: Option<String>,
135 #[serde(rename = "intelRdt", default, skip_serializing_if = "Option::is_none")]
136 pub intel_rdt: Option<OciIntelRdt>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct OciNamespace {
141 #[serde(rename = "type")]
142 pub namespace_type: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146pub struct OciIdMapping {
147 #[serde(rename = "containerID")]
148 pub container_id: u32,
149 #[serde(rename = "hostID")]
150 pub host_id: u32,
151 pub size: u32,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct OciResources {
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub memory: Option<OciMemory>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub cpu: Option<OciCpu>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub pids: Option<OciPids>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct OciMemory {
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub limit: Option<i64>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct OciCpu {
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub quota: Option<i64>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub period: Option<u64>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct OciPids {
180 pub limit: i64,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct OciRlimit {
188 #[serde(rename = "type")]
190 pub limit_type: String,
191 pub hard: u64,
193 pub soft: u64,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct OciConsoleSize {
200 pub height: u32,
201 pub width: u32,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct OciDevice {
209 #[serde(rename = "type")]
211 pub device_type: String,
212 pub path: String,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub major: Option<i64>,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub minor: Option<i64>,
220 #[serde(rename = "fileMode", skip_serializing_if = "Option::is_none")]
222 pub file_mode: Option<u32>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub uid: Option<u32>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub gid: Option<u32>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct OciSeccomp {
236 #[serde(rename = "defaultAction")]
238 pub default_action: String,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub architectures: Vec<String>,
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub syscalls: Vec<OciSeccompSyscall>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct OciSeccompSyscall {
250 pub names: Vec<String>,
252 pub action: String,
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
256 pub args: Vec<OciSeccompArg>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct OciSeccompArg {
262 pub index: u32,
264 pub value: u64,
266 #[serde(rename = "valueTwo", default, skip_serializing_if = "is_zero")]
268 pub value_two: u64,
269 pub op: String,
271}
272
273fn is_zero(v: &u64) -> bool {
274 *v == 0
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct OciIntelRdt {
282 #[serde(rename = "closID", default, skip_serializing_if = "Option::is_none")]
284 pub clos_id: Option<String>,
285 #[serde(
287 rename = "l3CacheSchema",
288 default,
289 skip_serializing_if = "Option::is_none"
290 )]
291 pub l3_cache_schema: Option<String>,
292 #[serde(
294 rename = "memBwSchema",
295 default,
296 skip_serializing_if = "Option::is_none"
297 )]
298 pub mem_bw_schema: Option<String>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct OciHook {
306 pub path: String,
308 #[serde(default, skip_serializing_if = "Vec::is_empty")]
310 pub args: Vec<String>,
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub env: Vec<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub timeout: Option<u32>,
317}
318
319#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct OciHooks {
324 #[serde(
326 rename = "createRuntime",
327 default,
328 skip_serializing_if = "Vec::is_empty"
329 )]
330 pub create_runtime: Vec<OciHook>,
331 #[serde(
333 rename = "createContainer",
334 default,
335 skip_serializing_if = "Vec::is_empty"
336 )]
337 pub create_container: Vec<OciHook>,
338 #[serde(
340 rename = "startContainer",
341 default,
342 skip_serializing_if = "Vec::is_empty"
343 )]
344 pub start_container: Vec<OciHook>,
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
347 pub poststart: Vec<OciHook>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub poststop: Vec<OciHook>,
351}
352
353#[derive(Debug, Clone, Serialize)]
357pub struct OciContainerState {
358 #[serde(rename = "ociVersion")]
359 pub oci_version: String,
360 pub id: String,
361 pub status: OciStatus,
362 pub pid: u32,
363 pub bundle: String,
364}
365
366impl OciHooks {
367 pub fn is_empty(&self) -> bool {
369 self.create_runtime.is_empty()
370 && self.create_container.is_empty()
371 && self.start_container.is_empty()
372 && self.poststart.is_empty()
373 && self.poststop.is_empty()
374 }
375
376 pub fn run_hooks(hooks: &[OciHook], state: &OciContainerState, phase: &str) -> Result<()> {
380 let state_json = serde_json::to_string(state).map_err(|e| {
381 NucleusError::HookError(format!(
382 "Failed to serialize container state for hook: {}",
383 e
384 ))
385 })?;
386
387 for (i, hook) in hooks.iter().enumerate() {
388 info!(
389 "Running {} hook [{}/{}]: {}",
390 phase,
391 i + 1,
392 hooks.len(),
393 hook.path
394 );
395 Self::execute_hook(hook, &state_json, phase)?;
396 }
397
398 Ok(())
399 }
400
401 pub fn run_hooks_best_effort(hooks: &[OciHook], state: &OciContainerState, phase: &str) {
406 let state_json = match serde_json::to_string(state) {
407 Ok(json) => json,
408 Err(e) => {
409 warn!(
410 "Failed to serialize container state for {} hooks: {}",
411 phase, e
412 );
413 return;
414 }
415 };
416
417 for (i, hook) in hooks.iter().enumerate() {
418 info!(
419 "Running {} hook [{}/{}]: {}",
420 phase,
421 i + 1,
422 hooks.len(),
423 hook.path
424 );
425 if let Err(e) = Self::execute_hook(hook, &state_json, phase) {
426 warn!("{} hook [{}] failed (continuing): {}", phase, i + 1, e);
427 }
428 }
429 }
430
431 fn execute_hook(hook: &OciHook, state_json: &str, phase: &str) -> Result<()> {
432 #[cfg(not(test))]
433 use std::os::unix::process::CommandExt;
434 use std::process::{Command, Stdio};
435
436 let hook_path = Path::new(&hook.path);
437 if !hook_path.is_absolute() {
438 return Err(NucleusError::HookError(format!(
439 "{} hook path must be absolute: {}",
440 phase, hook.path
441 )));
442 }
443
444 #[cfg(not(test))]
448 {
449 const TRUSTED_HOOK_PREFIXES: &[&str] = &[
450 "/usr/bin/",
451 "/usr/sbin/",
452 "/usr/lib/",
453 "/usr/libexec/",
454 "/usr/local/bin/",
455 "/usr/local/sbin/",
456 "/usr/local/libexec/",
457 "/bin/",
458 "/sbin/",
459 "/nix/store/",
460 "/opt/",
461 ];
462 if !TRUSTED_HOOK_PREFIXES
463 .iter()
464 .any(|prefix| hook.path.starts_with(prefix))
465 {
466 return Err(NucleusError::HookError(format!(
467 "{} hook path '{}' is not under a trusted directory ({:?})",
468 phase, hook.path, TRUSTED_HOOK_PREFIXES
469 )));
470 }
471 }
472
473 match std::fs::symlink_metadata(hook_path) {
477 Ok(meta) if meta.file_type().is_symlink() => {
478 return Err(NucleusError::HookError(format!(
479 "{} hook path is a symlink (refusing to follow): {}",
480 phase, hook.path
481 )));
482 }
483 Err(_) => {
484 return Err(NucleusError::HookError(format!(
485 "{} hook binary not found: {}",
486 phase, hook.path
487 )));
488 }
489 Ok(_) => {}
490 }
491
492 Self::validate_hook_binary(hook_path, phase)?;
497
498 let mut cmd = Command::new(&hook.path);
499 if !hook.args.is_empty() {
500 cmd.args(&hook.args[1..]);
502 }
503
504 if !hook.env.is_empty() {
505 cmd.env_clear();
506 for entry in &hook.env {
507 if let Some((key, value)) = entry.split_once('=') {
508 cmd.env(key, value);
509 }
510 }
511 }
512
513 cmd.stdin(Stdio::piped());
517 cmd.stdout(Stdio::piped());
518 cmd.stderr(Stdio::piped());
519
520 #[cfg(not(test))]
524 unsafe {
525 cmd.pre_exec(|| {
526 if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
529 return Err(std::io::Error::last_os_error());
530 }
531
532 let rlim_nproc = libc::rlimit {
533 rlim_cur: 1024,
534 rlim_max: 1024,
535 };
536 libc::setrlimit(libc::RLIMIT_NPROC, &rlim_nproc);
537
538 let rlim_nofile = libc::rlimit {
539 rlim_cur: 1024,
540 rlim_max: 1024,
541 };
542 libc::setrlimit(libc::RLIMIT_NOFILE, &rlim_nofile);
543
544 Ok(())
545 });
546 }
547
548 let mut child = cmd.spawn().map_err(|e| {
549 NucleusError::HookError(format!(
550 "Failed to spawn {} hook {}: {}",
551 phase, hook.path, e
552 ))
553 })?;
554
555 if let Some(mut stdin) = child.stdin.take() {
556 use std::io::Write as IoWrite;
557 let _ = stdin.write_all(state_json.as_bytes());
558 }
559
560 let timeout_secs = hook.timeout.unwrap_or(30) as u64;
561 let start = std::time::Instant::now();
562 let timeout = std::time::Duration::from_secs(timeout_secs);
563
564 loop {
565 match child.try_wait() {
566 Ok(Some(status)) => {
567 if status.success() {
568 debug!("{} hook {} completed successfully", phase, hook.path);
569 return Ok(());
570 } else {
571 let stderr = child
572 .stderr
573 .take()
574 .map(|mut e| {
575 let mut buf = String::new();
576 use std::io::Read;
577 let _ = e.read_to_string(&mut buf);
578 buf
579 })
580 .unwrap_or_default();
581 return Err(NucleusError::HookError(format!(
582 "{} hook {} exited with status: {}{}",
583 phase,
584 hook.path,
585 status,
586 if stderr.is_empty() {
587 String::new()
588 } else {
589 format!(" (stderr: {})", stderr.trim())
590 }
591 )));
592 }
593 }
594 Ok(None) => {
595 if start.elapsed() >= timeout {
596 let _ = child.kill();
597 let _ = child.wait();
598 return Err(NucleusError::HookError(format!(
599 "{} hook {} timed out after {}s",
600 phase, hook.path, timeout_secs
601 )));
602 }
603 std::thread::sleep(std::time::Duration::from_millis(50));
604 }
605 Err(e) => {
606 return Err(NucleusError::HookError(format!(
607 "Failed to wait for {} hook {}: {}",
608 phase, hook.path, e
609 )));
610 }
611 }
612 }
613 }
614
615 fn validate_hook_binary(hook_path: &Path, phase: &str) -> Result<()> {
621 let metadata = std::fs::symlink_metadata(hook_path).map_err(|e| {
625 NucleusError::HookError(format!(
626 "Failed to stat {} hook {}: {}",
627 phase,
628 hook_path.display(),
629 e
630 ))
631 })?;
632
633 use std::os::unix::fs::MetadataExt;
634 let mode = metadata.mode();
635 let uid = metadata.uid();
636 let gid = metadata.gid();
637 let effective_uid = nix::unistd::Uid::effective().as_raw();
638
639 if mode & 0o002 != 0 {
641 return Err(NucleusError::HookError(format!(
642 "{} hook {} is world-writable (mode {:04o}) — refusing to execute",
643 phase,
644 hook_path.display(),
645 mode & 0o7777
646 )));
647 }
648
649 if mode & 0o020 != 0 && uid != 0 {
651 return Err(NucleusError::HookError(format!(
652 "{} hook {} is group-writable and not owned by root (mode {:04o}, uid {}) — refusing to execute",
653 phase,
654 hook_path.display(),
655 mode & 0o7777,
656 uid
657 )));
658 }
659
660 if uid != 0 && uid != effective_uid {
662 return Err(NucleusError::HookError(format!(
663 "{} hook {} is owned by UID {} (expected 0 or {}) — refusing to execute",
664 phase,
665 hook_path.display(),
666 uid,
667 effective_uid
668 )));
669 }
670
671 if mode & 0o6000 != 0 {
673 return Err(NucleusError::HookError(format!(
674 "{} hook {} has setuid/setgid bits (mode {:04o}) — refusing to execute",
675 phase,
676 hook_path.display(),
677 mode & 0o7777
678 )));
679 }
680
681 debug!(
682 "{} hook {} validation passed (uid={}, gid={}, mode={:04o})",
683 phase,
684 hook_path.display(),
685 uid,
686 gid,
687 mode & 0o7777
688 );
689
690 Ok(())
691 }
692}
693
694impl OciConfig {
695 pub fn new(command: Vec<String>, hostname: Option<String>) -> Self {
697 Self {
698 oci_version: "1.0.2".to_string(),
699 root: OciRoot {
700 path: "rootfs".to_string(),
701 readonly: true,
702 },
703 process: OciProcess {
704 terminal: false,
705 user: OciUser {
706 uid: 0,
707 gid: 0,
708 additional_gids: None,
709 },
710 args: command,
711 env: vec![
712 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
713 ],
714 cwd: "/".to_string(),
715 no_new_privileges: true,
716 capabilities: Some(OciCapabilities {
717 bounding: vec![],
718 effective: vec![],
719 inheritable: vec![],
720 permitted: vec![],
721 ambient: vec![],
722 }),
723 rlimits: vec![],
724 console_size: None,
725 apparmor_profile: None,
726 selinux_label: None,
727 },
728 hostname,
729 mounts: vec![
730 OciMount {
731 destination: "/proc".to_string(),
732 source: "proc".to_string(),
733 mount_type: "proc".to_string(),
734 options: vec![
735 "nosuid".to_string(),
736 "noexec".to_string(),
737 "nodev".to_string(),
738 ],
739 },
740 OciMount {
741 destination: "/dev".to_string(),
742 source: "tmpfs".to_string(),
743 mount_type: "tmpfs".to_string(),
744 options: vec![
745 "nosuid".to_string(),
746 "noexec".to_string(),
747 "strictatime".to_string(),
748 "mode=755".to_string(),
749 "size=65536k".to_string(),
750 ],
751 },
752 OciMount {
753 destination: "/tmp".to_string(),
754 source: "tmpfs".to_string(),
755 mount_type: "tmpfs".to_string(),
756 options: vec![
757 "nosuid".to_string(),
758 "nodev".to_string(),
759 "noexec".to_string(),
760 "mode=1777".to_string(),
761 "size=65536k".to_string(),
762 ],
763 },
764 OciMount {
765 destination: "/sys".to_string(),
766 source: "sysfs".to_string(),
767 mount_type: "sysfs".to_string(),
768 options: vec![
769 "nosuid".to_string(),
770 "noexec".to_string(),
771 "nodev".to_string(),
772 "ro".to_string(),
773 ],
774 },
775 ],
776 hooks: None,
777 annotations: HashMap::new(),
778 linux: Some(OciLinux {
779 namespaces: Some(vec![
780 OciNamespace {
781 namespace_type: "pid".to_string(),
782 },
783 OciNamespace {
784 namespace_type: "network".to_string(),
785 },
786 OciNamespace {
787 namespace_type: "ipc".to_string(),
788 },
789 OciNamespace {
790 namespace_type: "uts".to_string(),
791 },
792 OciNamespace {
793 namespace_type: "mount".to_string(),
794 },
795 ]),
796 resources: None,
797 uid_mappings: vec![],
798 gid_mappings: vec![],
799 masked_paths: vec![
801 "/proc/acpi".to_string(),
802 "/proc/asound".to_string(),
803 "/proc/kcore".to_string(),
804 "/proc/keys".to_string(),
805 "/proc/latency_stats".to_string(),
806 "/proc/sched_debug".to_string(),
807 "/proc/scsi".to_string(),
808 "/proc/timer_list".to_string(),
809 "/proc/timer_stats".to_string(),
810 "/proc/sysrq-trigger".to_string(), "/proc/kpagecount".to_string(),
812 "/proc/kpageflags".to_string(),
813 "/proc/kpagecgroup".to_string(),
814 "/proc/config.gz".to_string(),
815 "/proc/kallsyms".to_string(),
816 "/sys/firmware".to_string(),
817 ],
818 readonly_paths: vec![
819 "/proc/bus".to_string(),
820 "/proc/fs".to_string(),
821 "/proc/irq".to_string(),
822 "/proc/sys".to_string(),
823 ],
824 devices: vec![
825 OciDevice {
826 device_type: "c".to_string(),
827 path: "/dev/null".to_string(),
828 major: Some(1),
829 minor: Some(3),
830 file_mode: Some(0o666),
831 uid: Some(0),
832 gid: Some(0),
833 },
834 OciDevice {
835 device_type: "c".to_string(),
836 path: "/dev/zero".to_string(),
837 major: Some(1),
838 minor: Some(5),
839 file_mode: Some(0o666),
840 uid: Some(0),
841 gid: Some(0),
842 },
843 OciDevice {
844 device_type: "c".to_string(),
845 path: "/dev/full".to_string(),
846 major: Some(1),
847 minor: Some(7),
848 file_mode: Some(0o666),
849 uid: Some(0),
850 gid: Some(0),
851 },
852 OciDevice {
853 device_type: "c".to_string(),
854 path: "/dev/random".to_string(),
855 major: Some(1),
856 minor: Some(8),
857 file_mode: Some(0o666),
858 uid: Some(0),
859 gid: Some(0),
860 },
861 OciDevice {
862 device_type: "c".to_string(),
863 path: "/dev/urandom".to_string(),
864 major: Some(1),
865 minor: Some(9),
866 file_mode: Some(0o666),
867 uid: Some(0),
868 gid: Some(0),
869 },
870 ],
871 seccomp: None,
872 rootfs_propagation: Some("rprivate".to_string()),
873 sysctl: HashMap::new(),
874 cgroups_path: None,
875 intel_rdt: None,
876 }),
877 }
878 }
879
880 pub fn with_resources(mut self, limits: &ResourceLimits) -> Self {
882 let mut resources = OciResources {
883 memory: None,
884 cpu: None,
885 pids: None,
886 };
887
888 if let Some(memory_bytes) = limits.memory_bytes {
889 resources.memory = Some(OciMemory {
890 limit: Some(memory_bytes as i64),
891 });
892 }
893
894 if let Some(quota_us) = limits.cpu_quota_us {
895 resources.cpu = Some(OciCpu {
896 quota: Some(quota_us as i64),
897 period: Some(limits.cpu_period_us),
898 });
899 }
900
901 if let Some(pids_max) = limits.pids_max {
902 resources.pids = Some(OciPids {
903 limit: pids_max as i64,
904 });
905 }
906
907 if let Some(linux) = &mut self.linux {
908 linux.resources = Some(resources);
909 }
910
911 self
912 }
913
914 pub fn with_env(mut self, vars: &[(String, String)]) -> Self {
916 for (key, value) in vars {
917 self.process.env.push(format!("{}={}", key, value));
918 }
919 self
920 }
921
922 pub fn with_sd_notify(mut self) -> Self {
924 if let Ok(notify_socket) = std::env::var("NOTIFY_SOCKET") {
925 self.process
926 .env
927 .push(format!("NOTIFY_SOCKET={}", notify_socket));
928 }
929 self
930 }
931
932 pub fn with_secret_mounts(mut self, secrets: &[crate::container::SecretMount]) -> Self {
934 for secret in secrets {
935 self.mounts.push(OciMount {
936 destination: secret.dest.to_string_lossy().to_string(),
937 source: secret.source.to_string_lossy().to_string(),
938 mount_type: "bind".to_string(),
939 options: vec![
940 "bind".to_string(),
941 "ro".to_string(),
942 "nosuid".to_string(),
943 "nodev".to_string(),
944 "noexec".to_string(),
945 ],
946 });
947 }
948 self
949 }
950
951 pub fn with_process_identity(mut self, identity: &crate::container::ProcessIdentity) -> Self {
953 self.process.user.uid = identity.uid;
954 self.process.user.gid = identity.gid;
955 self.process.user.additional_gids = if identity.additional_gids.is_empty() {
956 None
957 } else {
958 Some(identity.additional_gids.clone())
959 };
960 self
961 }
962
963 pub fn with_inmemory_secret_mounts(
967 mut self,
968 stage_dir: &Path,
969 secrets: &[crate::container::SecretMount],
970 ) -> Result<Self> {
971 self.mounts.push(OciMount {
972 destination: "/run/secrets".to_string(),
973 source: stage_dir.to_string_lossy().to_string(),
974 mount_type: "bind".to_string(),
975 options: vec![
976 "bind".to_string(),
977 "ro".to_string(),
978 "nosuid".to_string(),
979 "nodev".to_string(),
980 "noexec".to_string(),
981 ],
982 });
983
984 for secret in secrets {
985 let dest = normalize_container_destination(&secret.dest)?;
986 if !secret.source.starts_with(stage_dir) {
987 return Err(NucleusError::ConfigError(format!(
988 "Staged secret source {:?} must live under {:?}",
989 secret.source, stage_dir
990 )));
991 }
992 self.mounts.push(OciMount {
993 destination: dest.to_string_lossy().to_string(),
994 source: secret.source.to_string_lossy().to_string(),
995 mount_type: "bind".to_string(),
996 options: vec![
997 "bind".to_string(),
998 "ro".to_string(),
999 "nosuid".to_string(),
1000 "nodev".to_string(),
1001 "noexec".to_string(),
1002 ],
1003 });
1004 }
1005
1006 Ok(self)
1007 }
1008
1009 pub fn with_volume_mounts(mut self, volumes: &[crate::container::VolumeMount]) -> Result<Self> {
1011 use crate::container::VolumeSource;
1012
1013 for volume in volumes {
1014 let dest = normalize_container_destination(&volume.dest)?;
1015 match &volume.source {
1016 VolumeSource::Bind { source } => {
1017 let mut options = vec![
1018 "bind".to_string(),
1019 "nosuid".to_string(),
1020 "nodev".to_string(),
1021 ];
1022 if volume.read_only {
1023 options.push("ro".to_string());
1024 }
1025 self.mounts.push(OciMount {
1026 destination: dest.to_string_lossy().to_string(),
1027 source: source.to_string_lossy().to_string(),
1028 mount_type: "bind".to_string(),
1029 options,
1030 });
1031 }
1032 VolumeSource::Tmpfs { size } => {
1033 let mut options = vec![
1034 "nosuid".to_string(),
1035 "nodev".to_string(),
1036 "mode=0755".to_string(),
1037 ];
1038 if volume.read_only {
1039 options.push("ro".to_string());
1040 }
1041 if let Some(size) = size {
1042 options.push(format!("size={}", size));
1043 }
1044 self.mounts.push(OciMount {
1045 destination: dest.to_string_lossy().to_string(),
1046 source: "tmpfs".to_string(),
1047 mount_type: "tmpfs".to_string(),
1048 options,
1049 });
1050 }
1051 }
1052 }
1053
1054 Ok(self)
1055 }
1056
1057 pub fn with_context_bind(mut self, context_dir: &std::path::Path) -> Self {
1062 self.mounts.push(OciMount {
1063 destination: "/context".to_string(),
1064 source: context_dir.to_string_lossy().to_string(),
1065 mount_type: "bind".to_string(),
1066 options: vec![
1067 "bind".to_string(),
1068 "ro".to_string(),
1069 "nosuid".to_string(),
1070 "nodev".to_string(),
1071 ],
1072 });
1073 self
1074 }
1075
1076 pub fn with_rootfs_binds(mut self, rootfs_path: &std::path::Path) -> Self {
1078 let subdirs = ["bin", "sbin", "lib", "lib64", "usr", "etc", "nix"];
1079 for subdir in &subdirs {
1080 let source = rootfs_path.join(subdir);
1081 if source.exists() {
1082 self.mounts.push(OciMount {
1083 destination: format!("/{}", subdir),
1084 source: source.to_string_lossy().to_string(),
1085 mount_type: "bind".to_string(),
1086 options: vec![
1087 "bind".to_string(),
1088 "ro".to_string(),
1089 "nosuid".to_string(),
1090 "nodev".to_string(),
1091 ],
1092 });
1093 }
1094 }
1095 self
1096 }
1097
1098 pub fn with_namespace_config(mut self, config: &NamespaceConfig) -> Self {
1100 let mut namespaces = Vec::new();
1101
1102 if config.pid {
1103 namespaces.push(OciNamespace {
1104 namespace_type: "pid".to_string(),
1105 });
1106 }
1107 if config.net {
1108 namespaces.push(OciNamespace {
1109 namespace_type: "network".to_string(),
1110 });
1111 }
1112 if config.ipc {
1113 namespaces.push(OciNamespace {
1114 namespace_type: "ipc".to_string(),
1115 });
1116 }
1117 if config.uts {
1118 namespaces.push(OciNamespace {
1119 namespace_type: "uts".to_string(),
1120 });
1121 }
1122 if config.mnt {
1123 namespaces.push(OciNamespace {
1124 namespace_type: "mount".to_string(),
1125 });
1126 }
1127 if config.cgroup {
1128 namespaces.push(OciNamespace {
1129 namespace_type: "cgroup".to_string(),
1130 });
1131 }
1132 if config.time {
1133 namespaces.push(OciNamespace {
1134 namespace_type: "time".to_string(),
1135 });
1136 }
1137 if config.user {
1138 namespaces.push(OciNamespace {
1139 namespace_type: "user".to_string(),
1140 });
1141 }
1142
1143 if let Some(linux) = &mut self.linux {
1144 linux.namespaces = Some(namespaces);
1145 }
1146
1147 self
1148 }
1149
1150 pub fn with_host_runtime_binds(mut self) -> Self {
1156 let host_paths: BTreeSet<String> =
1159 ["/bin", "/sbin", "/usr", "/lib", "/lib64", "/nix/store"]
1160 .iter()
1161 .map(|s| s.to_string())
1162 .collect();
1163
1164 for host_path in host_paths {
1165 let source = Path::new(&host_path);
1166 if !source.exists() {
1167 continue;
1168 }
1169
1170 self.mounts.push(OciMount {
1171 destination: host_path.clone(),
1172 source: source.to_string_lossy().to_string(),
1173 mount_type: "bind".to_string(),
1174 options: vec![
1175 "bind".to_string(),
1176 "ro".to_string(),
1177 "nosuid".to_string(),
1178 "nodev".to_string(),
1179 ],
1180 });
1181 }
1182 self
1183 }
1184
1185 pub fn with_user_namespace(mut self) -> Self {
1187 if let Some(linux) = &mut self.linux {
1188 if let Some(namespaces) = &mut linux.namespaces {
1189 namespaces.push(OciNamespace {
1190 namespace_type: "user".to_string(),
1191 });
1192 }
1193 }
1194 self
1195 }
1196
1197 pub fn with_rootless_user_namespace(mut self, config: &UserNamespaceConfig) -> Self {
1204 if let Some(linux) = &mut self.linux {
1205 if let Some(namespaces) = &mut linux.namespaces {
1206 namespaces.retain(|ns| ns.namespace_type != "network");
1207 if !namespaces.iter().any(|ns| ns.namespace_type == "user") {
1208 namespaces.push(OciNamespace {
1209 namespace_type: "user".to_string(),
1210 });
1211 }
1212 }
1213 linux.uid_mappings = config.uid_mappings.iter().map(OciIdMapping::from).collect();
1214 linux.gid_mappings = config.gid_mappings.iter().map(OciIdMapping::from).collect();
1215 }
1216 self
1217 }
1218
1219 pub fn with_hooks(mut self, hooks: OciHooks) -> Self {
1221 if hooks.is_empty() {
1222 self.hooks = None;
1223 } else {
1224 self.hooks = Some(hooks);
1225 }
1226 self
1227 }
1228
1229 pub fn with_rlimits(mut self, pids_max: Option<u64>) -> Self {
1234 let nproc_limit = pids_max.unwrap_or(512);
1235 self.process.rlimits = vec![
1236 OciRlimit {
1237 limit_type: "RLIMIT_NPROC".to_string(),
1238 hard: nproc_limit,
1239 soft: nproc_limit,
1240 },
1241 OciRlimit {
1242 limit_type: "RLIMIT_NOFILE".to_string(),
1243 hard: 1024,
1244 soft: 1024,
1245 },
1246 OciRlimit {
1247 limit_type: "RLIMIT_MEMLOCK".to_string(),
1248 hard: 64 * 1024,
1249 soft: 64 * 1024,
1250 },
1251 ];
1252 self
1253 }
1254
1255 pub fn with_seccomp(mut self, seccomp: OciSeccomp) -> Self {
1257 if let Some(linux) = &mut self.linux {
1258 linux.seccomp = Some(seccomp);
1259 }
1260 self
1261 }
1262
1263 pub fn with_cgroups_path(mut self, path: String) -> Self {
1265 if let Some(linux) = &mut self.linux {
1266 linux.cgroups_path = Some(path);
1267 }
1268 self
1269 }
1270
1271 pub fn with_sysctl(mut self, sysctl: HashMap<String, String>) -> Self {
1273 if let Some(linux) = &mut self.linux {
1274 linux.sysctl = sysctl;
1275 }
1276 self
1277 }
1278
1279 pub fn with_annotations(mut self, annotations: HashMap<String, String>) -> Self {
1281 self.annotations = annotations;
1282 self
1283 }
1284}
1285
1286impl From<&IdMapping> for OciIdMapping {
1287 fn from(mapping: &IdMapping) -> Self {
1288 Self {
1289 container_id: mapping.container_id,
1290 host_id: mapping.host_id,
1291 size: mapping.count,
1292 }
1293 }
1294}
1295
1296pub struct OciBundle {
1300 bundle_path: PathBuf,
1301 config: OciConfig,
1302}
1303
1304impl OciBundle {
1305 pub fn new(bundle_path: PathBuf, config: OciConfig) -> Self {
1307 Self {
1308 bundle_path,
1309 config,
1310 }
1311 }
1312
1313 pub fn create(&self) -> Result<()> {
1315 info!("Creating OCI bundle at {:?}", self.bundle_path);
1316
1317 fs::create_dir_all(&self.bundle_path).map_err(|e| {
1319 NucleusError::GVisorError(format!(
1320 "Failed to create bundle directory {:?}: {}",
1321 self.bundle_path, e
1322 ))
1323 })?;
1324 fs::set_permissions(&self.bundle_path, fs::Permissions::from_mode(0o700)).map_err(|e| {
1325 NucleusError::GVisorError(format!(
1326 "Failed to secure bundle directory permissions {:?}: {}",
1327 self.bundle_path, e
1328 ))
1329 })?;
1330
1331 let rootfs = self.bundle_path.join("rootfs");
1333 fs::create_dir_all(&rootfs).map_err(|e| {
1334 NucleusError::GVisorError(format!("Failed to create rootfs directory: {}", e))
1335 })?;
1336 fs::set_permissions(&rootfs, fs::Permissions::from_mode(0o700)).map_err(|e| {
1337 NucleusError::GVisorError(format!(
1338 "Failed to secure rootfs directory permissions {:?}: {}",
1339 rootfs, e
1340 ))
1341 })?;
1342
1343 let config_path = self.bundle_path.join("config.json");
1345 let config_json = serde_json::to_string_pretty(&self.config).map_err(|e| {
1346 NucleusError::GVisorError(format!("Failed to serialize OCI config: {}", e))
1347 })?;
1348
1349 let mut file = OpenOptions::new()
1351 .create(true)
1352 .truncate(true)
1353 .write(true)
1354 .mode(0o600)
1355 .custom_flags(libc::O_NOFOLLOW)
1356 .open(&config_path)
1357 .map_err(|e| NucleusError::GVisorError(format!("Failed to open config.json: {}", e)))?;
1358 file.write_all(config_json.as_bytes()).map_err(|e| {
1359 NucleusError::GVisorError(format!("Failed to write config.json: {}", e))
1360 })?;
1361 file.sync_all()
1362 .map_err(|e| NucleusError::GVisorError(format!("Failed to sync config.json: {}", e)))?;
1363
1364 debug!("Created OCI bundle structure at {:?}", self.bundle_path);
1365
1366 Ok(())
1367 }
1368
1369 pub fn rootfs_path(&self) -> PathBuf {
1371 self.bundle_path.join("rootfs")
1372 }
1373
1374 pub fn bundle_path(&self) -> &Path {
1376 &self.bundle_path
1377 }
1378
1379 pub fn cleanup(&self) -> Result<()> {
1381 if self.bundle_path.exists() {
1382 fs::remove_dir_all(&self.bundle_path).map_err(|e| {
1383 NucleusError::GVisorError(format!("Failed to cleanup bundle: {}", e))
1384 })?;
1385 debug!("Cleaned up OCI bundle at {:?}", self.bundle_path);
1386 }
1387 Ok(())
1388 }
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394 use tempfile::TempDir;
1395
1396 #[test]
1397 fn test_oci_config_new() {
1398 let config = OciConfig::new(vec!["/bin/sh".to_string()], Some("test".to_string()));
1399
1400 assert_eq!(config.oci_version, "1.0.2");
1401 assert_eq!(config.root.path, "rootfs");
1402 assert_eq!(config.process.args, vec!["/bin/sh"]);
1403 assert_eq!(config.hostname, Some("test".to_string()));
1404 }
1405
1406 #[test]
1407 fn test_oci_config_with_resources() {
1408 let limits = ResourceLimits::unlimited()
1409 .with_memory("512M")
1410 .unwrap()
1411 .with_cpu_cores(2.0)
1412 .unwrap();
1413
1414 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_resources(&limits);
1415
1416 assert!(config.linux.is_some());
1417 let linux = config.linux.unwrap();
1418 assert!(linux.resources.is_some());
1419
1420 let resources = linux.resources.unwrap();
1421 assert!(resources.memory.is_some());
1422 assert!(resources.cpu.is_some());
1423 }
1424
1425 #[test]
1426 fn test_oci_bundle_create() {
1427 let temp_dir = TempDir::new().unwrap();
1428 let bundle_path = temp_dir.path().join("test-bundle");
1429
1430 let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
1431 let bundle = OciBundle::new(bundle_path.clone(), config);
1432
1433 bundle.create().unwrap();
1434
1435 assert!(bundle_path.exists());
1436 assert!(bundle_path.join("rootfs").exists());
1437 assert!(bundle_path.join("config.json").exists());
1438
1439 bundle.cleanup().unwrap();
1440 assert!(!bundle_path.exists());
1441 }
1442
1443 #[test]
1444 fn test_oci_config_serialization() {
1445 let config = OciConfig::new(vec!["/bin/sh".to_string()], Some("test".to_string()));
1446
1447 let json = serde_json::to_string_pretty(&config).unwrap();
1448 assert!(json.contains("ociVersion"));
1449 assert!(json.contains("1.0.2"));
1450 assert!(json.contains("/bin/sh"));
1451
1452 let deserialized: OciConfig = serde_json::from_str(&json).unwrap();
1454 assert_eq!(deserialized.oci_version, config.oci_version);
1455 assert_eq!(deserialized.process.args, config.process.args);
1456 }
1457
1458 #[test]
1459 fn test_host_runtime_binds_uses_fixed_paths_not_host_path() {
1460 std::env::set_var("PATH", "/tmp/evil-inject-path/bin:/opt/attacker/sbin");
1465 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_host_runtime_binds();
1466 let mount_dests: Vec<&str> = config
1467 .mounts
1468 .iter()
1469 .map(|m| m.destination.as_str())
1470 .collect();
1471 let mount_srcs: Vec<&str> = config.mounts.iter().map(|m| m.source.as_str()).collect();
1472 for path in &["/tmp/evil-inject-path", "/opt/attacker"] {
1474 assert!(
1475 !mount_dests.iter().any(|d| d.contains(path)),
1476 "with_host_runtime_binds must not use host $PATH — found {:?} in mount destinations",
1477 path
1478 );
1479 assert!(
1480 !mount_srcs.iter().any(|s| s.contains(path)),
1481 "with_host_runtime_binds must not use host $PATH — found {:?} in mount sources",
1482 path
1483 );
1484 }
1485 let allowed_prefixes = ["/bin", "/sbin", "/usr", "/lib", "/lib64", "/nix/store"];
1487 for mount in &config.mounts {
1488 if mount.mount_type == "bind" {
1489 assert!(
1490 allowed_prefixes
1491 .iter()
1492 .any(|p| mount.destination.starts_with(p)),
1493 "unexpected bind mount destination: {} — only FHS paths allowed",
1494 mount.destination
1495 );
1496 }
1497 }
1498 }
1499
1500 #[test]
1501 fn test_volume_mounts_include_bind_and_tmpfs_options() {
1502 let tmp = tempfile::TempDir::new().unwrap();
1503 let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
1504 .with_volume_mounts(&[
1505 crate::container::VolumeMount {
1506 source: crate::container::VolumeSource::Bind {
1507 source: tmp.path().to_path_buf(),
1508 },
1509 dest: std::path::PathBuf::from("/var/lib/app"),
1510 read_only: true,
1511 },
1512 crate::container::VolumeMount {
1513 source: crate::container::VolumeSource::Tmpfs {
1514 size: Some("64M".to_string()),
1515 },
1516 dest: std::path::PathBuf::from("/var/cache/app"),
1517 read_only: false,
1518 },
1519 ])
1520 .unwrap();
1521
1522 assert!(config.mounts.iter().any(|mount| {
1523 mount.destination == "/var/lib/app"
1524 && mount.mount_type == "bind"
1525 && mount.options.contains(&"ro".to_string())
1526 }));
1527 assert!(config.mounts.iter().any(|mount| {
1528 mount.destination == "/var/cache/app"
1529 && mount.mount_type == "tmpfs"
1530 && mount.options.contains(&"size=64M".to_string())
1531 }));
1532 }
1533
1534 #[test]
1535 fn test_oci_config_with_process_identity() {
1536 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_process_identity(
1537 &crate::container::ProcessIdentity {
1538 uid: 1001,
1539 gid: 1002,
1540 additional_gids: vec![1003, 1004],
1541 },
1542 );
1543
1544 assert_eq!(config.process.user.uid, 1001);
1545 assert_eq!(config.process.user.gid, 1002);
1546 assert_eq!(config.process.user.additional_gids, Some(vec![1003, 1004]));
1547 }
1548
1549 #[test]
1550 fn test_oci_config_uses_hardcoded_path_not_host() {
1551 std::env::set_var("PATH", "/nix/store/secret-hash/bin:/home/user/.local/bin");
1554 let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
1555 let path_env = config
1556 .process
1557 .env
1558 .iter()
1559 .find(|e| e.starts_with("PATH="))
1560 .expect("PATH env must be set");
1561 assert_eq!(
1562 path_env, "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1563 "OCI config must not leak host PATH"
1564 );
1565 assert!(
1566 !path_env.contains("/nix/store/secret"),
1567 "Host PATH must not leak into container"
1568 );
1569 }
1570
1571 #[test]
1572 fn test_oci_hooks_serialization_roundtrip() {
1573 let hooks = OciHooks {
1574 create_runtime: vec![OciHook {
1575 path: "/usr/bin/hook1".to_string(),
1576 args: vec!["hook1".to_string(), "--arg1".to_string()],
1577 env: vec!["FOO=bar".to_string()],
1578 timeout: Some(10),
1579 }],
1580 create_container: vec![],
1581 start_container: vec![],
1582 poststart: vec![OciHook {
1583 path: "/usr/bin/hook2".to_string(),
1584 args: vec![],
1585 env: vec![],
1586 timeout: None,
1587 }],
1588 poststop: vec![],
1589 };
1590
1591 let json = serde_json::to_string_pretty(&hooks).unwrap();
1592 assert!(json.contains("createRuntime"));
1593 assert!(json.contains("/usr/bin/hook1"));
1594 assert!(!json.contains("createContainer")); let deserialized: OciHooks = serde_json::from_str(&json).unwrap();
1597 assert_eq!(deserialized.create_runtime.len(), 1);
1598 assert_eq!(deserialized.create_runtime[0].path, "/usr/bin/hook1");
1599 assert_eq!(deserialized.create_runtime[0].timeout, Some(10));
1600 assert_eq!(deserialized.poststart.len(), 1);
1601 assert!(deserialized.create_container.is_empty());
1602 }
1603
1604 #[test]
1605 fn test_oci_hooks_is_empty() {
1606 let empty = OciHooks::default();
1607 assert!(empty.is_empty());
1608
1609 let not_empty = OciHooks {
1610 poststop: vec![OciHook {
1611 path: "/bin/cleanup".to_string(),
1612 args: vec![],
1613 env: vec![],
1614 timeout: None,
1615 }],
1616 ..Default::default()
1617 };
1618 assert!(!not_empty.is_empty());
1619 }
1620
1621 #[test]
1622 fn test_oci_config_with_hooks() {
1623 let hooks = OciHooks {
1624 create_runtime: vec![OciHook {
1625 path: "/usr/bin/setup".to_string(),
1626 args: vec![],
1627 env: vec![],
1628 timeout: None,
1629 }],
1630 ..Default::default()
1631 };
1632
1633 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_hooks(hooks);
1634 assert!(config.hooks.is_some());
1635
1636 let json = serde_json::to_string_pretty(&config).unwrap();
1637 assert!(json.contains("hooks"));
1638 assert!(json.contains("createRuntime"));
1639
1640 let deserialized: OciConfig = serde_json::from_str(&json).unwrap();
1641 assert!(deserialized.hooks.is_some());
1642 assert_eq!(deserialized.hooks.unwrap().create_runtime.len(), 1);
1643 }
1644
1645 #[test]
1646 fn test_oci_config_with_empty_hooks_serializes_without_hooks() {
1647 let config =
1648 OciConfig::new(vec!["/bin/sh".to_string()], None).with_hooks(OciHooks::default());
1649 assert!(config.hooks.is_none()); let json = serde_json::to_string_pretty(&config).unwrap();
1652 assert!(!json.contains("hooks"));
1653 }
1654
1655 #[test]
1656 fn test_oci_hook_rejects_relative_path() {
1657 let hook = OciHook {
1658 path: "relative/path".to_string(),
1659 args: vec![],
1660 env: vec![],
1661 timeout: None,
1662 };
1663 let state = OciContainerState {
1664 oci_version: "1.0.2".to_string(),
1665 id: "test".to_string(),
1666 status: OciStatus::Creating,
1667 pid: 1234,
1668 bundle: "/tmp/bundle".to_string(),
1669 };
1670 let result = OciHooks::run_hooks(&[hook], &state, "test");
1671 assert!(result.is_err());
1672 let err_msg = result.unwrap_err().to_string();
1673 assert!(err_msg.contains("absolute"), "error: {}", err_msg);
1674 }
1675
1676 fn original_path() -> String {
1682 if let Ok(environ) = std::fs::read("/proc/self/environ") {
1683 for entry in environ.split(|&b| b == 0) {
1684 if let Ok(s) = std::str::from_utf8(entry) {
1685 if let Some(val) = s.strip_prefix("PATH=") {
1686 return val.to_string();
1687 }
1688 }
1689 }
1690 }
1691 String::new()
1692 }
1693
1694 fn find_bash() -> String {
1696 let candidates = ["/bin/bash", "/usr/bin/bash"];
1697 for c in &candidates {
1698 if std::path::Path::new(c).exists() {
1699 return c.to_string();
1700 }
1701 }
1702 for dir in original_path().split(':') {
1703 let candidate = std::path::PathBuf::from(dir).join("bash");
1704 if candidate.exists() {
1705 return candidate.to_string_lossy().to_string();
1706 }
1707 }
1708 panic!("Cannot find bash binary for test");
1709 }
1710
1711 fn write_script(path: &std::path::Path, body: &str) {
1715 use std::io::Write as IoWrite;
1716 let bash = find_bash();
1717 let orig_path = original_path();
1718 let content = format!("#!{}\nexport PATH='{}'\n{}", bash, orig_path, body);
1719 let mut f = OpenOptions::new()
1720 .create(true)
1721 .truncate(true)
1722 .write(true)
1723 .mode(0o755)
1724 .open(path)
1725 .unwrap();
1726 f.write_all(content.as_bytes()).unwrap();
1727 f.sync_all().unwrap();
1728 drop(f);
1729 }
1730
1731 #[test]
1732 fn test_oci_hook_executes_successfully() {
1733 let temp_dir = TempDir::new().unwrap();
1734 let hook_script = temp_dir.path().join("hook.sh");
1735 let output_file = temp_dir.path().join("output.json");
1736
1737 write_script(
1738 &hook_script,
1739 &format!("cat > {}\n", output_file.to_string_lossy()),
1740 );
1741
1742 let hook = OciHook {
1743 path: hook_script.to_string_lossy().to_string(),
1744 args: vec![],
1745 env: vec![],
1746 timeout: Some(5),
1747 };
1748 let state = OciContainerState {
1749 oci_version: "1.0.2".to_string(),
1750 id: "test-container".to_string(),
1751 status: OciStatus::Creating,
1752 pid: 12345,
1753 bundle: "/tmp/test-bundle".to_string(),
1754 };
1755
1756 OciHooks::run_hooks(&[hook], &state, "createRuntime").unwrap();
1757
1758 let written = std::fs::read_to_string(&output_file).unwrap();
1760 let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
1761 assert_eq!(parsed["id"], "test-container");
1762 assert_eq!(parsed["pid"], 12345);
1763 assert_eq!(parsed["status"], "creating");
1764 }
1765
1766 #[test]
1767 fn test_oci_hook_nonzero_exit_is_error() {
1768 let temp_dir = TempDir::new().unwrap();
1769 let hook_script = temp_dir.path().join("fail.sh");
1770 write_script(&hook_script, "exit 1\n");
1771
1772 let hook = OciHook {
1773 path: hook_script.to_string_lossy().to_string(),
1774 args: vec![],
1775 env: vec![],
1776 timeout: Some(5),
1777 };
1778 let state = OciContainerState {
1779 oci_version: "1.0.2".to_string(),
1780 id: "test".to_string(),
1781 status: OciStatus::Creating,
1782 pid: 1,
1783 bundle: "".to_string(),
1784 };
1785
1786 let result = OciHooks::run_hooks(&[hook], &state, "test");
1787 assert!(result.is_err());
1788 assert!(result
1789 .unwrap_err()
1790 .to_string()
1791 .contains("exited with status"));
1792 }
1793
1794 #[test]
1795 fn test_oci_hooks_best_effort_continues_on_failure() {
1796 let temp_dir = TempDir::new().unwrap();
1797 let fail_script = temp_dir.path().join("fail.sh");
1798 write_script(&fail_script, "exit 1\n");
1799
1800 let marker = temp_dir.path().join("ran");
1801 let ok_script = temp_dir.path().join("ok.sh");
1802 write_script(&ok_script, &format!("touch {}\n", marker.to_string_lossy()));
1803
1804 let hooks = vec![
1805 OciHook {
1806 path: fail_script.to_string_lossy().to_string(),
1807 args: vec![],
1808 env: vec![],
1809 timeout: Some(5),
1810 },
1811 OciHook {
1812 path: ok_script.to_string_lossy().to_string(),
1813 args: vec![],
1814 env: vec![],
1815 timeout: Some(5),
1816 },
1817 ];
1818 let state = OciContainerState {
1819 oci_version: "1.0.2".to_string(),
1820 id: "test".to_string(),
1821 status: OciStatus::Stopped,
1822 pid: 0,
1823 bundle: "".to_string(),
1824 };
1825
1826 OciHooks::run_hooks_best_effort(&hooks, &state, "poststop");
1828 assert!(marker.exists(), "second hook should run after first fails");
1830 }
1831}