diff --git a/.github/workflows/bootc-revdep.yml b/.github/workflows/bootc-revdep.yml index d5dc1c4d..db3f608f 100644 --- a/.github/workflows/bootc-revdep.yml +++ b/.github/workflows/bootc-revdep.yml @@ -44,3 +44,8 @@ jobs: - name: Build and test bootc with local composefs-rs run: just bootc/test + env: + # Use bootc branch with OpenConfig API compatibility + # TODO: revert to main once bootc-dev/bootc#2044 is merged + COMPOSEFS_BOOTC_REPO: https://github.com/cgwalters/bootc + COMPOSEFS_BOOTC_REF: prep-composefs-manifest diff --git a/Justfile b/Justfile index 8bbbde3c..ce6725c1 100644 --- a/Justfile +++ b/Justfile @@ -25,6 +25,8 @@ check-feature-combos: cargo clippy -p cfsctl --no-default-features -- -D warnings cargo clippy -p cfsctl --no-default-features --features oci -- -D warnings cargo clippy -p cfsctl --no-default-features --features http -- -D warnings + cargo clippy -p composefs-oci -- -D warnings + cargo clippy -p composefs-oci --features boot -- -D warnings # Run rustfmt check fmt-check: diff --git a/crates/cfsctl/Cargo.toml b/crates/cfsctl/Cargo.toml index 35ef1108..3fd6536f 100644 --- a/crates/cfsctl/Cargo.toml +++ b/crates/cfsctl/Cargo.toml @@ -27,7 +27,7 @@ clap = { version = "4.5.0", default-features = false, features = ["std", "help", comfy-table = { version = "7.1", default-features = false } composefs = { workspace = true } composefs-boot = { workspace = true } -composefs-oci = { workspace = true, optional = true } +composefs-oci = { workspace = true, optional = true, features = ["boot"] } composefs-http = { workspace = true, optional = true } env_logger = { version = "0.11.0", default-features = false } hex = { version = "0.4.0", default-features = false } diff --git a/crates/cfsctl/src/lib.rs b/crates/cfsctl/src/lib.rs index bbcbf949..93f63580 100644 --- a/crates/cfsctl/src/lib.rs +++ b/crates/cfsctl/src/lib.rs @@ -135,6 +135,9 @@ enum OciCommand { image: String, /// optional reference name for the manifest, use as 'ref/' elsewhere name: Option, + /// Also generate a bootable EROFS image from the pulled OCI image + #[arg(long)] + bootable: bool, }, /// List all tagged OCI images in the repository #[clap(name = "images")] @@ -185,33 +188,21 @@ enum OciCommand { #[clap(long, conflicts_with = "dumpfile")] json: bool, }, + /// Mount an OCI image's composefs EROFS at the given mountpoint + Mount { + /// Image reference (tag name or manifest digest) + image: String, + /// Target mountpoint + mountpoint: String, + /// Mount the bootable variant instead of the regular EROFS image + #[arg(long)] + bootable: bool, + }, /// Compute the composefs image object id of the rootfs of a stored OCI image ComputeId { #[clap(flatten)] config_opts: OCIConfigFilesystemOptions, }, - /// Create the composefs image of the rootfs of a stored OCI image, commit it to the repo, and print its image object ID - CreateImage { - #[clap(flatten)] - config_opts: OCIConfigFilesystemOptions, - /// optional reference name for the image, use as 'ref/' elsewhere - #[clap(long)] - image_name: Option, - }, - /// Seal a stored OCI image by creating a cloned manifest with embedded verity digest (a.k.a. composefs image object ID) - /// in the repo, then prints the stream and verity digest of the new sealed manifest - Seal { - #[clap(flatten)] - config_opts: OCIConfigOptions, - }, - /// Mounts a stored and sealed OCI image by looking up its composefs image. Note that the composefs image must be built - /// and committed to the repo first - Mount { - /// the name of the target OCI manifest stream, either a stream ID in format oci-config-: or a reference in 'ref/' - name: String, - /// the mountpoint - mountpoint: String, - }, /// Create the composefs image of the rootfs of a stored OCI image, perform bootable transformation, commit it to the repo, /// then configure boot for the image by writing new boot resources and bootloader entries to boot partition. Performs /// state preparation for composefs-setup-root consumption as well. Note that state preparation here is not suitable for @@ -502,24 +493,48 @@ where let fs = load_filesystem_from_oci_image(&repo, config_opts)?; fs.print_dumpfile()?; } + OciCommand::Mount { + ref image, + ref mountpoint, + bootable, + } => { + let img = if image.starts_with("sha256:") { + composefs_oci::oci_image::OciImage::open(&repo, image, None)? + } else { + composefs_oci::oci_image::OciImage::open_ref(&repo, image)? + }; + let erofs_id = if bootable { + match img.boot_image_ref() { + Some(id) => id, + None => anyhow::bail!( + "No boot EROFS image linked — try pulling with --bootable" + ), + } + } else { + match img.image_ref() { + Some(id) => id, + None => anyhow::bail!( + "No composefs EROFS image linked — try re-pulling the image" + ), + } + }; + repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str())?; + } OciCommand::ComputeId { config_opts } => { let fs = load_filesystem_from_oci_image(&repo, config_opts)?; let id = fs.compute_image_id(); println!("{}", id.to_hex()); } - OciCommand::CreateImage { - config_opts, - ref image_name, + OciCommand::Pull { + ref image, + name, + bootable, } => { - let fs = load_filesystem_from_oci_image(&repo, config_opts)?; - let image_id = fs.commit_image(&repo, image_name.as_deref())?; - println!("{}", image_id.to_id()); - } - OciCommand::Pull { ref image, name } => { // If no explicit name provided, use the image reference as the tag let tag_name = name.as_deref().unwrap_or(image); + let repo_arc = Arc::new(repo); let (result, stats) = - composefs_oci::pull_image(&Arc::new(repo), image, Some(tag_name), None).await?; + composefs_oci::pull_image(&repo_arc, image, Some(tag_name), None).await?; println!("manifest {}", result.manifest_digest); println!("config {}", result.config_digest); @@ -532,6 +547,12 @@ where stats.bytes_copied, stats.bytes_inlined, ); + + if bootable { + let image_verity = + composefs_oci::generate_boot_image(&repo_arc, &result.manifest_digest)?; + println!("Boot image: {}", image_verity.to_hex()); + } } OciCommand::ListImages { json } => { let images = composefs_oci::oci_image::list_images(&repo)?; @@ -543,7 +564,7 @@ where } else { let mut table = Table::new(); table.load_preset(UTF8_FULL); - table.set_header(["NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS"]); + table.set_header(["NAME", "DIGEST", "ARCH", "LAYERS", "REFS"]); for img in images { let digest_short = img @@ -560,12 +581,10 @@ where } else { &img.architecture }; - let sealed = if img.sealed { "yes" } else { "no" }; table.add_row([ img.name.as_str(), digest_display, arch, - sealed, &img.layer_count.to_string(), &img.referrer_count.to_string(), ]); @@ -633,25 +652,6 @@ where composefs_oci::layer_tar(&repo, layer, &mut out)?; } } - OciCommand::Seal { - config_opts: - OCIConfigOptions { - ref config_name, - ref config_verity, - }, - } => { - let verity = verity_opt(config_verity)?; - let (digest, verity) = - composefs_oci::seal(&Arc::new(repo), config_name, verity.as_ref())?; - println!("config {digest}"); - println!("verity {}", verity.to_id()); - } - OciCommand::Mount { - ref name, - ref mountpoint, - } => { - composefs_oci::mount(&repo, name, mountpoint, None)?; - } OciCommand::PrepareBoot { config_opts: OCIConfigOptions { @@ -697,11 +697,6 @@ where create_dir_all(state.join("etc/work"))?; } }, - Command::ComputeId { fs_opts } => { - let fs = load_filesystem_from_ondisk_fs(&fs_opts, &repo)?; - let id = fs.compute_image_id(); - println!("{}", id.to_hex()); - } Command::CreateImage { fs_opts, ref image_name, @@ -710,6 +705,11 @@ where let id = fs.commit_image(&repo, image_name.as_deref())?; println!("{}", id.to_id()); } + Command::ComputeId { fs_opts } => { + let fs = load_filesystem_from_ondisk_fs(&fs_opts, &repo)?; + let id = fs.compute_image_id(); + println!("{}", id.to_hex()); + } Command::CreateDumpfile { fs_opts } => { let fs = load_filesystem_from_ondisk_fs(&fs_opts, &repo)?; fs.print_dumpfile()?; diff --git a/crates/composefs-oci/Cargo.toml b/crates/composefs-oci/Cargo.toml index eda0e0ec..6b7bd3c8 100644 --- a/crates/composefs-oci/Cargo.toml +++ b/crates/composefs-oci/Cargo.toml @@ -10,12 +10,17 @@ repository.workspace = true rust-version.workspace = true version.workspace = true +[features] +test = ["tar", "composefs/test"] +boot = ["composefs-boot"] + [dependencies] anyhow = { version = "1.0.87", default-features = false } fn-error-context = "0.2" async-compression = { version = "0.4.0", default-features = false, features = ["tokio", "zstd", "gzip"] } bytes = { version = "1", default-features = false } composefs = { workspace = true } +composefs-boot = { workspace = true, optional = true } containers-image-proxy = { version = "0.9.2", default-features = false } hex = { version = "0.4.0", default-features = false } indicatif = { version = "0.17.0", default-features = false, features = ["tokio"] } @@ -24,6 +29,7 @@ rustix = { version = "1.0.0", features = ["fs"] } serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = { version = "1.0", default-features = false, features = ["std"] } sha2 = { version = "0.10.1", default-features = false } +tar = { version = "0.4.38", default-features = false, optional = true } tar-core = "0.1.0" tokio = { version = "1.24.2", features = ["rt-multi-thread"] } tokio-util = { version = "0.7", default-features = false, features = ["io"] } @@ -32,6 +38,7 @@ tokio-util = { version = "0.7", default-features = false, features = ["io"] } similar-asserts = "1.7.0" tar = { version = "0.4.38", default-features = false } composefs = { workspace = true, features = ["test"] } +composefs-boot = { workspace = true } once_cell = "1.21.3" proptest = "1" tempfile = "3.8.0" diff --git a/crates/composefs-oci/src/boot.rs b/crates/composefs-oci/src/boot.rs new file mode 100644 index 00000000..2a3dc5d3 --- /dev/null +++ b/crates/composefs-oci/src/boot.rs @@ -0,0 +1,286 @@ +//! Boot image management for OCI containers. +//! +//! A bootable EROFS image is a derived artifact from an OCI container image +//! that filters out some components (such as the UKI) to avoid circular references. + +use std::sync::Arc; + +use anyhow::Result; + +use composefs::fsverity::FsVerityHashValue; +use composefs::repository::Repository; + +/// The name used for the bootable image reference in the config. +pub const BOOT_IMAGE_REF_NAME: &str = "cfs-oci-for-bootable"; + +/// Generate a bootable EROFS image from a pulled OCI manifest (idempotent). +#[cfg(feature = "boot")] +pub fn generate_boot_image( + repo: &Arc>, + manifest_digest: &str, +) -> Result { + if let Some(existing) = boot_image(repo, manifest_digest)? { + return Ok(existing); + } + + let erofs_id = crate::ensure_oci_composefs_erofs_boot(repo, manifest_digest, None, None)? + .expect("container image should produce boot EROFS"); + + Ok(erofs_id) +} + +/// Returns the boot EROFS image verity, if one exists. +pub fn boot_image( + repo: &Repository, + manifest_digest: &str, +) -> Result> { + crate::composefs_boot_erofs_for_manifest(repo, manifest_digest, None) +} + +/// Remove the bootable EROFS image reference (idempotent). +/// +/// The EROFS image itself is garbage-collected on the next `repo.gc()`. +pub fn remove_boot_image( + repo: &Arc>, + manifest_digest: &str, +) -> Result<()> { + let img = crate::oci_image::OciImage::open(repo, manifest_digest, None)?; + + if img.boot_image_ref().is_none() { + return Ok(()); + } + + let config = img + .config() + .ok_or_else(|| anyhow::anyhow!("not a container image"))? + .clone(); + + let (_config_digest, new_config_verity) = crate::write_config( + repo, + &config, + img.layer_refs().clone(), + img.image_ref(), + None, // no boot image + )?; + + let manifest_json = img.read_manifest_json(repo)?; + let layer_verities = img.layer_refs().clone(); + + crate::oci_image::rewrite_manifest( + repo, + &manifest_json, + manifest_digest, + &new_config_verity, + &layer_verities, + None, + )?; + + Ok(()) +} + +#[cfg(all(test, feature = "boot"))] +mod test { + use super::*; + use composefs::fsverity::Sha256HashValue; + use composefs::test::TestRepo; + use composefs_boot::bootloader::get_boot_resources; + + use crate::oci_image::OciImage; + use crate::test_util; + + #[tokio::test] + async fn test_boot_image_none_before_generate() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + let result = boot_image(repo, &img.manifest_digest).unwrap(); + assert!(result.is_none(), "no boot image should exist yet"); + } + + #[tokio::test] + async fn test_generate_boot_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + let image_verity = generate_boot_image(repo, &img.manifest_digest).unwrap(); + + let found = boot_image(repo, &img.manifest_digest).unwrap(); + assert_eq!(found, Some(image_verity.clone())); + + // Open by tag since manifest was rewritten + let oci = OciImage::open_ref(repo, "myapp:v1").unwrap(); + assert_eq!(oci.boot_image_ref(), Some(&image_verity)); + + let plain_image = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); + let plain_verity = plain_image.compute_image_id(); + assert_ne!( + image_verity, plain_verity, + "boot-transformed image should differ from non-transformed image" + ); + } + + #[tokio::test] + async fn test_generate_boot_image_idempotent() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + let v1 = generate_boot_image(repo, &img.manifest_digest).unwrap(); + let v2 = generate_boot_image(repo, &img.manifest_digest).unwrap(); + assert_eq!(v1, v2); + } + + #[tokio::test] + async fn test_remove_boot_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + generate_boot_image(repo, &img.manifest_digest).unwrap(); + assert!(boot_image(repo, &img.manifest_digest).unwrap().is_some()); + + remove_boot_image(repo, &img.manifest_digest).unwrap(); + assert!( + boot_image(repo, &img.manifest_digest).unwrap().is_none(), + "boot image should be gone after remove" + ); + + let oci = OciImage::open_ref(repo, "myapp:v1").unwrap(); + assert!(oci.is_container_image()); + + let gc = repo.gc(&[]).unwrap(); + assert_eq!( + gc.images_pruned, 1, + "exactly the EROFS image should be pruned" + ); + } + + #[tokio::test] + async fn test_remove_boot_image_idempotent() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + remove_boot_image(repo, &img.manifest_digest).unwrap(); + + generate_boot_image(repo, &img.manifest_digest).unwrap(); + remove_boot_image(repo, &img.manifest_digest).unwrap(); + remove_boot_image(repo, &img.manifest_digest).unwrap(); + + assert!(boot_image(repo, &img.manifest_digest).unwrap().is_none()); + } + + #[tokio::test] + async fn test_boot_image_gc_preserves_when_tagged() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + let image_verity = generate_boot_image(repo, &img.manifest_digest).unwrap(); + + let gc = repo.gc(&[]).unwrap(); + assert_eq!(gc.images_pruned, 0); + assert_eq!(gc.streams_pruned, 0); + + let oci = OciImage::open_ref(repo, "myapp:v1").unwrap(); + assert_eq!(oci.boot_image_ref(), Some(&image_verity)); + } + + #[tokio::test] + async fn test_boot_image_gc_collects_after_untag() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + generate_boot_image(repo, &img.manifest_digest).unwrap(); + + crate::oci_image::untag_image(repo, "myapp:v1").unwrap(); + + let gc = repo.gc(&[]).unwrap(); + assert!(gc.objects_removed > 0); + assert_eq!(gc.images_pruned, 1); + assert!(gc.streams_pruned > 0); + + let gc2 = repo.gc(&[]).unwrap(); + assert_eq!(gc2.objects_removed, 0); + assert_eq!(gc2.images_pruned, 0); + assert_eq!(gc2.streams_pruned, 0); + } + + #[tokio::test] + async fn test_remove_boot_image_then_gc_preserves_oci() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + generate_boot_image(repo, &img.manifest_digest).unwrap(); + + remove_boot_image(repo, &img.manifest_digest).unwrap(); + let gc = repo.gc(&[]).unwrap(); + assert_eq!(gc.images_pruned, 1); + + let oci = OciImage::open_ref(repo, "myapp:v1").unwrap(); + assert!(oci.is_container_image()); + assert!(oci.boot_image_ref().is_none()); + } + + #[tokio::test] + async fn test_boot_content_assertion() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("myapp:v1"), 1).await; + + let boot_verity = generate_boot_image(repo, &img.manifest_digest).unwrap(); + + let fs = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); + let boot_entries = get_boot_resources(&fs, repo).unwrap(); + assert_eq!(boot_entries.len(), 2); + assert!(boot_entries.iter().any(|e| matches!( + e, + composefs_boot::bootloader::BootEntry::UsrLibModulesVmLinuz(_) + )),); + assert!(boot_entries + .iter() + .any(|e| matches!(e, composefs_boot::bootloader::BootEntry::Type2(_))),); + + let plain_fs = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); + let plain_verity = plain_fs.commit_image(repo, None).unwrap(); + assert_ne!(boot_verity, plain_verity); + } + + #[tokio::test] + async fn test_boot_content_uki() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_bootable_image(repo, Some("uki:v1"), 1).await; + + let boot_verity = generate_boot_image(repo, &img.manifest_digest).unwrap(); + + let fs = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); + let boot_entries = get_boot_resources(&fs, repo).unwrap(); + assert_eq!(boot_entries.len(), 2); + assert!(boot_entries + .iter() + .any(|e| matches!(e, composefs_boot::bootloader::BootEntry::Type2(_))),); + assert!(boot_entries.iter().any(|e| matches!( + e, + composefs_boot::bootloader::BootEntry::UsrLibModulesVmLinuz(_) + )),); + + let plain_fs = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); + let plain_verity = plain_fs.commit_image(repo, None).unwrap(); + assert_ne!(boot_verity, plain_verity); + } +} diff --git a/crates/composefs-oci/src/image.rs b/crates/composefs-oci/src/image.rs index 7ddc33a4..4744e548 100644 --- a/crates/composefs-oci/src/image.rs +++ b/crates/composefs-oci/src/image.rs @@ -104,7 +104,9 @@ pub fn create_filesystem( ) -> Result> { let mut filesystem = FileSystem::new(Stat::uninitialized()); - let (config, map) = crate::open_config(repo, config_name, config_verity)?; + let oc = crate::open_config(repo, config_name, config_verity)?; + let config = oc.config; + let map = oc.layer_refs; for diff_id in config.rootfs().diff_ids() { let layer_verity = map diff --git a/crates/composefs-oci/src/lib.rs b/crates/composefs-oci/src/lib.rs index 362d49ed..a9bda81f 100644 --- a/crates/composefs-oci/src/lib.rs +++ b/crates/composefs-oci/src/lib.rs @@ -8,21 +8,26 @@ //! - Pulling container images from registries using skopeo //! - Converting OCI image layers from tar format to composefs split streams //! - Creating mountable filesystems from OCI image configurations -//! - Sealing containers with fs-verity hashes for integrity verification - #![forbid(unsafe_code)] +pub mod boot; pub mod image; pub mod oci_image; pub mod skopeo; pub mod tar; +/// Test utilities for building OCI images from dumpfile strings. +#[cfg(any(test, feature = "test"))] +#[allow(missing_docs, missing_debug_implementations)] +#[doc(hidden)] +pub mod test_util; + // Re-export the composefs crate for consumers who only need composefs-oci pub use composefs; use std::{collections::HashMap, sync::Arc}; -use anyhow::{bail, ensure, Context, Result}; +use anyhow::{ensure, Context, Result}; use containers_image_proxy::ImageProxyConfig; use oci_spec::image::ImageConfiguration; use sha2::{Digest, Sha256}; @@ -36,7 +41,16 @@ use composefs::{ use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE}; use crate::tar::get_entry; +/// Named ref key for the EROFS image derived from this OCI config. +pub const IMAGE_REF_KEY: &str = "composefs.image"; + +/// Named ref key for the boot EROFS image derived from this OCI config. +pub const BOOT_IMAGE_REF_KEY: &str = "composefs.image.boot"; + // Re-export key types for convenience +#[cfg(feature = "boot")] +pub use boot::generate_boot_image; +pub use boot::{boot_image, remove_boot_image, BOOT_IMAGE_REF_NAME}; pub use oci_image::{ add_referrer, layer_dumpfile, layer_info, layer_tar, list_images, list_referrers, list_refs, remove_referrer, remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo, @@ -118,6 +132,28 @@ pub struct PullResult { type ContentAndVerity = (String, ObjectID); +/// Parsed OCI config and its associated references. +pub struct OpenConfig { + /// The parsed OCI image configuration. + pub config: ImageConfiguration, + /// Map from layer diff_id to its fs-verity object ID. + pub layer_refs: HashMap, ObjectID>, + /// The EROFS image ObjectID linked to this config, if any. + pub image_ref: Option, + /// The boot EROFS image ObjectID linked to this config, if any. + pub boot_image_ref: Option, +} + +impl std::fmt::Debug for OpenConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenConfig") + .field("layer_refs", &self.layer_refs) + .field("image_ref", &self.image_ref) + .field("boot_image_ref", &self.boot_image_ref) + .finish_non_exhaustive() + } +} + fn layer_identifier(diff_id: &str) -> String { format!("oci-layer-{diff_id}") } @@ -189,7 +225,7 @@ pub async fn pull( ) -> Result> { let (config_digest, config_verity, stats) = skopeo::pull(repo, imgref, reference, img_proxy_config).await?; - Ok(PullResult { + Ok(crate::PullResult { config_digest, config_verity, stats, @@ -204,14 +240,16 @@ fn hash(bytes: &[u8]) -> String { /// Opens and parses a container configuration. /// -/// Reads the OCI image configuration from the repository and returns both the parsed -/// configuration and a digest map containing fs-verity hashes for all referenced layers. +/// Reads the OCI image configuration from the repository and returns an [`OpenConfig`] +/// containing the parsed configuration, a digest map of layer fs-verity hashes, and an +/// optional EROFS image ObjectID if one has been linked to this config. /// /// If verity is provided, it's used directly. Otherwise, the name must be a sha256 digest /// and the corresponding verity hash will be looked up (which is more expensive) and the content /// will be hashed and compared to the provided digest. /// -/// Returns the parsed image configuration and the map of layer references. +/// The returned layer refs map does not contain the [`IMAGE_REF_KEY`] — that is +/// returned separately in [`OpenConfig::image_ref`]. /// /// Note: if the verity value is known and trusted then the layer fs-verity values can also be /// trusted. If not, then you can use the layer map to find objects that are ostensibly the layers @@ -220,8 +258,8 @@ pub fn open_config( repo: &Repository, config_digest: &str, verity: Option<&ObjectID>, -) -> Result<(ImageConfiguration, HashMap, ObjectID>)> { - let (data, named_refs) = oci_image::read_external_splitstream( +) -> Result> { + let (data, mut named_refs) = oci_image::read_external_splitstream( repo, &config_identifier(config_digest), verity, @@ -236,8 +274,58 @@ pub fn open_config( ); } + let image_ref = named_refs.remove(IMAGE_REF_KEY); + let boot_image_ref = named_refs.remove(BOOT_IMAGE_REF_KEY); let config = ImageConfiguration::from_reader(&data[..])?; - Ok((config, named_refs)) + Ok(OpenConfig { + config, + layer_refs: named_refs, + image_ref, + boot_image_ref, + }) +} + +/// Returns the composefs EROFS ObjectID referenced by the given OCI config, if any. +pub fn composefs_erofs_for_config( + repo: &Repository, + config_digest: &str, + verity: Option<&ObjectID>, +) -> Result> { + let oc = open_config(repo, config_digest, verity)?; + Ok(oc.image_ref) +} + +/// Returns the composefs EROFS ObjectID for an OCI image identified by manifest, if any. +/// +/// This opens the manifest to find the config, then reads the config's +/// [`IMAGE_REF_KEY`] named ref. +pub fn composefs_erofs_for_manifest( + repo: &Repository, + manifest_digest: &str, + manifest_verity: Option<&ObjectID>, +) -> Result> { + let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?; + Ok(img.image_ref().cloned()) +} + +/// Returns the boot EROFS ObjectID from the given OCI config, if any. +pub fn composefs_boot_erofs_for_config( + repo: &Repository, + config_digest: &str, + verity: Option<&ObjectID>, +) -> Result> { + let oc = open_config(repo, config_digest, verity)?; + Ok(oc.boot_image_ref) +} + +/// Returns the boot EROFS ObjectID for an OCI image identified by manifest, if any. +pub fn composefs_boot_erofs_for_manifest( + repo: &Repository, + manifest_digest: &str, + manifest_verity: Option<&ObjectID>, +) -> Result> { + let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?; + Ok(img.boot_image_ref().cloned()) } /// Writes a container configuration to the repository. @@ -246,11 +334,20 @@ pub fn open_config( /// provided layer reference map. The configuration is stored as an external object so /// fsverity can be independently enabled on it. /// +/// If `image` is provided, a named ref with key [`IMAGE_REF_KEY`] is added to the +/// splitstream pointing to the EROFS image's ObjectID. This ensures the GC walk keeps +/// the EROFS image alive as long as the config is reachable. +/// +/// If `boot_image` is provided, a named ref with key [`BOOT_IMAGE_REF_KEY`] is added +/// pointing to the boot EROFS image's ObjectID. +/// /// Returns a tuple of (sha256 content hash, fs-verity hash value). pub fn write_config( repo: &Arc>, config: &ImageConfiguration, refs: HashMap, ObjectID>, + image: Option<&ObjectID>, + boot_image: Option<&ObjectID>, ) -> Result> { let json = config.to_string()?; let json_bytes = json.as_bytes(); @@ -259,51 +356,140 @@ pub fn write_config( for (name, value) in &refs { stream.add_named_stream_ref(name, value) } + if let Some(image_id) = image { + stream.add_named_stream_ref(IMAGE_REF_KEY, image_id); + } + if let Some(boot_id) = boot_image { + stream.add_named_stream_ref(BOOT_IMAGE_REF_KEY, boot_id); + } stream.write_external(json_bytes)?; let id = repo.write_stream(stream, &config_identifier(&config_digest), None)?; Ok((config_digest, id)) } -/// Seals a container by computing its filesystem fs-verity hash and adding it to the config. +/// Ensures a composefs EROFS image exists for the given OCI container image, +/// linking it to the config splitstream so GC keeps it alive through the tag chain. /// -/// Creates the complete filesystem from all layers, computes its fs-verity hash, and stores -/// this hash in the container config labels under "containers.composefs.fsverity". This allows -/// the container to be mounted with integrity protection. +/// This performs the following steps: +/// 1. Opens the manifest and config to get the image configuration +/// 2. Creates a composefs `FileSystem` from the OCI layers +/// 3. Commits the filesystem as an EROFS image to the repository +/// 4. Rewrites the config splitstream with an [`IMAGE_REF_KEY`] named ref +/// pointing to the EROFS image's ObjectID +/// 5. Rewrites the manifest splitstream with the updated config verity +/// 6. If `tag` is provided, updates the tag to point to the new manifest /// -/// Returns a tuple of (sha256 content hash, fs-verity hash value) for the updated configuration. -pub fn seal( +/// Calling this multiple times is safe — a new EROFS image is generated each +/// time (though usually identical via object dedup) and the config+manifest +/// splitstreams are rewritten. The old splitstream objects become unreferenced +/// and are collected by the next GC. +/// +/// Returns the EROFS image's ObjectID (fs-verity digest). +fn ensure_oci_composefs_erofs( repo: &Arc>, - config_name: &str, - config_verity: Option<&ObjectID>, -) -> Result> { - let (mut config, refs) = open_config(repo, config_name, config_verity)?; - let mut myconfig = config.config().clone().context("no config!")?; - let labels = myconfig.labels_mut().get_or_insert_with(HashMap::new); - let fs = crate::image::create_filesystem(repo, config_name, config_verity)?; - let id = fs.compute_image_id(); - labels.insert("containers.composefs.fsverity".to_string(), id.to_hex()); - config.set_config(Some(myconfig)); - write_config(repo, &config, refs) + manifest_digest: &str, + manifest_verity: Option<&ObjectID>, + tag: Option<&str>, +) -> Result> { + let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?; + if !img.is_container_image() { + return Ok(None); + } + + let config = img + .config() + .context("Container image missing config")? + .clone(); + + // Build the composefs filesystem from all layers + let fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?; + + // Commit as EROFS image (no name — the GC link comes from the config ref) + let erofs_id = fs.commit_image(repo, None)?; + + // Rewrite config with the EROFS image ref, using layer refs from the + // OciImage (which already stripped the old image ref if any). + // Preserve any existing boot image ref. + let (_config_digest, new_config_verity) = write_config( + repo, + &config, + img.layer_refs().clone(), + Some(&erofs_id), + img.boot_image_ref(), + )?; + + // Read original manifest JSON for rewriting + let manifest_json = img.read_manifest_json(repo)?; + + // Rewrite manifest with updated config verity, preserving layer verities. + // The layer_refs from OciImage are the same as the manifest's layer refs + // (both ultimately come from the config's diff_id → verity map). + let layer_verities = img.layer_refs().clone(); + + let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest( + repo, + &manifest_json, + manifest_digest, + &new_config_verity, + &layer_verities, + tag, + )?; + + Ok(Some(erofs_id)) } -/// Mounts a sealed container filesystem at the specified mountpoint. -/// -/// Reads the container configuration to extract the fs-verity hash from the -/// "containers.composefs.fsverity" label, then mounts the corresponding filesystem. -/// The container must have been previously sealed using `seal()`. -/// -/// Returns an error if the container is not sealed or if mounting fails. -pub fn mount( - repo: &Repository, - name: &str, - mountpoint: &str, - verity: Option<&ObjectID>, -) -> Result<()> { - let (config, _map) = open_config(repo, name, verity)?; - let Some(id) = config.get_config_annotation("containers.composefs.fsverity") else { - bail!("Can only mount sealed containers"); - }; - repo.mount_at(id, mountpoint) +/// Boot-variant counterpart to [`ensure_oci_composefs_erofs`]; applies +/// `transform_for_boot` before committing. +#[cfg(feature = "boot")] +fn ensure_oci_composefs_erofs_boot( + repo: &Arc>, + manifest_digest: &str, + manifest_verity: Option<&ObjectID>, + tag: Option<&str>, +) -> Result> { + use composefs_boot::BootOps; + + let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?; + if !img.is_container_image() { + return Ok(None); + } + + let config = img + .config() + .context("Container image missing config")? + .clone(); + + // Build the composefs filesystem from all layers, then transform for boot + let mut fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?; + fs.transform_for_boot(repo)?; + + // Commit as EROFS image + let boot_erofs_id = fs.commit_image(repo, None)?; + + // Rewrite config with the boot EROFS image ref, preserving the existing image ref + let (_config_digest, new_config_verity) = write_config( + repo, + &config, + img.layer_refs().clone(), + img.image_ref(), + Some(&boot_erofs_id), + )?; + + // Read original manifest JSON for rewriting + let manifest_json = img.read_manifest_json(repo)?; + + let layer_verities = img.layer_refs().clone(); + + let (_new_manifest_digest, _new_manifest_verity) = oci_image::rewrite_manifest( + repo, + &manifest_json, + manifest_digest, + &new_config_verity, + &layer_verities, + tag, + )?; + + Ok(Some(boot_erofs_id)) } #[cfg(test)] @@ -387,19 +573,21 @@ mod test { let mut refs = HashMap::new(); refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY); - let (config_digest, config_verity) = write_config(&repo, &config, refs.clone()).unwrap(); + let (config_digest, config_verity) = + write_config(&repo, &config, refs.clone(), None, None).unwrap(); assert!(config_digest.starts_with("sha256:")); - let (opened_config, opened_refs) = - open_config(&repo, &config_digest, Some(&config_verity)).unwrap(); - assert_eq!(opened_config.architecture().to_string(), "amd64"); - assert_eq!(opened_config.os().to_string(), "linux"); - assert_eq!(opened_refs.len(), 1); - assert!(opened_refs.contains_key("sha256:abc123def456")); + let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + assert_eq!(oc.config.architecture().to_string(), "amd64"); + assert_eq!(oc.config.os().to_string(), "linux"); + assert_eq!(oc.layer_refs.len(), 1); + assert!(oc.layer_refs.contains_key("sha256:abc123def456")); + assert!(oc.image_ref.is_none()); + assert!(oc.boot_image_ref.is_none()); - let (opened_config2, _) = open_config(&repo, &config_digest, None).unwrap(); - assert_eq!(opened_config2.architecture().to_string(), "amd64"); + let oc2 = open_config(&repo, &config_digest, None).unwrap(); + assert_eq!(oc2.config.architecture().to_string(), "amd64"); } #[test] @@ -422,7 +610,8 @@ mod test { .build() .unwrap(); - let (config_digest, config_verity) = write_config(&repo, &config, HashMap::new()).unwrap(); + let (config_digest, config_verity) = + write_config(&repo, &config, HashMap::new(), None, None).unwrap(); // Re-open the splitstream and check that the config JSON is stored // as an external object reference (not inline). This is important @@ -481,7 +670,8 @@ mod test { .build() .unwrap(); - let (config_digest, _config_verity) = write_config(&repo, &config, HashMap::new()).unwrap(); + let (config_digest, _config_verity) = + write_config(&repo, &config, HashMap::new(), None, None).unwrap(); let bad_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; let result = open_config::(&repo, bad_digest, None); @@ -491,6 +681,241 @@ mod test { assert!(result.is_ok()); } + #[test] + fn test_config_with_image_ref() { + use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; + + let repo_dir = tempdir(); + let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(vec!["sha256:abc123def456".to_string()]) + .build() + .unwrap(); + + let config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .build() + .unwrap(); + + let mut refs = HashMap::new(); + let layer_id = Sha256HashValue::EMPTY; + refs.insert("sha256:abc123def456".into(), layer_id); + + // Use a fake EROFS image ID + let fake_erofs_id: Sha256HashValue = + composefs::fsverity::compute_verity(b"fake-erofs-image"); + + let (config_digest, config_verity) = + write_config(&repo, &config, refs.clone(), Some(&fake_erofs_id), None).unwrap(); + + // Reopen and verify + let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + assert_eq!( + oc.layer_refs.len(), + 1, + "layer refs should not include image ref" + ); + assert!(oc.layer_refs.contains_key("sha256:abc123def456")); + assert_eq!( + oc.image_ref, + Some(fake_erofs_id.clone()), + "image ref should be returned" + ); + + // Also verify via the convenience function + let img_ref = + composefs_erofs_for_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + assert_eq!(img_ref, Some(fake_erofs_id)); + } + + #[test] + fn test_config_without_image_ref() { + use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; + + let repo_dir = tempdir(); + let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(vec!["sha256:abc123def456".to_string()]) + .build() + .unwrap(); + + let config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .build() + .unwrap(); + + let mut refs = HashMap::new(); + refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY); + + let (config_digest, config_verity) = + write_config(&repo, &config, refs.clone(), None, None).unwrap(); + + let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + assert_eq!(oc.layer_refs.len(), 1); + assert!(oc.layer_refs.contains_key("sha256:abc123def456")); + assert!(oc.image_ref.is_none(), "no image ref should be present"); + + let img_ref = + composefs_erofs_for_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + assert!(img_ref.is_none()); + } + + #[tokio::test] + async fn test_ensure_oci_composefs_erofs() { + use composefs::test::TestRepo; + + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_base_image(repo, Some("test:v1")).await; + + // Create the EROFS image and link it to the config + let erofs_id = ensure_oci_composefs_erofs( + repo, + &img.manifest_digest, + Some(&img.manifest_verity), + Some("test:v1"), + ) + .unwrap() + .expect("container image should produce EROFS"); + + // The EROFS image should exist in the repository + assert!( + repo.open_image(&erofs_id.to_hex()).is_ok(), + "EROFS image should be accessible" + ); + + // The manifest+config were rewritten with the EROFS ref + let oci = oci_image::OciImage::open_ref(repo, "test:v1").unwrap(); + assert_ne!( + oci.manifest_verity(), + &img.manifest_verity, + "manifest should have been rewritten with new config verity" + ); + assert_eq!( + oci.image_ref(), + Some(&erofs_id), + "config should reference the EROFS image" + ); + // Also verify via the convenience functions + let erofs_ref = + composefs_erofs_for_config(repo, oci.config_digest(), Some(oci.config_verity())) + .unwrap(); + assert_eq!(erofs_ref, Some(erofs_id.clone())); + + let erofs_ref2 = + composefs_erofs_for_manifest(repo, &img.manifest_digest, Some(oci.manifest_verity())) + .unwrap(); + assert_eq!(erofs_ref2, Some(erofs_id.clone())); + + // Verify the EROFS content by round-tripping through erofs_to_filesystem + let erofs_data = repo.read_object(&erofs_id).unwrap(); + let fs = + composefs::erofs::reader::erofs_to_filesystem::(&erofs_data).unwrap(); + let mut dump = Vec::new(); + composefs::dumpfile::write_dumpfile(&mut dump, &fs).unwrap(); + let dump = String::from_utf8(dump).unwrap(); + similar_asserts::assert_eq!( + dump, + "\ +/ 0 40755 6 0 0 0 0.0 - - - +/etc 0 40755 2 0 0 0 0.0 - - - +/etc/hostname 9 100644 1 0 0 0 0.0 - testhost\\x0a - +/etc/os-release 23 100644 1 0 0 0 0.0 - ID\\x3dtest\\x0aVERSION_ID\\x3d1.0\\x0a - +/etc/passwd 34 100644 1 0 0 0 0.0 - root:x:0:0:root:/root:/usr/bin/sh\\x0a - +/tmp 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 5 0 0 0 0.0 - - - +/usr/bin 0 40755 2 0 0 0 0.0 - - - +/usr/bin/busybox 22 100755 1 0 0 0 0.0 - busybox-binary-content - +/usr/bin/cat 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/cp 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/ls 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/mv 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/myapp 25 100755 1 0 0 0 0.0 - #!/usr/bin/sh\\x0aecho\\x20hello\\x0a - +/usr/bin/rm 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/sh 7 120777 1 0 0 0 0.0 busybox - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/share 0 40755 3 0 0 0 0.0 - - - +/usr/share/myapp 0 40755 2 0 0 0 0.0 - - - +/usr/share/myapp/data.txt 16 100644 1 0 0 0 0.0 - application-data - +/var 0 40755 2 0 0 0 0.0 - - - +" + ); + } + + #[tokio::test] + async fn test_ensure_oci_composefs_erofs_gc() { + use composefs::test::TestRepo; + + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = test_util::create_base_image(repo, Some("gctest:v1")).await; + + // After pull, nothing is garbage + let dry = repo.gc_dry_run(&[]).unwrap(); + assert_eq!(dry.objects_removed, 0); + assert_eq!(dry.streams_pruned, 0); + assert_eq!(dry.images_pruned, 0); + + let erofs_id = ensure_oci_composefs_erofs( + repo, + &img.manifest_digest, + Some(&img.manifest_verity), + Some("gctest:v1"), + ) + .unwrap() + .expect("container image should produce EROFS"); + + // ensure_oci_composefs_erofs rewrites config+manifest, leaving 2 old splitstream + // objects unreferenced (the original config and manifest splitstreams) + let gc1 = repo.gc(&[]).unwrap(); + assert_eq!( + gc1.objects_removed, 2, + "old config+manifest splitstream objects" + ); + assert_eq!(gc1.streams_pruned, 0); + assert_eq!(gc1.images_pruned, 0); + + // After GC, everything is clean — EROFS survives via config ref + let dry = repo.gc_dry_run(&[]).unwrap(); + assert_eq!(dry.objects_removed, 0); + assert!( + repo.open_image(&erofs_id.to_hex()).is_ok(), + "EROFS image should survive GC while tagged" + ); + + // Untag and GC — everything gets collected + oci_image::untag_image(repo, "gctest:v1").unwrap(); + let gc2 = repo.gc(&[]).unwrap(); + // 10 objects: 5 layer splitstreams + config JSON + manifest JSON + // + EROFS image + new config splitstream + new manifest splitstream + assert_eq!(gc2.objects_removed, 10, "all objects collected after untag"); + // 7 streams: 5 layers + 1 config + 1 manifest (tag ref removed by untag) + assert_eq!(gc2.streams_pruned, 7, "all stream symlinks pruned"); + // 1 image: the EROFS symlink under images/ + assert_eq!(gc2.images_pruned, 1, "EROFS image symlink pruned"); + + assert!( + repo.open_image(&erofs_id.to_hex()).is_err(), + "EROFS image should be collected after untag + GC" + ); + + // Repo is completely empty now + let dry = repo.gc_dry_run(&[]).unwrap(); + assert_eq!(dry.objects_removed, 0); + assert_eq!(dry.streams_pruned, 0); + assert_eq!(dry.images_pruned, 0); + } + #[test] fn test_import_stats_display() { let stats = ImportStats { diff --git a/crates/composefs-oci/src/oci_image.rs b/crates/composefs-oci/src/oci_image.rs index aa125d07..ef3fca13 100644 --- a/crates/composefs-oci/src/oci_image.rs +++ b/crates/composefs-oci/src/oci_image.rs @@ -106,6 +106,10 @@ pub struct OciImage { config: Option, /// Map from layer diff_id to its fs-verity object ID layer_refs: HashMap, ObjectID>, + /// The EROFS image ObjectID linked to this config, if any + image_ref: Option, + /// The boot EROFS image ObjectID linked to this config, if any + boot_image_ref: Option, /// The fs-verity ID of the manifest splitstream manifest_verity: ObjectID, } @@ -151,7 +155,7 @@ impl OciImage { )?; // Try to parse as ImageConfiguration, but don't fail for artifacts - let (config, layer_refs) = match manifest.config().media_type() { + let (config, mut layer_refs) = match manifest.config().media_type() { MediaType::ImageConfig => { let config = ImageConfiguration::from_reader(&config_data[..])?; (Some(config), config_named_refs) @@ -174,6 +178,10 @@ impl OciImage { } }; + // Strip the EROFS image ref from layer_refs (it's not a layer) + let image_ref = layer_refs.remove(crate::IMAGE_REF_KEY); + let boot_image_ref = layer_refs.remove(crate::BOOT_IMAGE_REF_KEY); + let manifest_verity = if let Some(v) = verity { v.clone() } else { @@ -188,6 +196,8 @@ impl OciImage { config_verity, config, layer_refs, + image_ref, + boot_image_ref, manifest_verity, }) } @@ -223,11 +233,31 @@ impl OciImage { &self.config_digest } + /// Returns the config fs-verity hash. + pub fn config_verity(&self) -> &ObjectID { + &self.config_verity + } + /// Returns the OCI config, if this is a container image. pub fn config(&self) -> Option<&ImageConfiguration> { self.config.as_ref() } + /// Returns the layer refs map (diff_id → fs-verity ObjectID). + pub fn layer_refs(&self) -> &HashMap, ObjectID> { + &self.layer_refs + } + + /// Returns the EROFS image ObjectID linked to this config, if any. + pub fn image_ref(&self) -> Option<&ObjectID> { + self.image_ref.as_ref() + } + + /// Returns the boot EROFS image ObjectID linked to this config, if any. + pub fn boot_image_ref(&self) -> Option<&ObjectID> { + self.boot_image_ref.as_ref() + } + /// Returns the image architecture (empty string for artifacts). pub fn architecture(&self) -> String { self.config @@ -249,18 +279,6 @@ impl OciImage { self.config.as_ref().and_then(|c| c.created().as_deref()) } - /// Returns the composefs seal digest, if sealed. - pub fn seal_digest(&self) -> Option<&str> { - self.config - .as_ref() - .and_then(|c| c.get_config_annotation("containers.composefs.fsverity")) - } - - /// Returns whether this image has been sealed. - pub fn is_sealed(&self) -> bool { - self.seal_digest().is_some() - } - /// Opens an artifact layer's backing object by index, returning a /// read-only file descriptor to the raw blob data. /// @@ -387,11 +405,21 @@ impl OciImage { .map(|(digest, _verity)| serde_json::json!({ "digest": digest })) .collect(); - Ok(serde_json::json!({ + let mut result = serde_json::json!({ "manifest": manifest_value, "config": config_value, "referrers": referrers_value, - })) + }); + + if let Some(ref erofs_id) = self.image_ref { + result["composefs_erofs"] = serde_json::json!(erofs_id.to_hex()); + } + + if let Some(ref boot_id) = self.boot_image_ref { + result["composefs_boot_erofs"] = serde_json::json!(boot_id.to_hex()); + } + + Ok(result) } } @@ -499,8 +527,6 @@ pub struct ImageInfo { pub os: String, /// Creation timestamp pub created: Option, - /// Whether sealed with composefs - pub sealed: bool, /// Number of layers/blobs pub layer_count: usize, /// Number of OCI referrers (signatures, attestations, etc.) @@ -524,7 +550,6 @@ pub fn list_images( architecture: img.architecture(), os: img.os(), created: img.created().map(String::from), - sealed: img.is_sealed(), layer_count: img.layer_descriptors().len(), referrer_count, }); @@ -594,6 +619,46 @@ pub fn write_manifest( Ok((manifest_digest.to_string(), id)) } +/// Rewrites a manifest splitstream with updated named refs. +/// +/// Unlike [`write_manifest`], this always writes the splitstream even if the +/// content identifier already exists. This is needed when the manifest JSON +/// hasn't changed but the config splitstream's verity has (e.g., because an +/// EROFS image ref was added to the config). +/// +/// If `reference` is provided, the manifest is also tagged with that name. +pub(crate) fn rewrite_manifest( + repo: &Arc>, + manifest_json: &[u8], + manifest_digest: &str, + config_verity: &ObjectID, + layer_verities: &HashMap, ObjectID>, + reference: Option<&str>, +) -> Result<(String, ObjectID)> { + let content_id = manifest_identifier(manifest_digest); + + let config_digest = { + let manifest = ImageManifest::from_reader(manifest_json)?; + manifest.config().digest().to_string() + }; + + let mut stream = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE); + + let config_key = format!("config:{config_digest}"); + stream.add_named_stream_ref(&config_key, config_verity); + + for (diff_id, verity) in layer_verities { + stream.add_named_stream_ref(diff_id, verity); + } + + stream.write_external(manifest_json)?; + + let oci_ref = reference.map(oci_ref_path); + let id = repo.write_stream(stream, &content_id, oci_ref.as_deref())?; + + Ok((manifest_digest.to_string(), id)) +} + /// Checks if a manifest exists. pub fn has_manifest( repo: &Repository, diff --git a/crates/composefs-oci/src/skopeo.rs b/crates/composefs-oci/src/skopeo.rs index e0c44e35..4523cbb3 100644 --- a/crates/composefs-oci/src/skopeo.rs +++ b/crates/composefs-oci/src/skopeo.rs @@ -465,9 +465,22 @@ pub async fn pull_image( .await .with_context(|| format!("Unable to pull container image {imgref}"))?; - if let Some(name) = reference { - tag_image(repo, &result.manifest_digest, name)?; + // Generate the composefs EROFS image and link it to the config splitstream. + // For container images this rewrites the config+manifest with the EROFS ref + // and tags the final manifest. Artifacts are skipped and tagged as-is. + let erofs = crate::ensure_oci_composefs_erofs( + repo, + &result.manifest_digest, + Some(&result.manifest_verity), + reference, + )?; + if erofs.is_none() { + // Not a container image (artifact) — tag the manifest directly + if let Some(name) = reference { + tag_image(repo, &result.manifest_digest, name)?; + } } + Ok((result, stats)) } diff --git a/crates/composefs-oci/src/test_util.rs b/crates/composefs-oci/src/test_util.rs new file mode 100644 index 00000000..6c811d71 --- /dev/null +++ b/crates/composefs-oci/src/test_util.rs @@ -0,0 +1,762 @@ +/// Shared test utilities for composefs-oci. +/// +/// Provides helpers to build multi-layer OCI images from composefs dumpfile +/// strings, so that `transform_for_boot` actually extracts boot entries and +/// produces a filesystem different from the raw OCI one. +/// +/// Each layer is a `&str` in standard composefs dumpfile format: +/// +/// ```text +/// /path size mode nlink uid gid rdev mtime payload content digest +/// ``` +/// +/// For example: +/// +/// ```text +/// /usr/bin 0 40755 2 0 0 0 0.0 - - - +/// /usr/bin/hello 5 100644 1 0 0 0 0.0 - world - +/// /usr/bin/sh 0 120777 1 0 0 0 0.0 busybox - - +/// ``` +use std::collections::HashMap; +use std::io::Read as _; +use std::sync::Arc; + +use composefs::dumpfile_parse::{Entry, Item}; +use composefs::fsverity::Sha256HashValue; +use composefs::repository::Repository; +use containers_image_proxy::oci_spec::image::{ + ConfigBuilder, DescriptorBuilder, Digest as OciDigest, ImageConfigurationBuilder, + ImageManifestBuilder, MediaType, RootFsBuilder, +}; +use rustix::fs::FileType; +use sha2::{Digest, Sha256}; +use std::str::FromStr; + +use crate::oci_image::write_manifest; +use crate::skopeo::OCI_CONFIG_CONTENT_TYPE; + +fn hash(bytes: &[u8]) -> String { + let mut context = Sha256::new(); + context.update(bytes); + format!("sha256:{}", hex::encode(context.finalize())) +} + +/// Convert composefs dumpfile lines into tar bytes. +/// +/// Parses each line as a composefs [`Entry`] and builds the corresponding +/// tar entry. The root directory (`/`) is skipped since tar archives don't +/// include it. Only regular files (inline), directories, and symlinks are +/// supported — this is sufficient for test images. +fn dumpfile_to_tar(dumpfile: &str) -> Vec { + let mut builder = ::tar::Builder::new(vec![]); + + for line in dumpfile.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let entry = + Entry::parse(line).unwrap_or_else(|e| panic!("bad dumpfile line {line:?}: {e}")); + + // Skip the root directory — tar doesn't need it + if entry.path.as_ref() == std::path::Path::new("/") { + continue; + } + + // Strip leading / for tar paths + let path = entry + .path + .to_str() + .expect("non-UTF8 path") + .trim_start_matches('/'); + + let ty = FileType::from_raw_mode(entry.mode); + match ty { + FileType::Directory => { + let mut header = ::tar::Header::new_ustar(); + header.set_uid(entry.uid.into()); + header.set_gid(entry.gid.into()); + header.set_mode(entry.mode & 0o7777); + header.set_entry_type(::tar::EntryType::Directory); + header.set_size(0); + builder + .append_data(&mut header, path, std::io::empty()) + .unwrap(); + } + FileType::RegularFile => match &entry.item { + Item::RegularInline { content, .. } => { + let mut header = ::tar::Header::new_ustar(); + header.set_uid(entry.uid.into()); + header.set_gid(entry.gid.into()); + header.set_mode(entry.mode & 0o7777); + header.set_entry_type(::tar::EntryType::Regular); + header.set_size(content.len() as u64); + builder + .append_data(&mut header, path, &content[..]) + .unwrap(); + } + Item::Regular { size, .. } => { + // External file with no inline content — create sized entry + let mut header = ::tar::Header::new_ustar(); + header.set_uid(entry.uid.into()); + header.set_gid(entry.gid.into()); + header.set_mode(entry.mode & 0o7777); + header.set_entry_type(::tar::EntryType::Regular); + header.set_size(*size); + builder + .append_data(&mut header, path, std::io::repeat(0u8).take(*size)) + .unwrap(); + } + other => panic!("unexpected regular file item variant: {other:?}"), + }, + FileType::Symlink => { + let target = match &entry.item { + Item::Symlink { target, .. } => target, + other => panic!("expected Symlink item, got {other:?}"), + }; + let mut header = ::tar::Header::new_ustar(); + header.set_uid(entry.uid.into()); + header.set_gid(entry.gid.into()); + header.set_mode(entry.mode & 0o7777); + header.set_entry_type(::tar::EntryType::Symlink); + header.set_size(0); + header + .set_link_name(target.as_ref()) + .expect("failed to set symlink target"); + builder + .append_data(&mut header, path, std::io::empty()) + .unwrap(); + } + other => panic!("unsupported file type in test dumpfile: {other:?}"), + } + } + + builder.into_inner().unwrap() +} + +/// Return value from image creation helpers. +#[allow(dead_code)] +pub struct TestImage { + pub manifest_digest: String, + pub manifest_verity: Sha256HashValue, + pub config_digest: String, +} + +/// Create an OCI image from multiple layers, each described in composefs +/// dumpfile format. +/// +/// For each layer: parses the dumpfile, builds tar bytes, imports via +/// [`import_layer`](crate::import_layer), then assembles a proper OCI +/// config and manifest referencing all layers in order. +async fn create_multi_layer_image( + repo: &Arc>, + tag: Option<&str>, + layers: &[&str], +) -> TestImage { + let mut layer_digests = Vec::new(); + let mut layer_verities_map: HashMap, Sha256HashValue> = HashMap::new(); + let mut layer_descriptors = Vec::new(); + + for dumpfile in layers { + let tar_data = dumpfile_to_tar(dumpfile); + let digest = hash(&tar_data); + + let (verity, _stats) = crate::import_layer(repo, &digest, None, &tar_data[..]) + .await + .unwrap(); + + let descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageLayerGzip) + .digest(OciDigest::from_str(&digest).unwrap()) + .size(tar_data.len() as u64) + .build() + .unwrap(); + + layer_verities_map.insert(digest.clone().into_boxed_str(), verity); + layer_digests.push(digest); + layer_descriptors.push(descriptor); + } + + // Build OCI config + let rootfs = RootFsBuilder::default() + .typ("layers") + .diff_ids(layer_digests.clone()) + .build() + .unwrap(); + + let cfg = ConfigBuilder::default().build().unwrap(); + + let config = ImageConfigurationBuilder::default() + .architecture("amd64") + .os("linux") + .rootfs(rootfs) + .config(cfg) + .build() + .unwrap(); + + let config_json = config.to_string().unwrap(); + let config_digest = hash(config_json.as_bytes()); + + let mut config_stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE); + for (digest, verity) in &layer_verities_map { + config_stream.add_named_stream_ref(digest, verity); + } + config_stream + .write_external(config_json.as_bytes()) + .unwrap(); + let config_verity = repo + .write_stream( + config_stream, + &crate::config_identifier(&config_digest), + None, + ) + .unwrap(); + + // Build OCI manifest + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageConfig) + .digest(OciDigest::from_str(&config_digest).unwrap()) + .size(config_json.len() as u64) + .build() + .unwrap(); + + let manifest = ImageManifestBuilder::default() + .schema_version(2u32) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(layer_descriptors) + .build() + .unwrap(); + + let manifest_json = manifest.to_string().unwrap(); + let manifest_digest = hash(manifest_json.as_bytes()); + + let (_stored_digest, manifest_verity) = write_manifest( + repo, + &manifest, + &manifest_digest, + &config_verity, + &layer_verities_map, + tag, + ) + .unwrap(); + + TestImage { + manifest_digest, + manifest_verity, + config_digest, + } +} + +// --------------------------------------------------------------------------- +// Layer definitions in composefs dumpfile format +// +// Format: /path size mode nlink uid gid rdev mtime payload content digest +// +// Directories: /path 0 40755 2 0 0 0 0.0 - - - +// Inline files: /path 100644 1 0 0 0 0.0 - - +// Executables: /path 100755 1 0 0 0 0.0 - - +// Symlinks: /path 120777 1 0 0 0 0.0 - - +// --------------------------------------------------------------------------- + +const LAYER_ROOT_STRUCTURE: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/bin 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/share 0 40755 2 0 0 0 0.0 - - - +/etc 0 40755 2 0 0 0 0.0 - - - +/var 0 40755 2 0 0 0 0.0 - - - +/tmp 0 40755 2 0 0 0 0.0 - - - +"; + +const LAYER_BUSYBOX: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/bin 0 40755 2 0 0 0 0.0 - - - +/usr/bin/busybox 22 100755 1 0 0 0 0.0 - busybox-binary-content - +/usr/bin/sh 7 120777 1 0 0 0 0.0 busybox - - +"; + +const LAYER_CORE_UTILS: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/bin 0 40755 2 0 0 0 0.0 - - - +/usr/bin/ls 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/cat 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/cp 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/mv 7 120777 1 0 0 0 0.0 busybox - - +/usr/bin/rm 7 120777 1 0 0 0 0.0 busybox - - +"; + +const LAYER_CONFIG: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/etc 0 40755 2 0 0 0 0.0 - - - +/etc/os-release 26 100644 1 0 0 0 0.0 - ID=test\\nVERSION_ID=1.0\\n - +/etc/hostname 9 100644 1 0 0 0 0.0 - testhost\\n - +/etc/passwd 36 100644 1 0 0 0 0.0 - root:x:0:0:root:/root:/usr/bin/sh\\n - +"; + +const LAYER_APP: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/share 0 40755 2 0 0 0 0.0 - - - +/usr/share/myapp 0 40755 2 0 0 0 0.0 - - - +/usr/share/myapp/data.txt 16 100644 1 0 0 0 0.0 - application-data - +/usr/bin 0 40755 2 0 0 0 0.0 - - - +/usr/bin/myapp 26 100755 1 0 0 0 0.0 - #!/usr/bin/sh\\necho\\x20hello\\n - +"; + +const LAYER_BOOT_DIRS: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/boot 0 40755 2 0 0 0 0.0 - - - +/boot/EFI 0 40755 2 0 0 0 0.0 - - - +/boot/EFI/Linux 0 40755 2 0 0 0 0.0 - - - +/sysroot 0 40755 2 0 0 0 0.0 - - - +"; + +const LAYER_KERNEL_MODULES_DIR: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.1.0 0 40755 2 0 0 0 0.0 - - - +"; + +// Version-specific boot layers. v1 and v2 share userspace (layers 1-5 +// and 14-20) but ship different kernels, initramfs, modules, and UKIs. +// This exercises shared-object deduplication in the repo and ensures GC +// correctly handles content referenced by multiple images. + +const LAYER_KERNEL_V1: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.1.0 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.1.0/vmlinuz 28 100755 1 0 0 0 0.0 - fake-kernel-6.1.0-image-v1 - +"; + +const LAYER_KERNEL_V2: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.2.0 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.2.0/vmlinuz 28 100755 1 0 0 0 0.0 - fake-kernel-6.2.0-image-v2 - +"; + +const LAYER_INITRAMFS_V1: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.1.0 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.1.0/initramfs.img 24 100644 1 0 0 0 0.0 - fake-initramfs-6.1.0-v1 - +"; + +const LAYER_INITRAMFS_V2: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.2.0 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.2.0/initramfs.img 24 100644 1 0 0 0 0.0 - fake-initramfs-6.2.0-v2 - +"; + +const LAYER_KERNEL_MODULES_V1: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.1.0 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.1.0/modules.dep 14 100644 1 0 0 0 0.0 - kmod-deps-v1\\n - +/usr/lib/modules/6.1.0/modules.alias 16 100644 1 0 0 0 0.0 - kmod-aliases-v1\\n - +"; + +const LAYER_KERNEL_MODULES_V2: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.2.0 0 40755 2 0 0 0 0.0 - - - +/usr/lib/modules/6.2.0/modules.dep 14 100644 1 0 0 0 0.0 - kmod-deps-v2\\n - +/usr/lib/modules/6.2.0/modules.alias 16 100644 1 0 0 0 0.0 - kmod-aliases-v2\\n - +"; + +const LAYER_UKI_V1: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/boot 0 40755 2 0 0 0 0.0 - - - +/boot/EFI 0 40755 2 0 0 0 0.0 - - - +/boot/EFI/Linux 0 40755 2 0 0 0 0.0 - - - +/boot/EFI/Linux/test-6.1.0.efi 21 100755 1 0 0 0 0.0 - MZ-fake-uki-6.1.0-v1 - +"; + +const LAYER_UKI_V2: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/boot 0 40755 2 0 0 0 0.0 - - - +/boot/EFI 0 40755 2 0 0 0 0.0 - - - +/boot/EFI/Linux 0 40755 2 0 0 0 0.0 - - - +/boot/EFI/Linux/test-6.2.0.efi 21 100755 1 0 0 0 0.0 - MZ-fake-uki-6.2.0-v2 - +"; + +const LAYER_SYSTEMD: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/systemd 0 40755 2 0 0 0 0.0 - - - +/usr/lib/systemd/system 0 40755 2 0 0 0 0.0 - - - +/usr/lib/systemd/system/multi-user.target 0 100644 1 0 0 0 0.0 - - - +"; + +const LAYER_SYSROOT_MARKER: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/sysroot 0 40755 2 0 0 0 0.0 - - - +/sysroot/.ostree-root 0 100644 1 0 0 0 0.0 - - - +"; + +const LAYER_LIBS_1: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/libc.so.6 16 100644 1 0 0 0 0.0 - fake-libc-content - +/usr/lib/libm.so.6 16 100644 1 0 0 0 0.0 - fake-libm-content - +"; + +const LAYER_LIBS_2: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/lib 0 40755 2 0 0 0 0.0 - - - +/usr/lib/libpthread.so.0 22 100644 1 0 0 0 0.0 - fake-libpthread-content - +/usr/lib/libdl.so.2 16 100644 1 0 0 0 0.0 - fake-libdl-content - +"; + +const LAYER_LOCALE: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/share 0 40755 2 0 0 0 0.0 - - - +/usr/share/locale 0 40755 2 0 0 0 0.0 - - - +/usr/share/locale/en_US 0 40755 2 0 0 0 0.0 - - - +/usr/share/locale/en_US/LC_MESSAGES 0 40755 2 0 0 0 0.0 - - - +/usr/share/locale/en_US/LC_MESSAGES/messages 11 100644 1 0 0 0 0.0 - fake-locale - +"; + +const LAYER_DOCS: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/share 0 40755 2 0 0 0 0.0 - - - +/usr/share/doc 0 40755 2 0 0 0 0.0 - - - +/usr/share/doc/readme.txt 21 100644 1 0 0 0 0.0 - documentation-content - +"; + +const LAYER_NSS_CONFIG: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/etc 0 40755 2 0 0 0 0.0 - - - +/etc/nsswitch.conf 27 100644 1 0 0 0 0.0 - passwd:files\\ngroup:files\\n - +/etc/resolv.conf 22 100644 1 0 0 0 0.0 - nameserver\\x20127.0.0.53\\n - +"; + +const LAYER_ZONEINFO: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/usr 0 40755 2 0 0 0 0.0 - - - +/usr/share 0 40755 2 0 0 0 0.0 - - - +/usr/share/zoneinfo 0 40755 2 0 0 0 0.0 - - - +/usr/share/zoneinfo/UTC 12 100644 1 0 0 0 0.0 - fake-tz-data - +"; + +const LAYER_VAR_LOG: &str = "\ +/ 0 40755 2 0 0 0 0.0 - - - +/var 0 40755 2 0 0 0 0.0 - - - +/var/log 0 40755 2 0 0 0 0.0 - - - +/var/log/.keepdir 0 100644 1 0 0 0 0.0 - - - +"; + +/// Base image layers: a busybox-like app image (5 layers). +const BASE_LAYERS: &[&str] = &[ + LAYER_ROOT_STRUCTURE, + LAYER_BUSYBOX, + LAYER_CORE_UTILS, + LAYER_CONFIG, + LAYER_APP, +]; + +/// Shared userspace layers used by all bootable image versions. +/// These are identical across v1/v2, so the repo deduplicates them. +const SHARED_SYSTEM_LAYERS: &[&str] = &[ + LAYER_SYSTEMD, + LAYER_SYSROOT_MARKER, + LAYER_LIBS_1, + LAYER_LIBS_2, + LAYER_LOCALE, + LAYER_DOCS, + LAYER_NSS_CONFIG, + LAYER_ZONEINFO, + LAYER_VAR_LOG, +]; + +/// Build the full layer list for a bootable image at the given version. +fn bootable_layers(version: u32) -> Vec<&'static str> { + let (kernel, initramfs, modules, uki) = match version { + 1 => ( + LAYER_KERNEL_V1, + LAYER_INITRAMFS_V1, + LAYER_KERNEL_MODULES_V1, + LAYER_UKI_V1, + ), + 2 => ( + LAYER_KERNEL_V2, + LAYER_INITRAMFS_V2, + LAYER_KERNEL_MODULES_V2, + LAYER_UKI_V2, + ), + _ => panic!("unsupported test image version: {version}"), + }; + + let mut layers = Vec::with_capacity(20); + // Layers 1-5: base userspace (shared across versions) + layers.extend_from_slice(BASE_LAYERS); + // Layers 6-7: boot directory structure (shared) + layers.push(LAYER_BOOT_DIRS); + layers.push(LAYER_KERNEL_MODULES_DIR); + // Layers 8-11: version-specific boot content + layers.push(kernel); + layers.push(initramfs); + layers.push(modules); + layers.push(uki); + // Layers 12-20: shared system content + layers.extend_from_slice(SHARED_SYSTEM_LAYERS); + layers +} + +/// Create a base (non-bootable) test OCI image with 5 layers. +/// +/// Layers contain a busybox-like userspace: root directory structure, busybox +/// binary with shell symlink, core utility symlinks, configuration files, and +/// a small application. +pub async fn create_base_image( + repo: &Arc>, + tag: Option<&str>, +) -> TestImage { + create_multi_layer_image(repo, tag, BASE_LAYERS).await +} + +/// Create a bootable test OCI image with 20 layers. +/// +/// `version` controls the kernel/initramfs/UKI content: +/// - v1: kernel 6.1.0, UKI test-6.1.0.efi +/// - v2: kernel 6.2.0, UKI test-6.2.0.efi +/// +/// Userspace layers (busybox, libs, systemd, configs) are identical across +/// versions — when both v1 and v2 are pulled into the same repo, the shared +/// layers are deduplicated. This exercises GC correctness with content +/// referenced by multiple images. +pub async fn create_bootable_image( + repo: &Arc>, + tag: Option<&str>, + version: u32, +) -> TestImage { + let layers = bootable_layers(version); + create_multi_layer_image(repo, tag, &layers).await +} + +/// Create a base test OCI image in a repository at the given path. +/// +/// This is a convenience wrapper for integration tests that work with repo +/// paths rather than `Repository` handles. Opens the repo, creates the +/// image with `create_base_image`, generates the EROFS, and returns. +pub fn create_test_oci_image(repo_path: &std::path::Path, tag: &str) -> anyhow::Result<()> { + let mut repo = Repository::::open_path(rustix::fs::CWD, repo_path)?; + repo.set_insecure(true); + let repo = Arc::new(repo); + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(create_base_image(&repo, Some(tag))); + ensure_erofs_for_image(&repo, tag)?; + Ok(()) +} + +/// Create a bootable test OCI image in a repository at the given path. +/// +/// Like [`create_test_oci_image`] but builds a 20-layer bootable image +/// (version 1) and generates both the plain EROFS and the boot EROFS. +/// Requires the `boot` feature. +#[cfg(feature = "boot")] +pub fn create_test_bootable_oci_image( + repo_path: &std::path::Path, + tag: &str, +) -> anyhow::Result<()> { + let mut repo = Repository::::open_path(rustix::fs::CWD, repo_path)?; + repo.set_insecure(true); + let repo = Arc::new(repo); + let rt = tokio::runtime::Runtime::new()?; + let img = rt.block_on(create_bootable_image(&repo, Some(tag), 1)); + ensure_erofs_for_image(&repo, tag)?; + crate::boot::generate_boot_image(&repo, &img.manifest_digest)?; + Ok(()) +} + +/// Generate the composefs EROFS for a tagged OCI image and link it to the +/// config splitstream. +/// +/// This is the test-visible wrapper around the crate-internal +/// `ensure_oci_composefs_erofs`. Integration tests that create images via +/// `create_base_image` (which bypasses `pull_image`) need this to populate +/// the EROFS ref before testing `cfsctl oci mount`. +pub fn ensure_erofs_for_image( + repo: &Arc>, + tag: &str, +) -> anyhow::Result { + let oci = crate::oci_image::OciImage::open_ref(repo, tag)?; + let erofs_id = crate::ensure_oci_composefs_erofs( + repo, + oci.manifest_digest(), + Some(oci.manifest_verity()), + Some(tag), + )? + .ok_or_else(|| anyhow::anyhow!("image is not a container image"))?; + Ok(erofs_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use composefs::test::TestRepo; + + #[test] + fn test_dumpfile_to_tar_directory() { + let tar_data = dumpfile_to_tar( + "/ 0 40755 2 0 0 0 0.0 - - -\n\ + /mydir 0 40755 2 0 0 0 0.0 - - -\n", + ); + let mut archive = ::tar::Archive::new(&tar_data[..]); + let entries: Vec<_> = archive + .entries() + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(entries.len(), 1); // root is skipped + assert_eq!(entries[0].path().unwrap().to_str().unwrap(), "mydir"); + assert_eq!( + entries[0].header().entry_type(), + ::tar::EntryType::Directory + ); + assert_eq!(entries[0].header().mode().unwrap(), 0o755); + } + + #[test] + fn test_dumpfile_to_tar_file() { + let tar_data = dumpfile_to_tar( + "/ 0 40755 2 0 0 0 0.0 - - -\n\ + /hello 5 100644 1 0 0 0 0.0 - world -\n", + ); + let mut archive = ::tar::Archive::new(&tar_data[..]); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().unwrap().unwrap(); + assert_eq!(entry.path().unwrap().to_str().unwrap(), "hello"); + assert_eq!(entry.header().entry_type(), ::tar::EntryType::Regular); + assert_eq!(entry.header().mode().unwrap(), 0o644); + let mut content = String::new(); + std::io::Read::read_to_string(&mut entry, &mut content).unwrap(); + assert_eq!(content, "world"); + } + + #[test] + fn test_dumpfile_to_tar_executable() { + let tar_data = dumpfile_to_tar( + "/ 0 40755 2 0 0 0 0.0 - - -\n\ + /bin/app 14 100755 1 0 0 0 0.0 - binary-content -\n", + ); + let mut archive = ::tar::Archive::new(&tar_data[..]); + let entries: Vec<_> = archive + .entries() + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(entries[0].header().mode().unwrap(), 0o755); + } + + #[test] + fn test_dumpfile_to_tar_symlink() { + let tar_data = dumpfile_to_tar( + "/ 0 40755 2 0 0 0 0.0 - - -\n\ + /usr/bin/sh 7 120777 1 0 0 0 0.0 busybox - -\n", + ); + let mut archive = ::tar::Archive::new(&tar_data[..]); + let entries: Vec<_> = archive + .entries() + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].header().entry_type(), ::tar::EntryType::Symlink); + assert_eq!( + entries[0].link_name().unwrap().unwrap().to_str().unwrap(), + "busybox" + ); + } + + #[tokio::test] + async fn test_create_base_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = create_base_image(repo, Some("base:v1")).await; + assert!(img.manifest_digest.starts_with("sha256:")); + assert!(img.config_digest.starts_with("sha256:")); + } + + #[tokio::test] + async fn test_create_bootable_image() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let img = create_bootable_image(repo, Some("boot:v1"), 1).await; + assert!(img.manifest_digest.starts_with("sha256:")); + assert!(img.config_digest.starts_with("sha256:")); + } + + /// v1 and v2 share userspace layers but differ in kernel/UKI. + /// Pulling both into the same repo deduplicates the shared content. + #[tokio::test] + async fn test_versioned_images_share_layers() { + let test_repo = TestRepo::::new(); + let repo = &test_repo.repo; + + let v1 = create_bootable_image(repo, Some("os:v1"), 1).await; + let v2 = create_bootable_image(repo, Some("os:v2"), 2).await; + + // Different manifests (different kernel content) + assert_ne!(v1.manifest_digest, v2.manifest_digest); + // Different configs (different layer digests for kernel layers) + assert_ne!(v1.config_digest, v2.config_digest); + + // Both should be openable + let oci_v1 = crate::oci_image::OciImage::open_ref(repo, "os:v1").unwrap(); + let oci_v2 = crate::oci_image::OciImage::open_ref(repo, "os:v2").unwrap(); + assert!(oci_v1.is_container_image()); + assert!(oci_v2.is_container_image()); + + // Untagging v1 and running GC should collect v1-specific objects + // (its manifest, config, and version-specific layer streams) + // but shared layers must survive for v2. + crate::oci_image::untag_image(repo, "os:v1").unwrap(); + let gc = repo.gc(&[]).unwrap(); + // v1-specific: manifest splitstream + config splitstream + manifest JSON + + // config JSON + 4 version-specific layer splitstreams (kernel, initramfs, + // modules, UKI — each has unique content per version) + assert_eq!(gc.objects_removed, 8, "v1-specific objects collected"); + // 4 v1-specific layer streams + manifest + config = 6 stream symlinks + // (the 16 shared layers are still live via v2) + assert_eq!(gc.streams_pruned, 6, "v1-specific stream symlinks pruned"); + + // v2 should still be fully intact after v1 is GC'd + let oci_v2 = crate::oci_image::OciImage::open_ref(repo, "os:v2").unwrap(); + assert!(oci_v2.is_container_image()); + + // GC again — nothing more should be collected (shared layers are live) + let gc2 = repo.gc(&[]).unwrap(); + assert_eq!(gc2.objects_removed, 0, "no more objects to collect"); + assert_eq!(gc2.streams_pruned, 0, "no more streams to prune"); + assert_eq!(gc2.images_pruned, 0, "no images to prune"); + } +} diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 476a096c..f63aa370 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -15,7 +15,9 @@ path = "src/main.rs" [dependencies] anyhow = "1" cap-std-ext = "4.0" -composefs = { workspace = true } +# Only the test_util module is used — for creating test OCI images. +# All verification must go through the cfsctl CLI. +composefs-oci = { workspace = true, features = ["test", "boot"] } libtest-mimic = "0.8" linkme = "0.3" ocidir = "0.6" diff --git a/crates/integration-tests/src/tests/cli.rs b/crates/integration-tests/src/tests/cli.rs index 5daa8517..fa34a89e 100644 --- a/crates/integration-tests/src/tests/cli.rs +++ b/crates/integration-tests/src/tests/cli.rs @@ -371,7 +371,7 @@ fn test_oci_pull_and_inspect() -> Result<()> { integration_test!(test_oci_pull_and_inspect); fn test_oci_layer_inspect() -> Result<()> { - use composefs::dumpfile_parse::{Entry, Item}; + use composefs_oci::composefs::dumpfile_parse::{Entry, Item}; use std::io::Read; use std::path::Path; diff --git a/crates/integration-tests/src/tests/privileged.rs b/crates/integration-tests/src/tests/privileged.rs index 85b7bfd0..41fce1d3 100644 --- a/crates/integration-tests/src/tests/privileged.rs +++ b/crates/integration-tests/src/tests/privileged.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, ensure, Result}; use xshell::{cmd, Shell}; -use crate::{cfsctl, create_test_rootfs, integration_test}; +use crate::{cfsctl, integration_test}; /// Ensure we're running as root, or re-exec this test inside a VM. /// @@ -125,83 +125,184 @@ fn privileged_repo_without_insecure() -> Result<()> { } integration_test!(privileged_repo_without_insecure); -fn privileged_create_image() -> Result<()> { - if require_privileged("privileged_create_image")?.is_some() { +/// Build a bootable test OCI image, mount it via `cfsctl oci mount` (plain +/// and `--bootable`), and verify the filesystem content differs correctly. +/// The plain mount should contain /boot/EFI/Linux/test-6.1.0.efi (the UKI), +/// while the bootable mount should have an empty /boot (transform_for_boot +/// clears it) but still have /usr content intact. +fn privileged_oci_bootable_mount() -> Result<()> { + if require_privileged("privileged_oci_bootable_mount")?.is_some() { return Ok(()); } let sh = Shell::new()?; let cfsctl = cfsctl()?; let verity_dir = VerityTempDir::new()?; - let repo = verity_dir.path().join("repo"); - let fixture_dir = tempfile::tempdir()?; - let rootfs = create_test_rootfs(fixture_dir.path())?; + let repo_path = verity_dir.path().join("repo"); + let repo_arg = repo_path.to_str().unwrap(); + let hash = "sha256"; + + composefs_oci::test_util::create_test_bootable_oci_image(&repo_path, "boot-test:v1")?; + + let inspect_output = cmd!( + sh, + "{cfsctl} --insecure --hash {hash} --repo {repo_arg} oci inspect boot-test:v1" + ) + .read()?; + let inspect: serde_json::Value = serde_json::from_str(&inspect_output)?; + ensure!( + inspect.get("composefs_erofs").is_some(), + "inspect should show composefs_erofs field" + ); + ensure!( + inspect.get("composefs_boot_erofs").is_some(), + "inspect should show composefs_boot_erofs field" + ); + + // Plain mount: full filesystem including /boot + let mountpoint1 = tempfile::tempdir()?; + let mp1 = mountpoint1.path().to_str().unwrap(); + cmd!( + sh, + "{cfsctl} --insecure --hash {hash} --repo {repo_arg} oci mount boot-test:v1 {mp1}" + ) + .run()?; + + ensure!( + mountpoint1 + .path() + .join("boot/EFI/Linux/test-6.1.0.efi") + .exists(), + "plain mount should contain UKI at /boot/EFI/Linux/test-6.1.0.efi" + ); + + cmd!(sh, "umount {mp1}").run()?; + + // Bootable mount: /boot empty, /usr intact + let mountpoint2 = tempfile::tempdir()?; + let mp2 = mountpoint2.path().to_str().unwrap(); + cmd!( + sh, + "{cfsctl} --insecure --hash {hash} --repo {repo_arg} oci mount --bootable boot-test:v1 {mp2}" + ) + .run()?; + + let boot_dir = mountpoint2.path().join("boot"); + ensure!( + boot_dir.is_dir(), + "bootable mount should have /boot directory" + ); + let boot_entries: Vec<_> = std::fs::read_dir(&boot_dir)?.collect(); + ensure!( + boot_entries.is_empty(), + "bootable mount /boot should be empty, found {} entries", + boot_entries.len() + ); - let output = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; ensure!( - !output.trim().is_empty(), - "expected image ID output, got nothing" + !mountpoint2 + .path() + .join("boot/EFI/Linux/test-6.1.0.efi") + .exists(), + "bootable mount should NOT contain UKI" ); + + ensure!( + mountpoint2 + .path() + .join("usr/lib/modules/6.1.0/vmlinuz") + .exists(), + "bootable mount should still have kernel at /usr/lib/modules/6.1.0/vmlinuz" + ); + + let os_release = std::fs::read_to_string(mountpoint2.path().join("etc/os-release"))?; + ensure!( + os_release.contains("ID=test"), + "bootable mount os-release missing ID=test: {os_release:?}" + ); + + cmd!(sh, "umount {mp2}").run()?; + Ok(()) } -integration_test!(privileged_create_image); +integration_test!(privileged_oci_bootable_mount); -/// Create an image and mount it via `cfsctl mount`, verifying the overlayfs -/// composefs mount works. This exercises the kernel-version-dependent -/// lowerdir+/datadir+ setup in mountcompat.rs. -fn privileged_mount_image() -> Result<()> { - if require_privileged("privileged_mount_image")?.is_some() { +/// Build a test OCI image, mount it via `cfsctl oci mount`, and verify +/// the filesystem content. Uses the library only for image creation (test +/// setup); all verification goes through the CLI. +fn privileged_oci_pull_mount() -> Result<()> { + if require_privileged("privileged_oci_pull_mount")?.is_some() { return Ok(()); } let sh = Shell::new()?; let cfsctl = cfsctl()?; let verity_dir = VerityTempDir::new()?; - let repo = verity_dir.path().join("repo"); - let fixture_dir = tempfile::tempdir()?; - let rootfs = create_test_rootfs(fixture_dir.path())?; + let repo_path = verity_dir.path().join("repo"); + let repo_arg = repo_path.to_str().unwrap(); + + // Create a test OCI image with EROFS linked (library used only for setup) + composefs_oci::test_util::create_test_oci_image(&repo_path, "mount-test:v1")?; - let image_id_full = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; - // create-image outputs "algo:hex", mount expects just the hex part - let image_id = image_id_full - .trim() - .split_once(':') - .map(|(_, hex)| hex) - .unwrap_or(image_id_full.trim()); + // test_util creates SHA-256 repos; tell cfsctl to match + let hash = "sha256"; + + // Verify inspect shows the EROFS ref + let inspect_output = cmd!( + sh, + "{cfsctl} --insecure --hash {hash} --repo {repo_arg} oci inspect mount-test:v1" + ) + .read()?; + let inspect: serde_json::Value = serde_json::from_str(&inspect_output)?; + ensure!( + inspect.get("composefs_erofs").is_some(), + "inspect should show composefs_erofs field" + ); + // Mount via cfsctl oci mount let mountpoint = tempfile::tempdir()?; let mp = mountpoint.path().to_str().unwrap(); - cmd!(sh, "{cfsctl} --repo {repo} mount {image_id} {mp}").run()?; + cmd!( + sh, + "{cfsctl} --insecure --hash {hash} --repo {repo_arg} oci mount mount-test:v1 {mp}" + ) + .run()?; + // Verify file content at the mountpoint let hostname = std::fs::read_to_string(mountpoint.path().join("etc/hostname"))?; + ensure!(hostname == "testhost\n", "hostname mismatch: {hostname:?}"); + + let os_release = std::fs::read_to_string(mountpoint.path().join("etc/os-release"))?; ensure!( - hostname == "integration-test\n", - "hostname mismatch through composefs mount: {hostname:?}" + os_release.contains("ID=test"), + "os-release missing ID: {os_release:?}" ); - cmd!(sh, "umount {mp}").run()?; - Ok(()) -} -integration_test!(privileged_mount_image); + let busybox = std::fs::read(mountpoint.path().join("usr/bin/busybox"))?; + ensure!( + busybox == b"busybox-binary-content", + "busybox content mismatch" + ); -fn privileged_create_image_idempotent() -> Result<()> { - if require_privileged("privileged_create_image_idempotent")?.is_some() { - return Ok(()); - } + let sh_target = std::fs::read_link(mountpoint.path().join("usr/bin/sh"))?; + ensure!( + sh_target.to_str() == Some("busybox"), + "sh symlink target mismatch: {sh_target:?}" + ); - let sh = Shell::new()?; - let cfsctl = cfsctl()?; - let verity_dir = VerityTempDir::new()?; - let repo = verity_dir.path().join("repo"); - let fixture_dir = tempfile::tempdir()?; - let rootfs = create_test_rootfs(fixture_dir.path())?; + let app_data = std::fs::read_to_string(mountpoint.path().join("usr/share/myapp/data.txt"))?; + ensure!( + app_data == "application-data", + "app data mismatch: {app_data:?}" + ); - let id1 = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; - let id2 = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; + ensure!(mountpoint.path().join("tmp").is_dir(), "/tmp missing"); + ensure!(mountpoint.path().join("var").is_dir(), "/var missing"); ensure!( - id1.trim() == id2.trim(), - "creating the same image twice should produce the same ID: {id1} vs {id2}" + mountpoint.path().join("usr/lib").is_dir(), + "/usr/lib missing" ); + Ok(()) } -integration_test!(privileged_create_image_idempotent); +integration_test!(privileged_oci_pull_mount); diff --git a/doc/plans/oci-sealing-impl.md b/doc/plans/oci-sealing-impl.md deleted file mode 100644 index dea523c9..00000000 --- a/doc/plans/oci-sealing-impl.md +++ /dev/null @@ -1,210 +0,0 @@ -# OCI Sealing Implementation in composefs-rs - -This document describes the implementation of OCI sealing in composefs-rs. For the generic specification applicable to any composefs implementation, see [oci-sealing-spec.md](oci-sealing-spec.md). - - - -## Current Implementation Status - -### What Exists - -The `composefs-oci` crate at `crates/composefs-oci/src/image.rs` already implements the core sealing mechanism. The `seal()` function computes the fsverity digest via `compute_image_id()`, creates an EROFS image from merged layers with whiteouts applied, and stores the digest in `config.labels["containers.composefs.fsverity"]`. A new config with updated labels is written via `write_config()`, returning both the SHA256 config digest and fsverity image digest. - -The implementation includes fsverity computation and verification through the `composefs` crate's fsverity module. Config label storage follows the OCI specification with digest mapping from SHA256 to fsverity maintained in split streams. Repository-level integrity verification is provided through `check_stream()` and `check_image()`. Mount operations check for the seal label and use fsverity verification when present. - -All objects in the repository are fsverity-enabled by default, with digests stored using the generic `ObjectID` type parameterized over `FsVerityHashValue`. Images are tracked separately in the `images/` directory, distinct from general objects due to the kernel security model that restricts non-root filesystem mounting. - -### Current Workflow - -The sealing workflow in composefs-rs begins with `create_filesystem()` building the filesystem from OCI layers. Layer tar streams are imported via `import_layer()`, converting them to composefs split streams. Files 64 bytes or smaller are stored inline in the split stream, while larger files are stored in the object store with fsverity digests. Layers are processed in order, applying overlayfs semantics including whiteout handling (`.wh.` files). Hardlinks are tracked properly across layers to maintain filesystem semantics. - -After building the filesystem, `compute_image_id()` generates the EROFS image and computes its fsverity digest. The digest is stored in the config label `containers.composefs.fsverity`. The `write_config()` function writes the new config to the repository with the digest mapping, and both the SHA256 config digest and fsverity image digest are returned. - -For mounting, the `mount()` operation requires the `containers.composefs.fsverity` label to be present. It extracts the image ID from the label and mounts at the specified path with kernel fsverity verification. - -## Repository Architecture - -The composefs-rs repository architecture at `crates/composefs/src/repository.rs` supports sealing without major changes. Objects are stored in a content-addressed layout under `objects/XX/YYY...` where `XX` is the first byte of the fsverity digest and `YYY` are the remaining 62 hex characters. All files in `objects/` must have fsverity enabled, enforced via `ensure_verity_equal()`. - -Images are tracked separately in the `images/` directory as symlinks to objects, with refs providing named references and garbage collection roots. Split streams are stored in the `streams/` directory, also as symlinks to objects. The repository has an "insecure" mode for development without fsverity filesystem support, but sealing operations should explicitly fail in this mode. - -Two-level naming allows access by fsverity digest (verified) or by ref name (unverified). The `ensure_stream()` method provides idempotent stream creation with SHA256-based deduplication. Streams can reference other streams via digest maps stored in split stream headers, enabling the layer→config relationship tracking. - -## Required Enhancements - -### Manifest Annotations - -Manifest annotations should be added to indicate sealed images and enable discovery without parsing configs. The sealing operation should add `containers.composefs.sealed` set to `"true"` and optionally `containers.composefs.image.fsverity` containing the image digest. This allows registries to discover sealed images and clients to optimize pull strategies. - -### Per-Layer Digest Annotations - -Per-layer digests enable incremental verification and caching. A `SealedImageInfo` structure should track the image fsverity digest, config SHA256 digest, optional config fsverity digest, and a list of layer seal information. Each `LayerSealInfo` entry should contain the original tar layer digest, the composefs fsverity of the layer, and the split stream digest in the repository. - -During sealing, layer descriptors should be annotated with `containers.composefs.layer.fsverity` after processing each layer. This allows verification of individual layers before merging and enables caching where shared layers have known composefs digests. - -### Verification API - -A standalone verification API separate from mounting should be implemented. The verification function should check manifest annotations for the seal flag, fetch and verify the config against the manifest's config descriptor, extract the fsverity digest from the config label, verify annotated layers if present, and optionally verify the image exists in the repository. - -This enables verification before mounting and provides detailed seal information without building the filesystem. The returned `SealedImageInfo` structure contains all digest relationships and layer details. - -### Pull Integration - -The `pull()` function in `crates/composefs-oci/src/image.rs` should be enhanced to handle sealed images. When a verify_seal flag is enabled, the pull operation should check manifest annotations for the sealed flag and verify the seal during pull if present. If the image is sealed and verification passes, some integrity checks can be skipped since the composefs digests are trusted. - -An optimization is that sealed images don't require re-computing digests during import if verification already passed. The pull result should include optional seal information alongside the manifest and config. - -### Push Integration - -Support for pushing sealed images back to registries requires preserving seal annotations through the registry round-trip. The push operation should construct the manifest with seal annotations, push the config with the composefs label, push layers optionally with layer annotations, and push the manifest with seal annotations. - -The challenge is maintaining digest mappings through the registry round-trip, as registries may re-compress or re-package layers while preserving content digests. - -### Insecure Mode Handling - -Repository sealing operations should explicitly fail when the repository is in insecure mode. The rationale is that if the repository doesn't enforce fsverity, sealing provides no security benefit. The check should be performed at the beginning of seal operations, returning an error if `repo.is_insecure()` is true. - -## Implementation Phases - -### Phase 1: Core Sealing (Completed) - -Phase 1 is complete with basic `seal()` implementation in `composefs-oci`, fsverity computation and storage, config label with digest, and mount with seal verification. - -### Phase 2: Manifest Annotations (Planned) - -Phase 2 will add manifest annotation support to `seal()`, create the `SealedImageInfo` type, implement the `verify_seal()` API, document the label/annotation schema, and add tests for sealed image workflows. - -Deliverables include `seal()` emitting manifests with annotations, standalone verification without mounting, and updated documentation in `doc/oci.md`. - -### Phase 3: Per-Layer Digests (Planned) - -Phase 3 will record per-layer fsverity during sealing, add layer annotations to manifests, implement incremental verification, and optimize pull for sealed images. - -Deliverables include full `SealedImageInfo` with layer details, layer-by-layer verification API, and performance improvements for sealed pulls. - -### Phase 4: Push/Registry Integration (Planned) - -Phase 4 will implement push support for sealed images, preserve annotations through registry round-trip, test with standard OCI registries, and document registry compatibility. - -Deliverables include bidirectional registry support, a registry compatibility matrix, and integration tests with real registries. - -### Phase 5: Advanced Features (Future) - -Future work includes dumpfile digest support, eager/lazy verification modes, zstd:chunked integration, the three-digest model, and signature integration. - -## API Design Considerations - -### Type Safety - -The generic `ObjectID` type parameterized over `FsVerityHashValue` provides type safety for digest handling. Both `Sha256HashValue` and `Sha512HashValue` implement the `FsVerityHashValue` trait with hex encoding/decoding, object pathname format, and algorithm ID constants. - -### Async/Await - -Operations like `seal()` and `pull()` are async to support parallel layer fetching with semaphore-based concurrency control. The repository is wrapped in `Arc` to enable sharing across async contexts. - -### Error Handling - -The codebase uses `anyhow::Result` for error handling with context. Seal operations should provide clear error messages distinguishing between fsverity failures, missing labels, and repository integrity issues. - -### Verification Modes - -Supporting both eager and lazy verification requires a configuration option, potentially as an enum `SealVerificationMode` with variants `Eager`, `Lazy`, and `Never`. Different defaults may apply for user versus system repositories. - -## Integration Points - -### Split Streams - -Split streams at `crates/composefs/src/splitstream.rs` are the intermediate format between OCI tar layers and composefs EROFS images. They contain inline data for small files and references to objects for large files. Split stream headers include digest maps linking SHA256 layer digests to fsverity digests. - -Per-layer sealing should leverage split streams to maintain the digest mapping. The split stream format doesn't need changes but seal metadata should reference split stream digests. - -### EROFS Generation - -EROFS image generation via `mkfs_erofs()` in `crates/composefs/src/erofs/` creates reproducible images from filesystem trees. The EROFS writer handles inline data, shared data, and metadata blocks with deterministic layout. The same input filesystem produces the same EROFS digest. - -Sealing relies on this determinism for verification. The EROFS format version may evolve, which is why dumpfile digests are being considered as a format-agnostic alternative. - -### Fsverity Module - -The fsverity module at `crates/composefs/src/fsverity/` provides userspace computation matching kernel behavior and ioctl wrappers for kernel interaction. Digest computation uses a hardcoded 4096-byte block size with no salt support, matching kernel fs-verity defaults. - -Sealing uses `compute_verity()` for userspace digest computation during EROFS generation and `enable_verity_maybe_copy()` to handle ETXTBSY by copying files if needed. Verification uses `measure_verity()` to get kernel-measured digests and `ensure_verity_equal()` to compare against expected values. - -## Open Implementation Questions - -### Config Annotation Method - -The current code calls `config.get_config_annotation()` which actually reads from labels, not annotations. This naming suggests potential confusion between OCI label and annotation semantics. Clarification is needed whether storing in labels is intentional or if annotations should be used for the digest. - -### Sealed Config Mutability - -Sealing modifies config content by adding the label, creating a new SHA256 for the config and breaking existing references to the old config digest. This may be acceptable since the sealed config is a new artifact, but it needs clear documentation about the relationship between sealed and unsealed images. - -### Performance at Scale - -Computing fsverity for large images is expensive as `compute_image_id()` builds the entire EROFS in memory. Streaming approaches or caching strategies should be considered for multi-GB images. The EROFS writer could be enhanced to support streaming output with incremental digest computation. - -### Seal Metadata Persistence - -Optionally persisting `SealedImageInfo` as `.seal.json` alongside images in the repository could enable faster seal information retrieval without re-parsing configs. This metadata cache would need invalidation strategies and shouldn't be security-critical. - -### Repository Ref Strategy - -Sealed images have different config digests than unsealed images. The ref strategy for managing variants should avoid keeping both sealed and unsealed versions indefinitely. Garbage collection should understand the relationship between sealed and unsealed images, potentially tracking seal derivation relationships. - -## Testing Strategy - -Testing should cover sealing unsealed images and verifying the config label is added correctly with the expected fsverity digest. Mounting sealed images should verify that fsverity is checked by the kernel. Verification API tests should check correct extraction of seal information from manifest and config. - -Per-layer annotation tests should verify layer digests are computed and annotated correctly. Pull integration tests should verify detection and verification of sealed images during pull. Push integration tests should verify seal metadata is preserved through registry round-trip. - -Negative tests should verify that seal operations fail in insecure mode, mounting fails with incorrect fsverity digest, and verification fails with missing or incorrect labels. - -Performance tests should measure sealing time for various image sizes and verify parallel layer processing performance. - -## Compatibility Considerations - -### OCI Registry Compatibility - -Standard OCI registries should store and serve sealed images without special handling. Unknown labels and annotations are preserved by spec-compliant registries. Testing should verify round-trip through common registries like Docker Hub, Quay, and GitHub Container Registry. - -### Existing Composefs-rs Versions - -The seal format version label enables detection of format changes. Forward compatibility means newer implementations can read older seals. Backward compatibility means older implementations should gracefully ignore newer seal formats they don't understand. - -### C Composefs Compatibility - -While composefs-rs aims to become the reference implementation, compatibility with the C composefs implementation should be maintained where feasible. EROFS images and dumpfiles should be interchangeable. Digest computation must match exactly between implementations. - -## Future Implementation Work - -### Dumpfile Digest Support - -Supporting dumpfile digests requires adding `containers.composefs.dumpfile.sha256` label computation during sealing. Verification should support parsing EROFS back to dumpfile format and verifying the digest. Caching the dumpfile→fsverity mapping requires careful security consideration to avoid cache poisoning. - -### zstd:chunked Integration - -Integration with zstd:chunked requires reading and writing TOC metadata with fsverity digests added to entries. The TOC format from the estargz/stargz-snapshotter projects would need extension for fsverity. Direct TOC→dumpfile conversion would enable unified metadata handling. - -### Non-Root Mounting Helper - -A separate composefs-mount-helper service would accept dumpfiles from unprivileged users, generate EROFS images, validate fsverity, and return mount file descriptors. This requires privileged service implementation with careful input validation on the dumpfile format. - -### Signature Integration - -Integrating with cosign or sigstore requires fetching and verifying signatures during pull, associating signatures with sealed images in the repository, and potentially storing signature references in seal metadata. The signature verification should happen before seal verification in the trust chain. - -## References - -See [oci-sealing-spec.md](oci-sealing-spec.md) for the generic specification and complete reference list. - -**Implementation references**: -- `crates/composefs-oci/src/image.rs` - OCI image operations including seal() -- `crates/composefs/src/repository.rs` - Repository management -- `crates/composefs/src/fsverity/` - Fsverity computation and verification -- `crates/composefs/src/splitstream.rs` - Split stream format -- `crates/composefs/src/erofs/` - EROFS generation - -**Related composefs-rs issues**: -- Check for existing issues about OCI sealing enhancements -- File new issues for specific implementation work items diff --git a/doc/repository.md b/doc/repository.md index c9c2a3ed..5fd3f9a4 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -149,3 +149,109 @@ For example: cfsctl mount refs/system/rootfs/some_id /mnt # does not check fs-verity cfsctl mount 974d04eaff[...] /mnt # enforces fs-verity ``` + +## OCI image storage + +OCI container images are stored using streams exclusively. Each OCI artifact +(manifest, config, layer) becomes a splitstream, and OCI "tags" are refs under +`streams/refs/oci/`. + +### Naming conventions + +| OCI artifact | Stream name pattern | Example | +|---------------|------------------------------------|------------------------------------| +| Manifest | `oci-manifest-{manifest_digest}` | `oci-manifest-sha256:abc123...` | +| Config | `oci-config-{config_digest}` | `oci-config-sha256:def456...` | +| Layer | `oci-layer-{diff_id}` | `oci-layer-sha256:ghi789...` | +| Blob | `oci-blob-{blob_digest}` | `oci-blob-sha256:jkl012...` | + +Tags are stored under `streams/refs/oci/` with percent-encoding for +filesystem safety (`/` → `%2F`): + +``` +streams/refs/oci/myimage:latest → ../../oci-manifest-sha256:abc123... +``` + +### Splitstream reference chains + +Each splitstream contains `named_refs` (semantic labels mapping to entries +in the `stream_refs` array) and `object_refs` (raw objects referenced by +the compressed stream data). For OCI images the chain is: + +**Manifest splitstream** (`oci-manifest-sha256:...`): + - `object_refs`: the manifest JSON blob + - `named_refs`: + - `config:{config_digest}` → config splitstream verity + - `{diff_id}` → layer splitstream verity (one per layer) + +**Config splitstream** (`oci-config-sha256:...`): + - `object_refs`: the config JSON blob + - `named_refs`: + - `{diff_id}` → layer splitstream verity (one per layer) + +**Layer splitstream** (`oci-layer-sha256:...`): + - `object_refs`: file content objects extracted from the tar + - `named_refs`: none (leaf node) + +Both the manifest and config redundantly reference the layers. The GC +can reach layers from either path. + +### Garbage collection + +The GC walks all refs under `streams/refs/` to find root splitstreams, +then transitively follows `named_refs` (by resolving fs-verity IDs +through a stream name map) and collects `object_refs`. Any object not +reachable from a root is deleted. + +Concretely, for a tagged container image: + + 1. Tag `streams/refs/oci/myimage:v1` resolves to `oci-manifest-sha256:abc` + 2. Walk the manifest: mark its JSON blob and follow `named_refs` to + the config and layer streams + 3. Walk the config: mark its JSON blob and follow `named_refs` to layers + (already visited, skipped) + 4. Walk each layer: mark all file content objects + +When a tag is removed, the manifest and everything reachable only from it +becomes GC-eligible. Layers shared between images survive as long as any +referencing manifest remains tagged. + +### EROFS image tracking via config splitstream refs + +When an EROFS image is generated from an OCI image (via +`create_filesystem` + `commit_image`), its object ID (fs-verity digest) +is stored as a named ref on the config splitstream with the key +`composefs.image`. + +GC walks from tag → manifest → config, and finds the `composefs.image` +named ref. The EROFS object ID is added to the live set, keeping the +EROFS image alive. The EROFS image still needs an entry under `images/` +for the kernel mount security model (see above), but `images/` is not a +GC root — the config ref is what keeps the object alive. + +This means a single OCI tag is sufficient to keep the entire image +(manifest, config, layers, and the EROFS image) alive through GC. + +### Bootable image variant + +For bootable images, a second EROFS may be generated after +`transform_for_boot` (stripping `/boot`, etc.). This boot EROFS is +stored as a second named ref on the config, `composefs.image.boot`. + +Since the config splitstream content changes (new named ref), it gets a +new fs-verity digest. This cascades: the manifest must also be +rewritten (its `config:` named ref now points to the new config verity), +producing a new manifest verity. The tag is re-pointed to the new +manifest. The old config and manifest splitstreams become unreferenced +and are collected by GC. + +The result: one tag still keeps everything alive — layers, raw EROFS, +and boot EROFS. + +### Future: sealed images + +For sealed/signed images, the EROFS comes pre-built from the registry as +part of a composefs OCI artifact (referrer pattern). The artifact +splitstream would hold references to the pre-fetched EROFS layers. This +is complementary to the unsealed case — both use the same GC mechanism +(named refs pointing to EROFS objects). diff --git a/examples/common/make-image b/examples/common/make-image index 5f4e5b19..91e6a93a 100755 --- a/examples/common/make-image +++ b/examples/common/make-image @@ -4,8 +4,10 @@ set -eux output="$1" -# check that the image doesn't have errors -fsck.erofs tmp/sysroot/composefs/images/* +# check that the images don't have errors +for img in tmp/sysroot/composefs/images/*; do + fsck.erofs "$img" +done fakeroot "${0%/*}/run-repart" tmp/image.raw qemu-img convert -f raw tmp/image.raw -O qcow2 "${output}"