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 sha2::{Digest, Sha256};
15
16use composefs::{
17    fsverity::FsVerityHashValue,
18    repository::Repository,
19    tree::{Directory, FileSystem, Inode, Leaf, Stat},
20};
21
22use crate::skopeo::TAR_LAYER_CONTENT_TYPE;
23use crate::tar::{TarEntry, TarItem};
24
25/// Processes a single tar entry and adds it to the filesystem.
26///
27/// Handles various tar entry types (regular files, directories, symlinks, hardlinks, devices, fifos)
28/// and implements overlayfs whiteout semantics for proper layer merging. Files named `.wh.<name>`
29/// delete the corresponding file, and `.wh..wh.opq` marks a directory as opaque (clearing all contents).
30///
31/// Returns an error if the entry cannot be processed or added to the filesystem.
32pub fn process_entry<ObjectID: FsVerityHashValue>(
33    filesystem: &mut FileSystem<ObjectID>,
34    entry: TarEntry<ObjectID>,
35) -> Result<()> {
36    if entry.path.file_name().is_none() {
37        // special handling for the root directory
38        ensure!(
39            matches!(entry.item, TarItem::Directory),
40            "Unpacking layer tar: filename {:?} must be a directory",
41            entry.path
42        );
43
44        // Update the stat, but don't do anything else
45        filesystem.set_root_stat(entry.stat);
46        return Ok(());
47    }
48
49    let inode = match entry.item {
50        TarItem::Directory => Inode::Directory(Box::from(Directory::new(entry.stat))),
51        TarItem::Leaf(content) => Inode::Leaf(Rc::new(Leaf {
52            stat: entry.stat,
53            content,
54        })),
55        TarItem::Hardlink(target) => {
56            let (dir, filename) = filesystem.root.split(&target)?;
57            Inode::Leaf(dir.ref_leaf(filename)?)
58        }
59    };
60
61    let (dir, filename) = filesystem
62        .root
63        .split_mut(entry.path.as_os_str())
64        .with_context(|| {
65            format!(
66                "Error unpacking container layer file {:?} {:?}",
67                entry.path, inode
68            )
69        })?;
70
71    let bytes = filename.as_bytes();
72    if let Some(whiteout) = bytes.strip_prefix(b".wh.") {
73        if whiteout == b".wh..opq" {
74            // complete name is '.wh..wh..opq'
75            dir.clear();
76        } else {
77            dir.remove(OsStr::from_bytes(whiteout));
78        }
79    } else {
80        dir.merge(filename, inode);
81    }
82
83    Ok(())
84}
85
86/// Creates a filesystem from the given OCI container.  No special transformations are performed to
87/// make the filesystem bootable.
88///
89/// OCI container layer tars often don't include a root directory entry, and when they do,
90/// container runtimes typically ignore it (using hardcoded defaults instead). This makes
91/// root metadata non-deterministic. To ensure consistent digests, this function copies
92/// root metadata from `/usr` after processing all layers.
93/// See: <https://github.com/containers/storage/pull/743>
94///
95/// If `config_verity` is given it is used to get the OCI config splitstream by its fs-verity ID
96/// and the entire process is substantially faster.  If it is not given, the config and layers will
97/// be hashed to ensure that they match their claimed blob IDs.
98pub fn create_filesystem<ObjectID: FsVerityHashValue>(
99    repo: &Repository<ObjectID>,
100    config_name: &str,
101    config_verity: Option<&ObjectID>,
102) -> Result<FileSystem<ObjectID>> {
103    let mut filesystem = FileSystem::new(Stat::uninitialized());
104
105    let (config, map) = crate::open_config(repo, config_name, config_verity)?;
106
107    for diff_id in config.rootfs().diff_ids() {
108        let layer_verity = map
109            .get(diff_id.as_str())
110            .context("OCI config splitstream missing named ref to layer {diff_id}")?;
111
112        if config_verity.is_none() {
113            // We don't have any proof that the named references in the config splitstream are
114            // trustworthy. We have no choice but to perform expensive validation of the layer
115            // stream.
116            let mut layer_stream =
117                repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
118            let mut context = Sha256::new();
119            layer_stream.cat(repo, &mut context)?;
120            let content_hash = format!("sha256:{}", hex::encode(context.finalize()));
121            ensure!(content_hash == *diff_id, "Layer has incorrect checksum");
122        }
123
124        let mut layer_stream =
125            repo.open_stream("", Some(layer_verity), Some(TAR_LAYER_CONTENT_TYPE))?;
126        while let Some(entry) = crate::tar::get_entry(&mut layer_stream)? {
127            process_entry(&mut filesystem, entry)?;
128        }
129    }
130
131    // Apply OCI container transformations for consistent digests.
132    // See https://github.com/containers/composefs-rs/issues/132
133    filesystem.transform_for_oci()?;
134
135    Ok(filesystem)
136}
137
138#[cfg(test)]
139mod test {
140    use composefs::{
141        dumpfile::write_dumpfile,
142        fsverity::Sha256HashValue,
143        tree::{LeafContent, RegularFile, Stat},
144    };
145    use std::{cell::RefCell, collections::BTreeMap, io::BufRead, path::PathBuf};
146
147    use super::*;
148
149    fn file_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
150        TarEntry {
151            path: PathBuf::from(path),
152            stat: Stat {
153                st_mode: 0o644,
154                st_uid: 0,
155                st_gid: 0,
156                st_mtim_sec: 0,
157                xattrs: RefCell::new(BTreeMap::new()),
158            },
159            item: TarItem::Leaf(LeafContent::Regular(RegularFile::Inline([].into()))),
160        }
161    }
162
163    fn dir_entry<ObjectID: FsVerityHashValue>(path: &str) -> TarEntry<ObjectID> {
164        TarEntry {
165            path: PathBuf::from(path),
166            stat: Stat {
167                st_mode: 0o755,
168                st_uid: 0,
169                st_gid: 0,
170                st_mtim_sec: 0,
171                xattrs: RefCell::new(BTreeMap::new()),
172            },
173            item: TarItem::Directory,
174        }
175    }
176
177    fn assert_files(fs: &FileSystem<impl FsVerityHashValue>, expected: &[&str]) -> Result<()> {
178        let mut out = vec![];
179        write_dumpfile(&mut out, fs)?;
180        let actual: Vec<String> = out
181            .lines()
182            .map(|line| line.unwrap().split_once(' ').unwrap().0.into())
183            .collect();
184
185        similar_asserts::assert_eq!(actual, expected);
186        Ok(())
187    }
188
189    #[test]
190    fn test_process_entry() -> Result<()> {
191        let mut fs = FileSystem::<Sha256HashValue>::new(Stat::uninitialized());
192
193        // both with and without leading slash should be supported
194        process_entry(&mut fs, dir_entry("/a"))?;
195        process_entry(&mut fs, dir_entry("b"))?;
196        process_entry(&mut fs, dir_entry("c"))?;
197        assert_files(&fs, &["/", "/a", "/b", "/c"])?;
198
199        // add some files
200        process_entry(&mut fs, file_entry("/a/b"))?;
201        process_entry(&mut fs, file_entry("/a/c"))?;
202        process_entry(&mut fs, file_entry("/b/a"))?;
203        process_entry(&mut fs, file_entry("/b/c"))?;
204        process_entry(&mut fs, file_entry("/c/a"))?;
205        process_entry(&mut fs, file_entry("/c/c"))?;
206        assert_files(
207            &fs,
208            &[
209                "/", "/a", "/a/b", "/a/c", "/b", "/b/a", "/b/c", "/c", "/c/a", "/c/c",
210            ],
211        )?;
212
213        // try some whiteouts
214        process_entry(&mut fs, file_entry(".wh.a"))?; // entire dir
215        process_entry(&mut fs, file_entry("/b/.wh..wh..opq"))?; // opaque dir
216        process_entry(&mut fs, file_entry("/c/.wh.c"))?; // single file
217        assert_files(&fs, &["/", "/b", "/c", "/c/a"])?;
218
219        Ok(())
220    }
221}