From e9c890bf8774fca8e463c8f0630bc38b7190b1a1 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sun, 31 May 2026 10:58:42 -0400 Subject: [PATCH 1/4] 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 12111f46e5..2d2c5eaa19 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -542,8 +542,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 @@ -551,6 +554,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 17feee550f..424e31bfad 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 bf19060af0859f5bcc30edaa3c53bf451b3c75e8 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 25 May 2026 09:26:48 -0400 Subject: [PATCH 2/4] 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 | 9 +- crates/initramfs/bootc-root-setup.service | 3 +- crates/lib/src/bootc_composefs/boot.rs | 148 +++++++++++++----- crates/lib/src/bootc_composefs/digest.rs | 29 +++- crates/lib/src/bootc_composefs/gc.rs | 39 +++-- crates/lib/src/bootc_composefs/repo.rs | 5 +- crates/lib/src/bootc_composefs/soft_reboot.rs | 18 ++- crates/lib/src/bootc_composefs/status.rs | 78 ++++++--- crates/lib/src/bootc_composefs/update.rs | 71 ++++++--- 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 | 15 +- 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 +- docs/src/man/bootc-container-ukify.8.md | 10 ++ tmt/tests/Dockerfile.upgrade | 6 +- .../booted/readonly/046-test-erofs-version.nu | 64 ++++++++ tmt/tests/booted/tap.nu | 5 +- .../test-install-to-filesystem-var-mount.sh | 1 + 25 files changed, 608 insertions(+), 141 deletions(-) create mode 100644 tmt/tests/booted/readonly/046-test-erofs-version.nu diff --git a/Dockerfile b/Dockerfile index bd8c289a89..8c68ba7687 100644 --- a/Dockerfile +++ b/Dockerfile @@ -354,6 +354,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 \ @@ -381,7 +382,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 86f14dd278..692af2d109 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 @@ -75,6 +77,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" \ @@ -290,7 +293,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 @@ -508,6 +511,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 7ee03b44c6..d83c5e72f6 100755 --- a/contrib/packaging/seal-uki +++ b/contrib/packaging/seal-uki @@ -4,6 +4,7 @@ set -xeuo pipefail missing_verity=() dumpfile_args=() +erofs_version=v1 while [ ! -z "${1:-}" ]; do case "$1" in @@ -45,6 +46,12 @@ while [ ! -z "${1:-}" ]; do shift ;; + "--erofs-version") + erofs_version="$2" + shift + shift + ;; + # Path to the directory containing kernel and initramfs "--kernel-dir") kernel_dir="$2" @@ -92,4 +99,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[@]}" "${dumpfile_args[@]}" -- "${ukifyargs[@]}" +bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" "${dumpfile_args[@]}" --erofs-version="${erofs_version}" -- "${ukifyargs[@]}" diff --git a/crates/initramfs/bootc-root-setup.service b/crates/initramfs/bootc-root-setup.service index 23525c7bc2..99c442f532 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 c87110ba55..327f4c1729 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -76,6 +76,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::{FileSystem, RegularFile}; @@ -90,16 +91,19 @@ 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 ostree_ext::composefs::dumpfile; 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; @@ -657,6 +661,15 @@ struct BLSEntryPath { config_path: Utf8PathBuf, } +/// Replace either karg spelling to ensure only the selected EROFS format 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 parameter = Parameter::parse(new_karg).context("Parsing composefs kernel parameter")?; + cmdline.add_or_modify(¶meter); + Ok(()) +} + /// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk /// /// # Returns @@ -666,6 +679,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 { @@ -684,9 +698,12 @@ pub(crate) fn setup_composefs_bls_boot( } } - 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 @@ -732,14 +749,14 @@ 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); + replace_composefs_karg( + &mut cmdline, + &build_composefs_karg( + id.clone(), + format_version, + booted_cfs.cmdline.allow_missing_fsverity, + ), + )?; // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; @@ -963,6 +980,7 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, + composefs_digest: Sha512HashValue, } /// Determines the directory (under `mounted_efi`) that a PE binary should be written to. @@ -1008,6 +1026,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> { @@ -1034,7 +1053,7 @@ fn write_pe_to_esp( let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) .context("Parsing composefs=")? .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; - let composefs_cmdline = composefs_info.digest(); + 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 @@ -1053,16 +1072,17 @@ fn write_pe_to_esp( _ => { /* no-op */ } } - let file_name = file_path.file_name(); - - if *composefs_cmdline != *uki_id { + if !boot_ids.contains(&composefs_digest) { return Err(UKIDigestMismatch { - actual: composefs_cmdline.to_hex(), + actual: composefs_digest.to_hex(), expected: uki_id.to_hex(), - uki_name: file_name.map(|x| x.to_string()), + uki_name: file_path.file_name().map(|name| name.to_string()), } .into()); } + 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")?; @@ -1079,6 +1099,7 @@ fn write_pe_to_esp( version: parsed_osrel.get_version(), os_id: parsed_osrel.get_value(&["ID"]), boot_digest, + composefs_digest, }); } @@ -1088,8 +1109,12 @@ fn write_pe_to_esp( let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) .with_context(|| format!("Opening {final_pe_path:?}"))?; + let pe_name_owned; let pe_name = match pe_type { - PEType::Uki => &get_uki_name(&uki_id.to_hex()), + PEType::Uki => { + pe_name_owned = get_uki_name(&boot_label.as_ref().unwrap().composefs_digest.to_hex()); + &pe_name_owned + } PEType::UkiAddon | PEType::GlobalUkiAddon => file_path .components() .last() @@ -1274,8 +1299,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)) => { @@ -1361,6 +1387,7 @@ pub(crate) fn setup_composefs_uki_boot( utf8_file_path, entry.pe_type, &id, + boot_ids, missing_fsverity_allowed, esp_mount.dir.path(), )?; @@ -1376,18 +1403,27 @@ pub(crate) fn setup_composefs_uki_boot( uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?; let boot_digest = uki_info.boot_digest.clone(); + 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 @@ -1611,6 +1647,12 @@ pub(crate) async fn setup_composefs_boot( ) .context("Generating bootable EROFS image")?; + 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, @@ -1727,24 +1769,35 @@ 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(), - )?, + let (provisional_deploy_id, provisional_format) = match boot_id_v1.as_ref() { + Some(v1) => (v1.clone(), FormatVersion::V1), + None => (id.clone(), repo.erofs_version()), + }; + 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 => { let uki_setup_result = setup_composefs_uki_boot( BootSetupType::Setup((&root_setup, &state, &postfetch)), &repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, ); match uki_setup_result { - Ok(boot_digest) => boot_digest, + Ok(result) => result, Err(e) => match e.downcast::() { Ok(mismatch) => { print_uki_dumpfile_diff(&mismatch, &repo, &fs); @@ -1758,7 +1811,7 @@ pub(crate) async fn setup_composefs_boot( write_composefs_state( &root_setup.physical_root_path, - &id, + &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), None, boot_type, @@ -1775,6 +1828,21 @@ pub(crate) async fn setup_composefs_boot( mod tests { use super::*; + #[test] + fn test_replace_composefs_karg() { + let mut cmdline = + Cmdline::from("root=UUID=abc composefs=old composefs.digest=v1-sha512-12:stale"); + replace_composefs_karg( + &mut cmdline, + "composefs.digest=v1-sha512-12:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ) + .unwrap(); + let rendered = cmdline.to_string(); + assert!(!rendered.contains("composefs=old")); + assert!(!rendered.contains(":stale")); + assert!(rendered.contains("root=UUID=abc")); + } + #[test] fn test_pe_output_dir() { let mounted_efi = Path::new("/esp"); diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index 227bbf6c3b..c72e98bd10 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( @@ -82,7 +91,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 { @@ -136,7 +145,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!( @@ -151,7 +162,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" @@ -160,7 +173,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 5c1b5bf275..6bf50bb5c8 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -51,6 +51,17 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { type BootBinary = (BootType, String); +fn image_refs_match( + image_ref_v1: Option<&composefs::fsverity::Sha512HashValue>, + image_ref_v2: Option<&composefs::fsverity::Sha512HashValue>, + verity: &str, +) -> bool { + [image_ref_v1, image_ref_v2] + .into_iter() + .flatten() + .any(|image_ref| image_ref.to_hex() == verity) +} + /// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem /// /// Returns a vector of binary type (UKI/Type1) + name of all boot binaries @@ -403,16 +414,16 @@ 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. + if image_refs_match(img.image_ref_v1(), img.image_ref_v2(), 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; } } } @@ -508,6 +519,14 @@ mod tests { use crate::bootc_composefs::status::list_type1_entries; use crate::testutils::{ChangeType, TestRoot}; + #[test] + fn test_image_refs_match_v2_when_v1_is_present() { + let v1 = composefs::fsverity::Sha512HashValue::from_hex(&"11".repeat(64)).unwrap(); + let v2 = composefs::fsverity::Sha512HashValue::from_hex(&"22".repeat(64)).unwrap(); + + assert!(image_refs_match(Some(&v1), Some(&v2), &v2.to_hex())); + } + /// Reproduce the shared-entry GC bug from issue #2102. /// /// Scenario with both shared and non-shared kernels: diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 9454e61c5d..375db93a79 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -103,12 +103,13 @@ 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 { + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + config = if allow_missing_fsverity { config.set_insecure() } else { config }; + 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")?; diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 1d8ecfc223..393ae84f64 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 7badda8fa3..b76c139704 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,24 +91,30 @@ impl ComposefsCmdline { } } - pub(crate) fn build(digest: &str, allow_missing_fsverity: bool) -> Self { - ComposefsCmdline { - allow_missing_fsverity, - digest: digest.into(), + /// Search for either supported composefs kernel command line parameter. + pub(crate) fn find_in_cmdline(cmdline: &Cmdline) -> Option { + let parsed = BootComposefsCmdline::::from_cmdline(cmdline).ok()??; + Some(Self { + allow_missing_fsverity: parsed.is_insecure(), + digest: parsed.digest().to_hex().into(), is_transient: false, - } + }) } +} - /// Search for the `composefs=` parameter in the passed in kernel command line - 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, +/// Render a composefs karg that identifies the 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() } impl std::fmt::Display for ComposefsCmdline { @@ -157,11 +166,9 @@ pub(crate) fn composefs_booted() -> Result> { 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())?; @@ -730,10 +737,11 @@ fn find_bls_entry<'a>( Ok(None) } -/// Compares cmdline `first` and `second` skipping `composefs=` +/// Compares cmdline `first` and `second` skipping either composefs karg spelling. fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool { for param in first { - if param.key() == COMPOSEFS_CMDLINE.into() { + if param.key() == COMPOSEFS_CMDLINE.into() || param.key() == COMPOSEFS_DIGEST_CMDLINE.into() + { continue; } @@ -1154,7 +1162,7 @@ mod tests { #[test] fn test_composefs_parsing() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; let v = ComposefsCmdline::new(DIGEST); assert!(!v.allow_missing_fsverity); assert_eq!(v.digest.as_ref(), DIGEST); @@ -1163,6 +1171,30 @@ 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(); + + assert_eq!( + build_composefs_karg(digest(), FormatVersion::V1, false), + format!("composefs.digest=v1-sha512-12:{hex}") + ); + assert_eq!( + build_composefs_karg(digest(), FormatVersion::V2, true), + format!("composefs=?{hex}") + ); + + let cmdline = Cmdline::from(format!("composefs.digest=v1-sha512-12:{hex}")); + assert_eq!( + ComposefsCmdline::find_in_cmdline(&cmdline) + .unwrap() + .digest + .as_ref(), + hex + ); + } + #[test] fn classify_bootloader_cases() { struct Case { @@ -1387,7 +1419,7 @@ mod tests { #[test] fn test_find_in_cmdline() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + 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 c2b43c8b86..6d1652d152 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; @@ -148,13 +149,18 @@ pub(crate) fn validate_update( let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity), &Default::default())?; fs.transform_for_boot(&repo)?; - let image_id = fs.compute_image_id(repo.erofs_version()); + let image_ids = [ + fs.compute_image_id(FormatVersion::V1), + 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()); + let found_depl = all_deployments.iter().find(|d| { + image_ids + .iter() + .any(|id| d.deployment.verity == id.to_hex()) + }); if let Some(collision) = found_depl { if is_switch { @@ -194,16 +200,19 @@ pub(crate) fn validate_update( BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, } - // Remove state directory + // Remove state directories for either serialisation of the same rootfs. let state_dir = storage .physical_root .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")?; + for image_id in image_ids { + let image_id = image_id.to_hex(); + if state_dir.exists(&image_id) { + state_dir + .remove_dir_all(&image_id) + .context("Removing state")?; + } } Ok(UpdateAction::Proceed) @@ -315,25 +324,43 @@ pub(crate) async fn do_upgrade( let boot_type = BootType::from(entry); - let boot_digest = match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), - &repo, - &id, - entry, - &mounted_fs, - )?, + 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(); + let (provisional_deploy_id, provisional_format) = match boot_id_v1.as_ref() { + Some(v1) => (v1.clone(), FormatVersion::V1), + None => (id.clone(), repo.erofs_version()), + }; + 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::Upgrade((storage, booted_cfs, &host)), + &repo, + &provisional_deploy_id, + provisional_format, + entry, + &mounted_fs, + )?, + provisional_deploy_id, + ), BootType::Uki => { let uki_setup_result = setup_composefs_uki_boot( BootSetupType::Upgrade((storage, booted_cfs, &host)), &repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, ); match uki_setup_result { - Ok(boot_digest) => boot_digest, + Ok(result) => result, Err(e) => match e.downcast::() { Ok(mismatch) => { print_uki_dumpfile_diff(&mismatch, &repo, &oci_fs); @@ -360,13 +387,13 @@ pub(crate) async fn do_upgrade( drop(repo); let staged_state = StagedDeployment { - depl_id: id.to_hex(), + depl_id: deploy_id.to_hex(), finalization_locked: opts.download_only, }; write_composefs_state( &Utf8PathBuf::from("/sysroot"), - &id, + &deploy_id, imgref, Some(staged_state), boot_type, @@ -392,7 +419,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("Applying downloaded upgrade")] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 86b4eb9998..f2f2d296cb 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; @@ -420,6 +421,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)] @@ -428,6 +436,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, }, @@ -471,6 +486,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, @@ -517,6 +540,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 @@ -1990,16 +2031,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(); @@ -2059,6 +2107,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { rootfs, kargs, allow_missing_verity, + erofs_version, write_dumpfile_to, kernel_dir, args, @@ -2091,6 +2140,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 8617f1005b..6e455980ad 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 3c5182fd75..c431938ebd 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2027,10 +2027,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 c796ffdab1..f13b67e232 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 d417b17dec..6fa6793d52 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -125,6 +125,14 @@ use crate::utils::{deployment_fd, open_dir_remount_rw}; /// See pub type ComposefsRepository = composefs::repository::Repository; +/// Configure new repositories to retain boot images for both supported formats. +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"; @@ -722,9 +730,10 @@ impl Storage { repo } Err(RepositoryOpenError::MetadataMissing) => { - // No meta.json — this is a fresh directory. Initialize a new - // repository with the current defaults. - let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + // No meta.json — this is a fresh directory. Existing repositories + // above retain their recorded format configuration. + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + set_dual_erofs_formats(&mut config); let config = if ostree_verity.enabled { config } else { diff --git a/crates/lib/src/testutils.rs b/crates/lib/src/testutils.rs index e24a80a5fa..9b682d3d4d 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 cd434a3960..d4f3f62d8e 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 14396107b3..951acc24eb 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 424e31bfad..e09567b863 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/docs/src/man/bootc-container-ukify.8.md b/docs/src/man/bootc-container-ukify.8.md index d98e325894..4542f8bc33 100644 --- a/docs/src/man/bootc-container-ukify.8.md +++ b/docs/src/man/bootc-container-ukify.8.md @@ -31,6 +31,16 @@ Any additional arguments after `--` are passed through to ukify unchanged. Make fs-verity validation optional in case the filesystem doesn't support it +**--erofs-version**=*EROFS_VERSION* + + EROFS format version to use when computing the composefs digest + + Possible values: + - v1 + - v2 + + Default: v1 + **--write-dumpfile-to**=*WRITE_DUMPFILE_TO* Write a dumpfile to this path diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade index b66393114e..a4878c181b 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 0000000000..14e2270417 --- /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 b4f0dd23d3..d295a6b556 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -75,7 +75,7 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string] { +export def make_uki_containerfile [containerfile: string, --erofs-version: string = "v1"] { let is_cfs = (is_composefs) if not $is_cfs { @@ -121,7 +121,8 @@ export def make_uki_containerfile [containerfile: string] { --secrets /run/secrets ($allow_missing_verity) \\ --kernel-dir /run/kernel/boot/${kver} \\ --write-dumpfile-to /out/${kver}.dump \\ - --seal-state ($seal_state) + --seal-state ($seal_state) \\ + --erofs-version ($erofs_version) EOF 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 94cdd4d3f0..6f6f657bd4 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 8e7417f72c52d2bb84aa59777348504f3b6223e3 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 2 Jul 2026 18:08:51 -0400 Subject: [PATCH 3/4] 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 36e269ce15..a23345d7e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,9 +302,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" From 45e92e455d731ba983b9d2541408e40680c8a7c4 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 1 Sep 2026 07:57:06 -0400 Subject: [PATCH 4/4] tests/composefs: Use valid digest for missing deployment The typed composefs argument parser rejects the old malformed fixture before status handling can exercise missing-origin resilience. Keep the digest syntactically valid so the test reaches its intended warning path. Assisted-by: AI Signed-off-by: Colin Walters --- tmt/tests/booted/test-composefs-corruped-state-resilience.nu | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tmt/tests/booted/test-composefs-corruped-state-resilience.nu b/tmt/tests/booted/test-composefs-corruped-state-resilience.nu index cbd10f67be..d0c40fd791 100644 --- a/tmt/tests/booted/test-composefs-corruped-state-resilience.nu +++ b/tmt/tests/booted/test-composefs-corruped-state-resilience.nu @@ -35,6 +35,7 @@ def first_boot [] { } let booted_verity = $st.status.booted.composefs.verity + let missing_verity = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" # Add some random entry in /boot/loader/entries to simulate # https://github.com/bootc-dev/bootc/issues/2208 @@ -43,7 +44,7 @@ def first_boot [] { cd ($entries_dir) cp * new-entry.conf - sed -i 's;($booted_verity);bad-verity;' new-entry.conf + sed -i 's;($booted_verity);($missing_verity);' new-entry.conf " # This should work but log a warning in journal @@ -51,7 +52,7 @@ def first_boot [] { assert ( journalctl F_MESSAGE_ID=d264f924dadb4c31bff0412107d391fb - | str contains $"No origin file for deployment bad-verity" + | str contains $"No origin file for deployment ($missing_verity)" ) # Create a simple derived image to switch to