composefs_oci/
lib.rs

1//! OCI container image support for composefs.
2//!
3//! This crate provides functionality for working with OCI (Open Container Initiative) container images
4//! in the context of composefs. It enables importing, extracting, and mounting container images as
5//! composefs filesystems with fs-verity integrity protection.
6//!
7//! Key functionality includes:
8//! - Pulling container images from registries using skopeo
9//! - Converting OCI image layers from tar format to composefs split streams
10//! - Creating mountable filesystems from OCI image configurations
11//! - Sealing containers with fs-verity hashes for integrity verification
12
13pub mod image;
14pub mod skopeo;
15pub mod tar;
16
17use std::{collections::HashMap, io::Read, sync::Arc};
18
19use anyhow::{bail, ensure, Context, Result};
20use containers_image_proxy::ImageProxyConfig;
21use oci_spec::image::ImageConfiguration;
22use sha2::{Digest, Sha256};
23
24use composefs::{fsverity::FsVerityHashValue, repository::Repository};
25
26use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE};
27use crate::tar::get_entry;
28
29type ContentAndVerity<ObjectID> = (String, ObjectID);
30
31fn layer_identifier(diff_id: &str) -> String {
32    format!("oci-layer-{diff_id}")
33}
34
35fn config_identifier(config: &str) -> String {
36    format!("oci-config-{config}")
37}
38
39/// Imports a container layer from a tar stream into the repository.
40///
41/// Converts the tar stream into a composefs split stream format and stores it in the repository.
42/// If a name is provided, creates a reference to the imported layer for easier access.
43///
44/// Returns the fs-verity hash value of the stored split stream.
45pub fn import_layer<ObjectID: FsVerityHashValue>(
46    repo: &Arc<Repository<ObjectID>>,
47    diff_id: &str,
48    name: Option<&str>,
49    tar_stream: &mut impl Read,
50) -> Result<ObjectID> {
51    repo.ensure_stream(
52        &layer_identifier(diff_id),
53        TAR_LAYER_CONTENT_TYPE,
54        |writer| tar::split(tar_stream, writer),
55        name,
56    )
57}
58
59/// Lists the contents of a container layer stored in the repository.
60///
61/// Reads the split stream for the named layer and prints each tar entry to stdout
62/// in composefs dumpfile format.
63pub fn ls_layer<ObjectID: FsVerityHashValue>(
64    repo: &Repository<ObjectID>,
65    diff_id: &str,
66) -> Result<()> {
67    let mut split_stream = repo.open_stream(
68        &layer_identifier(diff_id),
69        None,
70        Some(TAR_LAYER_CONTENT_TYPE),
71    )?;
72
73    while let Some(entry) = get_entry(&mut split_stream)? {
74        println!("{entry}");
75    }
76
77    Ok(())
78}
79
80/// Pull the target image, and add the provided tag. If this is a mountable
81/// image (i.e. not an artifact), it is *not* unpacked by default.
82pub async fn pull<ObjectID: FsVerityHashValue>(
83    repo: &Arc<Repository<ObjectID>>,
84    imgref: &str,
85    reference: Option<&str>,
86    img_proxy_config: Option<ImageProxyConfig>,
87) -> Result<(String, ObjectID)> {
88    skopeo::pull(repo, imgref, reference, img_proxy_config).await
89}
90
91fn hash(bytes: &[u8]) -> String {
92    let mut context = Sha256::new();
93    context.update(bytes);
94    format!("sha256:{}", hex::encode(context.finalize()))
95}
96
97/// Opens and parses a container configuration.
98///
99/// Reads the OCI image configuration from the repository and returns both the parsed
100/// configuration and a digest map containing fs-verity hashes for all referenced layers.
101///
102/// If verity is provided, it's used directly. Otherwise, the name must be a sha256 digest
103/// and the corresponding verity hash will be looked up (which is more expensive) and the content
104/// will be hashed and compared to the provided digest.
105///
106/// Returns the parsed image configuration and the map of layer references.
107///
108/// Note: if the verity value is known and trusted then the layer fs-verity values can also be
109/// trusted.  If not, then you can use the layer map to find objects that are ostensibly the layers
110/// in question, but you'll have to verity their content hashes yourself.
111pub fn open_config<ObjectID: FsVerityHashValue>(
112    repo: &Repository<ObjectID>,
113    config_digest: &str,
114    verity: Option<&ObjectID>,
115) -> Result<(ImageConfiguration, HashMap<Box<str>, ObjectID>)> {
116    let mut stream = repo.open_stream(
117        &config_identifier(config_digest),
118        verity,
119        Some(OCI_CONFIG_CONTENT_TYPE),
120    )?;
121
122    let config = if verity.is_none() {
123        // No verity means we need to verify the content hash
124        let mut data = vec![];
125        stream.read_to_end(&mut data)?;
126        ensure!(config_digest == hash(&data), "Data integrity issue");
127        ImageConfiguration::from_reader(&data[..])?
128    } else {
129        ImageConfiguration::from_reader(&mut stream)?
130    };
131
132    Ok((config, stream.into_named_refs()))
133}
134
135/// Writes a container configuration to the repository.
136///
137/// Serializes the image configuration to JSON and stores it as a split stream with the
138/// provided layer reference map. The configuration is stored inline since it's typically small.
139///
140/// Returns a tuple of (sha256 content hash, fs-verity hash value).
141pub fn write_config<ObjectID: FsVerityHashValue>(
142    repo: &Arc<Repository<ObjectID>>,
143    config: &ImageConfiguration,
144    refs: HashMap<Box<str>, ObjectID>,
145) -> Result<ContentAndVerity<ObjectID>> {
146    let json = config.to_string()?;
147    let json_bytes = json.as_bytes();
148    let config_digest = hash(json_bytes);
149    let mut stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE);
150    for (name, value) in &refs {
151        stream.add_named_stream_ref(name, value)
152    }
153    stream.write_inline(json_bytes);
154    let id = repo.write_stream(stream, &config_identifier(&config_digest), None)?;
155    Ok((config_digest, id))
156}
157
158/// Seals a container by computing its filesystem fs-verity hash and adding it to the config.
159///
160/// Creates the complete filesystem from all layers, computes its fs-verity hash, and stores
161/// this hash in the container config labels under "containers.composefs.fsverity". This allows
162/// the container to be mounted with integrity protection.
163///
164/// Returns a tuple of (sha256 content hash, fs-verity hash value) for the updated configuration.
165pub fn seal<ObjectID: FsVerityHashValue>(
166    repo: &Arc<Repository<ObjectID>>,
167    config_name: &str,
168    config_verity: Option<&ObjectID>,
169) -> Result<ContentAndVerity<ObjectID>> {
170    let (mut config, refs) = open_config(repo, config_name, config_verity)?;
171    let mut myconfig = config.config().clone().context("no config!")?;
172    let labels = myconfig.labels_mut().get_or_insert_with(HashMap::new);
173    let fs = crate::image::create_filesystem(repo, config_name, config_verity)?;
174    let id = fs.compute_image_id();
175    labels.insert("containers.composefs.fsverity".to_string(), id.to_hex());
176    config.set_config(Some(myconfig));
177    write_config(repo, &config, refs)
178}
179
180/// Mounts a sealed container filesystem at the specified mountpoint.
181///
182/// Reads the container configuration to extract the fs-verity hash from the
183/// "containers.composefs.fsverity" label, then mounts the corresponding filesystem.
184/// The container must have been previously sealed using `seal()`.
185///
186/// Returns an error if the container is not sealed or if mounting fails.
187pub fn mount<ObjectID: FsVerityHashValue>(
188    repo: &Repository<ObjectID>,
189    name: &str,
190    mountpoint: &str,
191    verity: Option<&ObjectID>,
192) -> Result<()> {
193    let (config, _map) = open_config(repo, name, verity)?;
194    let Some(id) = config.get_config_annotation("containers.composefs.fsverity") else {
195        bail!("Can only mount sealed containers");
196    };
197    repo.mount_at(id, mountpoint)
198}
199
200#[cfg(test)]
201mod test {
202    use std::{fmt::Write, io::Read};
203
204    use rustix::fs::CWD;
205    use sha2::{Digest, Sha256};
206
207    use composefs::{fsverity::Sha256HashValue, repository::Repository, test::tempdir};
208
209    use super::*;
210
211    fn append_data(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, size: usize) {
212        let mut header = ::tar::Header::new_ustar();
213        header.set_uid(0);
214        header.set_gid(0);
215        header.set_mode(0o700);
216        header.set_entry_type(::tar::EntryType::Regular);
217        header.set_size(size as u64);
218        builder
219            .append_data(&mut header, name, std::io::repeat(0u8).take(size as u64))
220            .unwrap();
221    }
222
223    fn example_layer() -> Vec<u8> {
224        let mut builder = ::tar::Builder::new(vec![]);
225        append_data(&mut builder, "file0", 0);
226        append_data(&mut builder, "file4095", 4095);
227        append_data(&mut builder, "file4096", 4096);
228        append_data(&mut builder, "file4097", 4097);
229        builder.into_inner().unwrap()
230    }
231
232    #[test]
233    fn test_layer() {
234        let layer = example_layer();
235        let mut context = Sha256::new();
236        context.update(&layer);
237        let layer_id = format!("sha256:{}", hex::encode(context.finalize()));
238
239        let repo_dir = tempdir();
240        let repo = Arc::new(Repository::<Sha256HashValue>::open_path(CWD, &repo_dir).unwrap());
241        let id = import_layer(&repo, &layer_id, Some("name"), &mut layer.as_slice()).unwrap();
242
243        let mut dump = String::new();
244        let mut split_stream = repo.open_stream("refs/name", Some(&id), None).unwrap();
245        while let Some(entry) = tar::get_entry(&mut split_stream).unwrap() {
246            writeln!(dump, "{entry}").unwrap();
247        }
248        similar_asserts::assert_eq!(dump, "\
249/file0 0 100700 1 0 0 0 0.0 - - -
250/file4095 4095 100700 1 0 0 0 0.0 53/72beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719 - 5372beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719
251/file4096 4096 100700 1 0 0 0 0.0 ba/bc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e - babc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e
252/file4097 4097 100700 1 0 0 0 0.0 09/3756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 - 093756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743
253");
254    }
255}