Skip to main content

arcbox_oci/
bundle.rs

1//! OCI bundle handling.
2//!
3//! An OCI bundle is a directory containing everything needed to run a container:
4//! - `config.json`: The OCI runtime specification
5//! - `rootfs/`: The root filesystem (optional if config.json specifies an external root)
6//!
7//! Reference: <https://github.com/opencontainers/runtime-spec/blob/main/bundle.md>
8
9use std::path::{Path, PathBuf};
10
11use tracing::{debug, warn};
12
13use crate::config::Spec;
14use crate::error::{OciError, Result};
15
16/// OCI bundle directory name constants.
17pub mod paths {
18    /// Standard config file name.
19    pub const CONFIG_FILE: &str = "config.json";
20    /// Default rootfs directory name.
21    pub const ROOTFS_DIR: &str = "rootfs";
22}
23
24/// OCI bundle representation.
25///
26/// A bundle encapsulates an OCI runtime configuration and its associated
27/// root filesystem.
28#[derive(Debug, Clone)]
29pub struct Bundle {
30    /// Absolute path to the bundle directory.
31    path: PathBuf,
32    /// Parsed OCI specification.
33    spec: Spec,
34}
35
36impl Bundle {
37    /// Load an OCI bundle from a directory.
38    ///
39    /// This reads and validates the config.json file and checks that the
40    /// bundle structure is valid.
41    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
42        let path = path.as_ref();
43
44        // Ensure bundle directory exists.
45        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        // Convert to absolute path.
57        let path = path
58            .canonicalize()
59            .map_err(|e| OciError::InvalidBundle(format!("failed to resolve path: {e}")))?;
60
61        // Load config.json.
62        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    /// Create a new bundle from an existing spec.
77    ///
78    /// This creates the bundle directory structure and writes the config.json.
79    pub fn create<P: AsRef<Path>>(path: P, spec: Spec) -> Result<Self> {
80        let path = path.as_ref();
81
82        // Create bundle directory.
83        std::fs::create_dir_all(path)?;
84
85        // Convert to absolute path.
86        let path = path
87            .canonicalize()
88            .map_err(|e| OciError::InvalidBundle(format!("failed to resolve path: {e}")))?;
89
90        // Write config.json.
91        let config_path = path.join(paths::CONFIG_FILE);
92        spec.save(&config_path)?;
93        debug!("Wrote OCI spec to {}", config_path.display());
94
95        // Create default rootfs directory if root path is relative.
96        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    /// Create a bundle with default Linux configuration.
108    pub fn create_default<P: AsRef<Path>>(path: P) -> Result<Self> {
109        Self::create(path, Spec::default_linux())
110    }
111
112    /// Get the bundle directory path.
113    #[must_use]
114    pub fn path(&self) -> &Path {
115        &self.path
116    }
117
118    /// Get the OCI specification.
119    #[must_use]
120    pub const fn spec(&self) -> &Spec {
121        &self.spec
122    }
123
124    /// Get mutable reference to the OCI specification.
125    pub const fn spec_mut(&mut self) -> &mut Spec {
126        &mut self.spec
127    }
128
129    /// Get the config.json path.
130    #[must_use]
131    pub fn config_path(&self) -> PathBuf {
132        self.path.join(paths::CONFIG_FILE)
133    }
134
135    /// Get the root filesystem path.
136    ///
137    /// Returns the resolved absolute path to the rootfs.
138    #[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    /// Check if the rootfs exists.
154    #[must_use]
155    pub fn rootfs_exists(&self) -> bool {
156        self.rootfs_path().exists()
157    }
158
159    /// Check if the rootfs is configured as read-only.
160    #[must_use]
161    pub fn rootfs_readonly(&self) -> bool {
162        self.spec.root.as_ref().is_some_and(|r| r.readonly)
163    }
164
165    /// Validate the bundle structure.
166    pub fn validate(&self) -> Result<()> {
167        // Validate the spec.
168        self.spec.validate()?;
169
170        // Check rootfs exists (warn if not, error if we need it).
171        let rootfs = self.rootfs_path();
172        if !rootfs.exists() {
173            // Rootfs might be mounted later or provided externally.
174            warn!("Rootfs does not exist: {}", rootfs.display());
175        }
176
177        // Validate hooks if present.
178        if let Some(ref hooks) = self.spec.hooks {
179            hooks.validate()?;
180        }
181
182        Ok(())
183    }
184
185    /// Save any modifications to the spec back to config.json.
186    pub fn save(&self) -> Result<()> {
187        self.spec.save(self.config_path())
188    }
189
190    /// Update the spec and save.
191    pub fn update_spec(&mut self, spec: Spec) -> Result<()> {
192        spec.validate()?;
193        self.spec = spec;
194        self.save()
195    }
196}
197
198/// Bundle builder for creating new bundles.
199#[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    /// Create a new bundle builder with default Linux spec.
212    #[must_use]
213    pub fn new() -> Self {
214        Self {
215            spec: Spec::default_linux(),
216        }
217    }
218
219    /// Create a new bundle builder with a custom spec.
220    #[must_use]
221    pub const fn with_spec(spec: Spec) -> Self {
222        Self { spec }
223    }
224
225    /// Set the hostname.
226    #[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    /// Set the process arguments.
233    #[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    /// Set the process environment.
242    #[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    /// Add an environment variable.
251    #[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    /// Set the working directory.
260    #[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    /// Set the user.
269    #[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    /// Set the rootfs path.
281    #[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    /// Set rootfs as read-only.
290    #[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    /// Enable terminal.
299    #[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    /// Add an annotation.
308    #[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    /// Add a mount.
315    #[must_use]
316    pub fn mount(mut self, mount: crate::config::Mount) -> Self {
317        self.spec.mounts.push(mount);
318        self
319    }
320
321    /// Build the bundle at the specified path.
322    pub fn build<P: AsRef<Path>>(self, path: P) -> Result<Bundle> {
323        Bundle::create(path, self.spec)
324    }
325
326    /// Get the spec without building.
327    #[must_use]
328    pub fn into_spec(self) -> Spec {
329        self.spec
330    }
331}
332
333/// Utilities for working with bundles.
334pub mod utils {
335    use super::{OciError, Result, paths};
336    use std::path::{Path, PathBuf};
337
338    /// Check if a directory is a valid OCI bundle.
339    #[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    /// Find all bundles in a directory (non-recursive).
346    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    /// Copy rootfs from source to bundle.
366    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        // Ensure destination rootfs directory exists before recursive copy.
378        std::fs::create_dir_all(&dest)?;
379
380        // Copy directory contents (basic implementation).
381        copy_dir_recursive(source, &dest)?;
382
383        Ok(())
384    }
385
386    /// Recursively copy a directory.
387    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        // Create two bundles.
503        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        // Create a non-bundle directory.
511        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        // Bundle path should be absolute.
523        assert!(bundle.path().is_absolute());
524
525        // Config path should end with config.json.
526        assert!(bundle.config_path().ends_with("config.json"));
527
528        // Rootfs path should end with rootfs.
529        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        // Modify the spec.
540        bundle.spec_mut().hostname = Some("modified-hostname".to_string());
541        bundle.save().unwrap();
542
543        // Reload and verify.
544        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        // Create a new spec.
559        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        // Reload and verify.
565        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        // Create bundle with relative rootfs path.
575        let bundle = BundleBuilder::new()
576            .rootfs("custom-rootfs")
577            .build(&bundle_path)
578            .unwrap();
579
580        // Rootfs path should be resolved to absolute.
581        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        // Create bundle with absolute rootfs path.
594        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        // Rootfs path should be the absolute path.
603        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        // Not readonly by default.
612        let bundle = Bundle::create_default(&bundle_path).unwrap();
613        assert!(!bundle.rootfs_readonly());
614
615        // Create readonly bundle.
616        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        // Check the added mount (after default mounts).
681        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        // Create source rootfs with some files.
770        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        // Copy to bundle.
777        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        // Verify files were copied.
783        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        // Verify content.
788        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        // Create a spec without root.
809        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        // Save config.json directly.
822        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        // Should default to "rootfs" subdirectory.
828        assert!(bundle.rootfs_path().ends_with("rootfs"));
829    }
830}