1use std::path::{Path, PathBuf};
10
11use tracing::{debug, warn};
12
13use crate::config::Spec;
14use crate::error::{OciError, Result};
15
16pub mod paths {
18 pub const CONFIG_FILE: &str = "config.json";
20 pub const ROOTFS_DIR: &str = "rootfs";
22}
23
24#[derive(Debug, Clone)]
29pub struct Bundle {
30 path: PathBuf,
32 spec: Spec,
34}
35
36impl Bundle {
37 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
42 let path = path.as_ref();
43
44 if !path.exists() {
46 return Err(OciError::BundleNotFound(path.to_path_buf()));
47 }
48
49 if !path.is_dir() {
50 return Err(OciError::InvalidBundle(format!(
51 "not a directory: {}",
52 path.display()
53 )));
54 }
55
56 let path = path
58 .canonicalize()
59 .map_err(|e| OciError::InvalidBundle(format!("failed to resolve path: {e}")))?;
60
61 let config_path = path.join(paths::CONFIG_FILE);
63 if !config_path.exists() {
64 return Err(OciError::ConfigNotFound(path));
65 }
66
67 let spec = Spec::load(&config_path)?;
68 debug!("Loaded OCI spec from {}", config_path.display());
69
70 let bundle = Self { path, spec };
71 bundle.validate()?;
72
73 Ok(bundle)
74 }
75
76 pub fn create<P: AsRef<Path>>(path: P, spec: Spec) -> Result<Self> {
80 let path = path.as_ref();
81
82 std::fs::create_dir_all(path)?;
84
85 let path = path
87 .canonicalize()
88 .map_err(|e| OciError::InvalidBundle(format!("failed to resolve path: {e}")))?;
89
90 let config_path = path.join(paths::CONFIG_FILE);
92 spec.save(&config_path)?;
93 debug!("Wrote OCI spec to {}", config_path.display());
94
95 if let Some(ref root) = spec.root {
97 let rootfs_path = path.join(&root.path);
98 if !rootfs_path.exists() {
99 std::fs::create_dir_all(&rootfs_path)?;
100 debug!("Created rootfs at {}", rootfs_path.display());
101 }
102 }
103
104 Ok(Self { path, spec })
105 }
106
107 pub fn create_default<P: AsRef<Path>>(path: P) -> Result<Self> {
109 Self::create(path, Spec::default_linux())
110 }
111
112 #[must_use]
114 pub fn path(&self) -> &Path {
115 &self.path
116 }
117
118 #[must_use]
120 pub const fn spec(&self) -> &Spec {
121 &self.spec
122 }
123
124 pub const fn spec_mut(&mut self) -> &mut Spec {
126 &mut self.spec
127 }
128
129 #[must_use]
131 pub fn config_path(&self) -> PathBuf {
132 self.path.join(paths::CONFIG_FILE)
133 }
134
135 #[must_use]
139 pub fn rootfs_path(&self) -> PathBuf {
140 self.spec.root.as_ref().map_or_else(
141 || self.path.join(paths::ROOTFS_DIR),
142 |root| {
143 let root_path = PathBuf::from(&root.path);
144 if root_path.is_absolute() {
145 root_path
146 } else {
147 self.path.join(&root.path)
148 }
149 },
150 )
151 }
152
153 #[must_use]
155 pub fn rootfs_exists(&self) -> bool {
156 self.rootfs_path().exists()
157 }
158
159 #[must_use]
161 pub fn rootfs_readonly(&self) -> bool {
162 self.spec.root.as_ref().is_some_and(|r| r.readonly)
163 }
164
165 pub fn validate(&self) -> Result<()> {
167 self.spec.validate()?;
169
170 let rootfs = self.rootfs_path();
172 if !rootfs.exists() {
173 warn!("Rootfs does not exist: {}", rootfs.display());
175 }
176
177 if let Some(ref hooks) = self.spec.hooks {
179 hooks.validate()?;
180 }
181
182 Ok(())
183 }
184
185 pub fn save(&self) -> Result<()> {
187 self.spec.save(self.config_path())
188 }
189
190 pub fn update_spec(&mut self, spec: Spec) -> Result<()> {
192 spec.validate()?;
193 self.spec = spec;
194 self.save()
195 }
196}
197
198#[derive(Debug)]
200pub struct BundleBuilder {
201 spec: Spec,
202}
203
204impl Default for BundleBuilder {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210impl BundleBuilder {
211 #[must_use]
213 pub fn new() -> Self {
214 Self {
215 spec: Spec::default_linux(),
216 }
217 }
218
219 #[must_use]
221 pub const fn with_spec(spec: Spec) -> Self {
222 Self { spec }
223 }
224
225 #[must_use]
227 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
228 self.spec.hostname = Some(hostname.into());
229 self
230 }
231
232 #[must_use]
234 pub fn args(mut self, args: Vec<String>) -> Self {
235 if let Some(ref mut process) = self.spec.process {
236 process.args = args;
237 }
238 self
239 }
240
241 #[must_use]
243 pub fn env(mut self, env: Vec<String>) -> Self {
244 if let Some(ref mut process) = self.spec.process {
245 process.env = env;
246 }
247 self
248 }
249
250 #[must_use]
252 pub fn add_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
253 if let Some(ref mut process) = self.spec.process {
254 process.env.push(format!("{}={}", key.into(), value.into()));
255 }
256 self
257 }
258
259 #[must_use]
261 pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
262 if let Some(ref mut process) = self.spec.process {
263 process.cwd = cwd.into();
264 }
265 self
266 }
267
268 #[must_use]
270 pub const fn user(mut self, uid: u32, gid: u32) -> Self {
271 if let Some(ref mut process) = self.spec.process {
272 if let Some(ref mut user) = process.user {
273 user.uid = uid;
274 user.gid = gid;
275 }
276 }
277 self
278 }
279
280 #[must_use]
282 pub fn rootfs(mut self, path: impl Into<String>) -> Self {
283 if let Some(ref mut root) = self.spec.root {
284 root.path = path.into();
285 }
286 self
287 }
288
289 #[must_use]
291 pub const fn readonly_rootfs(mut self, readonly: bool) -> Self {
292 if let Some(ref mut root) = self.spec.root {
293 root.readonly = readonly;
294 }
295 self
296 }
297
298 #[must_use]
300 pub const fn terminal(mut self, terminal: bool) -> Self {
301 if let Some(ref mut process) = self.spec.process {
302 process.terminal = terminal;
303 }
304 self
305 }
306
307 #[must_use]
309 pub fn annotation(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
310 self.spec.annotations.insert(key.into(), value.into());
311 self
312 }
313
314 #[must_use]
316 pub fn mount(mut self, mount: crate::config::Mount) -> Self {
317 self.spec.mounts.push(mount);
318 self
319 }
320
321 pub fn build<P: AsRef<Path>>(self, path: P) -> Result<Bundle> {
323 Bundle::create(path, self.spec)
324 }
325
326 #[must_use]
328 pub fn into_spec(self) -> Spec {
329 self.spec
330 }
331}
332
333pub mod utils {
335 use super::{OciError, Result, paths};
336 use std::path::{Path, PathBuf};
337
338 #[must_use]
340 pub fn is_bundle<P: AsRef<Path>>(path: P) -> bool {
341 let path = path.as_ref();
342 path.is_dir() && path.join(paths::CONFIG_FILE).is_file()
343 }
344
345 pub fn find_bundles<P: AsRef<Path>>(dir: P) -> Result<Vec<PathBuf>> {
347 let mut bundles = Vec::new();
348 let dir = dir.as_ref();
349
350 if !dir.is_dir() {
351 return Ok(bundles);
352 }
353
354 for entry in std::fs::read_dir(dir)? {
355 let entry = entry?;
356 let path = entry.path();
357 if is_bundle(&path) {
358 bundles.push(path);
359 }
360 }
361
362 Ok(bundles)
363 }
364
365 pub fn copy_rootfs<P: AsRef<Path>, Q: AsRef<Path>>(source: P, bundle: Q) -> Result<()> {
367 let source = source.as_ref();
368 let dest = bundle.as_ref().join(paths::ROOTFS_DIR);
369
370 if !source.exists() {
371 return Err(OciError::InvalidPath(format!(
372 "source rootfs does not exist: {}",
373 source.display()
374 )));
375 }
376
377 std::fs::create_dir_all(&dest)?;
379
380 copy_dir_recursive(source, &dest)?;
382
383 Ok(())
384 }
385
386 fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
388 std::fs::create_dir_all(dst)?;
389
390 for entry in std::fs::read_dir(src)? {
391 let entry = entry?;
392 let src_path = entry.path();
393 let dst_path = dst.join(entry.file_name());
394
395 if src_path.is_dir() {
396 copy_dir_recursive(&src_path, &dst_path)?;
397 } else {
398 std::fs::copy(&src_path, &dst_path)?;
399 }
400 }
401
402 Ok(())
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use std::fs;
410 use std::io::Write;
411
412 fn create_temp_bundle() -> tempfile::TempDir {
413 let dir = tempfile::tempdir().unwrap();
414 let config = r#"{
415 "ociVersion": "1.2.0",
416 "root": {
417 "path": "rootfs"
418 },
419 "process": {
420 "cwd": "/",
421 "args": ["sh"]
422 }
423 }"#;
424
425 let config_path = dir.path().join("config.json");
426 let mut file = fs::File::create(&config_path).unwrap();
427 file.write_all(config.as_bytes()).unwrap();
428
429 let rootfs = dir.path().join("rootfs");
430 fs::create_dir(&rootfs).unwrap();
431
432 dir
433 }
434
435 #[test]
436 fn test_load_bundle() {
437 let dir = create_temp_bundle();
438 let bundle = Bundle::load(dir.path()).unwrap();
439
440 assert_eq!(bundle.spec().oci_version, "1.2.0");
441 assert!(bundle.rootfs_exists());
442 }
443
444 #[test]
445 fn test_bundle_not_found() {
446 let result = Bundle::load("/nonexistent/path");
447 assert!(matches!(result, Err(OciError::BundleNotFound(_))));
448 }
449
450 #[test]
451 fn test_config_not_found() {
452 let dir = tempfile::tempdir().unwrap();
453 let result = Bundle::load(dir.path());
454 assert!(matches!(result, Err(OciError::ConfigNotFound(_))));
455 }
456
457 #[test]
458 fn test_create_bundle() {
459 let dir = tempfile::tempdir().unwrap();
460 let bundle_path = dir.path().join("test-bundle");
461
462 let bundle = Bundle::create_default(&bundle_path).unwrap();
463
464 assert!(bundle.config_path().exists());
465 assert!(bundle.rootfs_path().exists());
466 }
467
468 #[test]
469 fn test_bundle_builder() {
470 let dir = tempfile::tempdir().unwrap();
471 let bundle_path = dir.path().join("built-bundle");
472
473 let bundle = BundleBuilder::new()
474 .hostname("test-container")
475 .args(vec!["echo".to_string(), "hello".to_string()])
476 .add_env("MY_VAR", "my_value")
477 .cwd("/app")
478 .user(1000, 1000)
479 .readonly_rootfs(true)
480 .annotation("org.test.key", "value")
481 .build(&bundle_path)
482 .unwrap();
483
484 assert_eq!(bundle.spec().hostname, Some("test-container".to_string()));
485 assert!(bundle.rootfs_readonly());
486 assert!(bundle.spec().annotations.contains_key("org.test.key"));
487 }
488
489 #[test]
490 fn test_is_bundle() {
491 let dir = create_temp_bundle();
492 assert!(utils::is_bundle(dir.path()));
493
494 let empty_dir = tempfile::tempdir().unwrap();
495 assert!(!utils::is_bundle(empty_dir.path()));
496 }
497
498 #[test]
499 fn test_find_bundles() {
500 let root = tempfile::tempdir().unwrap();
501
502 for name in ["bundle1", "bundle2"] {
504 let path = root.path().join(name);
505 fs::create_dir(&path).unwrap();
506 let config = r#"{"ociVersion": "1.2.0"}"#;
507 fs::write(path.join("config.json"), config).unwrap();
508 }
509
510 fs::create_dir(root.path().join("not-a-bundle")).unwrap();
512
513 let bundles = utils::find_bundles(root.path()).unwrap();
514 assert_eq!(bundles.len(), 2);
515 }
516
517 #[test]
518 fn test_bundle_path_accessors() {
519 let dir = create_temp_bundle();
520 let bundle = Bundle::load(dir.path()).unwrap();
521
522 assert!(bundle.path().is_absolute());
524
525 assert!(bundle.config_path().ends_with("config.json"));
527
528 assert!(bundle.rootfs_path().ends_with("rootfs"));
530 }
531
532 #[test]
533 fn test_bundle_save() {
534 let dir = tempfile::tempdir().unwrap();
535 let bundle_path = dir.path().join("save-test");
536
537 let mut bundle = Bundle::create_default(&bundle_path).unwrap();
538
539 bundle.spec_mut().hostname = Some("modified-hostname".to_string());
541 bundle.save().unwrap();
542
543 let reloaded = Bundle::load(&bundle_path).unwrap();
545 assert_eq!(
546 reloaded.spec().hostname,
547 Some("modified-hostname".to_string())
548 );
549 }
550
551 #[test]
552 fn test_bundle_update_spec() {
553 let dir = tempfile::tempdir().unwrap();
554 let bundle_path = dir.path().join("update-test");
555
556 let mut bundle = Bundle::create_default(&bundle_path).unwrap();
557
558 let mut new_spec = Spec::default_linux();
560 new_spec.hostname = Some("new-hostname".to_string());
561
562 bundle.update_spec(new_spec).unwrap();
563
564 let reloaded = Bundle::load(&bundle_path).unwrap();
566 assert_eq!(reloaded.spec().hostname, Some("new-hostname".to_string()));
567 }
568
569 #[test]
570 fn test_bundle_rootfs_path_relative() {
571 let dir = tempfile::tempdir().unwrap();
572 let bundle_path = dir.path().join("relative-rootfs");
573
574 let bundle = BundleBuilder::new()
576 .rootfs("custom-rootfs")
577 .build(&bundle_path)
578 .unwrap();
579
580 let rootfs = bundle.rootfs_path();
582 assert!(rootfs.is_absolute());
583 assert!(rootfs.ends_with("custom-rootfs"));
584 }
585
586 #[test]
587 fn test_bundle_rootfs_path_absolute() {
588 let dir = tempfile::tempdir().unwrap();
589 let bundle_path = dir.path().join("absolute-rootfs");
590 let external_rootfs = dir.path().join("external-rootfs");
591 fs::create_dir(&external_rootfs).unwrap();
592
593 let mut spec = Spec::default_linux();
595 spec.root = Some(crate::config::Root {
596 path: external_rootfs.to_string_lossy().to_string(),
597 readonly: false,
598 });
599
600 let bundle = Bundle::create(&bundle_path, spec).unwrap();
601
602 assert_eq!(bundle.rootfs_path(), external_rootfs);
604 }
605
606 #[test]
607 fn test_bundle_rootfs_readonly() {
608 let dir = tempfile::tempdir().unwrap();
609 let bundle_path = dir.path().join("readonly-test");
610
611 let bundle = Bundle::create_default(&bundle_path).unwrap();
613 assert!(!bundle.rootfs_readonly());
614
615 let bundle_path2 = dir.path().join("readonly-test2");
617 let bundle = BundleBuilder::new()
618 .readonly_rootfs(true)
619 .build(&bundle_path2)
620 .unwrap();
621 assert!(bundle.rootfs_readonly());
622 }
623
624 #[test]
625 fn test_bundle_validate() {
626 let dir = create_temp_bundle();
627 let bundle = Bundle::load(dir.path()).unwrap();
628 assert!(bundle.validate().is_ok());
629 }
630
631 #[test]
632 fn test_bundle_builder_all_options() {
633 let dir = tempfile::tempdir().unwrap();
634 let bundle_path = dir.path().join("full-builder-test");
635
636 let mount = crate::config::Mount {
637 destination: "/data".to_string(),
638 source: Some("/host/data".to_string()),
639 mount_type: Some("bind".to_string()),
640 options: Some(vec!["rbind".to_string(), "ro".to_string()]),
641 ..Default::default()
642 };
643
644 let bundle = BundleBuilder::new()
645 .hostname("full-test")
646 .args(vec![
647 "nginx".to_string(),
648 "-g".to_string(),
649 "daemon off;".to_string(),
650 ])
651 .env(vec!["PATH=/usr/bin".to_string()])
652 .add_env("NGINX_HOST", "localhost")
653 .cwd("/var/www")
654 .user(1000, 1000)
655 .rootfs("rootfs")
656 .readonly_rootfs(false)
657 .terminal(true)
658 .annotation("org.test.key1", "value1")
659 .annotation("org.test.key2", "value2")
660 .mount(mount)
661 .build(&bundle_path)
662 .unwrap();
663
664 let spec = bundle.spec();
665 assert_eq!(spec.hostname, Some("full-test".to_string()));
666
667 let process = spec.process.as_ref().unwrap();
668 assert_eq!(process.args, vec!["nginx", "-g", "daemon off;"]);
669 assert!(process.env.iter().any(|e| e == "PATH=/usr/bin"));
670 assert!(process.env.iter().any(|e| e == "NGINX_HOST=localhost"));
671 assert_eq!(process.cwd, "/var/www");
672 assert!(process.terminal);
673
674 let user = process.user.as_ref().unwrap();
675 assert_eq!(user.uid, 1000);
676 assert_eq!(user.gid, 1000);
677
678 assert_eq!(spec.annotations.len(), 2);
679
680 assert!(spec.mounts.iter().any(|m| m.destination == "/data"));
682 }
683
684 #[test]
685 fn test_bundle_builder_with_spec() {
686 let dir = tempfile::tempdir().unwrap();
687 let bundle_path = dir.path().join("custom-spec-test");
688
689 let mut custom_spec = Spec::default_linux();
690 custom_spec.hostname = Some("custom".to_string());
691
692 let bundle = BundleBuilder::with_spec(custom_spec)
693 .annotation("added", "later")
694 .build(&bundle_path)
695 .unwrap();
696
697 assert_eq!(bundle.spec().hostname, Some("custom".to_string()));
698 assert_eq!(
699 bundle.spec().annotations.get("added"),
700 Some(&"later".to_string())
701 );
702 }
703
704 #[test]
705 fn test_bundle_builder_into_spec() {
706 let builder = BundleBuilder::new()
707 .hostname("spec-only")
708 .args(vec!["test".to_string()]);
709
710 let spec = builder.into_spec();
711 assert_eq!(spec.hostname, Some("spec-only".to_string()));
712 }
713
714 #[test]
715 fn test_bundle_not_directory() {
716 let dir = tempfile::tempdir().unwrap();
717 let file_path = dir.path().join("not-a-dir");
718 fs::write(&file_path, "content").unwrap();
719
720 let result = Bundle::load(&file_path);
721 assert!(matches!(result, Err(OciError::InvalidBundle(_))));
722 }
723
724 #[test]
725 fn test_find_bundles_empty_dir() {
726 let dir = tempfile::tempdir().unwrap();
727 let bundles = utils::find_bundles(dir.path()).unwrap();
728 assert!(bundles.is_empty());
729 }
730
731 #[test]
732 fn test_find_bundles_nonexistent() {
733 let bundles = utils::find_bundles("/nonexistent/path").unwrap();
734 assert!(bundles.is_empty());
735 }
736
737 #[test]
738 fn test_is_bundle_file() {
739 let dir = tempfile::tempdir().unwrap();
740 let file_path = dir.path().join("file");
741 fs::write(&file_path, "content").unwrap();
742 assert!(!utils::is_bundle(&file_path));
743 }
744
745 #[test]
746 fn test_bundle_with_hooks() {
747 let dir = tempfile::tempdir().unwrap();
748 let bundle_path = dir.path().join("hooks-test");
749
750 let mut spec = Spec::default_linux();
751 spec.hooks = Some(crate::hooks::Hooks {
752 create_runtime: vec![crate::hooks::Hook::new("/usr/bin/setup")],
753 poststart: vec![crate::hooks::Hook::new("/usr/bin/notify")],
754 ..Default::default()
755 });
756
757 let bundle = Bundle::create(&bundle_path, spec).unwrap();
758 assert!(bundle.spec().hooks.is_some());
759
760 let hooks = bundle.spec().hooks.as_ref().unwrap();
761 assert_eq!(hooks.create_runtime.len(), 1);
762 assert_eq!(hooks.poststart.len(), 1);
763 }
764
765 #[test]
766 fn test_copy_rootfs() {
767 let dir = tempfile::tempdir().unwrap();
768
769 let source = dir.path().join("source-rootfs");
771 fs::create_dir(&source).unwrap();
772 fs::write(source.join("file1.txt"), "content1").unwrap();
773 fs::create_dir(source.join("subdir")).unwrap();
774 fs::write(source.join("subdir/file2.txt"), "content2").unwrap();
775
776 let bundle_path = dir.path().join("bundle");
778 fs::create_dir(&bundle_path).unwrap();
779
780 utils::copy_rootfs(&source, &bundle_path).unwrap();
781
782 let dest_rootfs = bundle_path.join("rootfs");
784 assert!(dest_rootfs.join("file1.txt").exists());
785 assert!(dest_rootfs.join("subdir/file2.txt").exists());
786
787 let content = fs::read_to_string(dest_rootfs.join("file1.txt")).unwrap();
789 assert_eq!(content, "content1");
790 }
791
792 #[test]
793 fn test_copy_rootfs_nonexistent_source() {
794 let dir = tempfile::tempdir().unwrap();
795 let bundle_path = dir.path().join("bundle");
796 fs::create_dir(&bundle_path).unwrap();
797
798 let result = utils::copy_rootfs("/nonexistent/source", &bundle_path);
799 assert!(result.is_err());
800 }
801
802 #[test]
803 fn test_bundle_default_rootfs_when_root_none() {
804 let dir = tempfile::tempdir().unwrap();
805 let bundle_path = dir.path().join("no-root-test");
806 fs::create_dir(&bundle_path).unwrap();
807
808 let spec = Spec {
810 oci_version: "1.2.0".to_string(),
811 root: None,
812 process: None,
813 hostname: None,
814 domainname: None,
815 mounts: vec![],
816 hooks: None,
817 annotations: std::collections::HashMap::new(),
818 linux: None,
819 };
820
821 let config_path = bundle_path.join("config.json");
823 fs::write(&config_path, serde_json::to_string(&spec).unwrap()).unwrap();
824
825 let bundle = Bundle::load(&bundle_path).unwrap();
826
827 assert!(bundle.rootfs_path().ends_with("rootfs"));
829 }
830}