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 if libc::setrlimit(libc::RLIMIT_NPROC, &rlim_nproc) != 0 {
537 return Err(std::io::Error::last_os_error());
538 }
539
540 let rlim_nofile = libc::rlimit {
541 rlim_cur: 1024,
542 rlim_max: 1024,
543 };
544 if libc::setrlimit(libc::RLIMIT_NOFILE, &rlim_nofile) != 0 {
545 return Err(std::io::Error::last_os_error());
546 }
547
548 Ok(())
549 });
550 }
551
552 let mut child = cmd.spawn().map_err(|e| {
553 NucleusError::HookError(format!(
554 "Failed to spawn {} hook {}: {}",
555 phase, hook.path, e
556 ))
557 })?;
558
559 if let Some(mut stdin) = child.stdin.take() {
560 use std::io::Write as IoWrite;
561 let _ = stdin.write_all(state_json.as_bytes());
562 }
563
564 let timeout_secs = hook.timeout.unwrap_or(30) as u64;
565 let start = std::time::Instant::now();
566 let timeout = std::time::Duration::from_secs(timeout_secs);
567
568 loop {
569 match child.try_wait() {
570 Ok(Some(status)) => {
571 if status.success() {
572 debug!("{} hook {} completed successfully", phase, hook.path);
573 return Ok(());
574 } else {
575 let stderr = child
576 .stderr
577 .take()
578 .map(|mut e| {
579 let mut buf = String::new();
580 use std::io::Read;
581 let _ = e.read_to_string(&mut buf);
582 buf
583 })
584 .unwrap_or_default();
585 return Err(NucleusError::HookError(format!(
586 "{} hook {} exited with status: {}{}",
587 phase,
588 hook.path,
589 status,
590 if stderr.is_empty() {
591 String::new()
592 } else {
593 format!(" (stderr: {})", stderr.trim())
594 }
595 )));
596 }
597 }
598 Ok(None) => {
599 if start.elapsed() >= timeout {
600 let _ = child.kill();
601 let _ = child.wait();
602 return Err(NucleusError::HookError(format!(
603 "{} hook {} timed out after {}s",
604 phase, hook.path, timeout_secs
605 )));
606 }
607 std::thread::sleep(std::time::Duration::from_millis(50));
608 }
609 Err(e) => {
610 return Err(NucleusError::HookError(format!(
611 "Failed to wait for {} hook {}: {}",
612 phase, hook.path, e
613 )));
614 }
615 }
616 }
617 }
618
619 fn validate_hook_binary(hook_path: &Path, phase: &str) -> Result<()> {
625 let metadata = std::fs::symlink_metadata(hook_path).map_err(|e| {
629 NucleusError::HookError(format!(
630 "Failed to stat {} hook {}: {}",
631 phase,
632 hook_path.display(),
633 e
634 ))
635 })?;
636
637 use std::os::unix::fs::MetadataExt;
638 let mode = metadata.mode();
639 let uid = metadata.uid();
640 let gid = metadata.gid();
641 let effective_uid = nix::unistd::Uid::effective().as_raw();
642
643 if mode & 0o002 != 0 {
645 return Err(NucleusError::HookError(format!(
646 "{} hook {} is world-writable (mode {:04o}) – refusing to execute",
647 phase,
648 hook_path.display(),
649 mode & 0o7777
650 )));
651 }
652
653 if mode & 0o020 != 0 && uid != 0 {
655 return Err(NucleusError::HookError(format!(
656 "{} hook {} is group-writable and not owned by root (mode {:04o}, uid {}) – refusing to execute",
657 phase,
658 hook_path.display(),
659 mode & 0o7777,
660 uid
661 )));
662 }
663
664 if uid != 0 && uid != effective_uid {
666 return Err(NucleusError::HookError(format!(
667 "{} hook {} is owned by UID {} (expected 0 or {}) – refusing to execute",
668 phase,
669 hook_path.display(),
670 uid,
671 effective_uid
672 )));
673 }
674
675 if mode & 0o6000 != 0 {
677 return Err(NucleusError::HookError(format!(
678 "{} hook {} has setuid/setgid bits (mode {:04o}) – refusing to execute",
679 phase,
680 hook_path.display(),
681 mode & 0o7777
682 )));
683 }
684
685 debug!(
686 "{} hook {} validation passed (uid={}, gid={}, mode={:04o})",
687 phase,
688 hook_path.display(),
689 uid,
690 gid,
691 mode & 0o7777
692 );
693
694 Ok(())
695 }
696}
697
698impl OciConfig {
699 pub fn new(command: Vec<String>, hostname: Option<String>) -> Self {
701 Self {
702 oci_version: "1.0.2".to_string(),
703 root: OciRoot {
704 path: "rootfs".to_string(),
705 readonly: true,
706 },
707 process: OciProcess {
708 terminal: false,
709 user: OciUser {
710 uid: 0,
711 gid: 0,
712 additional_gids: None,
713 },
714 args: command,
715 env: vec![
716 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
717 ],
718 cwd: "/".to_string(),
719 no_new_privileges: true,
720 capabilities: Some(OciCapabilities {
721 bounding: vec![],
722 effective: vec![],
723 inheritable: vec![],
724 permitted: vec![],
725 ambient: vec![],
726 }),
727 rlimits: vec![],
728 console_size: None,
729 apparmor_profile: None,
730 selinux_label: None,
731 },
732 hostname,
733 mounts: vec![
734 OciMount {
735 destination: "/proc".to_string(),
736 source: "proc".to_string(),
737 mount_type: "proc".to_string(),
738 options: vec![
739 "nosuid".to_string(),
740 "noexec".to_string(),
741 "nodev".to_string(),
742 ],
743 },
744 OciMount {
745 destination: "/dev".to_string(),
746 source: "tmpfs".to_string(),
747 mount_type: "tmpfs".to_string(),
748 options: vec![
749 "nosuid".to_string(),
750 "noexec".to_string(),
751 "strictatime".to_string(),
752 "mode=755".to_string(),
753 "size=65536k".to_string(),
754 ],
755 },
756 OciMount {
757 destination: "/dev/shm".to_string(),
758 source: "shm".to_string(),
759 mount_type: "tmpfs".to_string(),
760 options: vec![
761 "nosuid".to_string(),
762 "noexec".to_string(),
763 "nodev".to_string(),
764 "mode=1777".to_string(),
765 "size=65536k".to_string(),
766 ],
767 },
768 OciMount {
769 destination: "/tmp".to_string(),
770 source: "tmpfs".to_string(),
771 mount_type: "tmpfs".to_string(),
772 options: vec![
773 "nosuid".to_string(),
774 "nodev".to_string(),
775 "noexec".to_string(),
776 "mode=1777".to_string(),
777 "size=65536k".to_string(),
778 ],
779 },
780 OciMount {
781 destination: "/sys".to_string(),
782 source: "sysfs".to_string(),
783 mount_type: "sysfs".to_string(),
784 options: vec![
785 "nosuid".to_string(),
786 "noexec".to_string(),
787 "nodev".to_string(),
788 "ro".to_string(),
789 ],
790 },
791 ],
792 hooks: None,
793 annotations: HashMap::new(),
794 linux: Some(OciLinux {
795 namespaces: Some(vec![
796 OciNamespace {
797 namespace_type: "pid".to_string(),
798 },
799 OciNamespace {
800 namespace_type: "network".to_string(),
801 },
802 OciNamespace {
803 namespace_type: "ipc".to_string(),
804 },
805 OciNamespace {
806 namespace_type: "uts".to_string(),
807 },
808 OciNamespace {
809 namespace_type: "mount".to_string(),
810 },
811 ]),
812 resources: None,
813 uid_mappings: vec![],
814 gid_mappings: vec![],
815 masked_paths: vec![
817 "/proc/acpi".to_string(),
818 "/proc/asound".to_string(),
819 "/proc/kcore".to_string(),
820 "/proc/keys".to_string(),
821 "/proc/latency_stats".to_string(),
822 "/proc/sched_debug".to_string(),
823 "/proc/scsi".to_string(),
824 "/proc/timer_list".to_string(),
825 "/proc/timer_stats".to_string(),
826 "/proc/sysrq-trigger".to_string(), "/proc/kpagecount".to_string(),
828 "/proc/kpageflags".to_string(),
829 "/proc/kpagecgroup".to_string(),
830 "/proc/config.gz".to_string(),
831 "/proc/kallsyms".to_string(),
832 "/sys/firmware".to_string(),
833 ],
834 readonly_paths: vec![
835 "/proc/bus".to_string(),
836 "/proc/fs".to_string(),
837 "/proc/irq".to_string(),
838 "/proc/sys".to_string(),
839 ],
840 devices: vec![
841 OciDevice {
842 device_type: "c".to_string(),
843 path: "/dev/null".to_string(),
844 major: Some(1),
845 minor: Some(3),
846 file_mode: Some(0o666),
847 uid: Some(0),
848 gid: Some(0),
849 },
850 OciDevice {
851 device_type: "c".to_string(),
852 path: "/dev/zero".to_string(),
853 major: Some(1),
854 minor: Some(5),
855 file_mode: Some(0o666),
856 uid: Some(0),
857 gid: Some(0),
858 },
859 OciDevice {
860 device_type: "c".to_string(),
861 path: "/dev/full".to_string(),
862 major: Some(1),
863 minor: Some(7),
864 file_mode: Some(0o666),
865 uid: Some(0),
866 gid: Some(0),
867 },
868 OciDevice {
869 device_type: "c".to_string(),
870 path: "/dev/random".to_string(),
871 major: Some(1),
872 minor: Some(8),
873 file_mode: Some(0o666),
874 uid: Some(0),
875 gid: Some(0),
876 },
877 OciDevice {
878 device_type: "c".to_string(),
879 path: "/dev/urandom".to_string(),
880 major: Some(1),
881 minor: Some(9),
882 file_mode: Some(0o666),
883 uid: Some(0),
884 gid: Some(0),
885 },
886 ],
887 seccomp: None,
888 rootfs_propagation: Some("rprivate".to_string()),
889 sysctl: HashMap::new(),
890 cgroups_path: None,
891 intel_rdt: None,
892 }),
893 }
894 }
895
896 pub fn with_resources(mut self, limits: &ResourceLimits) -> Self {
898 let mut resources = OciResources {
899 memory: None,
900 cpu: None,
901 pids: None,
902 };
903
904 if let Some(memory_bytes) = limits.memory_bytes {
905 resources.memory = Some(OciMemory {
906 limit: Some(memory_bytes as i64),
907 });
908 }
909
910 if let Some(quota_us) = limits.cpu_quota_us {
911 resources.cpu = Some(OciCpu {
912 quota: Some(quota_us as i64),
913 period: Some(limits.cpu_period_us),
914 });
915 }
916
917 if let Some(pids_max) = limits.pids_max {
918 resources.pids = Some(OciPids {
919 limit: pids_max as i64,
920 });
921 }
922
923 if let Some(linux) = &mut self.linux {
924 linux.resources = Some(resources);
925 }
926
927 self
928 }
929
930 pub fn with_env(mut self, vars: &[(String, String)]) -> Self {
932 for (key, value) in vars {
933 self.process.env.push(format!("{}={}", key, value));
934 }
935 self
936 }
937
938 pub fn with_sd_notify(mut self) -> Self {
940 if let Ok(notify_socket) = std::env::var("NOTIFY_SOCKET") {
941 self.process
942 .env
943 .push(format!("NOTIFY_SOCKET={}", notify_socket));
944 }
945 self
946 }
947
948 pub fn with_secret_mounts(mut self, secrets: &[crate::container::SecretMount]) -> Self {
950 for secret in secrets {
951 self.mounts.push(OciMount {
952 destination: secret.dest.to_string_lossy().to_string(),
953 source: secret.source.to_string_lossy().to_string(),
954 mount_type: "bind".to_string(),
955 options: vec![
956 "bind".to_string(),
957 "ro".to_string(),
958 "nosuid".to_string(),
959 "nodev".to_string(),
960 "noexec".to_string(),
961 ],
962 });
963 }
964 self
965 }
966
967 pub fn with_process_identity(mut self, identity: &crate::container::ProcessIdentity) -> Self {
969 self.process.user.uid = identity.uid;
970 self.process.user.gid = identity.gid;
971 self.process.user.additional_gids = if identity.additional_gids.is_empty() {
972 None
973 } else {
974 Some(identity.additional_gids.clone())
975 };
976 self
977 }
978
979 pub fn with_inmemory_secret_mounts(
983 mut self,
984 stage_dir: &Path,
985 secrets: &[crate::container::SecretMount],
986 ) -> Result<Self> {
987 self.mounts.push(OciMount {
988 destination: "/run/secrets".to_string(),
989 source: stage_dir.to_string_lossy().to_string(),
990 mount_type: "bind".to_string(),
991 options: vec![
992 "bind".to_string(),
993 "ro".to_string(),
994 "nosuid".to_string(),
995 "nodev".to_string(),
996 "noexec".to_string(),
997 ],
998 });
999
1000 for secret in secrets {
1001 let dest = normalize_container_destination(&secret.dest)?;
1002 if !secret.source.starts_with(stage_dir) {
1003 return Err(NucleusError::ConfigError(format!(
1004 "Staged secret source {:?} must live under {:?}",
1005 secret.source, stage_dir
1006 )));
1007 }
1008 self.mounts.push(OciMount {
1009 destination: dest.to_string_lossy().to_string(),
1010 source: secret.source.to_string_lossy().to_string(),
1011 mount_type: "bind".to_string(),
1012 options: vec![
1013 "bind".to_string(),
1014 "ro".to_string(),
1015 "nosuid".to_string(),
1016 "nodev".to_string(),
1017 "noexec".to_string(),
1018 ],
1019 });
1020 }
1021
1022 Ok(self)
1023 }
1024
1025 pub fn with_volume_mounts(mut self, volumes: &[crate::container::VolumeMount]) -> Result<Self> {
1027 use crate::container::VolumeSource;
1028
1029 for volume in volumes {
1030 let dest = normalize_container_destination(&volume.dest)?;
1031 match &volume.source {
1032 VolumeSource::Bind { source } => {
1033 crate::filesystem::validate_bind_mount_source(source)?;
1034 let mut options = vec![
1035 "bind".to_string(),
1036 "nosuid".to_string(),
1037 "nodev".to_string(),
1038 ];
1039 if volume.read_only {
1040 options.push("ro".to_string());
1041 }
1042 self.mounts.push(OciMount {
1043 destination: dest.to_string_lossy().to_string(),
1044 source: source.to_string_lossy().to_string(),
1045 mount_type: "bind".to_string(),
1046 options,
1047 });
1048 }
1049 VolumeSource::Tmpfs { size } => {
1050 let mut options = vec![
1051 "nosuid".to_string(),
1052 "nodev".to_string(),
1053 "mode=0755".to_string(),
1054 ];
1055 if volume.read_only {
1056 options.push("ro".to_string());
1057 }
1058 if let Some(size) = size {
1059 options.push(format!("size={}", size));
1060 }
1061 self.mounts.push(OciMount {
1062 destination: dest.to_string_lossy().to_string(),
1063 source: "tmpfs".to_string(),
1064 mount_type: "tmpfs".to_string(),
1065 options,
1066 });
1067 }
1068 }
1069 }
1070
1071 Ok(self)
1072 }
1073
1074 pub fn with_context_bind(mut self, context_dir: &std::path::Path) -> Self {
1079 self.mounts.push(OciMount {
1080 destination: "/context".to_string(),
1081 source: context_dir.to_string_lossy().to_string(),
1082 mount_type: "bind".to_string(),
1083 options: vec![
1084 "bind".to_string(),
1085 "ro".to_string(),
1086 "nosuid".to_string(),
1087 "nodev".to_string(),
1088 ],
1089 });
1090 self
1091 }
1092
1093 pub fn with_rootfs_binds(mut self, rootfs_path: &std::path::Path) -> Self {
1095 let subdirs = ["bin", "sbin", "lib", "lib64", "usr", "etc", "nix"];
1096 for subdir in &subdirs {
1097 let source = rootfs_path.join(subdir);
1098 if source.exists() {
1099 self.mounts.push(OciMount {
1100 destination: format!("/{}", subdir),
1101 source: source.to_string_lossy().to_string(),
1102 mount_type: "bind".to_string(),
1103 options: vec![
1104 "bind".to_string(),
1105 "ro".to_string(),
1106 "nosuid".to_string(),
1107 "nodev".to_string(),
1108 ],
1109 });
1110 }
1111 }
1112 self
1113 }
1114
1115 pub fn with_namespace_config(mut self, config: &NamespaceConfig) -> Self {
1117 let mut namespaces = Vec::new();
1118
1119 if config.pid {
1120 namespaces.push(OciNamespace {
1121 namespace_type: "pid".to_string(),
1122 });
1123 }
1124 if config.net {
1125 namespaces.push(OciNamespace {
1126 namespace_type: "network".to_string(),
1127 });
1128 }
1129 if config.ipc {
1130 namespaces.push(OciNamespace {
1131 namespace_type: "ipc".to_string(),
1132 });
1133 }
1134 if config.uts {
1135 namespaces.push(OciNamespace {
1136 namespace_type: "uts".to_string(),
1137 });
1138 }
1139 if config.mnt {
1140 namespaces.push(OciNamespace {
1141 namespace_type: "mount".to_string(),
1142 });
1143 }
1144 if config.cgroup {
1145 namespaces.push(OciNamespace {
1146 namespace_type: "cgroup".to_string(),
1147 });
1148 }
1149 if config.time {
1150 namespaces.push(OciNamespace {
1151 namespace_type: "time".to_string(),
1152 });
1153 }
1154 if config.user {
1155 namespaces.push(OciNamespace {
1156 namespace_type: "user".to_string(),
1157 });
1158 }
1159
1160 if let Some(linux) = &mut self.linux {
1161 linux.namespaces = Some(namespaces);
1162 }
1163
1164 self
1165 }
1166
1167 pub fn with_host_runtime_binds(mut self) -> Self {
1173 let host_paths: BTreeSet<String> =
1176 ["/bin", "/sbin", "/usr", "/lib", "/lib64", "/nix/store"]
1177 .iter()
1178 .map(|s| s.to_string())
1179 .collect();
1180
1181 for host_path in host_paths {
1182 let source = Path::new(&host_path);
1183 if !source.exists() {
1184 continue;
1185 }
1186
1187 self.mounts.push(OciMount {
1188 destination: host_path.clone(),
1189 source: source.to_string_lossy().to_string(),
1190 mount_type: "bind".to_string(),
1191 options: vec![
1192 "bind".to_string(),
1193 "ro".to_string(),
1194 "nosuid".to_string(),
1195 "nodev".to_string(),
1196 ],
1197 });
1198 }
1199 self
1200 }
1201
1202 pub fn with_user_namespace(mut self) -> Self {
1204 if let Some(linux) = &mut self.linux {
1205 if let Some(namespaces) = &mut linux.namespaces {
1206 namespaces.push(OciNamespace {
1207 namespace_type: "user".to_string(),
1208 });
1209 }
1210 }
1211 self
1212 }
1213
1214 pub fn with_rootless_user_namespace(mut self, config: &UserNamespaceConfig) -> Self {
1221 if let Some(linux) = &mut self.linux {
1222 if let Some(namespaces) = &mut linux.namespaces {
1223 namespaces.retain(|ns| ns.namespace_type != "network");
1224 if !namespaces.iter().any(|ns| ns.namespace_type == "user") {
1225 namespaces.push(OciNamespace {
1226 namespace_type: "user".to_string(),
1227 });
1228 }
1229 }
1230 linux.uid_mappings = config.uid_mappings.iter().map(OciIdMapping::from).collect();
1231 linux.gid_mappings = config.gid_mappings.iter().map(OciIdMapping::from).collect();
1232 }
1233 self
1234 }
1235
1236 pub fn with_hooks(mut self, hooks: OciHooks) -> Self {
1238 if hooks.is_empty() {
1239 self.hooks = None;
1240 } else {
1241 self.hooks = Some(hooks);
1242 }
1243 self
1244 }
1245
1246 pub fn with_rlimits(mut self, limits: &ResourceLimits) -> Self {
1251 let mut rlimits = Vec::with_capacity(3);
1252
1253 if let Some(nproc_limit) = limits.pids_max {
1254 rlimits.push(OciRlimit {
1255 limit_type: "RLIMIT_NPROC".to_string(),
1256 hard: nproc_limit,
1257 soft: nproc_limit,
1258 });
1259 }
1260
1261 rlimits.push(OciRlimit {
1262 limit_type: "RLIMIT_NOFILE".to_string(),
1263 hard: 1024,
1264 soft: 1024,
1265 });
1266
1267 let memlock_limit = limits.memlock_bytes.unwrap_or(64 * 1024);
1268 rlimits.push(OciRlimit {
1269 limit_type: "RLIMIT_MEMLOCK".to_string(),
1270 hard: memlock_limit,
1271 soft: memlock_limit,
1272 });
1273
1274 self.process.rlimits = rlimits;
1275 self
1276 }
1277
1278 pub fn with_seccomp(mut self, seccomp: OciSeccomp) -> Self {
1280 if let Some(linux) = &mut self.linux {
1281 linux.seccomp = Some(seccomp);
1282 }
1283 self
1284 }
1285
1286 pub fn with_cgroups_path(mut self, path: String) -> Self {
1288 if let Some(linux) = &mut self.linux {
1289 linux.cgroups_path = Some(path);
1290 }
1291 self
1292 }
1293
1294 pub fn with_sysctl(mut self, sysctl: HashMap<String, String>) -> Self {
1296 if let Some(linux) = &mut self.linux {
1297 linux.sysctl = sysctl;
1298 }
1299 self
1300 }
1301
1302 pub fn with_annotations(mut self, annotations: HashMap<String, String>) -> Self {
1304 self.annotations = annotations;
1305 self
1306 }
1307}
1308
1309impl From<&IdMapping> for OciIdMapping {
1310 fn from(mapping: &IdMapping) -> Self {
1311 Self {
1312 container_id: mapping.container_id,
1313 host_id: mapping.host_id,
1314 size: mapping.count,
1315 }
1316 }
1317}
1318
1319pub struct OciBundle {
1323 bundle_path: PathBuf,
1324 config: OciConfig,
1325}
1326
1327impl OciBundle {
1328 pub fn new(bundle_path: PathBuf, config: OciConfig) -> Self {
1330 Self {
1331 bundle_path,
1332 config,
1333 }
1334 }
1335
1336 pub fn create(&self) -> Result<()> {
1338 info!("Creating OCI bundle at {:?}", self.bundle_path);
1339
1340 fs::create_dir_all(&self.bundle_path).map_err(|e| {
1342 NucleusError::GVisorError(format!(
1343 "Failed to create bundle directory {:?}: {}",
1344 self.bundle_path, e
1345 ))
1346 })?;
1347 fs::set_permissions(&self.bundle_path, fs::Permissions::from_mode(0o700)).map_err(|e| {
1348 NucleusError::GVisorError(format!(
1349 "Failed to secure bundle directory permissions {:?}: {}",
1350 self.bundle_path, e
1351 ))
1352 })?;
1353
1354 let rootfs = self.bundle_path.join("rootfs");
1356 fs::create_dir_all(&rootfs).map_err(|e| {
1357 NucleusError::GVisorError(format!("Failed to create rootfs directory: {}", e))
1358 })?;
1359 fs::set_permissions(&rootfs, fs::Permissions::from_mode(0o755)).map_err(|e| {
1364 NucleusError::GVisorError(format!(
1365 "Failed to set rootfs directory permissions {:?}: {}",
1366 rootfs, e
1367 ))
1368 })?;
1369
1370 let config_path = self.bundle_path.join("config.json");
1372 let config_json = serde_json::to_string_pretty(&self.config).map_err(|e| {
1373 NucleusError::GVisorError(format!("Failed to serialize OCI config: {}", e))
1374 })?;
1375
1376 let mut file = OpenOptions::new()
1378 .create(true)
1379 .truncate(true)
1380 .write(true)
1381 .mode(0o600)
1382 .custom_flags(libc::O_NOFOLLOW)
1383 .open(&config_path)
1384 .map_err(|e| NucleusError::GVisorError(format!("Failed to open config.json: {}", e)))?;
1385 file.write_all(config_json.as_bytes()).map_err(|e| {
1386 NucleusError::GVisorError(format!("Failed to write config.json: {}", e))
1387 })?;
1388 file.sync_all()
1389 .map_err(|e| NucleusError::GVisorError(format!("Failed to sync config.json: {}", e)))?;
1390
1391 debug!("Created OCI bundle structure at {:?}", self.bundle_path);
1392
1393 Ok(())
1394 }
1395
1396 pub fn rootfs_path(&self) -> PathBuf {
1398 self.bundle_path.join("rootfs")
1399 }
1400
1401 pub fn bundle_path(&self) -> &Path {
1403 &self.bundle_path
1404 }
1405
1406 pub fn cleanup(&self) -> Result<()> {
1408 if self.bundle_path.exists() {
1409 fs::remove_dir_all(&self.bundle_path).map_err(|e| {
1410 NucleusError::GVisorError(format!("Failed to cleanup bundle: {}", e))
1411 })?;
1412 debug!("Cleaned up OCI bundle at {:?}", self.bundle_path);
1413 }
1414 Ok(())
1415 }
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420 use super::*;
1421 use tempfile::TempDir;
1422
1423 #[test]
1424 fn test_oci_config_new() {
1425 let config = OciConfig::new(vec!["/bin/sh".to_string()], Some("test".to_string()));
1426
1427 assert_eq!(config.oci_version, "1.0.2");
1428 assert_eq!(config.root.path, "rootfs");
1429 assert_eq!(config.process.args, vec!["/bin/sh"]);
1430 assert_eq!(config.hostname, Some("test".to_string()));
1431 }
1432
1433 #[test]
1434 fn test_oci_config_with_resources() {
1435 let limits = ResourceLimits::unlimited()
1436 .with_memory("512M")
1437 .unwrap()
1438 .with_cpu_cores(2.0)
1439 .unwrap();
1440
1441 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_resources(&limits);
1442
1443 assert!(config.linux.is_some());
1444 let linux = config.linux.unwrap();
1445 assert!(linux.resources.is_some());
1446
1447 let resources = linux.resources.unwrap();
1448 assert!(resources.memory.is_some());
1449 assert!(resources.cpu.is_some());
1450 }
1451
1452 #[test]
1453 fn test_oci_bundle_create() {
1454 let temp_dir = TempDir::new().unwrap();
1455 let bundle_path = temp_dir.path().join("test-bundle");
1456
1457 let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
1458 let bundle = OciBundle::new(bundle_path.clone(), config);
1459
1460 bundle.create().unwrap();
1461
1462 assert!(bundle_path.exists());
1463 assert!(bundle_path.join("rootfs").exists());
1464 assert!(bundle_path.join("config.json").exists());
1465
1466 bundle.cleanup().unwrap();
1467 assert!(!bundle_path.exists());
1468 }
1469
1470 #[test]
1471 fn test_oci_config_serialization() {
1472 let config = OciConfig::new(vec!["/bin/sh".to_string()], Some("test".to_string()));
1473
1474 let json = serde_json::to_string_pretty(&config).unwrap();
1475 assert!(json.contains("ociVersion"));
1476 assert!(json.contains("1.0.2"));
1477 assert!(json.contains("/bin/sh"));
1478
1479 let deserialized: OciConfig = serde_json::from_str(&json).unwrap();
1481 assert_eq!(deserialized.oci_version, config.oci_version);
1482 assert_eq!(deserialized.process.args, config.process.args);
1483 }
1484
1485 #[test]
1486 fn test_host_runtime_binds_uses_fixed_paths_not_host_path() {
1487 std::env::set_var("PATH", "/tmp/evil-inject-path/bin:/opt/attacker/sbin");
1492 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_host_runtime_binds();
1493 let mount_dests: Vec<&str> = config
1494 .mounts
1495 .iter()
1496 .map(|m| m.destination.as_str())
1497 .collect();
1498 let mount_srcs: Vec<&str> = config.mounts.iter().map(|m| m.source.as_str()).collect();
1499 for path in &["/tmp/evil-inject-path", "/opt/attacker"] {
1501 assert!(
1502 !mount_dests.iter().any(|d| d.contains(path)),
1503 "with_host_runtime_binds must not use host $PATH – found {:?} in mount destinations",
1504 path
1505 );
1506 assert!(
1507 !mount_srcs.iter().any(|s| s.contains(path)),
1508 "with_host_runtime_binds must not use host $PATH – found {:?} in mount sources",
1509 path
1510 );
1511 }
1512 let allowed_prefixes = ["/bin", "/sbin", "/usr", "/lib", "/lib64", "/nix/store"];
1514 for mount in &config.mounts {
1515 if mount.mount_type == "bind" {
1516 assert!(
1517 allowed_prefixes
1518 .iter()
1519 .any(|p| mount.destination.starts_with(p)),
1520 "unexpected bind mount destination: {} – only FHS paths allowed",
1521 mount.destination
1522 );
1523 }
1524 }
1525 }
1526
1527 #[test]
1528 fn test_volume_mounts_include_bind_and_tmpfs_options() {
1529 let tmp = tempfile::TempDir::new().unwrap();
1530 let config = OciConfig::new(vec!["/bin/sh".to_string()], None)
1531 .with_volume_mounts(&[
1532 crate::container::VolumeMount {
1533 source: crate::container::VolumeSource::Bind {
1534 source: tmp.path().to_path_buf(),
1535 },
1536 dest: std::path::PathBuf::from("/var/lib/app"),
1537 read_only: true,
1538 },
1539 crate::container::VolumeMount {
1540 source: crate::container::VolumeSource::Tmpfs {
1541 size: Some("64M".to_string()),
1542 },
1543 dest: std::path::PathBuf::from("/var/cache/app"),
1544 read_only: false,
1545 },
1546 ])
1547 .unwrap();
1548
1549 assert!(config.mounts.iter().any(|mount| {
1550 mount.destination == "/var/lib/app"
1551 && mount.mount_type == "bind"
1552 && mount.options.contains(&"ro".to_string())
1553 }));
1554 assert!(config.mounts.iter().any(|mount| {
1555 mount.destination == "/var/cache/app"
1556 && mount.mount_type == "tmpfs"
1557 && mount.options.contains(&"size=64M".to_string())
1558 }));
1559 }
1560
1561 #[test]
1562 fn test_volume_mounts_reject_sensitive_host_sources() {
1563 let err = OciConfig::new(vec!["/bin/sh".to_string()], None)
1564 .with_volume_mounts(&[crate::container::VolumeMount {
1565 source: crate::container::VolumeSource::Bind {
1566 source: std::path::PathBuf::from("/proc/sys"),
1567 },
1568 dest: std::path::PathBuf::from("/host-proc"),
1569 read_only: true,
1570 }])
1571 .unwrap_err();
1572
1573 assert!(err.to_string().contains("sensitive host path"));
1574 }
1575
1576 #[test]
1577 fn test_oci_config_with_process_identity() {
1578 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_process_identity(
1579 &crate::container::ProcessIdentity {
1580 uid: 1001,
1581 gid: 1002,
1582 additional_gids: vec![1003, 1004],
1583 },
1584 );
1585
1586 assert_eq!(config.process.user.uid, 1001);
1587 assert_eq!(config.process.user.gid, 1002);
1588 assert_eq!(config.process.user.additional_gids, Some(vec![1003, 1004]));
1589 }
1590
1591 #[test]
1592 fn test_oci_config_with_rlimits_uses_configured_memlock() {
1593 let limits = ResourceLimits::default()
1594 .with_pids(99)
1595 .unwrap()
1596 .with_memlock("8M")
1597 .unwrap();
1598
1599 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_rlimits(&limits);
1600
1601 assert!(config.process.rlimits.iter().any(|limit| {
1602 limit.limit_type == "RLIMIT_NPROC" && limit.soft == 99 && limit.hard == 99
1603 }));
1604 assert!(config.process.rlimits.iter().any(|limit| {
1605 limit.limit_type == "RLIMIT_MEMLOCK"
1606 && limit.soft == 8 * 1024 * 1024
1607 && limit.hard == 8 * 1024 * 1024
1608 }));
1609 }
1610
1611 #[test]
1612 fn test_oci_config_with_rlimits_omits_nproc_when_unlimited() {
1613 let limits = ResourceLimits {
1614 pids_max: None,
1615 ..ResourceLimits::default()
1616 };
1617
1618 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_rlimits(&limits);
1619
1620 assert!(
1621 !config
1622 .process
1623 .rlimits
1624 .iter()
1625 .any(|limit| limit.limit_type == "RLIMIT_NPROC"),
1626 "RLIMIT_NPROC must be omitted when pids_max is unlimited"
1627 );
1628 }
1629
1630 #[test]
1631 fn test_oci_config_uses_hardcoded_path_not_host() {
1632 std::env::set_var("PATH", "/nix/store/secret-hash/bin:/home/user/.local/bin");
1635 let config = OciConfig::new(vec!["/bin/sh".to_string()], None);
1636 let path_env = config
1637 .process
1638 .env
1639 .iter()
1640 .find(|e| e.starts_with("PATH="))
1641 .expect("PATH env must be set");
1642 assert_eq!(
1643 path_env, "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1644 "OCI config must not leak host PATH"
1645 );
1646 assert!(
1647 !path_env.contains("/nix/store/secret"),
1648 "Host PATH must not leak into container"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_oci_hooks_serialization_roundtrip() {
1654 let hooks = OciHooks {
1655 create_runtime: vec![OciHook {
1656 path: "/usr/bin/hook1".to_string(),
1657 args: vec!["hook1".to_string(), "--arg1".to_string()],
1658 env: vec!["FOO=bar".to_string()],
1659 timeout: Some(10),
1660 }],
1661 create_container: vec![],
1662 start_container: vec![],
1663 poststart: vec![OciHook {
1664 path: "/usr/bin/hook2".to_string(),
1665 args: vec![],
1666 env: vec![],
1667 timeout: None,
1668 }],
1669 poststop: vec![],
1670 };
1671
1672 let json = serde_json::to_string_pretty(&hooks).unwrap();
1673 assert!(json.contains("createRuntime"));
1674 assert!(json.contains("/usr/bin/hook1"));
1675 assert!(!json.contains("createContainer")); let deserialized: OciHooks = serde_json::from_str(&json).unwrap();
1678 assert_eq!(deserialized.create_runtime.len(), 1);
1679 assert_eq!(deserialized.create_runtime[0].path, "/usr/bin/hook1");
1680 assert_eq!(deserialized.create_runtime[0].timeout, Some(10));
1681 assert_eq!(deserialized.poststart.len(), 1);
1682 assert!(deserialized.create_container.is_empty());
1683 }
1684
1685 #[test]
1686 fn test_oci_hooks_is_empty() {
1687 let empty = OciHooks::default();
1688 assert!(empty.is_empty());
1689
1690 let not_empty = OciHooks {
1691 poststop: vec![OciHook {
1692 path: "/bin/cleanup".to_string(),
1693 args: vec![],
1694 env: vec![],
1695 timeout: None,
1696 }],
1697 ..Default::default()
1698 };
1699 assert!(!not_empty.is_empty());
1700 }
1701
1702 #[test]
1703 fn test_oci_config_with_hooks() {
1704 let hooks = OciHooks {
1705 create_runtime: vec![OciHook {
1706 path: "/usr/bin/setup".to_string(),
1707 args: vec![],
1708 env: vec![],
1709 timeout: None,
1710 }],
1711 ..Default::default()
1712 };
1713
1714 let config = OciConfig::new(vec!["/bin/sh".to_string()], None).with_hooks(hooks);
1715 assert!(config.hooks.is_some());
1716
1717 let json = serde_json::to_string_pretty(&config).unwrap();
1718 assert!(json.contains("hooks"));
1719 assert!(json.contains("createRuntime"));
1720
1721 let deserialized: OciConfig = serde_json::from_str(&json).unwrap();
1722 assert!(deserialized.hooks.is_some());
1723 assert_eq!(deserialized.hooks.unwrap().create_runtime.len(), 1);
1724 }
1725
1726 #[test]
1727 fn test_oci_config_with_empty_hooks_serializes_without_hooks() {
1728 let config =
1729 OciConfig::new(vec!["/bin/sh".to_string()], None).with_hooks(OciHooks::default());
1730 assert!(config.hooks.is_none()); let json = serde_json::to_string_pretty(&config).unwrap();
1733 assert!(!json.contains("hooks"));
1734 }
1735
1736 #[test]
1737 fn test_oci_hook_rejects_relative_path() {
1738 let hook = OciHook {
1739 path: "relative/path".to_string(),
1740 args: vec![],
1741 env: vec![],
1742 timeout: None,
1743 };
1744 let state = OciContainerState {
1745 oci_version: "1.0.2".to_string(),
1746 id: "test".to_string(),
1747 status: OciStatus::Creating,
1748 pid: 1234,
1749 bundle: "/tmp/bundle".to_string(),
1750 };
1751 let result = OciHooks::run_hooks(&[hook], &state, "test");
1752 assert!(result.is_err());
1753 let err_msg = result.unwrap_err().to_string();
1754 assert!(err_msg.contains("absolute"), "error: {}", err_msg);
1755 }
1756
1757 fn original_path() -> String {
1763 if let Ok(environ) = std::fs::read("/proc/self/environ") {
1764 for entry in environ.split(|&b| b == 0) {
1765 if let Ok(s) = std::str::from_utf8(entry) {
1766 if let Some(val) = s.strip_prefix("PATH=") {
1767 return val.to_string();
1768 }
1769 }
1770 }
1771 }
1772 String::new()
1773 }
1774
1775 fn find_bash() -> String {
1777 let candidates = ["/bin/bash", "/usr/bin/bash"];
1778 for c in &candidates {
1779 if std::path::Path::new(c).exists() {
1780 return c.to_string();
1781 }
1782 }
1783 for dir in original_path().split(':') {
1784 let candidate = std::path::PathBuf::from(dir).join("bash");
1785 if candidate.exists() {
1786 return candidate.to_string_lossy().to_string();
1787 }
1788 }
1789 panic!("Cannot find bash binary for test");
1790 }
1791
1792 fn write_script(path: &std::path::Path, body: &str) {
1796 use std::io::Write as IoWrite;
1797 let bash = find_bash();
1798 let orig_path = original_path();
1799 let content = format!("#!{}\nexport PATH='{}'\n{}", bash, orig_path, body);
1800 let mut f = OpenOptions::new()
1801 .create(true)
1802 .truncate(true)
1803 .write(true)
1804 .mode(0o755)
1805 .open(path)
1806 .unwrap();
1807 f.write_all(content.as_bytes()).unwrap();
1808 f.sync_all().unwrap();
1809 drop(f);
1810 }
1811
1812 #[test]
1813 fn test_oci_hook_executes_successfully() {
1814 let temp_dir = TempDir::new().unwrap();
1815 let hook_script = temp_dir.path().join("hook.sh");
1816 let output_file = temp_dir.path().join("output.json");
1817
1818 write_script(
1819 &hook_script,
1820 &format!("cat > {}\n", output_file.to_string_lossy()),
1821 );
1822
1823 let hook = OciHook {
1824 path: hook_script.to_string_lossy().to_string(),
1825 args: vec![],
1826 env: vec![],
1827 timeout: Some(5),
1828 };
1829 let state = OciContainerState {
1830 oci_version: "1.0.2".to_string(),
1831 id: "test-container".to_string(),
1832 status: OciStatus::Creating,
1833 pid: 12345,
1834 bundle: "/tmp/test-bundle".to_string(),
1835 };
1836
1837 OciHooks::run_hooks(&[hook], &state, "createRuntime").unwrap();
1838
1839 let written = std::fs::read_to_string(&output_file).unwrap();
1841 let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
1842 assert_eq!(parsed["id"], "test-container");
1843 assert_eq!(parsed["pid"], 12345);
1844 assert_eq!(parsed["status"], "creating");
1845 }
1846
1847 #[test]
1848 fn test_oci_hook_nonzero_exit_is_error() {
1849 let temp_dir = TempDir::new().unwrap();
1850 let hook_script = temp_dir.path().join("fail.sh");
1851 write_script(&hook_script, "exit 1\n");
1852
1853 let hook = OciHook {
1854 path: hook_script.to_string_lossy().to_string(),
1855 args: vec![],
1856 env: vec![],
1857 timeout: Some(5),
1858 };
1859 let state = OciContainerState {
1860 oci_version: "1.0.2".to_string(),
1861 id: "test".to_string(),
1862 status: OciStatus::Creating,
1863 pid: 1,
1864 bundle: "".to_string(),
1865 };
1866
1867 let result = OciHooks::run_hooks(&[hook], &state, "test");
1868 assert!(result.is_err());
1869 assert!(result
1870 .unwrap_err()
1871 .to_string()
1872 .contains("exited with status"));
1873 }
1874
1875 #[test]
1876 fn test_oci_hooks_best_effort_continues_on_failure() {
1877 let temp_dir = TempDir::new().unwrap();
1878 let fail_script = temp_dir.path().join("fail.sh");
1879 write_script(&fail_script, "exit 1\n");
1880
1881 let marker = temp_dir.path().join("ran");
1882 let ok_script = temp_dir.path().join("ok.sh");
1883 write_script(&ok_script, &format!("touch {}\n", marker.to_string_lossy()));
1884
1885 let hooks = vec![
1886 OciHook {
1887 path: fail_script.to_string_lossy().to_string(),
1888 args: vec![],
1889 env: vec![],
1890 timeout: Some(5),
1891 },
1892 OciHook {
1893 path: ok_script.to_string_lossy().to_string(),
1894 args: vec![],
1895 env: vec![],
1896 timeout: Some(5),
1897 },
1898 ];
1899 let state = OciContainerState {
1900 oci_version: "1.0.2".to_string(),
1901 id: "test".to_string(),
1902 status: OciStatus::Stopped,
1903 pid: 0,
1904 bundle: "".to_string(),
1905 };
1906
1907 OciHooks::run_hooks_best_effort(&hooks, &state, "poststop");
1909 assert!(marker.exists(), "second hook should run after first fails");
1911 }
1912}