From 58e3a0730a885080380e1717908ad0144d0a3bfd Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sun, 31 May 2026 10:58:42 -0400 Subject: [PATCH 1/3] xtask: Add --skip-bind-storage to run-tmt The `--bind-storage-ro` host container-storage passthrough relies on a libvirt-managed virtiofsd, which cannot run in some environments such as nested user namespaces or cloud/non-qemu setups. Plans that normally request bind-storage previously had no way to opt out short of editing plan metadata. Add a `--skip-bind-storage` flag (and matching `BOOTC_skip_bind_storage` env var) that forces those plans to run without the host container-storage mount. Default behavior is unchanged: bind-storage is still used wherever it is requested and supported. Plans that depend on a locally built upgrade image reaching the VM via bind-storage will be unable to perform the upgrade/switch step when this is set. Signed-off-by: Colin Walters --- crates/xtask/src/tmt.rs | 11 ++++++++-- crates/xtask/src/xtask.rs | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) 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..424e31bfa 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -45,6 +45,16 @@ 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 +241,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 +802,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()); + } +} From 38c3e49666e901f587e1dc56bf59c8709efe3bb2 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 25 May 2026 09:26:48 -0400 Subject: [PATCH 2/3] composefs: Generate both V1 and V2 EROFS images on install composefs-rs landed support for V1 EROFS, which we need to enable composefs on RHEL9. Make new installs produce both V1 and V2 EROFS images for committed composefs images, and make V1 the default wherever a single format must be chosen: the repository's default EROFS format, the `--erofs-version` flag on `bootc container ukify` and `compute-composefs-digest`, and the provisional BLS deploy key computed at install time. V2 remains available via `--erofs-version=v2` and is always generated alongside V1, so a deployment can still be booted via the legacy `composefs=` karg. This keeps the install path consistent with the upgrade and GC paths, which already prefer V1. Critically, a V1 digest must be written as a `composefs.digest=v1-...` karg, not the legacy `composefs=` shorthand (which upstream reserves for V2). Add `build_composefs_karg`, which selects the correct form via composefs-boot's own `ComposefsCmdline::new_v1`/`new_v2` and `to_cmdline_arg`, and use it everywhere bootc writes a new karg (install, upgrade, `container ukify`, soft-reboot) instead of the version-unaware helper that only ever emitted `composefs=`. Signed-off-by: Colin Walters --- Dockerfile | 4 +- Justfile | 6 +- contrib/packaging/seal-uki | 11 +- crates/initramfs/bootc-root-setup.service | 3 +- crates/lib/src/bootc_composefs/boot.rs | 246 +++++++++++++++--- crates/lib/src/bootc_composefs/digest.rs | 29 ++- crates/lib/src/bootc_composefs/gc.rs | 24 +- crates/lib/src/bootc_composefs/repo.rs | 21 +- crates/lib/src/bootc_composefs/soft_reboot.rs | 18 +- crates/lib/src/bootc_composefs/status.rs | 114 ++++++-- crates/lib/src/bootc_composefs/update.rs | 142 +++++++--- crates/lib/src/cli.rs | 54 +++- crates/lib/src/composefs_consts.rs | 4 +- crates/lib/src/install.rs | 7 +- crates/lib/src/parsers/bls_config.rs | 2 +- crates/lib/src/store/mod.rs | 53 +++- crates/lib/src/testutils.rs | 14 +- crates/lib/src/ukify.rs | 28 +- crates/tests-integration/src/container.rs | 125 +++++++++ crates/xtask/src/xtask.rs | 4 +- tmt/tests/Dockerfile.upgrade | 6 +- .../booted/readonly/046-test-erofs-version.nu | 64 +++++ tmt/tests/booted/tap.nu | 15 +- .../test-install-to-filesystem-var-mount.sh | 1 + 24 files changed, 823 insertions(+), 172 deletions(-) create mode 100644 tmt/tests/booted/readonly/046-test-erofs-version.nu diff --git a/Dockerfile b/Dockerfile index 4de0c2e8f..ca439d0f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -347,6 +347,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 \ @@ -374,7 +375,8 @@ if test "${boot_type}" = "uki"; then --secrets /run/secrets \ "${allow_missing_verity[@]}" \ --kernel-dir "/run/kernel/$kver" \ - --seal-state $seal_state + --seal-state $seal_state \ + --erofs-version $erofs_version fi EORUN diff --git a/Justfile b/Justfile index 3c16ea1aa..206ea26af 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" \ @@ -274,7 +277,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 @@ -492,6 +495,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 bfc7c4bec..b89935945 100755 --- a/contrib/packaging/seal-uki +++ b/contrib/packaging/seal-uki @@ -3,6 +3,8 @@ set -xeuo pipefail missing_verity=() +# EROFS format version to pass to bootc container ukify (optional, default: v1) +erofs_version=v1 while [ ! -z "${1:-}" ]; do case "$1" in @@ -45,6 +47,13 @@ while [ ! -z "${1:-}" ]; do shift ;; + # EROFS format version to pass to bootc container ukify + "--erofs-version") + erofs_version="$2" + shift + shift + ;; + * ) echo "Argument $1 not understood" exit 1 @@ -85,4 +94,4 @@ containerukifyargs=(--rootfs "${target}") # Build the UKI using bootc container ukify # This computes the composefs digest, reads kargs from kargs.d, and invokes ukify -bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" -- "${ukifyargs[@]}" +bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" --erofs-version="${erofs_version}" -- "${ukifyargs[@]}" diff --git a/crates/initramfs/bootc-root-setup.service b/crates/initramfs/bootc-root-setup.service index 23525c7bc..99c442f53 100644 --- a/crates/initramfs/bootc-root-setup.service +++ b/crates/initramfs/bootc-root-setup.service @@ -2,7 +2,8 @@ Description=bootc setup root Documentation=man:bootc(1) DefaultDependencies=no -ConditionKernelCommandLine=composefs +ConditionKernelCommandLine=|composefs +ConditionKernelCommandLine=|composefs.digest ConditionPathExists=/etc/initrd-release After=sysroot.mount After=ostree-prepare-root.service diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index dd4079c7a..3d664c34f 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -74,6 +74,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; @@ -82,21 +83,24 @@ 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; use composefs_ctl::composefs_oci; use fn_error_context::context; -use linux_kernel_cmdline::utf8::{Cmdline, Parameter}; +use linux_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey}; use rustix::{mount::MountFlags, path::Arg}; 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::composefs_consts::{ + COMPOSEFS_CMDLINE, COMPOSEFS_DIGEST_CMDLINE, TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, + TYPE1_ENT_PATH_STAGED, +}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; use crate::spec::BootloaderKind; use crate::task::Task; @@ -134,6 +138,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)), @@ -484,6 +489,29 @@ struct BLSEntryPath { config_path: Utf8PathBuf, } +/// Replace the composefs karg in `cmdline` with `new_karg`. +/// +/// The composefs karg's *key* differs by EROFS format version: V2 (and the +/// legacy pre-versioned format) uses `composefs=`, while V1/V0 use the +/// self-describing `composefs.digest=` form (see [`build_composefs_karg`]). +/// Since [`Cmdline::add_or_modify`] only replaces a parameter with a +/// matching key, switching formats between deployments (e.g. downgrading +/// from a V1 to a V2 deployment) would otherwise leave the *old* key's karg +/// behind on the command line. A stale `composefs.digest=` left over from a +/// prior V1 deployment takes precedence in composefs-boot's cmdline parser, +/// which would cause the wrong (old, possibly garbage-collected) rootfs to +/// be mounted. Remove both possible keys before adding the new one so only +/// it remains. +fn replace_composefs_karg(cmdline: &mut Cmdline, new_karg: &str) -> Result<()> { + cmdline.remove(&ParameterKey::from(COMPOSEFS_CMDLINE)); + cmdline.remove(&ParameterKey::from(COMPOSEFS_DIGEST_CMDLINE)); + + let param = Parameter::parse(new_karg).context("Failed to create 'composefs=' parameter")?; + cmdline.add_or_modify(¶m); + + Ok(()) +} + /// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk /// /// # Returns @@ -493,6 +521,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 +534,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 @@ -553,14 +585,13 @@ pub(crate) fn setup_composefs_bls_boot( _ => anyhow::bail!("Found NonEFI config"), }; - // Copy all cmdline args, replacing only `composefs=` - let cfs_cmdline = - ComposefsCmdline::build(&id_hex, booted_cfs.cmdline.allow_missing_fsverity) - .to_string(); - - let param = Parameter::parse(&cfs_cmdline) - .context("Failed to create 'composefs=' parameter")?; - cmdline.add_or_modify(¶m); + // Copy all cmdline args, replacing the composefs karg + let cfs_cmdline = build_composefs_karg( + id.clone(), + format_version, + booted_cfs.cmdline.allow_missing_fsverity, + ); + replace_composefs_karg(&mut cmdline, &cfs_cmdline)?; // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; @@ -784,6 +815,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 +830,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 +849,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 +873,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 +892,7 @@ fn write_pe_to_esp( version: parsed_osrel.get_version(), os_id: parsed_osrel.get_value(&["ID"]), boot_digest, + composefs_digest, }); } @@ -888,8 +926,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 +1123,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 +1205,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 +1223,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 +1419,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 +1521,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, @@ -1481,6 +1589,58 @@ pub(crate) async fn setup_composefs_boot( mod tests { use super::*; + #[test] + fn test_replace_composefs_karg_removes_stale_v1_digest() { + // Simulate a system that most recently booted a V1-formatted + // deployment (`composefs.digest=`), now upgrading/downgrading to a + // V2-formatted one (`composefs=`). + let mut cmdline = + Cmdline::from("root=UUID=abc rw composefs.digest=v1-sha512-12:aabbcc console=ttyS0"); + + let new_hex = "ff".repeat(64); + let new_karg = format!("composefs={new_hex}"); + replace_composefs_karg(&mut cmdline, &new_karg).unwrap(); + + let rendered = cmdline.to_string(); + + // The stale V1 key must be gone entirely, not just its value updated, + // since composefs-boot's cmdline parser checks `composefs.digest=` + // before `composefs=` and would otherwise pick the wrong deployment. + assert!( + !rendered.contains("composefs.digest"), + "stale composefs.digest karg was not removed: {rendered}" + ); + assert!( + rendered.contains(&new_karg), + "new composefs karg missing: {rendered}" + ); + // Unrelated kargs are preserved. + assert!(rendered.contains("root=UUID=abc")); + assert!(rendered.contains("console=ttyS0")); + } + + #[test] + fn test_replace_composefs_karg_removes_stale_v2_composefs() { + // The reverse direction: upgrading from a V2-formatted deployment to + // a V1-formatted one should remove the old `composefs=` key too. + let mut cmdline = Cmdline::from("root=UUID=abc rw composefs=aabbcc"); + + let new_hex = "ff".repeat(64); + let new_karg = format!("composefs.digest=v1-sha512-12:{new_hex}"); + replace_composefs_karg(&mut cmdline, &new_karg).unwrap(); + + let rendered = cmdline.to_string(); + + assert!( + !rendered.contains("composefs=aabbcc"), + "stale composefs karg was not removed: {rendered}" + ); + assert!( + rendered.contains(&new_karg), + "new composefs.digest karg missing: {rendered}" + ); + } + #[test] fn test_type1_filename_generation() { // Test basic os_id without hyphens 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 1d8ecfc22..393ae84f6 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}, @@ -13,6 +13,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 linux_kernel_cmdline::utf8::Cmdline; use ostree_ext::systemd_has_soft_reboot; @@ -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 e37790373..316f716f3 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -2,7 +2,9 @@ use std::{io::Read, sync::OnceLock}; use anyhow::{Context, Result}; 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; @@ -18,8 +20,9 @@ use crate::{ utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ - COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, - TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, USER_CFG_STAGED, + COMPOSEFS_CMDLINE, COMPOSEFS_DIGEST_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, + ORIGIN_KEY_MANIFEST_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, + USER_CFG_STAGED, }, install::EFI_LOADER_INFO, parsers::{ @@ -88,23 +91,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, + }) } } @@ -119,6 +115,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)] @@ -150,18 +177,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())?; @@ -656,10 +682,15 @@ fn find_bls_entry<'a>( Ok(None) } -/// Compares cmdline `first` and `second` skipping `composefs=` +/// Compares cmdline `first` and `second` skipping composefs kargs. +/// +/// Both V2 (`composefs=`) and V1 (`composefs.digest=`) karg keys are +/// skipped, since the composefs digest is expected to differ between +/// deployments and is compared separately. fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool { for param in first { - if param.key() == COMPOSEFS_CMDLINE.into() { + let key = param.key(); + if key == COMPOSEFS_CMDLINE.into() || key == COMPOSEFS_DIGEST_CMDLINE.into() { continue; } @@ -1089,6 +1120,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())?; @@ -1194,7 +1255,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 1f48021c7..bc37665ba 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 b70eb44ce..7184ad2e2 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; @@ -406,6 +407,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)] @@ -414,6 +422,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, }, @@ -457,6 +472,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, @@ -503,6 +526,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 @@ -1913,16 +1954,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(); @@ -1981,6 +2029,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { rootfs, kargs, allow_missing_verity, + erofs_version, write_dumpfile_to, kernel_dir, args, @@ -2013,6 +2062,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/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 8617f1005..6e455980a 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -1,5 +1,7 @@ -/// composefs= parameter in kernel cmdline +/// composefs= parameter in kernel cmdline (V2 format) pub const COMPOSEFS_CMDLINE: &str = "composefs"; +/// composefs.digest= parameter in kernel cmdline (V1 format) +pub const COMPOSEFS_DIGEST_CMDLINE: &str = "composefs.digest"; /// Directory to store transient state, such as staged deployemnts etc pub(crate) const COMPOSEFS_TRANSIENT_STATE_DIR: &str = "/run/composefs"; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 78974fef5..a3edd21e1 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2023,10 +2023,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 c796ffdab..f13b67e23 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 cd434a396..d4f3f62d8 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -13,8 +13,12 @@ use cap_std_ext::cap_std::fs::Dir; use fn_error_context::context; use linux_kernel_cmdline::utf8::Cmdline; +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 14396107b..951acc24e 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -351,6 +351,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<()> { @@ -364,6 +485,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/xtask.rs b/crates/xtask/src/xtask.rs index 424e31bfa..e09567b86 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -51,7 +51,9 @@ 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)")), + other => Err(format!( + "invalid value '{other}' (expected 0, 1, true, or false)" + )), } } diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade index b66393114..a4878c181 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 @@ -41,7 +42,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 \ @@ -65,7 +66,8 @@ if test "${boot_type}" = "uki"; then --secrets /run/secrets \ "${allow_missing_verity[@]}" \ --kernel-dir "/run/kernel/boot/$kver" \ - --seal-state $seal_state + --seal-state $seal_state \ + --erofs-version $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 c25a89479..741013fe7 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 { @@ -117,7 +127,8 @@ export def make_uki_containerfile [containerfile: string] { --output /out \\ --secrets /run/secrets ($allow_missing_verity) \\ --kernel-dir /run/kernel/boot/$\(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version'\) \\ - --seal-state ($seal_state) + --seal-state ($seal_state) \\ + --erofs-version ($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 94cdd4d3f..6f6f657bd 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -65,6 +65,7 @@ RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --secrets /run/secrets \ --kernel-dir /run/kernel/boot/\$kver \ --seal-state $seal_state \ + --erofs-version v1 \ "${allow_missing_verity[@]}" RUNEOF From cbff4b53f0f30d9485d45616407103e8306da905 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 2 Jul 2026 18:08:51 -0400 Subject: [PATCH 3/3] ci: Enable composefs sealed UKI tests on centos-9 With V1 EROFS as the default, sealed UKIs are now viable on CentOS-9. Replace the blanket composefs exclusion for centos-9 with targeted exclusions for the modes that still don't work: BLS (needs newer dracut) and unsealed composefs. This leaves composefs + sealed + UKI enabled. Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1112bab3..357822ac5 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"