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::{Descriptor, ImageConfiguration};
22use sha2::{Digest, Sha256};
23
24use composefs::{
25    fsverity::FsVerityHashValue,
26    repository::Repository,
27    splitstream::DigestMap,
28    util::{parse_sha256, Sha256Digest},
29};
30
31use crate::tar::get_entry;
32
33type ContentAndVerity<ObjectID> = (Sha256Digest, ObjectID);
34
35pub(crate) fn sha256_from_descriptor(descriptor: &Descriptor) -> Result<Sha256Digest> {
36    let Some(digest) = descriptor.as_digest_sha256() else {
37        bail!("Descriptor in oci config is not sha256");
38    };
39    Ok(parse_sha256(digest)?)
40}
41
42pub(crate) fn sha256_from_digest(digest: &str) -> Result<Sha256Digest> {
43    match digest.strip_prefix("sha256:") {
44        Some(rest) => Ok(parse_sha256(rest)?),
45        None => bail!("Manifest has non-sha256 digest"),
46    }
47}
48
49/// Imports a container layer from a tar stream into the repository.
50///
51/// Converts the tar stream into a composefs split stream format and stores it in the repository.
52/// If a name is provided, creates a reference to the imported layer for easier access.
53///
54/// Returns the fs-verity hash value of the stored split stream.
55pub fn import_layer<ObjectID: FsVerityHashValue>(
56    repo: &Arc<Repository<ObjectID>>,
57    sha256: &Sha256Digest,
58    name: Option<&str>,
59    tar_stream: &mut impl Read,
60) -> Result<ObjectID> {
61    repo.ensure_stream(sha256, |writer| tar::split(tar_stream, writer), name)
62}
63
64/// Lists the contents of a container layer stored in the repository.
65///
66/// Reads the split stream for the named layer and prints each tar entry to stdout
67/// in composefs dumpfile format.
68pub fn ls_layer<ObjectID: FsVerityHashValue>(
69    repo: &Repository<ObjectID>,
70    name: &str,
71) -> Result<()> {
72    let mut split_stream = repo.open_stream(name, None)?;
73
74    while let Some(entry) = get_entry(&mut split_stream)? {
75        println!("{entry}");
76    }
77
78    Ok(())
79}
80
81/// Pull the target image, and add the provided tag. If this is a mountable
82/// image (i.e. not an artifact), it is *not* unpacked by default.
83pub async fn pull<ObjectID: FsVerityHashValue>(
84    repo: &Arc<Repository<ObjectID>>,
85    imgref: &str,
86    reference: Option<&str>,
87    img_proxy_config: Option<ImageProxyConfig>,
88) -> Result<(Sha256Digest, ObjectID)> {
89    skopeo::pull(repo, imgref, reference, img_proxy_config).await
90}
91
92/// Opens and parses a container configuration, following all layer references.
93///
94/// Reads the OCI image configuration from the repository and returns both the parsed
95/// configuration and a digest map containing fs-verity hashes for all referenced layers.
96/// This performs a "deep" open that validates all layer references exist.
97///
98/// If verity is provided, it's used directly. Otherwise, the name must be a sha256 digest
99/// and the corresponding verity hash will be looked up (which is more expensive).
100///
101/// Returns the parsed image configuration and the digest map of layer references.
102pub fn open_config<ObjectID: FsVerityHashValue>(
103    repo: &Repository<ObjectID>,
104    name: &str,
105    verity: Option<&ObjectID>,
106) -> Result<(ImageConfiguration, DigestMap<ObjectID>)> {
107    let id = match verity {
108        Some(id) => id,
109        None => {
110            // take the expensive route
111            let sha256 = parse_sha256(name)
112                .context("Containers must be referred to by sha256 if verity is missing")?;
113            &repo
114                .check_stream(&sha256)?
115                .with_context(|| format!("Object {name} is unknown to us"))?
116        }
117    };
118    let mut stream = repo.open_stream(name, Some(id))?;
119    let config = ImageConfiguration::from_reader(&mut stream)?;
120    Ok((config, stream.refs))
121}
122
123fn hash(bytes: &[u8]) -> Sha256Digest {
124    let mut context = Sha256::new();
125    context.update(bytes);
126    context.finalize().into()
127}
128
129/// Opens and parses a container configuration without following layer references.
130///
131/// Reads only the OCI image configuration itself from the repository without validating
132/// that all referenced layers exist. This is faster than `open_config` when you only need
133/// the configuration metadata.
134///
135/// If verity is not provided, manually verifies the content digest matches the expected hash.
136///
137/// Returns the parsed image configuration.
138pub fn open_config_shallow<ObjectID: FsVerityHashValue>(
139    repo: &Repository<ObjectID>,
140    name: &str,
141    verity: Option<&ObjectID>,
142) -> Result<ImageConfiguration> {
143    match verity {
144        // with verity deep opens are just as fast as shallow ones
145        Some(id) => Ok(open_config(repo, name, Some(id))?.0),
146        None => {
147            // we need to manually check the content digest
148            let expected_hash = parse_sha256(name)
149                .context("Containers must be referred to by sha256 if verity is missing")?;
150            let mut stream = repo.open_stream(name, None)?;
151            let mut raw_config = vec![];
152            stream.read_to_end(&mut raw_config)?;
153            ensure!(hash(&raw_config) == expected_hash, "Data integrity issue");
154            Ok(ImageConfiguration::from_reader(&mut raw_config.as_slice())?)
155        }
156    }
157}
158
159/// Writes a container configuration to the repository.
160///
161/// Serializes the image configuration to JSON and stores it as a split stream with the
162/// provided layer reference map. The configuration is stored inline since it's typically small.
163///
164/// Returns a tuple of (sha256 content hash, fs-verity hash value).
165pub fn write_config<ObjectID: FsVerityHashValue>(
166    repo: &Arc<Repository<ObjectID>>,
167    config: &ImageConfiguration,
168    refs: DigestMap<ObjectID>,
169) -> Result<ContentAndVerity<ObjectID>> {
170    let json = config.to_string()?;
171    let json_bytes = json.as_bytes();
172    let sha256 = hash(json_bytes);
173    let mut stream = repo.create_stream(Some(sha256), Some(refs));
174    stream.write_inline(json_bytes);
175    let id = repo.write_stream(stream, None)?;
176    Ok((sha256, id))
177}
178
179/// Seals a container by computing its filesystem fs-verity hash and adding it to the config.
180///
181/// Creates the complete filesystem from all layers, computes its fs-verity hash, and stores
182/// this hash in the container config labels under "containers.composefs.fsverity". This allows
183/// the container to be mounted with integrity protection.
184///
185/// Returns a tuple of (sha256 content hash, fs-verity hash value) for the updated configuration.
186pub fn seal<ObjectID: FsVerityHashValue>(
187    repo: &Arc<Repository<ObjectID>>,
188    config_name: &str,
189    config_verity: Option<&ObjectID>,
190) -> Result<ContentAndVerity<ObjectID>> {
191    let (mut config, refs) = open_config(repo, config_name, config_verity)?;
192    let mut myconfig = config.config().clone().context("no config!")?;
193    let labels = myconfig.labels_mut().get_or_insert_with(HashMap::new);
194    let mut fs = crate::image::create_filesystem(repo, config_name, config_verity)?;
195    let id = fs.compute_image_id();
196    labels.insert("containers.composefs.fsverity".to_string(), id.to_hex());
197    config.set_config(Some(myconfig));
198    write_config(repo, &config, refs)
199}
200
201/// Mounts a sealed container filesystem at the specified mountpoint.
202///
203/// Reads the container configuration to extract the fs-verity hash from the
204/// "containers.composefs.fsverity" label, then mounts the corresponding filesystem.
205/// The container must have been previously sealed using `seal()`.
206///
207/// Returns an error if the container is not sealed or if mounting fails.
208pub fn mount<ObjectID: FsVerityHashValue>(
209    repo: &Repository<ObjectID>,
210    name: &str,
211    mountpoint: &str,
212    verity: Option<&ObjectID>,
213) -> Result<()> {
214    let config = open_config_shallow(repo, name, verity)?;
215    let Some(id) = config.get_config_annotation("containers.composefs.fsverity") else {
216        bail!("Can only mount sealed containers");
217    };
218    repo.mount_at(id, mountpoint)
219}
220
221#[cfg(test)]
222mod test {
223    use std::{fmt::Write, io::Read};
224
225    use rustix::fs::CWD;
226    use sha2::{Digest, Sha256};
227
228    use composefs::{fsverity::Sha256HashValue, repository::Repository, test::tempdir};
229
230    use super::*;
231
232    fn append_data(builder: &mut ::tar::Builder<Vec<u8>>, name: &str, size: usize) {
233        let mut header = ::tar::Header::new_ustar();
234        header.set_uid(0);
235        header.set_gid(0);
236        header.set_mode(0o700);
237        header.set_entry_type(::tar::EntryType::Regular);
238        header.set_size(size as u64);
239        builder
240            .append_data(&mut header, name, std::io::repeat(0u8).take(size as u64))
241            .unwrap();
242    }
243
244    fn example_layer() -> Vec<u8> {
245        let mut builder = ::tar::Builder::new(vec![]);
246        append_data(&mut builder, "file0", 0);
247        append_data(&mut builder, "file4095", 4095);
248        append_data(&mut builder, "file4096", 4096);
249        append_data(&mut builder, "file4097", 4097);
250        builder.into_inner().unwrap()
251    }
252
253    #[test]
254    fn test_layer() {
255        let layer = example_layer();
256        let mut context = Sha256::new();
257        context.update(&layer);
258        let layer_id: [u8; 32] = context.finalize().into();
259
260        let repo_dir = tempdir();
261        let repo = Arc::new(Repository::<Sha256HashValue>::open_path(CWD, &repo_dir).unwrap());
262        let id = import_layer(&repo, &layer_id, Some("name"), &mut layer.as_slice()).unwrap();
263
264        let mut dump = String::new();
265        let mut split_stream = repo.open_stream("refs/name", Some(&id)).unwrap();
266        while let Some(entry) = tar::get_entry(&mut split_stream).unwrap() {
267            writeln!(dump, "{entry}").unwrap();
268        }
269        similar_asserts::assert_eq!(dump, "\
270/file0 0 100700 1 0 0 0 0.0 - - -
271/file4095 4095 100700 1 0 0 0 0.0 53/72beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719 - 5372beb83c78537c8970c8361e3254119fafdf1763854ecd57d3f0fe2da7c719
272/file4096 4096 100700 1 0 0 0 0.0 ba/bc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e - babc284ee4ffe7f449377fbf6692715b43aec7bc39c094a95878904d34bac97e
273/file4097 4097 100700 1 0 0 0 0.0 09/3756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743 - 093756e4ea9683329106d4a16982682ed182c14bf076463a9e7f97305cbac743
274");
275    }
276}