composefs_oci/
image.rs

1//! OCI image processing and filesystem construction.
2//!
3//! This module handles the conversion of OCI container image layers into composefs filesystems.
4//! It processes tar entries from container layers, handles overlayfs semantics like whiteouts,
5//! and constructs the final filesystem tree that can be mounted or analyzed.
6//!
7//! The main functionality centers around `create_filesystem()` which takes an OCI image configuration
8//! and builds a complete filesystem by processing all layers in order. The `process_entry()` function
9//! handles individual tar entries and implements overlayfs whiteout semantics for proper layer merging.
10
11use std::{ffi::OsStr, os::unix::ffi::OsStrExt, rc::Rc};
12
13use anyhow::{ensure, Context, Result};
14use oci_spec::image::ImageConfiguration;
15
16use composefs::{
17    fsverity::FsVerityHashValue,
18    repository::Repository,
19    tree::{Directory, FileSystem, Inode, Leaf},
20};
21
22use crate::tar::{TarEntry, TarItem};
23
24/// Processes a single tar entry and adds it to the filesystem.
25///
26/// Handles various tar entry types (regular files, directories, symlinks, hardlinks, devices, fifos)
27/// and implements overlayfs whiteout semantics for proper layer merging. Files named `.wh.<name>`
28/// delete the corresponding file, and `.wh..wh.opq` marks a directory as opaque (clearing all contents).
29///
30/// Returns an error if the entry cannot be processed or added to the filesystem.
31pub fn process_entry<ObjectID: FsVerityHashValue>(
32    filesystem: &mut FileSystem<ObjectID>,
33    entry: TarEntry<ObjectID>,
34) -> Result<()> {
35    if entry.path.file_name().is_none() {
36        // special handling for the root directory
37        ensure!(
38            matches!(entry.item, TarItem::Directory),
39            "Unpacking layer tar: filename {:?} must be a directory",
40            entry.path
41        );
42
43        // Update the stat, but don't do anything else
44        filesystem.set_root_stat(entry.stat);
45        return Ok(());
46    }
47
48    let inode = match entry.item {
49        TarItem::Directory => Inode::Directory(Box::from(Directory::new(entry.stat))),
50        TarItem::Leaf(content) => Inode::Leaf(Rc::new(Leaf {
51            stat: entry.stat,
52            content,
53        })),
54        TarItem::Hardlink(target) => {
55            let (dir, filename) = filesystem.root.split(&target)?;
56            Inode::Leaf(dir.ref_leaf(filename)?)
57        }
58    };
59
60    let (dir, filename) = filesystem
61        .root
62        .split_mut(entry.path.as_os_str())
63        .with_context(|| {
64            format!(
65                "Error unpacking container layer file {:?} {:?}",
66                entry.path, inode
67            )
68        })?;
69
70    let bytes = filename.as_bytes();
71    if let Some(whiteout) = bytes.strip_prefix(b".wh.") {
72        if whiteout == b".wh.opq" {
73            // complete name is '.wh..wh.opq'
74            dir.clear();
75        } else {
76            dir.remove(OsStr::from_bytes(whiteout));
77        }
78    } else {
79        dir.merge(filename, inode);
80    }
81
82    Ok(())
83}
84
85/// Creates a filesystem from the given OCI container.  No special transformations are performed to
86/// make the filesystem bootable.
87pub fn create_filesystem<ObjectID: FsVerityHashValue>(
88    repo: &Repository<ObjectID>,
89    config_name: &str,
90    config_verity: Option<&ObjectID>,
91) -> Result<FileSystem<ObjectID>> {
92    let mut filesystem = FileSystem::default();
93
94    let mut config_stream = repo.open_stream(config_name, config_verity)?;
95    let config = ImageConfiguration::from_reader(&mut config_stream)?;
96
97    for diff_id in config.rootfs().diff_ids() {
98        let layer_sha256 = super::sha256_from_digest(diff_id)?;
99        let layer_verity = config_stream.lookup(&layer_sha256)?;
100
101        let mut layer_stream = repo.open_stream(&hex::encode(layer_sha256), Some(layer_verity))?;
102        while let Some(entry) = crate::tar::get_entry(&mut layer_stream)? {
103            process_entry(&mut filesystem, entry)?;
104        }
105    }
106
107    Ok(filesystem)
108}
109
110#[cfg(test)]
111mod test {
112    use composefs::{
113        dumpfile::write_dumpfile,
114        fsverity::Sha256HashValue,
115        tree::{LeafContent, RegularFile, Stat},
116    };
117    use std::{cell::RefCell, collections::BTreeMap, io::BufRead, path::PathBuf};
118
119    use super::*;
120
121    fn file_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
122        TarEntry {
123            path: PathBuf::from(path),
124            stat: Stat {
125                st_mode: 0o644,
126                st_uid: 0,
127                st_gid: 0,
128                st_mtim_sec: 0,
129                xattrs: RefCell::new(BTreeMap::new()),
130            },
131            item: TarItem::Leaf(LeafContent::Regular(RegularFile::Inline([].into()))),
132        }
133    }
134
135    fn dir_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
136        TarEntry {
137            path: PathBuf::from(path),
138            stat: Stat {
139                st_mode: 0o755,
140                st_uid: 0,
141                st_gid: 0,
142                st_mtim_sec: 0,
143                xattrs: RefCell::new(BTreeMap::new()),
144            },
145            item: TarItem::Directory,
146        }
147    }
148
149    fn assert_files(fs: &FileSystem<impl FsVerityHashValue>, expected: &[&str]) -> Result<()> {
150        let mut out = vec![];
151        write_dumpfile(&mut out, fs)?;
152        let actual: Vec<String> = out
153            .lines()
154            .map(|line| line.unwrap().split_once(' ').unwrap().0.into())
155            .collect();
156
157        similar_asserts::assert_eq!(actual, expected);
158        Ok(())
159    }
160
161    #[test]
162    fn test_process_entry() -> Result<()> {
163        let mut fs = FileSystem::<Sha256HashValue>::default();
164
165        // both with and without leading slash should be supported
166        process_entry(&mut fs, dir_entry("/a"))?;
167        process_entry(&mut fs, dir_entry("b"))?;
168        process_entry(&mut fs, dir_entry("c"))?;
169        assert_files(&fs, &["/", "/a", "/b", "/c"])?;
170
171        // add some files
172        process_entry(&mut fs, file_entry("/a/b"))?;
173        process_entry(&mut fs, file_entry("/a/c"))?;
174        process_entry(&mut fs, file_entry("/b/a"))?;
175        process_entry(&mut fs, file_entry("/b/c"))?;
176        process_entry(&mut fs, file_entry("/c/a"))?;
177        process_entry(&mut fs, file_entry("/c/c"))?;
178        assert_files(
179            &fs,
180            &[
181                "/", "/a", "/a/b", "/a/c", "/b", "/b/a", "/b/c", "/c", "/c/a", "/c/c",
182            ],
183        )?;
184
185        // try some whiteouts
186        process_entry(&mut fs, file_entry(".wh.a"))?; // entire dir
187        process_entry(&mut fs, file_entry("/b/.wh..wh.opq"))?; // opaque dir
188        process_entry(&mut fs, file_entry("/c/.wh.c"))?; // single file
189        assert_files(&fs, &["/", "/b", "/c", "/c/a"])?;
190
191        Ok(())
192    }
193}