diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ea98d4e..bc6536b5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,9 +225,14 @@ jobs: seal_state: ["sealed", "unsealed"] exclude: - # https://github.com/bootc-dev/bootc/issues/1812 + # centos-9 composefs: only sealed UKI is supported (V1 EROFS). + # BLS and unsealed modes require newer dracut/systemd features. - test_os: centos-9 variant: composefs + boot_type: bls + - test_os: centos-9 + variant: composefs + seal_state: unsealed - seal_state: "sealed" boot_type: bls - seal_state: "sealed" diff --git a/Dockerfile b/Dockerfile index 40f84b967..f668aaddc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -320,6 +320,7 @@ ARG variant ARG filesystem ARG seal_state ARG boot_type +ARG erofs_version=v1 # Install our bootc package (only needed for the compute-composefs-digest command) RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packages,src=/,target=/run/packages \ @@ -338,7 +339,7 @@ if [[ $filesystem == "xfs" ]]; then fi if test "${boot_type}" = "uki"; then - /run/packaging/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state + /run/packaging/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state $erofs_version fi EORUN diff --git a/Justfile b/Justfile index 6ba80d0c7..01be3cbd3 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,8 @@ filesystem := env("BOOTC_filesystem", "ext4") boot_type := env("BOOTC_boot_type", "bls") # Only used for composefs tests seal_state := env("BOOTC_seal_state", "unsealed") +# Only used for composefs UKI tests: "v1" or "v2" +erofs_version := env("BOOTC_erofs_version", "v1") # Baseconfigs to inject into the image for testing (e.g. "etc-transient" or "root-transient") baseconfigs := env("BOOTC_baseconfigs", "") # Base container image to build from @@ -72,6 +74,7 @@ base_buildargs := generic_buildargs + " " + _extra_src_args \ + " --build-arg=boot_type=" + boot_type \ + " --build-arg=seal_state=" + seal_state \ + " --build-arg=filesystem=" + filesystem \ + + " --build-arg=erofs_version=" + erofs_version \ + " --build-arg=baseconfigs=" + baseconfigs buildargs := base_buildargs \ + " --cap-add=all --security-opt=label=type:container_runtime_t --device /dev/fuse" \ @@ -271,7 +274,7 @@ test-container-export: build # Run tmt tests without rebuilding (for fast iteration) [group('testing')] test-tmt-nobuild *ARGS: - cargo xtask run-tmt --env=BOOTC_variant={{variant}} {{_baseconfigs_env}} --upgrade-image={{upgrade_img}} {{base_img}} {{ARGS}} + cargo xtask run-tmt --env=BOOTC_variant={{variant}} --env=BOOTC_erofs_version={{erofs_version}} {{_baseconfigs_env}} --upgrade-image={{upgrade_img}} {{base_img}} {{ARGS}} # Run readonly tests with a baseconfig baked into the image at build time. # Requires composefs variant. Example: just variant=composefs test-tmt-baseconfig root-transient @@ -489,6 +492,7 @@ _build-upgrade-image: --build-arg "boot_type={{boot_type}}" \ --build-arg "seal_state={{seal_state}}" \ --build-arg "filesystem={{filesystem}}" \ + --build-arg "erofs_version={{erofs_version}}" \ --secret=id=secureboot_key,src=target/test-secureboot/db.key \ --secret=id=secureboot_cert,src=target/test-secureboot/db.crt \ "${extra_args[@]}" \ diff --git a/contrib/packaging/seal-uki b/contrib/packaging/seal-uki index 66de92ffd..d0f1a62ca 100755 --- a/contrib/packaging/seal-uki +++ b/contrib/packaging/seal-uki @@ -15,6 +15,9 @@ allow_missing_verity=$1 shift seal_state=$1 shift +# EROFS format version to pass to bootc container ukify (optional, default: v1) +erofs_version=${1:-v1} +shift || true if [[ $seal_state == "sealed" && $allow_missing_verity == "true" ]]; then echo "Cannot have missing verity with sealed UKI" >&2 @@ -53,4 +56,4 @@ fi # Build the UKI using bootc container ukify # This computes the composefs digest, reads kargs from kargs.d, and invokes ukify -bootc container ukify "${containerukifyargs[@]}" "${missing_verity[@]}" -- "${ukifyargs[@]}" +bootc container ukify "${containerukifyargs[@]}" "${missing_verity[@]}" --erofs-version="${erofs_version}" -- "${ukifyargs[@]}" diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 5437fe44b..a253ef5ee 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -75,6 +75,7 @@ use cap_std_ext::{ dirext::CapStdExtDirExt, }; use clap::ValueEnum; +use composefs::erofs::format::FormatVersion; use composefs::fs::read_file; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs::tree::RegularFile; @@ -83,7 +84,7 @@ use composefs_boot::bootloader::{ UsrLibModulesVmlinuz, get_boot_resources, }; use composefs_boot::{ - cmdline::ComposefsCmdline as ComposefsBootCmdline, os_release::OsReleaseInfo, uki, + cmdline::ComposefsCmdline as BootComposefsCmdline, os_release::OsReleaseInfo, uki, }; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; @@ -94,7 +95,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; -use crate::bootc_composefs::status::ComposefsCmdline; +use crate::bootc_composefs::status::build_composefs_karg; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -134,6 +135,7 @@ const AUTH_EXT: &str = "auth"; /// This is relative to the ESP pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; +#[derive(Copy, Clone)] pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk Setup((&'a RootSetup, &'a State, &'a PostFetchState)), @@ -493,6 +495,7 @@ pub(crate) fn setup_composefs_bls_boot( setup_type: BootSetupType, repo: crate::store::ComposefsRepository, id: &Sha512HashValue, + format_version: FormatVersion, entry: &ComposefsBootEntry, mounted_erofs: &Dir, ) -> Result { @@ -505,9 +508,12 @@ pub(crate) fn setup_composefs_bls_boot( cmdline_options.extend(&root_setup.kargs); - let composefs_cmdline = - ComposefsCmdline::build(&id_hex, state.composefs_options.allow_missing_verity); - cmdline_options.extend(&Cmdline::from(&composefs_cmdline.to_string())); + let composefs_cmdline = build_composefs_karg( + id.clone(), + format_version, + state.composefs_options.allow_missing_verity, + ); + cmdline_options.extend(&Cmdline::from(&composefs_cmdline)); // If there's a separate /boot partition, add a systemd.mount-extra // karg so systemd mounts it after reboot. This avoids writing to @@ -554,9 +560,11 @@ pub(crate) fn setup_composefs_bls_boot( }; // Copy all cmdline args, replacing only `composefs=` - let cfs_cmdline = - ComposefsCmdline::build(&id_hex, booted_cfs.cmdline.allow_missing_fsverity) - .to_string(); + let cfs_cmdline = build_composefs_karg( + id.clone(), + format_version, + booted_cfs.cmdline.allow_missing_fsverity, + ); let param = Parameter::parse(&cfs_cmdline) .context("Failed to create 'composefs=' parameter")?; @@ -784,6 +792,11 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, + /// The composefs image digest parsed from (and validated against) the UKI's + /// own cmdline. For UKI boots, setup-root opens `state/deploy/` using + /// the karg baked into the UKI, so the deploy directory must be named after + /// exactly this value regardless of which EROFS format (V1 or V2) was sealed. + composefs_digest: Sha512HashValue, } /// Writes a PortableExecutable to ESP along with any PE specific or Global addons @@ -794,6 +807,7 @@ fn write_pe_to_esp( file_path: &Utf8Path, pe_type: PEType, uki_id: &Sha512HashValue, + boot_ids: &[Sha512HashValue], missing_fsverity_allowed: bool, mounted_efi: impl AsRef, ) -> Result> { @@ -812,10 +826,12 @@ fn write_pe_to_esp( if matches!(pe_type, PEType::Uki) { let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; - let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) + let composefs_info = BootComposefsCmdline::::from_cmdline(&cmdline) .context("Parsing composefs=")? - .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; - let composefs_cmdline = composefs_info.digest(); + .ok_or_else(|| { + anyhow::anyhow!("No composefs= or composefs.digest.v1= karg found in UKI cmdline") + })?; + let composefs_digest = composefs_info.digest().clone(); let missing_verity_allowed_cmdline = composefs_info.is_insecure(); // If the UKI cmdline does not match what the user has passed as cmdline option @@ -834,11 +850,9 @@ fn write_pe_to_esp( _ => { /* no-op */ } } - if *composefs_cmdline != *uki_id { - anyhow::bail!( - "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})" - ); - } + composefs_info + .validate_digest(boot_ids) + .context("Validating UKI composefs digest")?; uki_reader.seek(SeekFrom::Start(0))?; let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; @@ -855,6 +869,7 @@ fn write_pe_to_esp( version: parsed_osrel.get_version(), os_id: parsed_osrel.get_value(&["ID"]), boot_digest, + composefs_digest, }); } @@ -888,8 +903,19 @@ fn write_pe_to_esp( let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) .with_context(|| format!("Opening {final_pe_path:?}"))?; + // For UKIs, name the .efi file after the composefs cmdline digest (the + // deploy key that setup-root uses), NOT the provisional uki_id. When the + // UKI was sealed with --erofs-version=v1, uki_id (v2) and the cmdline + // digest (v1) differ; using the cmdline digest keeps the filename, BLS + // config, and state directory in agreement. + let pe_name_owned; let pe_name = match pe_type { - PEType::Uki => &get_uki_name(&uki_id.to_hex()), + PEType::Uki => { + // SAFETY: boot_label is always set to Some above when pe_type == Uki + let deploy_digest = &boot_label.as_ref().unwrap().composefs_digest; + pe_name_owned = get_uki_name(&deploy_digest.to_hex()); + &pe_name_owned + } PEType::UkiAddon => file_path .components() .last() @@ -1074,8 +1100,9 @@ pub(crate) fn setup_composefs_uki_boot( setup_type: BootSetupType, repo: crate::store::ComposefsRepository, id: &Sha512HashValue, + boot_ids: &[Sha512HashValue], entries: Vec>, -) -> Result { +) -> Result<(String, Sha512HashValue)> { let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch)) => { @@ -1155,7 +1182,8 @@ pub(crate) fn setup_composefs_uki_boot( &entry.file, utf8_file_path, entry.pe_type, - &id, + id, + boot_ids, missing_fsverity_allowed, esp_mount.dir.path(), )?; @@ -1172,17 +1200,32 @@ pub(crate) fn setup_composefs_uki_boot( let boot_digest = uki_info.boot_digest.clone(); + // The deploy key for a UKI boot is the composefs digest baked into the UKI + // cmdline (already validated against `boot_ids` in `write_pe_to_esp`). + // setup-root opens `state/deploy/` using that same karg, so we must + // key the deployment off exactly this value -- whether the UKI was sealed + // with a V1 or V2 EROFS digest. + let deploy_id = uki_info.composefs_digest.clone(); + match bootloader.kind()? { - BootloaderKind::GRUBClassic => { - write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)? - } + BootloaderKind::GRUBClassic => write_grub_uki_menuentry( + root_path, + &setup_type, + uki_info.boot_label, + &deploy_id, + &esp_device, + )?, - BootloaderKind::BLSCompatible => { - write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_info, id, &bootloader)? - } + BootloaderKind::BLSCompatible => write_systemd_uki_config( + &esp_mount.fd, + &setup_type, + uki_info, + &deploy_id, + &bootloader, + )?, }; - Ok(boot_digest) + Ok((boot_digest, deploy_id)) } /// A composefs image attached to a temporary directory with the ESP and a @@ -1353,6 +1396,15 @@ pub(crate) async fn setup_composefs_boot( let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) .context("Generating bootable EROFS image")?; + // Open the OCI image to read both stored boot EROFS digests. The UKI may + // have been sealed with either the V1 or V2 boot image digest, so we need + // both for verification. + let oci_img = + composefs_oci::oci_image::OciImage::open(&*repo, &pull_result.manifest_digest, None) + .context("Opening OCI image to read boot image refs")?; + let boot_id_v1 = oci_img.boot_image_ref_v1().cloned(); + let boot_id_v2 = oci_img.boot_image_ref_v2().cloned(); + // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.). let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None) .context("Creating composefs filesystem for boot entry discovery")?; @@ -1446,25 +1498,58 @@ pub(crate) async fn setup_composefs_boot( ) })?; - let boot_digest = match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch)), - repo, - &id, - entry, - mounted_root.dir(), - )?, + // The deployment key is the hash that setup-root looks for in + // state/deploy/, derived from the composefs karg. The two boot types + // establish that karg differently: + // + // * BLS: bootc writes the karg itself, so we are free to choose the key. + // Prefer the V1 digest, falling back to the primary id for legacy repos + // with no separate V1 boot ref. V1 is the preferred default because it + // works on both RHEL9 (which lacks V2 support) and newer kernels; this + // must stay in sync with the same choice made for upgrades in + // `do_upgrade`. + // + // * UKI: the karg is baked into the UKI at seal time, so the key is + // whatever digest the UKI carries. `setup_composefs_uki_boot` parses and + // validates that against the boot refs and returns it, so we override the + // provisional value below. This keeps us correct whether the UKI was + // sealed with the V1 (default) or V2 EROFS digest. + // + // `provisional_format` tracks which EROFS format `provisional_deploy_id` + // actually is, so the BLS karg we write can be tagged correctly (see + // `build_composefs_karg`). + let (provisional_deploy_id, provisional_format) = match boot_id_v1.as_ref() { + Some(v1) => (v1.clone(), FormatVersion::V1), + None => (id.clone(), repo.erofs_version()), + }; + + // Collect whichever boot image refs exist; the UKI cmdline may carry either. + let boot_ids: Vec = [boot_id_v1, boot_id_v2].into_iter().flatten().collect(); + + let (boot_digest, deploy_id) = match boot_type { + BootType::Bls => ( + setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch)), + repo, + &provisional_deploy_id, + provisional_format, + entry, + mounted_root.dir(), + )?, + provisional_deploy_id, + ), BootType::Uki => setup_composefs_uki_boot( BootSetupType::Setup((&root_setup, &state, &postfetch)), repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, )?, }; write_composefs_state( &root_setup.physical_root_path, - &id, + &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), None, boot_type, diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index af7d3746b..99111a49c 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -10,7 +10,8 @@ use camino::Utf8Path; use cap_std_ext::cap_std; use cap_std_ext::cap_std::fs::Dir; use composefs::dumpfile; -use composefs::fsverity::{Algorithm, FsVerityHashValue}; +use composefs::erofs::format::FormatVersion; +use composefs::fsverity::FsVerityHashValue; use composefs::repository::RepositoryConfig; use composefs_boot::BootOps as _; use composefs_ctl::composefs; @@ -21,10 +22,16 @@ use crate::store::ComposefsRepository; /// Creates a temporary composefs repository for computing digests. /// +/// The `erofs_version` controls which EROFS format the digest is computed for: +/// use `FormatVersion::V1` to get a `composefs.digest=v1-sha256-12:` karg (V1 EROFS, +/// C-tool compatible) or `FormatVersion::V2` for the legacy `composefs=` karg. +/// /// Returns the TempDir guard (must be kept alive for the repo to remain valid) /// and the repository wrapped in Arc. #[fn_error_context::context("Creating new temp composefs repo")] -pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc)> { +pub(crate) fn new_temp_composefs_repo( + erofs_version: FormatVersion, +) -> Result<(TempDir, Arc)> { let td_guard = tempfile::tempdir_in("/var/tmp")?; let td_path = td_guard.path(); let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?; @@ -32,7 +39,8 @@ pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc Result<(TempDir, Arc, ) -> Result { if path.as_str() == "/" { anyhow::bail!("Cannot operate on active root filesystem; mount separate target instead"); } - let (_td_guard, repo) = new_temp_composefs_repo()?; + let (_td_guard, repo) = new_temp_composefs_repo(erofs_version)?; // Read filesystem from path, transform for boot, compute digest let dirfd: OwnedFd = rustix::fs::open( @@ -81,7 +90,7 @@ pub(crate) async fn compute_composefs_digest( .await .context("Reading container root")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; - let id = fs.compute_image_id(repo.erofs_version()); + let id = fs.compute_image_id(erofs_version); let digest = id.to_hex(); if let Some(dumpfile_path) = write_dumpfile_to { @@ -135,7 +144,9 @@ mod tests { // Compute the digest let path = Utf8Path::from_path(td.path()).unwrap(); - let digest = compute_composefs_digest(path, None).await.unwrap(); + let digest = compute_composefs_digest(path, FormatVersion::V2, None) + .await + .unwrap(); // Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars) assert_eq!( @@ -150,7 +161,9 @@ mod tests { ); // Verify consistency - computing twice on the same filesystem produces the same result - let digest2 = compute_composefs_digest(path, None).await.unwrap(); + let digest2 = compute_composefs_digest(path, FormatVersion::V2, None) + .await + .unwrap(); assert_eq!( digest, digest2, "Digest should be consistent across multiple computations" @@ -159,7 +172,7 @@ mod tests { #[tokio::test] async fn test_compute_composefs_digest_rejects_root() { - let result = compute_composefs_digest(Utf8Path::new("/"), None).await; + let result = compute_composefs_digest(Utf8Path::new("/"), FormatVersion::V2, None).await; assert!(result.is_err()); let err = result.unwrap_err(); let found = err.chain().any(|e| { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index fb534c914..95f234de0 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -402,16 +402,20 @@ pub(crate) async fn composefs_gc( ref_digest, None, ) { - if let Some(img_ref) = img.image_ref(booted_cfs.repo.erofs_version()) { - if img_ref.to_hex() == *verity { - tracing::info!( - "Deployment {verity} has no manifest_digest in origin; \ - found matching manifest {ref_digest} via image_ref" - ); - live_manifest_digests.push(ref_digest.clone()); - found_manifest = true; - break; - } + // Check both V1 and V2 slots: the deployment verity + // may have been produced under either format. + let img_ref_hex = img + .image_ref_v1() + .or_else(|| img.image_ref_v2()) + .map(|id| id.to_hex()); + if img_ref_hex.as_deref() == Some(verity.as_str()) { + tracing::info!( + "Deployment {verity} has no manifest_digest in origin; \ + found matching manifest {ref_digest} via image_ref" + ); + live_manifest_digests.push(ref_digest.clone()); + found_manifest = true; + break; } } } diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index c5bfd4c32..ba92abec9 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -100,12 +100,15 @@ pub(crate) async fn initialize_composefs_repository( crate::store::ensure_composefs_dir(rootfs_dir)?; - let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); - let config = if allow_missing_fsverity { - config.set_insecure() - } else { - config + let mut config = { + let c = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + if allow_missing_fsverity { + c.set_insecure() + } else { + c + } }; + crate::store::set_dual_erofs_formats(&mut config); let (repo, _created) = crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config) .context("Failed to initialize composefs repository")?; @@ -173,7 +176,9 @@ pub(crate) async fn initialize_composefs_repository( pub(crate) struct PullRepoResult { pub(crate) repo: crate::store::ComposefsRepository, pub(crate) entries: Vec>, - pub(crate) id: Sha512HashValue, + /// The boot image digest from `generate_boot_image` for the repo's default + /// EROFS format. Used to mount a bootable EROFS for reading boot resources. + pub(crate) boot_id: Sha512HashValue, /// The OCI manifest content digest (e.g. "sha256:abc...") pub(crate) manifest_digest: String, } @@ -360,7 +365,7 @@ pub(crate) async fn pull_composefs_repo( ); // Generate the bootable EROFS image (idempotent). - let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) + let boot_id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) .context("Generating bootable EROFS image")?; // Get boot entries from the OCI filesystem (untransformed). @@ -380,7 +385,7 @@ pub(crate) async fn pull_composefs_repo( Ok(PullRepoResult { repo, entries, - id, + boot_id, manifest_digest: pull_result.manifest_digest.to_string(), }) } diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 74e35d204..9c492620c 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -1,7 +1,7 @@ use crate::{ bootc_composefs::{ service::start_finalize_stated_svc, - status::{ComposefsCmdline, get_composefs_status}, + status::{build_composefs_karg, get_composefs_status}, }, cli::SoftRebootMode, store::{BootedComposefs, Storage}, @@ -14,6 +14,7 @@ use camino::Utf8Path; use cap_std_ext::cap_std::ambient_authority; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use fn_error_context::context; use ostree_ext::systemd_has_soft_reboot; use rustix::mount::{UnmountFlags, unmount}; @@ -108,14 +109,25 @@ pub(crate) async fn prepare_soft_reboot_composefs( create_dir_all(NEXTROOT).context("Creating nextroot")?; - let cmdline = ComposefsCmdline::build(deployment_id, booted_cfs.cmdline.allow_missing_fsverity); + let deployment_digest = Sha512HashValue::from_hex(deployment_id) + .with_context(|| format!("Parsing deployment id '{deployment_id}'"))?; + // We don't persist which EROFS format each deployment was written with, so + // fall back to the repo's currently configured default. This only affects + // the karg's self-description, not whether the soft-reboot actually + // succeeds: `setup_root` (below) resolves the deployment purely from the + // digest, independent of the composefs=/composefs.digest= tag. + let cmdline = build_composefs_karg( + deployment_digest, + booted_cfs.repo.erofs_version(), + booted_cfs.cmdline.allow_missing_fsverity, + ); let args = bootc_initramfs_setup::Args { cmd: vec![], sysroot: PathBuf::from("/sysroot"), config: Default::default(), root_fs: None, - cmdline: Some(Cmdline::from(cmdline.to_string())), + cmdline: Some(Cmdline::from(cmdline)), target: Some(NEXTROOT.into()), }; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index c8e7851c3..18e10bcfc 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -3,7 +3,9 @@ use std::{io::Read, sync::OnceLock}; use anyhow::{Context, Result}; use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::inspect_filesystem; -use composefs_ctl::composefs::fsverity::Sha512HashValue; +use composefs_ctl::composefs::erofs::format::FormatVersion; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_ctl::composefs_boot::cmdline::ComposefsCmdline as BootComposefsCmdline; use composefs_ctl::composefs_oci; use composefs_oci::OciImage; use fn_error_context::context; @@ -87,23 +89,16 @@ impl ComposefsCmdline { } } - pub(crate) fn build(digest: &str, allow_missing_fsverity: bool) -> Self { - ComposefsCmdline { - allow_missing_fsverity, - digest: digest.into(), - is_transient: false, - } - } - - /// Search for the `composefs=` parameter in the passed in kernel command line + /// Search for `composefs=` (V2) or `composefs.digest=` (V1) in the kernel + /// command line. Delegates to the composefs-boot library which handles + /// both formats, extracting the raw hex hash in either case. pub(crate) fn find_in_cmdline(cmdline: &Cmdline) -> Option { - match cmdline.find(COMPOSEFS_CMDLINE) { - Some(param) => { - let value = param.value()?; - Some(Self::new(value)) - } - None => None, - } + let parsed = BootComposefsCmdline::::from_cmdline(cmdline).ok()??; + Some(ComposefsCmdline { + allow_missing_fsverity: parsed.is_insecure(), + digest: parsed.digest().to_hex().into(), + is_transient: false, + }) } } @@ -118,6 +113,37 @@ impl std::fmt::Display for ComposefsCmdline { } } +/// Render a *new* composefs kernel command line argument for `digest`. +/// +/// Unlike [`ComposefsCmdline`] (which only represents an already-parsed +/// digest and has no notion of which EROFS format produced it), this uses +/// the upstream `composefs-boot` cmdline types so the karg is correctly +/// self-described: +/// +/// - [`FormatVersion::V0`] and [`FormatVersion::V1`] share the same on-disk +/// EROFS layout ([`composefs::erofs::format::FormatEpoch::Epoch1`]) and +/// both produce `composefs.digest=v1--:`, required on +/// kernels without V2 EROFS support (e.g. RHEL9/CentOS-9). This matches +/// upstream `composefs-ctl`'s own mapping. +/// - [`FormatVersion::V2`] produces the legacy `composefs=` shorthand. +/// +/// Every caller that writes a new composefs karg (install, upgrade, sealing +/// a UKI, soft-reboot) must go through this so the tag always matches the +/// actual on-disk EROFS format of `digest`. +pub(crate) fn build_composefs_karg( + digest: Sha512HashValue, + format_version: FormatVersion, + allow_missing_fsverity: bool, +) -> String { + match format_version { + FormatVersion::V0 | FormatVersion::V1 => { + BootComposefsCmdline::new_v1(digest, allow_missing_fsverity) + } + FormatVersion::V2 => BootComposefsCmdline::new_v2(digest, allow_missing_fsverity), + } + .to_cmdline_arg() +} + /// The JSON schema for staged deployment information /// stored in `/run/composefs/staged-deployment` #[derive(Debug, Serialize, Deserialize)] @@ -149,18 +175,17 @@ pub(crate) struct BootloaderEntry { pub(crate) boot_artifact_name: String, } -/// Detect if we have `composefs=` in `/proc/cmdline` +/// Detect if we have `composefs=` or `composefs.digest=:` +/// in `/proc/cmdline`. pub(crate) fn composefs_booted() -> Result> { static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); if let Some(v) = CACHED_DIGEST_VALUE.get() { return Ok(v.as_ref()); } let cmdline = Cmdline::from_proc()?; - let Some(kv) = cmdline.find(COMPOSEFS_CMDLINE) else { + let Some(v) = ComposefsCmdline::find_in_cmdline(&cmdline) else { return Ok(None); }; - let Some(v) = kv.value() else { return Ok(None) }; - let v = ComposefsCmdline::new(v); // Find the source of / mountpoint as the cmdline doesn't change on soft-reboot let root_mnt = inspect_filesystem("/".into())?; @@ -1082,6 +1107,36 @@ mod tests { assert_eq!(v.digest.as_ref(), DIGEST); } + #[test] + fn test_build_composefs_karg() { + let hex = "ab".repeat(64); + let digest = || Sha512HashValue::from_hex(&hex).unwrap(); + + // V2 uses the legacy `composefs=` shorthand. + assert_eq!( + build_composefs_karg(digest(), FormatVersion::V2, false), + format!("composefs={hex}") + ); + assert_eq!( + build_composefs_karg(digest(), FormatVersion::V2, true), + format!("composefs=?{hex}") + ); + + // V1 and V0 (same on-disk EROFS layout, see `FormatEpoch::Epoch1`) both + // use the self-describing `composefs.digest=` form, required for + // kernels without V2 EROFS support (e.g. RHEL9/CentOS-9). + for v1_like in [FormatVersion::V1, FormatVersion::V0] { + assert_eq!( + build_composefs_karg(digest(), v1_like, false), + format!("composefs.digest=v1-sha512-12:{hex}") + ); + assert_eq!( + build_composefs_karg(digest(), v1_like, true), + format!("composefs.digest=?v1-sha512-12:{hex}") + ); + } + } + #[test] fn test_sorted_bls_boot_entries() -> Result<()> { let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; @@ -1187,7 +1242,8 @@ mod tests { #[test] fn test_find_in_cmdline() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + // Must be 128 hex chars (SHA-512) to match Sha512HashValue + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; // Test case: cmdline contains composefs parameter let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs={}", DIGEST)); diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index e0e677958..2918a6663 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use camino::Utf8PathBuf; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs::erofs::format::FormatVersion; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; use composefs_ctl::composefs; @@ -142,13 +143,18 @@ pub(crate) fn validate_update( let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity))?; fs.transform_for_boot(&repo)?; - let image_id = fs.compute_image_id(repo.erofs_version()); + // Match against both EROFS format ids: a deployment committed by a repo + // that generates both formats may be recorded under either the V1 + // (composefs.digest=v1-...) or V2 (composefs=, legacy shorthand) digest. + // compute_image_id is a cheap in-memory EROFS generation. + let id_v1 = fs.compute_image_id(FormatVersion::V1); + let id_v2 = fs.compute_image_id(FormatVersion::V2); let all_deployments = host.all_composefs_deployments()?; let found_depl = all_deployments .iter() - .find(|d| d.deployment.verity == image_id.to_hex()); + .find(|d| d.deployment.verity == id_v1.to_hex() || d.deployment.verity == id_v2.to_hex()); if let Some(collision) = found_depl { if is_switch { @@ -194,10 +200,13 @@ pub(crate) fn validate_update( .open_dir(STATE_DIR_RELATIVE) .context("Opening state dir")?; - if state_dir.exists(image_id.to_hex()) { - state_dir - .remove_dir_all(image_id.to_hex()) - .context("Removing state")?; + // Deployments may be keyed by either V1 or V2 digest; check both to + // clean up state from any prior version. + for id in [&id_v1, &id_v2] { + let hex = id.to_hex(); + if state_dir.exists(&hex) { + state_dir.remove_dir_all(&hex).context("Removing state")?; + } } Ok(UpdateAction::Proceed) @@ -236,6 +245,34 @@ async fn apply_upgrade( Ok(()) } +/// Refuse to deploy if `deploy_id` collides with an existing deployment +/// (booted, staged, rollback, or pinned). +/// +/// `deploy_id` names the `state/deploy/` directory whose `/etc` is +/// seeded from the deploying image. Two images from different sources can +/// produce identical content (hence the same fs-verity digest); silently +/// reusing such a directory would graft one image's `/etc` onto another, so we +/// bail instead. +/// +/// This complements `validate_update`, which only checks `switch` (and `Skip`s +/// for `upgrade`); the `img_pulled == None` path also reaches `do_upgrade` +/// without going through it, so the guard is enforced here against the real +/// deploy key. +fn ensure_no_deploy_collision(host: &Host, deploy_id: &Sha512HashValue) -> Result<()> { + let deploy_hex = deploy_id.to_hex(); + if let Some(collision) = host + .all_composefs_deployments()? + .iter() + .find(|d| d.deployment.verity == deploy_hex) + { + anyhow::bail!( + "Target image has the same fs-verity digest as the existing {:?} deployment.", + collision.ty, + ); + } + Ok(()) +} + /// Performs the Update or Switch operation #[context("Performing Upgrade Operation")] pub(crate) async fn do_upgrade( @@ -254,7 +291,7 @@ pub(crate) async fn do_upgrade( let crate::bootc_composefs::repo::PullRepoResult { repo, entries, - id, + boot_id, manifest_digest, } = pull_composefs_repo( imgref, @@ -263,56 +300,89 @@ pub(crate) async fn do_upgrade( ) .await?; - // If the target image produces the same fs-verity digest as any existing - // deployment (booted, staged, rollback, or pinned), error out. Two images - // from different sources can have identical content; we cannot silently reuse - // an existing state directory whose /etc was seeded from a different image. - let all_deployments = host.all_composefs_deployments()?; - if let Some(collision) = all_deployments - .iter() - .find(|d| d.deployment.verity == id.to_hex()) - { - anyhow::bail!( - "Target image has the same fs-verity digest as the existing {:?} deployment.", - collision.ty, - ); - } - let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); }; + let boot_type = BootType::from(entry); + + // Mounting just needs *a* valid bootable EROFS to read boot resources from; + // V1 and V2 are byte-distinct serializations of the same filesystem, so + // `boot_id` (the repo-default format from generate_boot_image) always works + // and is guaranteed to exist. let mounted_fs = Dir::reopen_dir( &repo - .mount(&id.to_hex()) + .mount(&boot_id.to_hex()) .context("Failed to mount composefs image")?, )?; - let boot_type = BootType::from(entry); + // Open the OCI image to read both stored boot EROFS digests. The UKI may + // have been sealed with either the V1 or V2 boot image digest, so we need + // both for verification. + let manifest_oci_digest: composefs_oci::OciDigest = manifest_digest + .parse() + .with_context(|| format!("Parsing manifest digest {manifest_digest}"))?; + let oci_img = composefs_oci::oci_image::OciImage::open(&repo, &manifest_oci_digest, None) + .context("Opening OCI image to read boot image refs")?; + let boot_id_v1 = oci_img.boot_image_ref_v1().cloned(); + let boot_id_v2 = oci_img.boot_image_ref_v2().cloned(); + + // The deployment key must equal the digest in the next-boot composefs karg + // (see setup_composefs_boot for the full rationale). V1 is the preferred + // default because it works on both RHEL9 (which lacks V2 support) and newer + // kernels. For BLS this is the final key; for UKI it is overridden by + // whatever digest the UKI cmdline actually carries. + // + // `provisional_format` tracks which EROFS format `provisional_deploy_id` + // actually is, so the BLS karg we write can be tagged correctly (see + // `build_composefs_karg`). + let (provisional_deploy_id, provisional_format) = match &boot_id_v1 { + Some(v1) => (v1.clone(), FormatVersion::V1), + None => (boot_id.clone(), repo.erofs_version()), + }; - let boot_digest = match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), - repo, - &id, - entry, - &mounted_fs, - )?, + let boot_ids: Vec = [boot_id_v1, boot_id_v2].into_iter().flatten().collect(); + + // Early collision check against the provisional key, before any ESP or + // bootloader writes. This catches BLS (where the provisional is the final + // key) and the common V1-sealed UKI case, avoiding leaving orphaned ESP + // entries behind on a bail. A UKI sealed with a V2 digest is still caught + // by the authoritative re-check below, once its real key is known. + ensure_no_deploy_collision(host, &provisional_deploy_id)?; + + let (boot_digest, deploy_id) = match boot_type { + BootType::Bls => ( + setup_composefs_bls_boot( + BootSetupType::Upgrade((storage, booted_cfs, &host)), + repo, + &provisional_deploy_id, + provisional_format, + entry, + &mounted_fs, + )?, + provisional_deploy_id, + ), BootType::Uki => setup_composefs_uki_boot( BootSetupType::Upgrade((storage, booted_cfs, &host)), repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, )?, }; + // Authoritative collision check against the final deploy key. For UKI this + // may differ from the provisional checked above (e.g. a UKI sealed with V2 + // instead of V1), so this is the load-bearing guarantee. + ensure_no_deploy_collision(host, &deploy_id)?; + write_composefs_state( &Utf8PathBuf::from("/sysroot"), - &id, + &deploy_id, imgref, Some(StagedDeployment { - depl_id: id.to_hex(), + depl_id: deploy_id.to_hex(), finalization_locked: opts.download_only, }), boot_type, @@ -334,7 +404,7 @@ pub(crate) async fn do_upgrade( ) .await?; - apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await + apply_upgrade(storage, booted_cfs, &deploy_id.to_hex(), opts).await } #[context("Upgrading composefs")] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 26a4ba8f3..d179b28dc 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -17,6 +17,7 @@ use clap::CommandFactory; use clap::Parser; use clap::ValueEnum; use composefs::dumpfile; +use composefs::erofs::format::FormatVersion; use composefs::fsverity; use composefs::fsverity::FsVerityHashValue; use composefs_ctl::composefs; @@ -405,6 +406,13 @@ pub(crate) enum ContainerOpts { /// Additionally generate a dumpfile written to the target path #[clap(long)] write_dumpfile_to: Option, + + /// EROFS format version to use when computing the composefs digest. + /// + /// V1 produces a `composefs.digest=v1-sha256-12:` karg (C-tool compatible). + /// V2 produces the legacy `composefs=` karg (composefs-rs native). + #[clap(long, default_value = "v1")] + erofs_version: ErofsVersionArg, }, /// Output the bootable composefs digest from container storage. #[clap(hide = true)] @@ -413,6 +421,13 @@ pub(crate) enum ContainerOpts { #[clap(long)] write_dumpfile_to: Option, + /// EROFS format version to use when computing the composefs digest. + /// + /// Must match the format used by `compute-composefs-digest` (and by + /// `container ukify`) for the two views to be comparable. + #[clap(long, default_value = "v1")] + erofs_version: ErofsVersionArg, + /// Identifier for image; if not provided, the running image will be used. image: Option, }, @@ -439,6 +454,14 @@ pub(crate) enum ContainerOpts { #[clap(long)] allow_missing_verity: bool, + /// EROFS format version to use when computing the composefs digest. + /// + /// V1 produces a `composefs.digest=v1-sha256-12:` karg (C-tool compatible). + /// V2 produces the legacy `composefs=` karg (composefs-rs native). + /// Must match the format version used when images were committed to the repository. + #[clap(long, default_value = "v1")] + erofs_version: ErofsVersionArg, + /// Write a dumpfile to this path #[clap(long)] write_dumpfile_to: Option, @@ -485,6 +508,24 @@ pub(crate) enum ContainerOpts { }, } +/// EROFS format version for `bootc container ukify --erofs-version`. +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub(crate) enum ErofsVersionArg { + /// V1 EROFS (C-tool compatible, `composefs.digest=v1-sha256-12:` karg). Default. + V1, + /// V2 EROFS (composefs-rs native, `composefs=` karg). + V2, +} + +impl From for FormatVersion { + fn from(v: ErofsVersionArg) -> Self { + match v { + ErofsVersionArg::V1 => FormatVersion::V1, + ErofsVersionArg::V2 => FormatVersion::V2, + } + } +} + #[derive(Debug, Clone, ValueEnum, PartialEq, Eq)] pub(crate) enum ExportFormat { /// Export as tar archive @@ -1844,16 +1885,23 @@ async fn run_from_opt(opt: Opt) -> Result<()> { ContainerOpts::ComputeComposefsDigest { path, write_dumpfile_to, + erofs_version, } => { - let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref()).await?; + let digest = compute_composefs_digest( + &path, + erofs_version.into(), + write_dumpfile_to.as_deref(), + ) + .await?; println!("{digest}"); Ok(()) } ContainerOpts::ComputeComposefsDigestFromStorage { write_dumpfile_to, + erofs_version, image, } => { - let (_td_guard, repo) = new_temp_composefs_repo()?; + let (_td_guard, repo) = new_temp_composefs_repo(erofs_version.into())?; let mut proxycfg = crate::deploy::new_proxy_config(); @@ -1912,6 +1960,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { rootfs, kargs, allow_missing_verity, + erofs_version, write_dumpfile_to, kernel_dir, args, @@ -1944,6 +1993,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { &args, kernel, allow_missing_verity, + erofs_version.into(), write_dumpfile_to.as_deref(), ) .await diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index fea62d28a..5cbaab51b 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2022,10 +2022,13 @@ async fn install_to_filesystem_impl( let imgref = &state.source.imageref; let img_manifest_config = get_container_manifest_and_config(&imgref).await?; crate::store::ensure_composefs_dir(&rootfs.physical_root)?; - // Use init_path since the repo may not exist yet during install - let config = + // Use init_path since the repo may not exist yet during install. + // Generate both V1 and V2 EROFS images (see initialize_composefs_repository); + // this config must match the one used there since it re-inits the same repo. + let mut config = RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512) .set_insecure(); + crate::store::set_dual_erofs_formats(&mut config); let (cfs_repo, _created) = crate::store::ComposefsRepository::init_path( &rootfs.physical_root, crate::store::COMPOSEFS, diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 1de39e4be..b9e30e11c 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -234,7 +234,7 @@ impl BLSConfig { .ok_or_else(|| anyhow::anyhow!("No options"))?; let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(&options)) - .ok_or_else(|| anyhow::anyhow!("No composefs= param"))?; + .ok_or_else(|| anyhow::anyhow!("No composefs= or composefs.digest= param"))?; Ok(cfs_cmdline.digest.to_string()) } diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 78926d173..79a4dd561 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -111,7 +111,7 @@ use ostree_ext::{gio, ostree}; use rustix::fs::Mode; use composefs::fsverity::Sha512HashValue; -use composefs::repository::RepositoryConfig; +use composefs::repository::{RepositoryConfig, RepositoryOpenError}; use composefs_ctl::composefs; use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix; @@ -125,6 +125,18 @@ use crate::utils::{deployment_fd, open_dir_remount_rw}; /// See pub type ComposefsRepository = composefs::repository::Repository; +/// Configure a [`RepositoryConfig`] to generate both V1 and V2 EROFS images. +/// +/// V1 is needed for RHEL9 kernels (which lack V2 support), V2 is the +/// preferred modern format. Having both on disk lets the same deployment +/// be booted via either `composefs.digest.v1=` or `composefs=` kargs. +pub(crate) fn set_dual_erofs_formats(config: &mut RepositoryConfig) { + config.erofs_formats = composefs::erofs::format::FormatConfig { + default: composefs::erofs::format::FormatVersion::V1, + extra: [composefs::erofs::format::FormatVersion::V2].into(), + }; +} + /// Path to the physical root pub const SYSROOT: &str = "sysroot"; @@ -621,14 +633,39 @@ impl Storage { let ostree = self.get_ostree()?; let ostree_repo = &ostree.repo(); let ostree_verity = ostree_ext::fsverity::is_verity_enabled(ostree_repo)?; - let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); - let config = if ostree_verity.enabled { - config - } else { - config.set_insecure() + if !ostree_verity.enabled { + tracing::debug!("Setting insecure mode for composefs repo"); + } + + // This is a runtime open-or-create path: the repository was almost + // always already initialized at install time, with a format + // (V2_ONLY or BOTH) chosen then. init_path() asserts that the + // requested config exactly matches the on-disk meta.json, so a + // static config here would fail to open repos created with the + // other format. Prefer open_path(), which adopts whatever format + // is on disk, and only fall back to init_path() (with BOTH, to + // match the install path) when the repository does not yet exist. + let composefs_dir = self.physical_root.open_dir(COMPOSEFS)?; + let composefs = match ComposefsRepository::open_path(&composefs_dir, ".") { + Ok(mut repo) => { + if !ostree_verity.enabled { + repo.set_insecure(); + } + repo + } + Err(RepositoryOpenError::MetadataMissing) => { + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + set_dual_erofs_formats(&mut config); + let config = if !ostree_verity.enabled { + config.set_insecure() + } else { + config + }; + let (repo, _created) = ComposefsRepository::init_path(&composefs_dir, ".", config)?; + repo + } + Err(e) => return Err(e.into()), }; - let (composefs, _created) = - ComposefsRepository::init_path(self.physical_root.open_dir(COMPOSEFS)?, ".", config)?; let composefs = Arc::new(composefs); let r = Arc::clone(self.composefs.get_or_init(|| composefs)); Ok(r) diff --git a/crates/lib/src/testutils.rs b/crates/lib/src/testutils.rs index e24a80a5f..9b682d3d4 100644 --- a/crates/lib/src/testutils.rs +++ b/crates/lib/src/testutils.rs @@ -25,16 +25,16 @@ use crate::store::ComposefsRepository; use ostree_ext::container::deploy::ORIGIN_CONTAINER; -/// Return a deterministic SHA-256 hex digest for a test build version. +/// Return a deterministic SHA-512 hex digest for a test build version. /// -/// Computes `sha256("build-{n}")`, producing a realistic 64-char hex digest +/// Computes `sha512("build-{n}")`, producing a realistic 128-char hex digest /// that is stable across runs. pub(crate) fn fake_digest_version(n: u32) -> String { let hash = openssl::hash::hash( - openssl::hash::MessageDigest::sha256(), + openssl::hash::MessageDigest::sha512(), format!("build-{n}").as_bytes(), ) - .expect("sha256"); + .expect("sha512"); hex::encode(hash) } @@ -499,10 +499,10 @@ impl TestRoot { } } LayoutMode::Legacy => { - // Legacy dirs are just the raw hex digest (64 chars). + // Legacy dirs are just the raw hex digest (128 chars for SHA-512). // Only include entries that look like hex digests to // avoid accidentally counting "loader" or other dirs. - if name.len() == 64 && name.chars().all(|c| c.is_ascii_hexdigit()) { + if name.len() == 128 && name.chars().all(|c| c.is_ascii_hexdigit()) { names.push(name); } } @@ -542,7 +542,7 @@ impl TestRoot { // compared to the real migration in PR #2128 which also // handles UKI PE files and GRUB configs. if !name.starts_with(TYPE1_BOOT_DIR_PREFIX) - && name.len() == 64 + && name.len() == 128 && name.chars().all(|c| c.is_ascii_hexdigit()) { to_rename.push(name); diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 523836217..525294a05 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -13,8 +13,12 @@ use camino::Utf8Path; use cap_std_ext::cap_std::fs::Dir; use fn_error_context::context; +use composefs::erofs::format::FormatVersion; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_ctl::composefs; + use crate::bootc_composefs::digest::compute_composefs_digest; -use crate::bootc_composefs::status::ComposefsCmdline; +use crate::bootc_composefs::status::build_composefs_karg; use crate::kernel::KernelInternal; /// Build a UKI from the given rootfs. @@ -33,6 +37,7 @@ pub(crate) async fn build_ukify( args: &[OsString], kernel: Option, allow_missing_fsverity: bool, + erofs_version: FormatVersion, write_dumpfile_to: Option<&Utf8Path>, ) -> Result<()> { // Warn if --karg is used (temporary workaround) @@ -97,15 +102,22 @@ pub(crate) async fn build_ukify( } // Compute the composefs digest - let composefs_digest = compute_composefs_digest(rootfs, write_dumpfile_to).await?; + let composefs_digest = + compute_composefs_digest(rootfs, erofs_version, write_dumpfile_to).await?; + let composefs_digest = Sha512HashValue::from_hex(&composefs_digest) + .context("Parsing computed composefs digest")?; // Get kernel arguments from kargs.d let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; - // Add the composefs digest - cmdline.extend(&Cmdline::from( - ComposefsCmdline::build(&composefs_digest, allow_missing_fsverity).to_string(), - )); + // Add the composefs digest, tagging the karg with the same EROFS format + // version used to compute it so it stays boot-compatible (see + // `build_composefs_karg`). + cmdline.extend(&Cmdline::from(build_composefs_karg( + composefs_digest, + erofs_version, + allow_missing_fsverity, + ))); // Add any extra kargs provided via --karg for karg in extra_kargs { @@ -152,7 +164,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); - let result = build_ukify(path, &[], &[], None, false, None).await; + let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -174,7 +186,7 @@ mod tests { ) .unwrap(); - let result = build_ukify(path, &[], &[], None, false, None).await; + let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index a7d8e89d6..be561ebbf 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -330,6 +330,127 @@ pub(crate) fn test_compute_composefs_digest() -> Result<()> { Ok(()) } +/// Test that `bootc container ukify --erofs-version` is plumbed correctly. +/// +/// Verifies that: +/// - `compute-composefs-digest --erofs-version=v1` and `=v2` produce distinct, +/// valid 128-char SHA-512 hex digests (different EROFS layouts → different IDs). +/// - `bootc container ukify --erofs-version=v1` either invokes ukify (skipping +/// gracefully if ukify is absent) or fails with a clear error before ukify. +pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + // Build a minimal rootfs that satisfies find_kernel() and build_ukify()'s + // existence checks. The files don't need to be real ELF/CPIO — bootc only + // stat-checks them before handing them off to ukify. + let td = tempfile::tempdir()?; + let root = td.path(); + + fs::create_dir_all(root.join("boot"))?; + fs::create_dir_all(root.join("sysroot"))?; + + let usr_bin = root.join("usr/bin"); + fs::create_dir_all(&usr_bin)?; + let hello = usr_bin.join("hello"); + fs::write(&hello, b"#!/bin/sh\necho hello\n")?; + fs::set_permissions(&hello, fs::Permissions::from_mode(0o755))?; + + // Kernel layout that find_kernel() expects + let kver = "6.1.0-test"; + let mod_dir = root.join("usr/lib/modules").join(kver); + fs::create_dir_all(&mod_dir)?; + fs::write(mod_dir.join("vmlinuz"), b"fake-vmlinuz")?; + fs::write(mod_dir.join("initramfs.img"), b"fake-initramfs")?; + + // ukify reads --os-release @usr/lib/os-release relative to the rootfs cwd + let os_release_dir = root.join("usr/lib"); + fs::create_dir_all(&os_release_dir)?; + fs::write( + os_release_dir.join("os-release"), + b"ID=test\nNAME=Test\nVERSION_ID=1\n", + )?; + + let root_str = root.to_str().unwrap(); + + // ── Part 1: compare V1 vs V2 digest via compute-composefs-digest ────────── + let sh = Shell::new()?; + + let digest_v2 = cmd!( + sh, + "bootc container compute-composefs-digest {root_str} --erofs-version=v2" + ) + .read()?; + let digest_v1 = cmd!( + sh, + "bootc container compute-composefs-digest {root_str} --erofs-version=v1" + ) + .read()?; + + let digest_v2 = digest_v2.trim(); + let digest_v1 = digest_v1.trim(); + + assert_eq!( + digest_v2.as_bytes().len(), + 128, + "V2 digest must be 128 hex chars" + ); + assert_eq!( + digest_v1.as_bytes().len(), + 128, + "V1 digest must be 128 hex chars" + ); + assert!( + digest_v2.chars().all(|c| c.is_ascii_hexdigit()), + "V2 digest contains non-hex chars: {digest_v2}" + ); + assert!( + digest_v1.chars().all(|c| c.is_ascii_hexdigit()), + "V1 digest contains non-hex chars: {digest_v1}" + ); + assert_ne!( + digest_v1, digest_v2, + "V1 and V2 EROFS digests must differ (they use different on-disk layouts)" + ); + + // ── Part 2: smoke-test the full ukify CLI path with --erofs-version=v1 ──── + // + // We don't assert success because ukify will fail on fake kernel blobs. + // What we're testing is that bootc reaches the ukify invocation stage — + // i.e. the --erofs-version plumbing is wired correctly all the way through. + let output = Command::new("bootc") + .args([ + "container", + "ukify", + "--rootfs", + root_str, + "--erofs-version=v1", + "--allow-missing-verity", + "--", + "--output=/dev/null", + ]) + .output()?; + + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.contains("ukify executable not found in PATH") { + // ukify binary absent: the CLI plumbing still ran up to that check. + eprintln!("note: ukify not found, skipping ukify invocation check"); + return Ok(()); + } + + // ukify was found and invoked. It will fail because of the fake kernel + // blobs, but bootc must have reached the `ukify build` invocation, which + // means the V1 digest was computed and the cmdline assembled. Assert that + // no *bootc* logic bailed before reaching ukify (i.e. no "No kernel found", + // "already contains a UKI", or similar early exits). + assert!( + !stderr.contains("No kernel found") && !stderr.contains("already contains a UKI"), + "bootc bailed before reaching ukify; stderr:\n{stderr}" + ); + + Ok(()) +} + /// Tests that should be run in a default container image. #[context("Container tests")] pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { @@ -343,6 +464,10 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { new_test("system-reinstall --help", test_system_reinstall_help), new_test("container export tar", test_container_export_tar), new_test("compute-composefs-digest", test_compute_composefs_digest), + new_test( + "container-ukify-erofs-versions", + test_container_ukify_erofs_versions, + ), ]; libtest_mimic::run(&testargs, tests.into()).exit() diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index a46c7b153..12fafe7fe 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -517,8 +517,11 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { let mut opts = Vec::new(); - // If test wants bind storage and distro supports it, add --bind-storage-ro - if try_bind_storage && supports_bind_storage_ro { + // If test wants bind storage, the distro supports it, and it wasn't + // explicitly disabled, add --bind-storage-ro + let use_bind_storage = + try_bind_storage && supports_bind_storage_ro && !args.skip_bind_storage; + if use_bind_storage { opts.push(BCVK_OPT_BIND_STORAGE_RO.to_string()); // If upgrade image is provided, set it as an environment variable for tmt @@ -526,6 +529,10 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { if let Some(ref upgrade_img) = args.upgrade_image { tmt_env_vars.push(format!("{}={}", ENV_BOOTC_UPGRADE_IMAGE, upgrade_img)); } + } else if try_bind_storage && args.skip_bind_storage { + println!( + "Note: Test requests bind storage but --skip-bind-storage was set; running without host container-storage mount" + ); } else if try_bind_storage && !supports_bind_storage_ro { println!( "Note: Test wants bind storage but skipping on {} (missing systemd.extra-unit.* support)", diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 17feee550..e09567b86 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -45,6 +45,18 @@ fn out_of_sync_error(message: &str) -> Result<()> { anyhow::bail!("{}; run `just update-generated` to update it", message) } +/// Parse a `0`/`1` boolean from a CLI/env value so the flag can be driven from +/// the Justfile (e.g. `BOOTC_skip_bind_storage=1`). +fn parse_cli_bool(s: &str) -> std::result::Result { + match s { + "1" | "true" => Ok(true), + "0" | "false" => Ok(false), + other => Err(format!( + "invalid value '{other}' (expected 0, 1, true, or false)" + )), + } +} + /// Build tasks for bootc #[derive(Debug, Parser)] #[command(name = "xtask")] @@ -231,6 +243,24 @@ pub(crate) struct RunTmtArgs { #[clap(long)] pub(crate) upgrade_image: Option, + /// Skip the `--bind-storage-ro` host container-storage virtiofs mount even for + /// plans that request it. Useful where libvirt-managed virtiofsd cannot run + /// (nested user namespaces, cloud/non-qemu). Plans that depend on a locally + /// built upgrade image being available in-VM via bind-storage will not be able + /// to perform the upgrade/switch step. + /// + /// Takes `0`/`1`/`true`/`false` so it can be driven from the Justfile via + /// `BOOTC_skip_bind_storage=1`. A bare `--skip-bind-storage` means `1`. + #[arg( + long, + env = "BOOTC_skip_bind_storage", + num_args = 0..=1, + default_value_t = false, + default_missing_value = "1", + value_parser = parse_cli_bool, + )] + pub(crate) skip_bind_storage: bool, + /// Preserve VMs after test completion (useful for debugging) #[arg(long)] pub(crate) preserve_vm: bool, @@ -774,3 +804,18 @@ fn validate_composefs_digest(sh: &Shell, args: &ValidateComposefsDigestArgs) -> anyhow::bail!("Composefs digest mismatch"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cli_bool() { + assert_eq!(parse_cli_bool("1"), Ok(true)); + assert_eq!(parse_cli_bool("true"), Ok(true)); + assert_eq!(parse_cli_bool("0"), Ok(false)); + assert_eq!(parse_cli_bool("false"), Ok(false)); + assert!(parse_cli_bool("").is_err()); + assert!(parse_cli_bool("maybe").is_err()); + } +} diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade index 561e2e0a7..996f3a306 100644 --- a/tmt/tests/Dockerfile.upgrade +++ b/tmt/tests/Dockerfile.upgrade @@ -8,6 +8,7 @@ ARG boot_type=bls ARG seal_state=unsealed ARG filesystem=ext4 +ARG erofs_version=v1 # Capture contrib/packaging scripts for use in later stages FROM scratch AS packaging @@ -31,7 +32,7 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ # bootc is already installed in localhost/bootc (our tools base); the # container ukify command it provides is needed for seal-uki. FROM tools AS sealed-upgrade-uki -ARG boot_type seal_state filesystem +ARG boot_type seal_state filesystem erofs_version RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=secret,id=secureboot_key \ --mount=type=secret,id=secureboot_cert \ @@ -45,7 +46,7 @@ if [ "${filesystem}" = "xfs" ]; then fi if test "${boot_type}" = "uki"; then - /run/packaging/seal-uki /run/target /out /run/secrets "${allow_missing_verity}" "${seal_state}" + /run/packaging/seal-uki /run/target /out /run/secrets "${allow_missing_verity}" "${seal_state}" "${erofs_version}" fi EORUN diff --git a/tmt/tests/booted/readonly/046-test-erofs-version.nu b/tmt/tests/booted/readonly/046-test-erofs-version.nu new file mode 100644 index 000000000..14e227041 --- /dev/null +++ b/tmt/tests/booted/readonly/046-test-erofs-version.nu @@ -0,0 +1,64 @@ +use std assert +use tap.nu + +tap begin "verify composefs UKI EROFS version boots correctly" + +let is_composefs = (tap is_composefs) + +if not $is_composefs { + print "# Skipping: not a composefs system" + tap ok + exit 0 +} + +let st = bootc status --json | from json +let is_uki = ($st.status.booted.composefs.bootType | str downcase) == "uki" + +if not $is_uki { + print "# Skipping: not a UKI boot" + tap ok + exit 0 +} + +let erofs_version = ($env.BOOTC_erofs_version? | default "v1") +print $"# Testing EROFS version: ($erofs_version)" + +# Verify composefs is active and status is healthy +assert (tap is_composefs) "composefs must be active" + +# Verify verity digest is a 128-char hex string (SHA-512) +let verity = $st.status.booted.composefs.verity +assert equal ($verity | str length) 128 "verity digest must be 128 hex chars" +print $"# Verified verity digest length: 128" + +# The karg format depends on which EROFS version was sealed into the UKI: +# v1 -> composefs.digest=v1--: (self-describing form) +# v2 -> composefs= (legacy shorthand) +let cmdline = open /proc/cmdline | str trim +let params = ($cmdline | split row " ") + +let cfs_digest = if $erofs_version == "v1" { + assert ( + $cmdline | str contains "composefs.digest=" + ) $"Expected composefs.digest= karg in cmdline, got: ($cmdline)" + + let param = ($params | where { |p| $p | str starts-with "composefs.digest=" } | first) + let value = ($param | str replace "composefs.digest=" "") + # Strip optional leading '?' for insecure mode, then the "v1--:" descriptor + let value = (if ($value | str starts-with "?") { $value | str substring 1.. } else { $value }) + ($value | split row ":" | last) +} else { + assert ( + $cmdline | str contains "composefs=" + ) $"Expected composefs= karg in cmdline, got: ($cmdline)" + + let param = ($params | where { |p| $p | str starts-with "composefs=" } | first) + let value = ($param | str replace "composefs=" "") + # Strip optional leading '?' for insecure mode + (if ($value | str starts-with "?") { $value | str substring 1.. } else { $value }) +} + +assert equal $cfs_digest $verity "composefs karg digest must match booted verity digest" +print $"# Verified composefs karg matches verity ($erofs_version)" + +tap ok diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index b8b0b3f7e..6e269508c 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -75,7 +75,17 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string] { +# Append UKI-sealing stages to a Containerfile string. +# +# On non-composefs or non-UKI systems the input is returned unchanged. +# erofs_version controls which EROFS format the composefs digest is computed +# in: "v1" produces a composefs.digest=v1-sha256-12: karg (RHEL9-compatible), +# "v1" (default) produces a composefs.digest=v1-sha256-12: karg. +# "v2" produces the legacy composefs= karg. +export def make_uki_containerfile [ + containerfile: string + --erofs-version: string = "v1" +] { let is_cfs = (is_composefs) if not $is_cfs { @@ -100,7 +110,7 @@ export def make_uki_containerfile [containerfile: string] { FROM base as sealed-uki RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \\ --mount=type=bind,from=base-final,src=/,target=/run/target \\ - /usr/bin/seal-uki /run/target /out /run/secrets ($allow_missing_verity) ($seal_state) + /usr/bin/seal-uki /run/target /out /run/secrets ($allow_missing_verity) ($seal_state) ($erofs_version) FROM base-final diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh index 8d588dd48..d0e42d3b7 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -44,7 +44,7 @@ if [[ $is_composefs != "null" && $boot_type == "uki" ]]; then FROM base as sealed-uki RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=base-final,src=/,target=/run/target \ - /usr/bin/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state + /usr/bin/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state v1 FROM base-final