From 3a1ac771a1cc330e906d0c051b3964b8db08ad40 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 14 Sep 2026 20:41:15 -0400 Subject: [PATCH 1/5] tmt,ci: Cover supported CentOS 9 composefs modes CentOS 9 cannot consume sealed host-built UKI upgrades because shared storage is unavailable and its guest-local builder produces unsigned images. Record that limitation while retaining installation, readonly, other upgrade variants, and newer-system sealed coverage. With V1 EROFS as the default, sealed UKIs are viable on CentOS 9; exclude only the BLS and unsealed modes that still require newer dracut/systemd features. Resolve both the runtime base and buildroot from each matrix OS. Otherwise CentOS 9 jobs silently build EL10 RPMs and binaries that cannot run against its older glibc. Generated-by: AI Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 32 +++++++++++++++++++++-------- contrib/packaging/install-buildroot | 13 ++++++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58e32e3854..28434adfa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -265,8 +265,10 @@ jobs: - name: Setup env run: | - BASE=$(just pullspec-for-os base ${{ matrix.test_os }}) - echo "BOOTC_base=${BASE}" >> $GITHUB_ENV + BASE="$(just pullspec-for-os base "${{ matrix.test_os }}")" + BUILDROOT_BASE="$(just pullspec-for-os buildroot-base "${{ matrix.test_os }}")" + printf 'BOOTC_base=%s\n' "$BASE" >> "$GITHUB_ENV" + printf 'BOOTC_buildroot_base=%s\n' "$BUILDROOT_BASE" >> "$GITHUB_ENV" - name: Build packages (and verify build system) run: just check-buildsys @@ -302,6 +304,14 @@ jobs: seal_state: ["sealed", "unsealed"] exclude: + # 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" @@ -352,8 +362,10 @@ jobs: - name: Setup env run: | - BASE=$(just pullspec-for-os base ${{ matrix.test_os }}) - echo "BOOTC_base=${BASE}" >> $GITHUB_ENV + BASE="$(just pullspec-for-os base "${{ matrix.test_os }}")" + BUILDROOT_BASE="$(just pullspec-for-os buildroot-base "${{ matrix.test_os }}")" + printf 'BOOTC_base=%s\n' "$BASE" >> "$GITHUB_ENV" + printf 'BOOTC_buildroot_base=%s\n' "$BUILDROOT_BASE" >> "$GITHUB_ENV" echo "RUST_BACKTRACE=full" >> $GITHUB_ENV echo "RUST_LOG=debug" >> $GITHUB_ENV @@ -451,8 +463,10 @@ jobs: - name: Setup env run: | - BASE=$(just pullspec-for-os base ${{ matrix.test_os }}) - echo "BOOTC_base=${BASE}" >> $GITHUB_ENV + BASE="$(just pullspec-for-os base "${{ matrix.test_os }}")" + BUILDROOT_BASE="$(just pullspec-for-os buildroot-base "${{ matrix.test_os }}")" + printf 'BOOTC_base=%s\n' "$BASE" >> "$GITHUB_ENV" + printf 'BOOTC_buildroot_base=%s\n' "$BUILDROOT_BASE" >> "$GITHUB_ENV" echo "BOOTC_variant=${{ matrix.variant }}" >> $GITHUB_ENV echo "BOOTC_SKIP_PACKAGE=1" >> $GITHUB_ENV echo "RUST_BACKTRACE=full" >> $GITHUB_ENV @@ -513,8 +527,10 @@ jobs: - name: Setup env run: | - BASE=$(just pullspec-for-os base ${{ matrix.test_os }}) - echo "BOOTC_base=${BASE}" >> $GITHUB_ENV + BASE="$(just pullspec-for-os base "${{ matrix.test_os }}")" + BUILDROOT_BASE="$(just pullspec-for-os buildroot-base "${{ matrix.test_os }}")" + printf 'BOOTC_base=%s\n' "$BASE" >> "$GITHUB_ENV" + printf 'BOOTC_buildroot_base=%s\n' "$BUILDROOT_BASE" >> "$GITHUB_ENV" echo "BOOTC_variant=composefs" >> $GITHUB_ENV echo "BOOTC_baseconfigs=${{ matrix.baseconfigs }}" >> $GITHUB_ENV echo "RUST_BACKTRACE=full" >> $GITHUB_ENV diff --git a/contrib/packaging/install-buildroot b/contrib/packaging/install-buildroot index 1bde1a2d28..e89178d7e5 100755 --- a/contrib/packaging/install-buildroot +++ b/contrib/packaging/install-buildroot @@ -15,8 +15,17 @@ if test -x /usr/bin/dnf5; then else dnf -y install 'dnf-command(builddep)' fi -# Handle version skew, xref https://gitlab.com/redhat/centos-stream/containers/bootc/-/issues/1174 -dnf -y distro-sync ostree{,-libs} systemd +# Handle version skew in packages already installed in the base image, +# xref https://gitlab.com/redhat/centos-stream/containers/bootc/-/issues/1174 +sync_packages=() +for package in ostree{,-libs} systemd; do + if rpm -q --quiet "$package"; then + sync_packages+=("$package") + fi +done +if test "${#sync_packages[@]}" -gt 0; then + dnf -y distro-sync "${sync_packages[@]}" +fi # Install base build requirements dnf -y builddep bootc.spec # And extra packages From 9a88a41fa0c8569d90fad4009495d4912849da6c Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 14 Sep 2026 20:41:33 -0400 Subject: [PATCH 2/5] 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. Assisted-by: AI 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 b129b213f9..0e2768aba9 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -557,8 +557,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 @@ -566,6 +569,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 76248f4bb3ca516470a8b510138d7a2cd2637fb5 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 14 Sep 2026 20:41:33 -0400 Subject: [PATCH 3/5] 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=`. Assisted-by: AI 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 72ff812a8f..1928e7529d 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 f3d8f3782d..9213864170 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -55,6 +55,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 @@ -407,16 +418,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; } } } @@ -592,6 +603,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 9a12624aaf..098ed1b8f8 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 33c323bd07..a859a6ebe3 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; } @@ -1163,7 +1171,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); @@ -1172,6 +1180,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 { @@ -1550,7 +1582,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 4a8ccea630..38490afd77 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 ExportSelinuxMode { /// Compute and apply SELinux labels; error if any file has no policy match. @@ -2046,16 +2087,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(); @@ -2115,6 +2163,7 @@ async fn run_from_opt(opt: Opt) -> Result { rootfs, kargs, allow_missing_verity, + erofs_version, write_dumpfile_to, kernel_dir, args, @@ -2147,6 +2196,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 c82d857f7e..b110821cb8 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 4162a68912843ded7582ac2e59aed40599caa279 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 14 Sep 2026 20:41:47 -0400 Subject: [PATCH 4/5] composefs+UKI: Try to generate image with matching digest Older and newer composefs-rs tooling can differ in xattr filtering and EROFS defaults, which otherwise breaks UKI upgrades across bootc versions. Search supported combinations for the digest embedded in the UKI so a newer client can adapt to an older target. Keep the missing-deployment resilience fixture syntactically valid so typed argument parsing reaches the warning path it is intended to exercise. Assisted-by: AI Signed-off-by: Colin Walters --- crates/lib/src/bootc_composefs/boot.rs | 201 +++++++++++++++++- crates/lib/src/bootc_composefs/digest.rs | 2 +- crates/lib/src/bootc_composefs/gc.rs | 11 +- crates/lib/src/bootc_composefs/repo.rs | 17 +- crates/lib/src/bootc_composefs/update.rs | 7 +- crates/lib/src/cli.rs | 2 +- ...st-composefs-corrupted-state-resilience.nu | 5 +- .../booted/test-composefs-uki-dumpfile.nu | 7 +- 8 files changed, 234 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 1928e7529d..1fff0cbdde 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1137,6 +1137,122 @@ fn write_pe_to_esp( Ok(boot_label) } +/// Scans `entries` for the primary UKI (`PEType::Uki`, not an addon) and +/// extracts the `composefs=` digest embedded in its kernel cmdline. +/// +/// Returns `Ok(None)` if there is no UKI entry (e.g. a BLS-only boot setup) — +/// there's nothing to validate against in that case. +/// +/// This mirrors the lookup [`write_pe_to_esp`] already does when writing the +/// UKI to the ESP; it's factored out here so callers can validate (and +/// repair) the freshly-generated boot image digest *before* it's used for +/// mounting, well before `write_pe_to_esp`'s own (too-late-to-repair) check +/// of the same thing runs. +fn find_expected_composefs_digest( + repo: &crate::store::ComposefsRepository, + entries: &[ComposefsBootEntry], +) -> Result> { + for entry in entries { + let ComposefsBootEntry::Type2(entry) = entry else { + continue; + }; + if !matches!(entry.pe_type, PEType::Uki) { + continue; + } + let mut uki_reader = match &entry.file { + RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { + std::fs::File::from(repo.open_object(id)?) + } + RegularFile::Inline(..) | RegularFile::Sparse(..) => { + anyhow::bail!("UKI file is not a regular external object") + } + }; + let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; + let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) + .context("Parsing composefs=")? + .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; + return Ok(Some(composefs_info.digest().clone())); + } + Ok(None) +} + +/// Validates that the freshly-generated boot image digest `computed_id` +/// matches what's embedded in the UKI (if any), and if not, searches for an +/// [`composefs_oci::XattrFiltering`] mode whose boot image digest does match +/// via [`composefs_oci::find_matching_boot_image`], before giving up. +/// +/// This handles images built with older (or newer) composefs-rs tooling +/// that computed their embedded UKI `composefs=` digest using a different +/// default xattr filtering mode than the one bootc's own build used. +#[context("Verifying composefs digest against UKI")] +pub(crate) fn ensure_correct_composefs_digest( + repo: &Arc, + manifest_digest: &composefs_oci::OciDigest, + computed_id: Sha512HashValue, + entries: &[ComposefsBootEntry], +) -> Result { + let Some(expected) = + find_expected_composefs_digest(repo, entries).context("Checking UKI composefs digest")? + else { + // No UKI (e.g. a BLS-only setup); nothing to cross-check. + return Ok(computed_id); + }; + if expected == computed_id { + // The UKI's expected digest already matches; no repair needed. + return Ok(computed_id); + } + // The UKI was built with a different xattr filtering mode and/or EROFS + // format version than the one bootc's own build used. Search for one + // whose boot image digest does match, for backward compatibility with + // older or newer image tooling. + tracing::info!( + "Freshly computed composefs digest ({computed_id:?}) doesn't match the digest \ + embedded in the UKI ({expected:?}); searching for an xattr filtering mode and/or \ + EROFS format version whose boot image matches, for backward compatibility with \ + older or newer image tooling" + ); + resolve_boot_image_match( + expected.clone(), + composefs_oci::find_matching_boot_image(repo, manifest_digest, &expected), + ) +} + +/// Interprets the result of searching for a boot image whose digest matches +/// `expected` (see [`composefs_oci::find_matching_boot_image`]): uses the +/// matching mode's digest if one was found, or fails with an error listing +/// every combination tried if not. +/// +/// Factored out from [`ensure_correct_composefs_digest`] purely so this +/// decision logic can be unit tested without a real repo or UKI fixture. +fn resolve_boot_image_match( + expected: Sha512HashValue, + find_matching_result: Result>, +) -> Result { + match find_matching_result.context( + "Searching for a boot image xattr filtering mode/format version matching the UKI digest", + )? { + composefs_oci::BootImageMatch::Found { + mode, + version, + digest, + } => { + tracing::info!( + "Boot image built with {mode:?} xattr filtering (EROFS {version:?}) matches \ + the UKI; using it" + ); + Ok(digest) + } + composefs_oci::BootImageMatch::NotFound(tried) => { + anyhow::bail!( + "The UKI's embedded composefs= digest ({expected:?}) doesn't match any of \ + {tried} supported xattr filtering mode/EROFS format version combinations. \ + The image may be corrupt, or was built with an incompatible composefs-rs \ + version." + ); + } + } +} + #[context("Writing Grub menuentry")] fn write_grub_uki_menuentry( root_path: Utf8PathBuf, @@ -1640,10 +1756,10 @@ pub(crate) async fn setup_composefs_boot( let repo = Arc::new(repo); // Generate the bootable EROFS image (idempotent). - let id = composefs_oci::generate_boot_image( + let generated_id = composefs_oci::generate_boot_image( &repo, &pull_result.manifest_digest, - &Default::default(), + &composefs_oci::OciTransformOptions::default(), ) .context("Generating bootable EROFS image")?; @@ -1658,12 +1774,22 @@ pub(crate) async fn setup_composefs_boot( &*repo, &pull_result.config_digest, Some(&pull_result.config_verity), - &Default::default(), + &composefs_oci::OciTransformOptions::default(), ) .context("Creating composefs filesystem for boot entry discovery")?; let entries = get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; + // If the UKI was built by tooling using a different xattr filtering + // mode, find the mode whose boot image matches the digest embedded in + // the UKI. + let id = ensure_correct_composefs_digest( + &repo, + &pull_result.manifest_digest, + generated_id, + &entries, + )?; + let composefs_mnt_fd = repo .mount(&id.to_hex()) .context("Failed to mount composefs image")?; @@ -1827,6 +1953,7 @@ pub(crate) async fn setup_composefs_boot( #[cfg(test)] mod tests { use super::*; + use composefs::erofs::format::FormatVersion; #[test] fn test_replace_composefs_karg() { @@ -1996,4 +2123,72 @@ mod tests { "RHEL should sort before Fedora in descending order" ); } + + /// A distinct, non-`EMPTY` digest to use as "the other" digest in + /// `resolve_boot_image_match` tests. + fn other_digest() -> Sha512HashValue { + Sha512HashValue::from_hex("aa".repeat(64)).unwrap() + } + + #[test] + fn test_resolve_boot_image_match_found() { + let expected = other_digest(); + // A non-default mode, to make the test case meaningful. + let found_mode = composefs_oci::XattrFiltering::KeepUserXattrs; + + let result = resolve_boot_image_match( + expected.clone(), + Ok(composefs_oci::BootImageMatch::Found { + mode: found_mode, + version: FormatVersion::V2, + digest: expected.clone(), + }), + ); + assert_eq!(result.unwrap(), expected); + } + + #[test] + fn test_resolve_boot_image_match_error_paths() { + let expected = other_digest(); + // 2 xattr filtering modes x 2 EROFS format versions. + let combinations_tried = 4; + + enum FindMatching { + /// Succeeds, but no combination's digest matches `expected`. + NotFound, + /// The search itself fails. + Errors, + } + + // (find_matching behavior, substrings that must appear in the resulting error) + let cases = [ + ( + FindMatching::NotFound, + vec![format!("{expected:?}"), format!("{combinations_tried}")], + ), + ( + FindMatching::Errors, + vec![ + "search blew up".to_string(), + "Searching for a boot image xattr filtering mode/format version matching \ + the UKI digest" + .to_string(), + ], + ), + ]; + + for (find_matching, want_substrings) in cases { + let find_matching_result = match find_matching { + FindMatching::NotFound => { + Ok(composefs_oci::BootImageMatch::NotFound(combinations_tried)) + } + FindMatching::Errors => Err(anyhow::anyhow!("search blew up")), + }; + let result = resolve_boot_image_match(expected.clone(), find_matching_result); + let msg = format!("{:#}", result.unwrap_err()); + for want in &want_substrings { + assert!(msg.contains(want), "expected {msg:?} to contain {want:?}"); + } + } + } } diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index c72e98bd10..bda66f1099 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -86,7 +86,7 @@ pub(crate) async fn compute_composefs_digest( dirfd, std::path::PathBuf::from("."), Some(repo.clone()), - &Default::default(), + &composefs::generic_tree::OciTransformOptions::default(), ) .await .context("Reading container root")?; diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 9213864170..a8687c741c 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -513,8 +513,15 @@ pub(crate) async fn composefs_gc( continue; }; - let linked_images = linked_erofs_images(&booted_cfs.repo, &verity_to_sha) - .with_context(|| anyhow::anyhow!("Getting linked images for {verity}"))?; + let linked_images = match linked_erofs_images(&booted_cfs.repo, &verity_to_sha) { + Ok(images) => images, + Err(err) => { + tracing::warn!( + "Unable to inspect linked EROFS images for orphan '{verity}', skipping boot-object cleanup: {err:#}" + ); + continue; + } + }; // Get any image that is non-bootable let Some(non_bootable_img) = linked_images.iter().find(|img| !img.bootable) else { diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 098ed1b8f8..4890c6ce6d 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -56,6 +56,7 @@ use ostree_ext::containers_image_proxy; use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use crate::bootc_composefs::boot::ensure_correct_composefs_digest; use crate::bootc_composefs::progress; use crate::composefs_consts::BOOTC_TAG_PREFIX; use crate::install::{RootSetup, State}; @@ -397,10 +398,10 @@ pub(crate) async fn pull_composefs_repo( ); // Generate the bootable EROFS image (idempotent). - let id = composefs_oci::generate_boot_image( + let generated_id = composefs_oci::generate_boot_image( &repo, &pull_result.manifest_digest, - &Default::default(), + &composefs_oci::OciTransformOptions::default(), ) .context("Generating bootable EROFS image")?; @@ -409,12 +410,22 @@ pub(crate) async fn pull_composefs_repo( &*repo, &pull_result.config_digest, Some(&pull_result.config_verity), - &Default::default(), + &composefs_oci::OciTransformOptions::default(), ) .context("Creating composefs filesystem for boot entry discovery")?; let entries = get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; + // If the UKI was built by tooling using a different xattr filtering + // mode, find the mode whose boot image matches the digest embedded in + // the UKI. + let id = ensure_correct_composefs_digest( + &repo, + &pull_result.manifest_digest, + generated_id, + &entries, + )?; + // Unwrap the Arc to get the owned repo back. let mut repo = Arc::try_unwrap(repo).map_err(|_| { anyhow::anyhow!("BUG: Arc still has other references after pull completed") diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 6d1652d152..54a198974c 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -146,7 +146,12 @@ pub(crate) fn validate_update( let oci_digest: composefs_oci::OciDigest = img_digest .parse() .with_context(|| format!("Parsing config digest {img_digest}"))?; - let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity), &Default::default())?; + let mut fs = create_filesystem( + repo, + &oci_digest, + Some(config_verity), + &composefs_oci::OciTransformOptions::default(), + )?; fs.transform_for_boot(&repo)?; let image_ids = [ diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 38490afd77..636579911d 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -2143,7 +2143,7 @@ async fn run_from_opt(opt: Opt) -> Result { &repo, &pull_result.config_digest, Some(&pull_result.config_verity), - &Default::default(), + &composefs_oci::OciTransformOptions::default(), ) .context("Populating fs")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; diff --git a/tmt/tests/booted/test-composefs-corrupted-state-resilience.nu b/tmt/tests/booted/test-composefs-corrupted-state-resilience.nu index 2f51024c7f..01d4d8424b 100644 --- a/tmt/tests/booted/test-composefs-corrupted-state-resilience.nu +++ b/tmt/tests/booted/test-composefs-corrupted-state-resilience.nu @@ -33,6 +33,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 @@ -41,7 +42,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 @@ -49,7 +50,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 diff --git a/tmt/tests/booted/test-composefs-uki-dumpfile.nu b/tmt/tests/booted/test-composefs-uki-dumpfile.nu index e6079c30c8..a6c938e303 100644 --- a/tmt/tests/booted/test-composefs-uki-dumpfile.nu +++ b/tmt/tests/booted/test-composefs-uki-dumpfile.nu @@ -36,14 +36,12 @@ def first_boot [] { let result = do { bootc switch --transport containers-storage localhost/dump-diff } | complete - let actual_digest = bootc internals cfs oci compute-id --bootable $"@(podman images --no-trunc | grep dump-diff | awk '{print $3}')" - assert ($result.exit_code != 0) "bootc switch should fail" print ($result.stderr) - assert ($result.stderr | str contains "The UKI has the wrong composefs= parameter") $"Expected 'The UKI has the wrong composefs= parameter' in stderr" - assert ($result.stderr | str contains $"should be '($actual_digest)'") $"Expected digest to be ($actual_digest) in stderr" + assert ($result.stderr | str contains "embedded composefs= digest") "Expected an embedded composefs digest mismatch in stderr" + assert ($result.stderr | str contains "doesn't match any") "Expected no compatible composefs image in stderr" assert ($result.stderr | str contains "/usr/share/new-file") $"Expected '/usr/share/new-file' in stderr" tap ok @@ -55,4 +53,3 @@ def main [] { $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, } } - From f83949020d82dfcbe537478d010dba65e4e6ceb7 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 14 Sep 2026 20:41:47 -0400 Subject: [PATCH 5/5] composefs: Preserve 1.16 upgrades with V1-default UKIs We want to support upgrades from older bootc. Keep a legacy V2 digest in UKIs with a V1-capable initramfs, and select V2-only when resealing a legacy initramfs so the mounted root and deployment state retain the same identity. Determine compatibility from the actual initramfs rather than installed userspace. Preserve strict repository requirements when recovering a non-default boot image; changing serialization must not relax integrity. Assisted-by: AI Signed-off-by: Colin Walters --- Dockerfile | 2 +- Justfile | 7 +- contrib/packaging/seal-uki | 7 +- crates/initramfs/dracut/module-setup.sh | 7 + crates/initramfs/src/lib.rs | 166 +++++++- crates/lib/src/bootc_composefs/boot.rs | 402 ++++++++++++++---- crates/lib/src/bootc_composefs/digest.rs | 2 +- crates/lib/src/bootc_composefs/repo.rs | 135 +++++- crates/lib/src/bootc_composefs/update.rs | 25 +- crates/lib/src/cli.rs | 37 +- crates/lib/src/install.rs | 12 +- crates/lib/src/ukify.rs | 376 +++++++++++++++- crates/tests-integration/src/container.rs | 141 ++++-- crates/xtask/src/tmt.rs | 11 + crates/xtask/src/xtask.rs | 4 + docs/src/experimental-composefs.md | 186 +++++++- docs/src/man/bootc-container-ukify.8.md | 2 - tmt/plans/integration.fmf | 13 + tmt/tests/Dockerfile.upgrade | 13 +- tmt/tests/booted/README.md | 58 +++ .../booted/readonly/046-test-erofs-version.nu | 10 +- tmt/tests/booted/tap.nu | 21 +- .../booted/test-49-composefs-1-16-bridge.nu | 201 +++++++++ tmt/tests/booted/test-image-upgrade-reboot.nu | 21 +- tmt/tests/tests.fmf | 9 + 25 files changed, 1658 insertions(+), 210 deletions(-) create mode 100644 tmt/tests/booted/test-49-composefs-1-16-bridge.nu diff --git a/Dockerfile b/Dockerfile index 8c68ba7687..963ec12c7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -354,7 +354,7 @@ ARG variant ARG filesystem ARG seal_state ARG boot_type -ARG erofs_version=v1 +ARG erofs_version=auto # 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 \ diff --git a/Justfile b/Justfile index 692af2d109..3679c2e0ff 100644 --- a/Justfile +++ b/Justfile @@ -27,8 +27,8 @@ mod bcvk 'bcvk.just' # sealed → requires boot_type=uki and filesystem with fsverity (ext4/btrfs) # uki → requires bootloader=systemd -# Output image name -base_img := "localhost/bootc" +# Output image name (override with BOOTC_image to isolate worktree images) +base_img := env("BOOTC_image", "localhost/bootc") # Synthetic upgrade image for testing upgrade_img := base_img + "-upgrade" # Base image with tmt dependencies added, used as the boot source for upgrade tests @@ -253,6 +253,7 @@ test-upgrade *ARGS: build _build-upgrade-source-image --karg=enforcing=0) fi cargo xtask run-tmt --env=BOOTC_variant={{variant}} \ + --env=BOOTC_erofs_version={{erofs_version}} \ --env=BOOTC_test_upgrade_image={{base_img}} \ --upgrade-image={{base_img}} \ "${composefs_args[@]}" \ @@ -303,6 +304,7 @@ test-tmt-baseconfig baseconfig *ARGS: just variant=composefs baseconfigs={{baseconfig}} _build-upgrade-image cargo xtask run-tmt \ --env=BOOTC_variant=composefs \ + --env=BOOTC_erofs_version={{erofs_version}} \ --env=BOOTC_baseconfigs={{baseconfig}} \ --upgrade-image={{upgrade_img}} \ --composefs-backend \ @@ -511,6 +513,7 @@ _build-upgrade-image: --build-arg "boot_type={{boot_type}}" \ --build-arg "seal_state={{seal_state}}" \ --build-arg "filesystem={{filesystem}}" \ + --build-arg "base={{base_img}}" \ --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 \ diff --git a/contrib/packaging/seal-uki b/contrib/packaging/seal-uki index d83c5e72f6..7a2ca27bda 100755 --- a/contrib/packaging/seal-uki +++ b/contrib/packaging/seal-uki @@ -4,7 +4,7 @@ set -xeuo pipefail missing_verity=() dumpfile_args=() -erofs_version=v1 +erofs_version=auto while [ ! -z "${1:-}" ]; do case "$1" in @@ -96,7 +96,10 @@ fi # Baseline container ukify options containerukifyargs=(--rootfs "${target}") +if [[ $erofs_version != "auto" ]]; then + containerukifyargs+=(--erofs-version="${erofs_version}") +fi # Build the UKI using bootc container ukify # This computes the composefs digest, reads kargs from kargs.d, and invokes ukify -bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" "${dumpfile_args[@]}" --erofs-version="${erofs_version}" -- "${ukifyargs[@]}" +bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" "${dumpfile_args[@]}" -- "${ukifyargs[@]}" diff --git a/crates/initramfs/dracut/module-setup.sh b/crates/initramfs/dracut/module-setup.sh index f23c0fd643..a256575d08 100755 --- a/crates/initramfs/dracut/module-setup.sh +++ b/crates/initramfs/dracut/module-setup.sh @@ -12,6 +12,7 @@ depends() { } install() { local service=bootc-root-setup.service + local features_dir="${initdir}/usr/lib/bootc/initramfs-features" dracut_install /usr/lib/bootc/initramfs-setup inst_simple "${systemdsystemunitdir}/${service}" mkdir -p "${initdir}${systemdsystemunitdir}/initrd-root-fs.target.wants" @@ -26,4 +27,10 @@ install() { # dracut --force in a Containerfile RUN layer). [[ -e /usr/lib/composefs/setup-root-conf.toml ]] && \ inst_simple /usr/lib/composefs/setup-root-conf.toml + + # This capability belongs to the generated initramfs, not the build host. + # UKI producers use it to decide whether this initramfs can consume the V1 + # composefs digest and its matching state representation. + mkdir -p "${features_dir}" + printf '%s\n' 'composefs-digest-v1 state-v1' > "${features_dir}/composefs-digest-v1" } diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index 8d2de21911..9786629981 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -25,10 +25,11 @@ use serde::Deserialize; use composefs::{ fsverity::{FsVerityHashValue, Sha512HashValue}, mount::FsHandle, + mount::{MountOptions, VerityRequirement, composefs_fsmount}, mountcompat::{overlayfs_set_fd, overlayfs_set_lower_and_data_fds, prepare_mount}, - repository::Repository, + repository::{ImageNotFound, Repository}, }; -use composefs_boot::cmdline::ComposefsCmdline; +use composefs_boot::cmdline::{ComposefsCmdline, KARG_COMPOSEFS_DIGEST, KARG_V2, split_cmdline}; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; @@ -353,15 +354,89 @@ pub fn mount_composefs_image( if allow_missing_fsverity { repo.set_insecure(); } - let rootfs = repo - .mount(name) - .context("Failed to mount composefs image")?; + let (image, enable_verity) = repo.open_image(name)?; + validate_image_verity(enable_verity, allow_missing_fsverity) + .with_context(|| format!("Validating fs-verity for composefs image {name}"))?; + let objects = repo.objects_dir().context("Getting objects directory")?; + let verity = if enable_verity { + VerityRequirement::Required + } else { + VerityRequirement::Disabled + }; + let rootfs = composefs_fsmount( + image, + name, + &[objects.as_fd()], + verity, + &MountOptions::default(), + ) + .context("Creating filesystem mount")?; set_mount_readonly(&rootfs)?; Ok(rootfs) } +fn parse_composefs_candidates(cmdline: &str) -> Result>> { + let mut candidates = Vec::new(); + for token in split_cmdline(cmdline) { + if token.starts_with(&format!("{KARG_COMPOSEFS_DIGEST}=")) + || token.starts_with(&format!("{KARG_V2}=")) + { + if let Some(candidate) = ComposefsCmdline::::from_cmdline(token)? { + candidates.push(candidate); + } + } + } + Ok(candidates) +} + +fn validate_image_verity(enable_verity: bool, allow_missing_fsverity: bool) -> Result<()> { + if !allow_missing_fsverity && !enable_verity { + anyhow::bail!("composefs image is not fs-verity sealed"); + } + Ok(()) +} + +fn select_composefs_candidate( + candidates: &[ComposefsCmdline], + mut mount: impl FnMut(&ComposefsCmdline) -> Result>, +) -> Result<(T, ComposefsCmdline)> { + for candidate in candidates { + if let Some(mounted) = mount(candidate)? { + return Ok((mounted, candidate.clone())); + } + } + let tried = candidates + .iter() + .map(|candidate| candidate.digest().to_hex()) + .collect::>() + .join(", "); + anyhow::bail!("no composefs image found (tried: {tried})") +} + +fn mount_composefs_candidate( + sysroot: &OwnedFd, + candidate: &ComposefsCmdline, + allow_missing_fsverity: bool, +) -> Result> { + if candidate.is_insecure() && !allow_missing_fsverity { + anyhow::bail!( + "composefs candidate requests insecure fs-verity, but the repository policy is strict" + ); + } + + match mount_composefs_image( + sysroot, + &candidate.digest().to_hex(), + allow_missing_fsverity, + ) { + Ok(rootfs) => Ok(Some(rootfs)), + Err(error) if error.downcast_ref::().is_some() => Ok(None), + Err(error) => Err(error), + } +} + /// Mounts a subdirectory with the specified configuration #[context("Mounting subdirectory")] pub fn mount_subdir( @@ -468,17 +543,27 @@ pub fn setup_root(args: Args) -> Result<()> { config }; - let composefs_info = ComposefsCmdline::::from_cmdline(&cmdline) - .context("Failed to parse composefs cmdline")? - .ok_or_else(|| anyhow::anyhow!("No composefs image in cmdline"))?; - - let new_root = match &args.root_fs { - Some(path) => open_root_fs(path).context("Failed to clone specified root fs")?, - None => mount_composefs_image( - &sysroot, - &composefs_info.digest().to_hex(), - composefs_info.is_insecure(), - )?, + let candidates = + parse_composefs_candidates(&cmdline).context("Failed to parse composefs cmdline")?; + if candidates.is_empty() { + anyhow::bail!("No composefs image in cmdline"); + } + let allow_missing_fsverity = candidates[0].is_insecure(); + if candidates + .iter() + .any(|candidate| candidate.is_insecure() != allow_missing_fsverity) + { + anyhow::bail!("composefs candidates have mixed fs-verity policy"); + } + + let (new_root, composefs_info) = match &args.root_fs { + Some(path) => ( + open_root_fs(path).context("Failed to clone specified root fs")?, + candidates[0].clone(), + ), + None => select_composefs_candidate(&candidates, |candidate| { + mount_composefs_candidate(&sysroot, candidate, allow_missing_fsverity) + })?, }; // we need to clone this before the next step to make sure we get the old one @@ -633,4 +718,53 @@ mod tests { assert_eq!(config.root.transient, true); assert_eq!(config.etc.mount, Some(MountType::None)); } + + fn v1(digest: Sha512HashValue, insecure: bool) -> String { + ComposefsCmdline::new_v1(digest, insecure).to_cmdline_arg() + } + + fn v2(digest: Sha512HashValue, insecure: bool) -> String { + ComposefsCmdline::new_v2(digest, insecure).to_cmdline_arg() + } + + #[test] + fn test_composefs_candidate_priority_and_selected_state() { + let v1_digest = Sha512HashValue::EMPTY; + let v2_digest = Sha512HashValue::from_hex("aa".repeat(64)).unwrap(); + let cmdline = format!( + "quiet {} {}", + v1(v1_digest.clone(), false), + v2(v2_digest.clone(), false) + ); + let candidates = parse_composefs_candidates(&cmdline).unwrap(); + let (selected, selected_cmdline) = select_composefs_candidate(&candidates, |candidate| { + Ok((candidate.digest() == &v2_digest).then_some(candidate.digest().to_hex())) + }) + .unwrap(); + assert_eq!(selected, v2_digest.to_hex()); + assert_eq!(selected_cmdline.digest(), &v2_digest); + + let (selected, _) = select_composefs_candidate(&candidates, |candidate| { + Ok(Some(candidate.digest().to_hex())) + }) + .unwrap(); + assert_eq!(selected, v1_digest.to_hex()); + } + + #[test] + fn test_composefs_candidate_parse_and_policy_errors() { + assert!(parse_composefs_candidates("composefs=not-a-digest").is_err()); + assert!(parse_composefs_candidates("composefs.digest=v9-sha512-12:aa").is_err()); + + let strict = parse_composefs_candidates(&v1(Sha512HashValue::EMPTY, false)).unwrap(); + let insecure = parse_composefs_candidates(&v2(Sha512HashValue::EMPTY, true)).unwrap(); + assert_ne!(strict[0].is_insecure(), insecure[0].is_insecure()); + } + + #[test] + fn test_image_verity_policy_is_per_image() { + assert!(validate_image_verity(true, false).is_ok()); + assert!(validate_image_verity(false, false).is_err()); + assert!(validate_image_verity(false, true).is_ok()); + } } diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 1fff0cbdde..02288e5476 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -162,11 +162,52 @@ pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; pub(crate) const GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons"; #[derive(thiserror::Error, Debug)] -#[error("The UKI has the wrong composefs= parameter (is '{actual}', should be '{expected}')")] -pub(crate) struct UKIDigestMismatch { - pub actual: String, - pub expected: String, - pub uki_name: Option, +pub(crate) enum UKIDigestMismatch { + #[error("The UKI has the wrong composefs= parameter (is '{actual}', should be '{expected}')")] + DigestParameter { + actual: String, + expected: String, + uki_name: Option, + }, + #[error( + "The UKI '{uki_name}' embedded composefs= digest ({actual:?}) doesn't match any of \ + {combinations_tried} supported xattr filtering mode/EROFS format version combinations. \ + The image may be corrupt, or was built with an incompatible composefs-rs version." + )] + UnsupportedCompatibility { + actual: Sha512HashValue, + uki_name: String, + combinations_tried: usize, + }, +} + +impl UKIDigestMismatch { + fn digest_parameter(actual: String, expected: String, uki_name: Option) -> Self { + Self::DigestParameter { + actual, + expected, + uki_name, + } + } + + fn unsupported_compatibility( + actual: Sha512HashValue, + uki_name: String, + combinations_tried: usize, + ) -> Self { + Self::UnsupportedCompatibility { + actual, + uki_name, + combinations_tried, + } + } + + fn uki_name(&self) -> Option<&str> { + match self { + Self::DigestParameter { uki_name, .. } => uki_name.as_deref(), + Self::UnsupportedCompatibility { uki_name, .. } => Some(uki_name), + } + } } pub(crate) fn print_uki_dumpfile_diff( @@ -175,8 +216,7 @@ pub(crate) fn print_uki_dumpfile_diff( fs: &FileSystem, ) { let dumpfile_name = mismatch - .uki_name - .as_ref() + .uki_name() .and_then(|x| x.strip_suffix(EFI_EXT).map(|x| format!("{x}.dump"))); let Some(dumpfile_name) = &dumpfile_name else { @@ -231,6 +271,20 @@ pub(crate) fn print_uki_dumpfile_diff( } } +/// Print the dumpfile diff when a UKI digest mismatch is about to escape. +pub(crate) fn print_uki_dumpfile_diff_on_mismatch( + result: Result, + repo: &ComposefsRepository, + fs: &FileSystem, +) -> Result { + if let Err(error) = &result { + if let Some(mismatch) = error.downcast_ref::() { + print_uki_dumpfile_diff(mismatch, repo, fs); + } + } + result +} + fn read_regular_file( file: &RegularFile, repo: &ComposefsRepository, @@ -1056,6 +1110,12 @@ fn write_pe_to_esp( let composefs_digest = composefs_info.digest().clone(); let missing_verity_allowed_cmdline = composefs_info.is_insecure(); + validate_uki_fsverity_policy( + !missing_fsverity_allowed, + missing_fsverity_allowed, + missing_verity_allowed_cmdline, + )?; + // If the UKI cmdline does not match what the user has passed as cmdline option // NOTE: This will only be checked for new installs and now upgrades/switches match missing_fsverity_allowed { @@ -1073,11 +1133,11 @@ fn write_pe_to_esp( } if !boot_ids.contains(&composefs_digest) { - return Err(UKIDigestMismatch { - actual: composefs_digest.to_hex(), - expected: uki_id.to_hex(), - uki_name: file_path.file_name().map(|name| name.to_string()), - } + return Err(UKIDigestMismatch::digest_parameter( + composefs_digest.to_hex(), + uki_id.to_hex(), + file_path.file_name().map(|name| name.to_string()), + ) .into()); } composefs_info @@ -1137,6 +1197,85 @@ fn write_pe_to_esp( Ok(boot_label) } +/// Reject an insecure UKI before any persistent ESP state is created when the +/// repository is configured to require fs-verity. The repository policy is +/// authoritative here: an image's `composefs=?` marker is only usable when +/// the repository itself was opened with the explicit missing-verity option. +fn validate_uki_fsverity_policy( + repo_requires_fsverity: bool, + missing_fsverity_allowed: bool, + uki_allows_missing_fsverity: bool, +) -> Result<()> { + if repo_requires_fsverity && uki_allows_missing_fsverity { + let option_hint = if missing_fsverity_allowed { + "The repository policy is still strict; verify that --allow-missing-fsverity was applied when opening it." + } else { + "Use --allow-missing-fsverity only when missing fs-verity is explicitly supported for this install." + }; + anyhow::bail!( + "The UKI requests insecure composefs operation, but this repository requires fs-verity. {option_hint}" + ); + } + Ok(()) +} + +/// Validate every primary UKI before any bootloader or ESP operation. The +/// write path repeats this check as a defense in depth, but must not be the +/// first place where an image is inspected: addons and bootloader setup can +/// otherwise leave persistent state behind before a later UKI fails. +fn prevalidate_uki_entries( + repo: &crate::store::ComposefsRepository, + entries: &[ComposefsBootEntry], + uki_id: &Sha512HashValue, + boot_ids: &[Sha512HashValue], + missing_fsverity_allowed: bool, +) -> Result<()> { + for entry in entries { + let ComposefsBootEntry::Type2(entry) = entry else { + continue; + }; + if !matches!(entry.pe_type, PEType::Uki) { + continue; + } + + let mut uki_reader = match &entry.file { + RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { + std::fs::File::from(repo.open_object(id)?) + } + RegularFile::Inline(..) | RegularFile::Sparse(..) => { + anyhow::bail!("UKI file is not a regular external object") + } + }; + let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; + let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) + .context("Parsing composefs=")? + .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; + let composefs_digest = composefs_info.digest(); + + validate_uki_fsverity_policy( + !missing_fsverity_allowed, + missing_fsverity_allowed, + composefs_info.is_insecure(), + )?; + + if !boot_ids.contains(composefs_digest) { + return Err(UKIDigestMismatch::digest_parameter( + composefs_digest.to_hex(), + uki_id.to_hex(), + entry + .file_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + ) + .into()); + } + composefs_info + .validate_digest(boot_ids) + .context("Validating UKI composefs digest")?; + } + Ok(()) +} + /// Scans `entries` for the primary UKI (`PEType::Uki`, not an addon) and /// extracts the `composefs=` digest embedded in its kernel cmdline. /// @@ -1148,10 +1287,15 @@ fn write_pe_to_esp( /// repair) the freshly-generated boot image digest *before* it's used for /// mounting, well before `write_pe_to_esp`'s own (too-late-to-repair) check /// of the same thing runs. +struct ExpectedComposefsDigest { + digest: Sha512HashValue, + uki_name: String, +} + fn find_expected_composefs_digest( repo: &crate::store::ComposefsRepository, entries: &[ComposefsBootEntry], -) -> Result> { +) -> Result> { for entry in entries { let ComposefsBootEntry::Type2(entry) = entry else { continue; @@ -1171,7 +1315,16 @@ fn find_expected_composefs_digest( let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) .context("Parsing composefs=")? .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; - return Ok(Some(composefs_info.digest().clone())); + let uki_name = entry + .file_path + .file_name() + .ok_or_else(|| anyhow!("Could not get UKI file name"))? + .to_string_lossy() + .into_owned(); + return Ok(Some(ExpectedComposefsDigest { + digest: composefs_info.digest().clone(), + uki_name, + })); } Ok(None) } @@ -1197,7 +1350,7 @@ pub(crate) fn ensure_correct_composefs_digest( // No UKI (e.g. a BLS-only setup); nothing to cross-check. return Ok(computed_id); }; - if expected == computed_id { + if expected.digest == computed_id { // The UKI's expected digest already matches; no repair needed. return Ok(computed_id); } @@ -1207,13 +1360,16 @@ pub(crate) fn ensure_correct_composefs_digest( // older or newer image tooling. tracing::info!( "Freshly computed composefs digest ({computed_id:?}) doesn't match the digest \ - embedded in the UKI ({expected:?}); searching for an xattr filtering mode and/or \ + embedded in the UKI ({:?}); searching for an xattr filtering mode and/or \ EROFS format version whose boot image matches, for backward compatibility with \ - older or newer image tooling" + older or newer image tooling", + expected.digest, ); + let mismatch = + UKIDigestMismatch::unsupported_compatibility(expected.digest.clone(), expected.uki_name, 0); resolve_boot_image_match( - expected.clone(), - composefs_oci::find_matching_boot_image(repo, manifest_digest, &expected), + mismatch, + composefs_oci::find_matching_boot_image(repo, manifest_digest, &expected.digest), ) } @@ -1225,7 +1381,7 @@ pub(crate) fn ensure_correct_composefs_digest( /// Factored out from [`ensure_correct_composefs_digest`] purely so this /// decision logic can be unit tested without a real repo or UKI fixture. fn resolve_boot_image_match( - expected: Sha512HashValue, + mismatch: UKIDigestMismatch, find_matching_result: Result>, ) -> Result { match find_matching_result.context( @@ -1242,14 +1398,12 @@ fn resolve_boot_image_match( ); Ok(digest) } - composefs_oci::BootImageMatch::NotFound(tried) => { - anyhow::bail!( - "The UKI's embedded composefs= digest ({expected:?}) doesn't match any of \ - {tried} supported xattr filtering mode/EROFS format version combinations. \ - The image may be corrupt, or was built with an incompatible composefs-rs \ - version." - ); - } + composefs_oci::BootImageMatch::NotFound(tried) => match mismatch { + UKIDigestMismatch::UnsupportedCompatibility { + actual, uki_name, .. + } => Err(UKIDigestMismatch::unsupported_compatibility(actual, uki_name, tried).into()), + _ => unreachable!("compatibility search always creates an unsupported mismatch"), + }, } } @@ -1457,6 +1611,8 @@ pub(crate) fn setup_composefs_uki_boot( } }; + prevalidate_uki_entries(repo, &entries, id, boot_ids, missing_fsverity_allowed)?; + let esp_mount = mount_esp_writable(&esp_device).context("Mounting ESP")?; let mut uki_info: Option = None; @@ -1763,12 +1919,6 @@ 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, @@ -1783,11 +1933,37 @@ pub(crate) async fn setup_composefs_boot( // If the UKI was built by tooling using a different xattr filtering // mode, find the mode whose boot image matches the digest embedded in // the UKI. - let id = ensure_correct_composefs_digest( + let id = print_uki_dumpfile_diff_on_mismatch( + ensure_correct_composefs_digest( + &repo, + &pull_result.manifest_digest, + generated_id, + &entries, + ), &repo, - &pull_result.manifest_digest, - generated_id, - &entries, + &fs, + )?; + + // Digest recovery above may have generated a boot image in a format that + // was not present initially. Read the refs after recovery so UKI + // validation accepts the selected image as well as both standard refs. + 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(); + let boot_ids = accepted_boot_image_ids(boot_id_v1.clone(), boot_id_v2.clone(), &id); + + print_uki_dumpfile_diff_on_mismatch( + prevalidate_uki_entries( + &repo, + &entries, + &id, + &boot_ids, + state.composefs_options.allow_missing_verity, + ), + &repo, + &fs, )?; let composefs_mnt_fd = repo @@ -1899,8 +2075,6 @@ pub(crate) async fn setup_composefs_boot( 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( @@ -1913,26 +2087,17 @@ pub(crate) async fn setup_composefs_boot( )?, provisional_deploy_id, ), - BootType::Uki => { - let uki_setup_result = setup_composefs_uki_boot( + BootType::Uki => print_uki_dumpfile_diff_on_mismatch( + setup_composefs_uki_boot( BootSetupType::Setup((&root_setup, &state, &postfetch)), &repo, &provisional_deploy_id, &boot_ids, entries, - ); - - match uki_setup_result { - Ok(result) => result, - Err(e) => match e.downcast::() { - Ok(mismatch) => { - print_uki_dumpfile_diff(&mismatch, &repo, &fs); - return Err(mismatch.into()); - } - Err(e) => Err(e)?, - }, - } - } + ), + &repo, + &fs, + )?, }; write_composefs_state( @@ -1950,6 +2115,29 @@ pub(crate) async fn setup_composefs_boot( Ok(()) } +/// Return every boot image digest that is valid for UKI verification. +/// +/// A digest selected by xattr/format recovery is not necessarily exposed by +/// the default V1/V2 refs. Keep it in the accepted set, and deduplicate all +/// values so equal refs are handled consistently. +pub(crate) fn accepted_boot_image_ids( + boot_id_v1: Option, + boot_id_v2: Option, + selected: &Sha512HashValue, +) -> Vec { + let mut ids = Vec::with_capacity(3); + for id in [boot_id_v1, boot_id_v2] + .into_iter() + .flatten() + .chain(Some(selected.clone())) + { + if !ids.contains(&id) { + ids.push(id); + } + } + ids +} + #[cfg(test)] mod tests { use super::*; @@ -2131,43 +2319,33 @@ mod tests { } #[test] - fn test_resolve_boot_image_match_found() { - let expected = other_digest(); - // A non-default mode, to make the test case meaningful. - let found_mode = composefs_oci::XattrFiltering::KeepUserXattrs; - - let result = resolve_boot_image_match( - expected.clone(), - Ok(composefs_oci::BootImageMatch::Found { - mode: found_mode, - version: FormatVersion::V2, - digest: expected.clone(), - }), - ); - assert_eq!(result.unwrap(), expected); - } - - #[test] - fn test_resolve_boot_image_match_error_paths() { + fn test_resolve_boot_image_match() { let expected = other_digest(); + let uki_name = "uki.efi"; // 2 xattr filtering modes x 2 EROFS format versions. let combinations_tried = 4; + #[derive(Copy, Clone)] enum FindMatching { - /// Succeeds, but no combination's digest matches `expected`. + Found, NotFound, - /// The search itself fails. Errors, } - // (find_matching behavior, substrings that must appear in the resulting error) let cases = [ + (FindMatching::Found, true, vec![]), ( FindMatching::NotFound, - vec![format!("{expected:?}"), format!("{combinations_tried}")], + false, + vec![ + uki_name.into(), + format!("{expected:?}"), + format!("doesn't match any of {combinations_tried} supported"), + ], ), ( FindMatching::Errors, + false, vec![ "search blew up".to_string(), "Searching for a boot image xattr filtering mode/format version matching \ @@ -2177,18 +2355,82 @@ mod tests { ), ]; - for (find_matching, want_substrings) in cases { + for (find_matching, should_succeed, want_substrings) in cases { let find_matching_result = match find_matching { + FindMatching::Found => Ok(composefs_oci::BootImageMatch::Found { + mode: composefs_oci::XattrFiltering::KeepUserXattrs, + version: FormatVersion::V2, + digest: expected.clone(), + }), FindMatching::NotFound => { Ok(composefs_oci::BootImageMatch::NotFound(combinations_tried)) } FindMatching::Errors => Err(anyhow::anyhow!("search blew up")), }; - let result = resolve_boot_image_match(expected.clone(), find_matching_result); - let msg = format!("{:#}", result.unwrap_err()); + let mismatch = + UKIDigestMismatch::unsupported_compatibility(expected.clone(), uki_name.into(), 0); + let result = resolve_boot_image_match(mismatch, find_matching_result); + if should_succeed { + assert_eq!(result.unwrap(), expected); + continue; + } + let error = result.unwrap_err(); + let msg = format!("{error:#}"); for want in &want_substrings { assert!(msg.contains(want), "expected {msg:?} to contain {want:?}"); } + if matches!(find_matching, FindMatching::NotFound) { + let mismatch = error.downcast_ref::().unwrap(); + assert_eq!(mismatch.uki_name(), Some(uki_name)); + } else { + assert!(error.downcast_ref::().is_none()); + } + } + } + + #[test] + fn test_accepted_boot_image_ids_includes_selected_and_deduplicates() { + let v1 = Sha512HashValue::EMPTY; + let v2 = other_digest(); + let selected = Sha512HashValue::from_hex("bb".repeat(64)).unwrap(); + + let ids = accepted_boot_image_ids(Some(v1.clone()), Some(v2.clone()), &selected); + assert_eq!(ids, vec![v1.clone(), v2.clone(), selected.clone()]); + + assert_eq!( + accepted_boot_image_ids(Some(v1.clone()), Some(v1.clone()), &v1), + vec![v1.clone()] + ); + assert_eq!( + accepted_boot_image_ids(Some(v1.clone()), Some(selected.clone()), &selected), + vec![v1, selected] + ); + } + + #[test] + fn test_uki_fsverity_policy() { + let cases = [ + (false, false, false, true), + (false, false, true, true), + (false, true, true, true), + (true, false, false, true), + (true, true, false, true), + (true, false, true, false), + (true, true, true, false), + ]; + + for (repo_requires, option_allowed, uki_insecure, should_pass) in cases { + let result = validate_uki_fsverity_policy(repo_requires, option_allowed, uki_insecure); + assert_eq!( + result.is_ok(), + should_pass, + "policy result for repo_requires={repo_requires}, option_allowed={option_allowed}, uki_insecure={uki_insecure}" + ); + if !should_pass { + let message = format!("{:#}", result.unwrap_err()); + assert!(message.contains("requires fs-verity")); + assert!(message.contains("allow-missing-fsverity")); + } } } } diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index bda66f1099..961cc2d169 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -23,7 +23,7 @@ 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, +/// use `FormatVersion::V1` to get a `composefs.digest=v1-sha512-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) diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 4890c6ce6d..7ec7411ce1 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -56,7 +56,9 @@ use ostree_ext::containers_image_proxy; use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; -use crate::bootc_composefs::boot::ensure_correct_composefs_digest; +use crate::bootc_composefs::boot::{ + ensure_correct_composefs_digest, print_uki_dumpfile_diff_on_mismatch, +}; use crate::bootc_composefs::progress; use crate::composefs_consts::BOOTC_TAG_PREFIX; use crate::install::{RootSetup, State}; @@ -78,6 +80,33 @@ pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result RepositoryConfig { + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + if allow_missing_fsverity { + config = config.set_insecure(); + } + crate::store::set_dual_erofs_formats(&mut config); + config +} + +/// Enforce the durable repository policy without forbidding an explicit +/// per-session relaxation of a strict repository. +pub(crate) fn validate_repository_policy( + repo: &crate::store::ComposefsRepository, + allow_missing_fsverity: bool, +) -> Result<()> { + if !allow_missing_fsverity && repo.is_insecure() { + anyhow::bail!( + "Existing composefs repository is insecure, but this install requires fs-verity; refusing to continue" + ); + } + Ok(()) +} + pub(crate) async fn initialize_composefs_repository( state: &State, root_setup: &RootSetup, @@ -104,16 +133,17 @@ pub(crate) async fn initialize_composefs_repository( crate::store::ensure_composefs_dir(rootfs_dir)?; - 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) = + let config = composefs_repository_config(allow_missing_fsverity); + let (mut repo, _created) = crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config) .context("Failed to initialize composefs repository")?; + // `set_insecure()` is an explicit per-handle relaxation. It must also be + // applied when init_path opened an existing strict repository; init_path + // correctly derives the durable policy from meta.json and does not rewrite + // it merely because this session permits missing fs-verity. + if allow_missing_fsverity { + repo.set_insecure(); + } let imgref: containers_image_proxy::ImageReference = state .source @@ -419,11 +449,15 @@ pub(crate) async fn pull_composefs_repo( // If the UKI was built by tooling using a different xattr filtering // mode, find the mode whose boot image matches the digest embedded in // the UKI. - let id = ensure_correct_composefs_digest( + let id = print_uki_dumpfile_diff_on_mismatch( + ensure_correct_composefs_digest( + &repo, + &pull_result.manifest_digest, + generated_id, + &entries, + ), &repo, - &pull_result.manifest_digest, - generated_id, - &entries, + &fs, )?; // Unwrap the Arc to get the owned repo back. @@ -454,4 +488,79 @@ mod tests { assert_eq!(tag, "localhost/bootc-sha256:abc123def456"); assert!(tag.starts_with(BOOTC_TAG_PREFIX)); } + + #[test] + fn test_repository_init_preserves_requested_verity_policy() { + for allow_missing in [true, false] { + let tempdir = tempfile::tempdir().unwrap(); + let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap(); + let result = crate::store::ComposefsRepository::init_path( + &root, + ".", + composefs_repository_config(allow_missing), + ); + + match result { + Ok((repo, _)) => assert_eq!(repo.is_insecure(), allow_missing), + Err(error) if !allow_missing => { + // A host without fs-verity support must fail strict + // initialization rather than silently creating insecure + // metadata. + let message = format!("{error:#}"); + assert!( + message.to_ascii_lowercase().contains("verity"), + "strict initialization failed for an unrelated reason: {message}" + ); + } + Err(error) => panic!("insecure initialization failed: {error:#}"), + } + } + } + + #[test] + fn test_existing_repository_policy_is_one_way() { + let tempdir = tempfile::tempdir().unwrap(); + let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap(); + let (strict_repo, _) = match crate::store::ComposefsRepository::init_path( + &root, + ".", + composefs_repository_config(false), + ) { + Ok(result) => result, + Err(error) if format!("{error:#}").to_ascii_lowercase().contains("verity") => { + // The host test filesystem may not support fs-verity. The + // strict fresh-install behavior is covered by the same init + // probe above and by the VM test environment. + return; + } + Err(error) => panic!("strict initialization failed unexpectedly: {error:#}"), + }; + assert!(!strict_repo.is_insecure()); + + let metadata_before = std::fs::read(tempdir.path().join("meta.json")).unwrap(); + let (mut relaxed_repo, _) = crate::store::ComposefsRepository::init_path( + &root, + ".", + composefs_repository_config(true), + ) + .unwrap(); + relaxed_repo.set_insecure(); + assert!(relaxed_repo.is_insecure()); + assert_eq!( + metadata_before, + std::fs::read(tempdir.path().join("meta.json")).unwrap() + ); + assert!(validate_repository_policy(&relaxed_repo, true).is_ok()); + + let insecure_tempdir = tempfile::tempdir().unwrap(); + let insecure_root = + Dir::open_ambient_dir(insecure_tempdir.path(), ambient_authority()).unwrap(); + let (insecure_repo, _) = crate::store::ComposefsRepository::init_path( + &insecure_root, + ".", + composefs_repository_config(true), + ) + .unwrap(); + assert!(validate_repository_policy(&insecure_repo, false).is_err()); + } } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 54a198974c..467739df68 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -19,7 +19,7 @@ use crate::spec::BootloaderKind; use crate::{ bootc_composefs::{ boot::{ - BootSetupType, BootType, UKIDigestMismatch, print_uki_dumpfile_diff, + BootSetupType, BootType, accepted_boot_image_ids, print_uki_dumpfile_diff_on_mismatch, setup_composefs_bls_boot, setup_composefs_uki_boot, }, gc::composefs_gc, @@ -340,7 +340,7 @@ pub(crate) async fn do_upgrade( 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_ids = accepted_boot_image_ids(boot_id_v1, boot_id_v2, &id); let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( @@ -355,26 +355,17 @@ pub(crate) async fn do_upgrade( provisional_deploy_id, ), - BootType::Uki => { - let uki_setup_result = setup_composefs_uki_boot( + BootType::Uki => print_uki_dumpfile_diff_on_mismatch( + setup_composefs_uki_boot( BootSetupType::Upgrade((storage, booted_cfs, &host)), &repo, &provisional_deploy_id, &boot_ids, entries, - ); - - match uki_setup_result { - Ok(result) => result, - Err(e) => match e.downcast::() { - Ok(mismatch) => { - print_uki_dumpfile_diff(&mismatch, &repo, &oci_fs); - return Err(mismatch.into()); - } - Err(e) => Err(e)?, - }, - } - } + ), + &repo, + &oci_fs, + )?, }; // `repo` holds its own flock(LOCK_SH) on /sysroot/composefs, taken out by diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 636579911d..81fe432b0a 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -418,13 +418,13 @@ pub(crate) enum ContainerOpts { #[clap(default_value = "/target")] path: Utf8PathBuf, - /// Additionally generate a dumpfile written to the target path + /// Additionally generate a dumpfile for the preferred digest, 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). + /// V1 produces a `composefs.digest=v1-sha512-12:` karg (C-tool compatible). /// V2 produces the legacy `composefs=` karg (composefs-rs native). #[clap(long, default_value = "v1")] erofs_version: ErofsVersionArg, @@ -432,7 +432,7 @@ pub(crate) enum ContainerOpts { /// Output the bootable composefs digest from container storage. #[clap(hide = true)] ComputeComposefsDigestFromStorage { - /// Additionally generate a dumpfile written to the target path + /// Additionally generate a dumpfile for the preferred digest, written to the target path #[clap(long)] write_dumpfile_to: Option, @@ -488,11 +488,11 @@ pub(crate) enum ContainerOpts { /// EROFS format version to use when computing the composefs digest. /// - /// V1 produces a `composefs.digest=v1-sha256-12:` karg (C-tool compatible). + /// V1 produces a `composefs.digest=v1-sha512-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, + #[clap(long)] + erofs_version: Option, /// Write a dumpfile to this path #[clap(long)] @@ -543,7 +543,7 @@ 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 EROFS (C-tool compatible, `composefs.digest=v1-sha512-12:` karg). Default. V1, /// V2 EROFS (composefs-rs native, `composefs=` karg). V2, @@ -2196,7 +2196,7 @@ async fn run_from_opt(opt: Opt) -> Result { &args, kernel, allow_missing_verity, - erofs_version.into(), + erofs_version, write_dumpfile_to.as_deref(), ) .await @@ -2739,6 +2739,27 @@ mod tests { ); } + #[test] + fn test_parse_ukify_erofs_version_args() { + for (command, expected) in [ + (&["bootc", "container", "ukify"][..], None), + ( + &["bootc", "container", "ukify", "--erofs-version=v1"][..], + Some(ErofsVersionArg::V1), + ), + ( + &["bootc", "container", "ukify", "--erofs-version=v2"][..], + Some(ErofsVersionArg::V2), + ), + ] { + let opt = Opt::try_parse_from(command).unwrap(); + let Opt::Container(ContainerOpts::Ukify { erofs_version, .. }) = opt else { + panic!("expected container ukify options"); + }; + assert_eq!(erofs_version, expected); + } + } + #[test] fn test_parse_opts() { assert!(matches!( diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index b110821cb8..e2fd0674a3 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -204,7 +204,6 @@ use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; use bootc_mount::Filesystem; -use composefs_ctl::composefs::repository::RepositoryConfig; use linux_kernel_cmdline::{bytes, utf8}; /// The toplevel boot directory @@ -2030,15 +2029,18 @@ async fn install_to_filesystem_impl( // 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 allow_missing_fsverity = state.composefs_options.allow_missing_verity; + let config = + crate::bootc_composefs::repo::composefs_repository_config(allow_missing_fsverity); let (cfs_repo, _created) = crate::store::ComposefsRepository::init_path( &rootfs.physical_root, crate::store::COMPOSEFS, config, )?; + crate::bootc_composefs::repo::validate_repository_policy( + &cfs_repo, + allow_missing_fsverity, + )?; crate::deploy::check_disk_space_composefs( &cfs_repo, &img_manifest_config.manifest, diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index d4f3f62d8e..e642e4b77c 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -1,10 +1,11 @@ //! Build Unified Kernel Images (UKI) using ukify. //! //! This module provides functionality to build UKIs by computing the necessary -//! arguments from a container image and invoking the ukify tool. +//! arguments from a container image and invoking the ukify tool. Default V1 +//! UKIs also carry the legacy V2 digest for older bootc clients. -use std::ffi::OsString; -use std::process::Command; +use std::ffi::{OsStr, OsString}; +use std::process::{Command, Output}; use anyhow::{Context, Result}; use bootc_utils::CommandRunExt; @@ -19,8 +20,112 @@ use composefs_ctl::composefs; use crate::bootc_composefs::digest::compute_composefs_digest; use crate::bootc_composefs::status::build_composefs_karg; +use crate::cli::ErofsVersionArg; use crate::kernel::KernelInternal; +const COMPOSEFS_DIGEST_V1_FEATURE: &str = "/usr/lib/bootc/initramfs-features/composefs-digest-v1"; +const COMPOSEFS_DIGEST_V1_FEATURE_CONTENT: &[u8] = b"composefs-digest-v1 state-v1\n"; + +/// Query `lsinitrd` without unpacking or executing any initramfs contents. +fn run_lsinitrd(args: &[&OsStr]) -> Result { + Command::new("lsinitrd") + .args(args) + .output() + .context("Running lsinitrd") +} + +fn resolve_erofs_version_with( + requested: Option, + initramfs: &Utf8Path, + run: F, +) -> Result +where + F: Fn(&[&OsStr]) -> Result, +{ + if let Some(ErofsVersionArg::V2) = requested { + return Ok(FormatVersion::V2); + } + + let archive_args = [initramfs.as_ref()]; + let archive = + run(&archive_args).with_context(|| format!("Validating initramfs {initramfs}"))?; + if !archive.status.success() { + anyhow::bail!( + "Validating initramfs {initramfs} failed: lsinitrd could not read the archive: {}", + String::from_utf8_lossy(&archive.stderr).trim() + ); + } + + let feature_args = [ + OsStr::new("--file"), + OsStr::new(COMPOSEFS_DIGEST_V1_FEATURE), + initramfs.as_ref(), + ]; + let feature = run(&feature_args) + .with_context(|| format!("Reading composefs capability from initramfs {initramfs}"))?; + let marker_listed = archive + .stdout + .windows(COMPOSEFS_DIGEST_V1_FEATURE.len()) + .any(|entry| entry == COMPOSEFS_DIGEST_V1_FEATURE.as_bytes()); + let marker_present = if feature.stdout == COMPOSEFS_DIGEST_V1_FEATURE_CONTENT { + true + } else if feature.stdout.is_empty() && !marker_listed && !feature.status.success() { + // lsinitrd reports a missing file with a nonzero exit status on some + // supported dracut versions. + false + } else if feature.stdout.is_empty() && !marker_listed && feature.status.success() { + // Other supported versions successfully extract zero bytes when a + // requested file is absent. The successful full-archive probe above + // has already ruled out a malformed archive. + false + } else { + anyhow::bail!( + "Initramfs {initramfs} has an unexpected composefs V1 capability marker; rebuild its initramfs" + ); + }; + if marker_present { + return Ok(FormatVersion::V1); + } + + if requested == Some(ErofsVersionArg::V1) { + anyhow::bail!( + "--erofs-version=v1 requires an initramfs with composefs V1 support; rebuild the initramfs with current bootc or use --erofs-version=v2" + ); + } + tracing::warn!( + "Initramfs lacks composefs V1 support; generating a V2-only UKI for compatibility" + ); + Ok(FormatVersion::V2) +} + +fn resolve_erofs_version( + requested: Option, + initramfs: &Utf8Path, +) -> Result { + resolve_erofs_version_with(requested, initramfs, run_lsinitrd) +} + +fn composefs_kargs_for_uki( + preferred_digest: Sha512HashValue, + preferred_version: FormatVersion, + compatibility_v2_digest: Option, + allow_missing_fsverity: bool, +) -> Vec { + let mut kargs = vec![build_composefs_karg( + preferred_digest, + preferred_version, + allow_missing_fsverity, + )]; + if let Some(v2_digest) = compatibility_v2_digest { + kargs.push(build_composefs_karg( + v2_digest, + FormatVersion::V2, + allow_missing_fsverity, + )); + } + kargs +} + /// Build a UKI from the given rootfs. /// /// This function: @@ -37,7 +142,7 @@ pub(crate) async fn build_ukify( args: &[OsString], kernel: Option, allow_missing_fsverity: bool, - erofs_version: FormatVersion, + erofs_version: Option, write_dumpfile_to: Option<&Utf8Path>, ) -> Result<()> { // Warn if --karg is used (temporary workaround) @@ -48,13 +153,6 @@ pub(crate) async fn build_ukify( ); } - // Verify ukify is available - if !crate::utils::have_executable("ukify")? { - anyhow::bail!( - "ukify executable not found in PATH. Please install systemd-ukify or equivalent." - ); - } - // Open the rootfs directory let root = Dir::open_ambient_dir(rootfs, cap_std_ext::cap_std::ambient_authority()) .with_context(|| format!("Opening rootfs {rootfs}"))?; @@ -101,11 +199,34 @@ pub(crate) async fn build_ukify( } } - // Compute the composefs digest + let initramfs_archive = if initramfs_path.is_absolute() { + initramfs_path.clone() + } else { + rootfs.join(initramfs_path) + }; + let erofs_version = resolve_erofs_version(erofs_version, &initramfs_archive)?; + + // Validate the selected initramfs before checking ukify. This keeps an + // invalid archive actionable even in minimal producer environments where + // ukify is not installed yet. + if !crate::utils::have_executable("ukify")? { + anyhow::bail!( + "ukify executable not found in PATH. Please install systemd-ukify or equivalent." + ); + } + + // Compute the preferred digest. With V1, retain the dumpfile behavior for + // that preferred digest and add a legacy V2 compatibility digest below. 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")?; + let compatibility_v2_digest = if erofs_version == FormatVersion::V1 { + let digest = compute_composefs_digest(rootfs, FormatVersion::V2, None).await?; + Some(Sha512HashValue::from_hex(&digest).context("Parsing computed V2 digest")?) + } else { + None + }; // Get kernel arguments from kargs.d let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; @@ -113,11 +234,14 @@ pub(crate) async fn build_ukify( // 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( + for karg in composefs_kargs_for_uki( composefs_digest, erofs_version, + compatibility_v2_digest, allow_missing_fsverity, - ))); + ) { + cmdline.extend(&Cmdline::from(karg)); + } // Add any extra kargs provided via --karg for karg in extra_kargs { @@ -157,14 +281,53 @@ mod tests { use bootc_utils::create_minimal_pe; use super::*; - use std::fs; + use std::{fs, io::Write, process::Stdio}; + + fn build_cpio_initramfs(marker: Option<&[u8]>) -> Result { + let tempdir = tempfile::tempdir()?; + let root = tempdir.path(); + let archive = root.join("initramfs.img"); + let mut filenames = b"etc/legacy\0".to_vec(); + fs::create_dir_all(root.join("etc"))?; + fs::write(root.join("etc/legacy"), b"legacy\n")?; + if let Some(marker) = marker { + let marker_path = root.join(COMPOSEFS_DIGEST_V1_FEATURE.trim_start_matches('/')); + fs::create_dir_all(marker_path.parent().expect("marker has a parent"))?; + fs::write(&marker_path, marker)?; + filenames.extend_from_slice( + COMPOSEFS_DIGEST_V1_FEATURE + .trim_start_matches('/') + .as_bytes(), + ); + filenames.push(0); + } + + let mut command = Command::new("cpio"); + command + .current_dir(root) + .args(["--create", "--format=newc", "--null"]) + .stdin(Stdio::piped()) + .stdout(fs::File::create(&archive)?); + let mut child = command.spawn().context("Creating CPIO initramfs fixture")?; + child + .stdin + .take() + .expect("stdin was requested") + .write_all(&filenames)?; + anyhow::ensure!( + child.wait()?.success(), + "Creating CPIO initramfs fixture failed" + ); + Ok(tempdir) + } #[tokio::test] async fn test_build_ukify_no_kernel() { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); - let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; + let result = + build_ukify(path, &[], &[], None, false, Some(ErofsVersionArg::V2), None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -186,7 +349,8 @@ mod tests { ) .unwrap(); - let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; + let result = + build_ukify(path, &[], &[], None, false, Some(ErofsVersionArg::V2), None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -194,4 +358,182 @@ mod tests { "Unexpected error message: {err}" ); } + + #[test] + fn test_composefs_kargs_for_uki() { + let v1 = Sha512HashValue::EMPTY; + let v2 = Sha512HashValue::from_hex("aa".repeat(64)).unwrap(); + for (version, fallback, expected_len, expected_prefixes) in [ + ( + FormatVersion::V1, + Some(v2), + 2, + ["composefs.digest=v1-sha512-12:", "composefs="], + ), + (FormatVersion::V2, None, 1, ["composefs=", ""]), + ] { + let kargs = composefs_kargs_for_uki(v1.clone(), version, fallback, false); + assert_eq!(kargs.len(), expected_len); + for (karg, prefix) in kargs.iter().zip(expected_prefixes) { + assert!(karg.starts_with(prefix), "unexpected karg: {karg}"); + } + } + } + + #[test] + fn resolve_erofs_version_uses_initramfs_capabilities() { + use std::os::unix::process::ExitStatusExt; + + fn output(success: bool, stdout: &[u8]) -> Output { + Output { + status: std::process::ExitStatus::from_raw(if success { 0 } else { 1 << 8 }), + stdout: stdout.into(), + stderr: b"fixture error".to_vec(), + } + } + + let initramfs = Utf8Path::new("/test/initramfs.img"); + for (name, requested, responses, expected) in [ + ( + "auto capable", + None, + vec![ + output(true, b""), + output(true, COMPOSEFS_DIGEST_V1_FEATURE_CONTENT), + ], + Ok(FormatVersion::V1), + ), + ( + "auto legacy", + None, + vec![output(true, b""), output(false, b"")], + Ok(FormatVersion::V2), + ), + ( + "explicit v1 legacy", + Some(ErofsVersionArg::V1), + vec![output(true, b""), output(false, b"")], + Err("--erofs-version=v1 requires"), + ), + ( + "malformed archive", + None, + vec![output(false, b"")], + Err("could not read the archive"), + ), + ( + "unexpected marker", + None, + vec![output(true, b""), output(true, b"wrong\n")], + Err("unexpected composefs V1 capability marker"), + ), + ( + "explicit v2 skips probing", + Some(ErofsVersionArg::V2), + vec![], + Ok(FormatVersion::V2), + ), + ] { + let calls = std::cell::RefCell::new(Vec::new()); + let responses = std::cell::RefCell::new(responses.into_iter()); + let result = resolve_erofs_version_with(requested, initramfs, |args| { + calls.borrow_mut().push( + args.iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + ); + responses + .borrow_mut() + .next() + .ok_or_else(|| anyhow::anyhow!("unexpected lsinitrd invocation")) + }); + match expected { + Ok(version) => assert_eq!(result.unwrap(), version, "case {name}"), + Err(message) => assert!( + result.unwrap_err().to_string().contains(message), + "case {name}" + ), + } + if requested == Some(ErofsVersionArg::V2) { + assert!(calls.borrow().is_empty(), "case {name}"); + } else { + assert_eq!(calls.borrow()[0], ["/test/initramfs.img"], "case {name}"); + if calls.borrow().len() == 2 { + assert_eq!( + calls.borrow()[1], + ["--file", COMPOSEFS_DIGEST_V1_FEATURE, "/test/initramfs.img",], + "case {name}" + ); + } + } + } + } + + #[test] + fn resolve_erofs_version_fails_closed_when_lsinitrd_is_unavailable() { + let error = resolve_erofs_version_with(None, Utf8Path::new("/test/initramfs.img"), |_| { + Err(anyhow::anyhow!("lsinitrd executable not found")) + }) + .unwrap_err(); + assert!(error.to_string().contains("Validating initramfs")); + } + + #[test] + fn resolve_erofs_version_with_real_cpio_initramfs() -> Result<()> { + let legacy = build_cpio_initramfs(None)?; + let legacy_archive = Utf8Path::from_path(&legacy.path().join("initramfs.img")) + .expect("temporary path is UTF-8") + .to_owned(); + assert_eq!( + resolve_erofs_version(None, &legacy_archive)?, + FormatVersion::V2 + ); + assert!( + resolve_erofs_version(Some(ErofsVersionArg::V1), &legacy_archive) + .unwrap_err() + .to_string() + .contains("--erofs-version=v1 requires") + ); + + let capable = build_cpio_initramfs(Some(COMPOSEFS_DIGEST_V1_FEATURE_CONTENT))?; + let capable_archive = Utf8Path::from_path(&capable.path().join("initramfs.img")) + .expect("temporary path is UTF-8") + .to_owned(); + let version = resolve_erofs_version(None, &capable_archive)?; + assert_eq!(version, FormatVersion::V1); + let kargs = composefs_kargs_for_uki( + Sha512HashValue::EMPTY, + version, + Some(Sha512HashValue::from_hex("aa".repeat(64))?), + false, + ); + assert!(kargs[0].starts_with("composefs.digest=v1-sha512-12:")); + assert!(kargs[1].starts_with("composefs=")); + + let bad_marker = build_cpio_initramfs(Some(b"unexpected\n"))?; + let bad_marker_archive = Utf8Path::from_path(&bad_marker.path().join("initramfs.img")) + .expect("temporary path is UTF-8") + .to_owned(); + assert!( + resolve_erofs_version(None, &bad_marker_archive) + .unwrap_err() + .to_string() + .contains("unexpected composefs V1 capability marker") + ); + + let corrupt = tempfile::NamedTempFile::new()?; + fs::write(corrupt.path(), b"not an initramfs")?; + let corrupt_archive = Utf8Path::from_path(corrupt.path()).expect("temporary path is UTF-8"); + assert!( + resolve_erofs_version(None, corrupt_archive) + .unwrap_err() + .to_string() + .contains("could not read the archive") + ); + assert_eq!( + resolve_erofs_version(Some(ErofsVersionArg::V2), corrupt_archive)?, + FormatVersion::V2 + ); + Ok(()) + } } diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index 951acc24eb..69b042facb 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -3,7 +3,7 @@ use cap_std_ext::cap_std::fs::Dir; use indoc::indoc; use scopeguard::defer; use serde::Deserialize; -use std::process::Command; +use std::process::{Command, Stdio}; use std::{fs, path::Path}; use anyhow::{Context, Result}; @@ -356,10 +356,43 @@ pub(crate) fn test_compute_composefs_digest() -> Result<()> { /// 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. +/// - a synthetic non-archive initramfs is rejected by the automatic capability +/// probe before it can be handed to ukify. pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { - use std::os::unix::fs::PermissionsExt; + use std::{io::Write, os::unix::fs::PermissionsExt}; + + const MARKER: &str = "usr/lib/bootc/initramfs-features/composefs-digest-v1"; + const MARKER_CONTENT: &[u8] = b"composefs-digest-v1 state-v1\n"; + + fn write_cpio_initramfs(path: &Path, marker: Option<&[u8]>) -> Result<()> { + let source = tempfile::tempdir()?; + fs::create_dir_all(source.path().join("etc"))?; + fs::write(source.path().join("etc/legacy"), b"legacy\n")?; + let mut filenames = b"etc/legacy\0".to_vec(); + if let Some(marker) = marker { + let marker_path = source.path().join(MARKER); + fs::create_dir_all(marker_path.parent().expect("marker has a parent"))?; + fs::write(marker_path, marker)?; + filenames.extend_from_slice(MARKER.as_bytes()); + filenames.push(0); + } + let mut cpio = Command::new("cpio"); + cpio.current_dir(source.path()) + .args(["--create", "--format=newc", "--null"]) + .stdin(Stdio::piped()) + .stdout(fs::File::create(path)?); + let mut child = cpio.spawn().context("Creating CPIO initramfs fixture")?; + child + .stdin + .take() + .expect("stdin was requested") + .write_all(&filenames)?; + anyhow::ensure!( + child.wait()?.success(), + "Creating CPIO initramfs fixture failed" + ); + Ok(()) + } // 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 @@ -381,7 +414,8 @@ pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { 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")?; + let initramfs = mod_dir.join("initramfs.img"); + write_cpio_initramfs(&initramfs, None)?; // ukify reads --os-release @usr/lib/os-release relative to the rootfs cwd let os_release_dir = root.join("usr/lib"); @@ -433,12 +467,8 @@ pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { "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") + // ── Part 2: exercise the real lsinitrd probe through the CLI ────────────── + let legacy_v1 = Command::new("bootc") .args([ "container", "ukify", @@ -446,27 +476,86 @@ pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { root_str, "--erofs-version=v1", "--allow-missing-verity", - "--", - "--output=/dev/null", ]) .output()?; + assert!( + !legacy_v1.status.success() + && String::from_utf8_lossy(&legacy_v1.stderr).contains("--erofs-version=v1 requires"), + "explicit V1 did not reject a valid legacy initramfs: {}", + String::from_utf8_lossy(&legacy_v1.stderr) + ); - let stderr = String::from_utf8_lossy(&output.stderr); + fs::write(&initramfs, b"corrupt initramfs")?; + let corrupt_auto = Command::new("bootc") + .args(["container", "ukify", "--rootfs", root_str]) + .output()?; + assert!( + !corrupt_auto.status.success() + && String::from_utf8_lossy(&corrupt_auto.stderr).contains("Validating initramfs"), + "auto mode did not reject a corrupt initramfs: {}", + String::from_utf8_lossy(&corrupt_auto.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(()); - } + write_cpio_initramfs(&initramfs, Some(b"bad marker\n"))?; + let bad_marker = Command::new("bootc") + .args(["container", "ukify", "--rootfs", root_str]) + .output()?; + assert!( + !bad_marker.status.success() + && String::from_utf8_lossy(&bad_marker.stderr) + .contains("unexpected composefs V1 capability marker"), + "auto mode did not reject a bad marker: {}", + String::from_utf8_lossy(&bad_marker.stderr) + ); - // 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). + let fake_bin = td.path().join("fake-bin"); + fs::create_dir(&fake_bin)?; + let fake_ukify = fake_bin.join("ukify"); + fs::write( + &fake_ukify, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$UKIFY_ARGS\"\n", + )?; + fs::set_permissions(&fake_ukify, fs::Permissions::from_mode(0o755))?; + let ukify_args = td.path().join("ukify-args"); + let path_with_fake_ukify = format!("{}:{}", fake_bin.display(), std::env::var("PATH")?); + + write_cpio_initramfs(&initramfs, Some(MARKER_CONTENT))?; + let capable_auto = Command::new("bootc") + .env("PATH", &path_with_fake_ukify) + .env("UKIFY_ARGS", &ukify_args) + .args(["container", "ukify", "--rootfs", root_str]) + .output()?; + assert!( + capable_auto.status.success(), + "auto mode failed for a capable initramfs: {}", + String::from_utf8_lossy(&capable_auto.stderr) + ); + let args = fs::read_to_string(&ukify_args)?; + let cmdline = args + .lines() + .skip_while(|arg| *arg != "--cmdline") + .nth(1) + .context("fake ukify did not receive --cmdline")?; + let v1 = cmdline.find("composefs.digest=v1-sha512-12:").unwrap(); + let v2 = cmdline.find("composefs=").unwrap(); + assert!(v1 < v2, "expected ordered V1 then V2 kargs: {cmdline}"); + + fs::write(&initramfs, b"corrupt initramfs")?; + let explicit_v2 = Command::new("bootc") + .env("PATH", &path_with_fake_ukify) + .env("UKIFY_ARGS", &ukify_args) + .args([ + "container", + "ukify", + "--rootfs", + root_str, + "--erofs-version=v2", + ]) + .output()?; assert!( - !stderr.contains("No kernel found") && !stderr.contains("already contains a UKI"), - "bootc bailed before reaching ukify; stderr:\n{stderr}" + explicit_v2.status.success(), + "explicit V2 unexpectedly probed the initramfs: {}", + String::from_utf8_lossy(&explicit_v2.stderr) ); Ok(()) diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 0e2768aba9..4836aea229 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -21,6 +21,7 @@ const COMMON_INST_ARGS: &[&str] = &["--label=bootc.test=1"]; const FIELD_TRY_BIND_STORAGE: &str = "try_bind_storage"; const FIELD_SUMMARY: &str = "summary"; const FIELD_ADJUST: &str = "adjust"; +const FIELD_ENABLED: &str = "enabled"; const FIELD_FIXME_SKIP_IF_COMPOSEFS: &str = "fixme_skip_if_composefs"; const FIELD_FIXME_SKIP_IF_UKI: &str = "fixme_skip_if_uki"; @@ -32,6 +33,7 @@ const FIELD_SKIP_IF_OSTREE: &str = "skip_if_ostree"; // bcvk options const BCVK_OPT_BIND_STORAGE_RO: &str = "--bind-storage-ro"; const ENV_BOOTC_UPGRADE_IMAGE: &str = "BOOTC_upgrade_image"; +const ENV_BOOTC_BRIDGE_IMAGE: &str = "BOOTC_bridge_image"; // Distro identifiers const DISTRO_CENTOS_9: &str = "centos-9"; @@ -569,6 +571,9 @@ 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)); } + if let Some(ref bridge_img) = args.bridge_image { + tmt_env_vars.push(format!("{}={}", ENV_BOOTC_BRIDGE_IMAGE, bridge_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" @@ -1311,6 +1316,12 @@ fn generate_integration() -> Result<(String, String)> { summary.clone(), ); } + if let Some(enabled) = map.get(&serde_yaml::Value::String(FIELD_ENABLED.to_string())) { + plan_value.insert( + serde_yaml::Value::String(FIELD_ENABLED.to_string()), + enabled.clone(), + ); + } } // Build discover section diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index e09567b863..5a55b45ea7 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -243,6 +243,10 @@ pub(crate) struct RunTmtArgs { #[clap(long)] pub(crate) upgrade_image: Option, + /// Bridge image to use when bind-storage-ro is available + #[clap(long)] + pub(crate) bridge_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 diff --git a/docs/src/experimental-composefs.md b/docs/src/experimental-composefs.md index c4f04eca74..720e4ec2bc 100644 --- a/docs/src/experimental-composefs.md +++ b/docs/src/experimental-composefs.md @@ -7,28 +7,99 @@ do provide feedback on them. The composefs backend is an experimental alternative storage backend that uses [composefs-rs](https://github.com/composefs/composefs-rs) instead of ostree for storing and managing bootc system deployments. -**Status**: Experimental, but close to stabilization! We are committed to in-place upgrades from all systems deployed since bootc 1.16.0. +**Status: experimental.** The current implementation is moving new composefs +repositories and UKIs to EROFS V1, while retaining a V2 compatibility path. +This is not yet a general compatibility promise for every pre-change bootc +release or every existing composefs installation. The remaining validation and +recovery work is tracked in [Stabilization status](#stabilization-status). The composefs backend supports two distinct levels of integrity guarantee, controlled by whether fsverity is strictly enforced on the root filesystem (i.e. whether the image was built with `--allow-missing-verity`): - **Sealed**: The composefs digest is baked into the kernel command line of a UKI and *required* to match at boot. - **Unsealed**: fsverity enforcement is optional, so composefs still provides content-addressed, deduplicated storage and garbage collection, but without a guarantee that the root filesystem matches what was signed. Unsealed composefs most commonly boots via a traditional `vmlinuz`/`initramfs.img` and a BLS boot entry, but a UKI built with `--allow-missing-verity` is *also* unsealed in this sense — packaging as a UKI is a boot convenience here, not by itself a security boundary. See [Bootloader Support](#bootloader-support) below. +## EROFS V1 transition and compatibility + +EROFS V1 is the default for newly initialized composefs repositories. For +`bootc container ukify`, automatic selection probes the initramfs capability +marker: an old bootc 1.16 initramfs without the marker produces V2-only; an +initramfs with the current marker produces V1 followed by a V2 fallback. V1 uses the C-tool-compatible kernel argument +`composefs.digest=v1-sha512-12:`. V2 is the legacy composefs-rs format +and uses `composefs=`. Both digests are SHA-512 values, but they name +different EROFS encodings and must not be substituted for one another. + +When building a UKI with the default V1, bootc computes both values and puts +the V1 argument first and the V2 argument second. A current initramfs tries +candidates in command-line order, so it selects V1 when its image is present. + +The normal older-client bridge has two hops. An old stager creates V2 state; +the later current producer supplies a dual UKI, and the old embedded +initramfs selects its V2 image because no V1 image exists in that old +repository. After a current client stages a later update, it can select V1. +This is distinct from a current stager producing a dual UKI with an old +embedded initramfs: that combination creates V1 state, while the old +initramfs selects V2 and needs V2 state. It is unsafe and is not supported by +the normal bridge. + +The only historical release in the tested compatibility scope is bootc 1.16.0. +Other historical releases, including v1.9's SHA-256 V2 identity, are not part +of this compatibility contract. + +New repositories are configured to retain V1 as the default and V2 as an +additional format. Existing repositories retain the format configuration +recorded in their metadata when opened; they are not silently reinitialized +as V1 repositories. A successful fallback still requires the matching V2 +image and an initramfs able to mount it. Missing images, malformed or +unrecognized kernel arguments, fs-verity policy rejection, or a UKI digest +that does not match the repository are boot/staging failures, not a safe +conversion to another digest. + +For controlled V2 UKI generation, the supported CLI spelling is: + +```bash +bootc container ukify --erofs-version=v2 ... +``` + +That produces only the V2 `composefs=` argument; it does not add a V1 +fallback. The same `--erofs-version=v1` or `--erofs-version=v2` option is +available on the hidden `bootc container compute-composefs-digest` helpers. +The selected format must match images committed to the repository. + +For the existing TMT build tests, `BOOTC_erofs_version=v1` or +`BOOTC_erofs_version=v2` selects the image format and is forwarded by +`just test-tmt-nobuild`; use the same setting for the base and synthetic +upgrade images. It is not an install-time flag: installation consumes the +UKI already in the image. Current tests cover current-client-to-current-client +same-format upgrades, not the old-client bridge. There is no supported BLS +install-time V2 control. A typed install-configuration option is needed to +set repository format, BLS content, and state-directory selection together. + ## Storage and repository structure Unlike the ostree backend, which keeps its repository at `/ostree/repo`, the composefs backend splits its on-disk state across two top-level directories in the physical sysroot: - `/composefs`: The [composefs-rs repository](https://github.com/composefs/composefs-rs/blob/main/crates/composefs/src/repository_format.rs) (mode `0700`), containing: - `objects/`: content-addressed file storage, keyed by SHA-512 fsverity digest and shared via reflink (`FICLONE`) where the filesystem supports it - - `images/`: EROFS images describing each deployment's root filesystem metadata + - `images/`: EROFS images describing each deployment's root filesystem metadata; a transition repository can contain both the V1 and V2 images for one root filesystem - `streams/`: OCI manifest, config, and layer splitstreams captured during image pulls - `bootc/storage/`: the `containers-storage:` instance backing logically bound images, reflink-shared with the composefs object store -- `/state/deploy//`: Persistent per-deployment state, one directory per deployment (named after its composefs digest): +- `/state/deploy//`: Persistent per-deployment state, one directory per deployment (named after the deployment identity selected while staging): - `etc/`: a writable copy of the deployment's `/etc`, bind-mounted onto the booted root's `/etc` - `var`: a symlink to the shared `/state/os/default/var`, bind-mounted onto the booted root's `/var` - `.origin`: an INI file recording the image reference, boot type (BLS or UKI) and digest, and the OCI manifest digest (the latter is what keeps a deployment's objects alive across garbage collection) -Although composefs-rs supports other fsverity hash algorithms, bootc currently hardcodes `SHA-512` for the repository (see `Algorithm::SHA512` at every repository init/open call site, and the `ComposefsRepository` type alias in `crates/lib/src/store/mod.rs`). This is why deployment and object identifiers throughout this document (and in `bootc status`) are 128-character hex strings. +Although composefs-rs supports other fsverity hash algorithms, bootc currently hardcodes `SHA-512` for the repository. This is why EROFS image IDs and object identifiers are 128-character hex strings. + +Several identifiers appear together but have different purposes: + +- The OCI manifest digest identifies the pulled container content and is recorded in the origin data; it is used to retain pull objects for garbage collection. +- A V1 or V2 EROFS/fs-verity digest identifies one bootable EROFS image. It is the value checked by the corresponding UKI kernel argument and is the root mount identity. +- The state-directory deployment ID identifies the writable `/etc` and `/var` state attached to a staged deployment. In the V1 transition it may be the preferred V1 boot image identity, as demonstrated by the current-client upgrade evidence. Do not infer it from an arbitrary V2 fallback digest or treat it as the OCI manifest digest. + +This separation is important during fallback: an older client may boot the V2 +root image and its existing state, while a later current-client upgrade can +select the V1 root image and the state directory selected for that deployment. +The repository's multiple boot-image IDs do not alias state directories. There is no `/ostree/repo`; the composefs backend doesn't use the ostree repository at all. A minimal `/ostree` directory is still created, but only to hold a compatibility symlink (`ostree/bootc -> ../composefs/bootc`) so that existing tooling expecting `/usr/lib/bootc/storage` to resolve through `ostree/bootc` keeps working. @@ -152,6 +223,7 @@ This is the recommended way to build a UKI for a bootc image. It computes the co - `--rootfs `: Root filesystem to operate on (default: `/`) - `--kernel-dir `: Directory containing `vmlinuz`/`initramfs.img`, named `/parent/`. Needed when the kernel has already been split out of `--rootfs`, e.g. via `split-kernel-and-rootfs` - `--allow-missing-verity`: Make fsverity validation optional, for filesystems that don't support it (e.g. XFS) +- `--erofs-version `: Override automatic initramfs-capability selection. Explicit `v1` requires the current capability marker; explicit `v2` produces only the legacy V2 digest. See [EROFS V1 transition and compatibility](#erofs-v1-transition-and-compatibility). - `--write-dumpfile-to `: Write a composefs dumpfile for debugging ### The `bootc container compute-composefs-digest` Command @@ -165,6 +237,7 @@ A lower-level primitive, used internally by `ukify` above, that computes just th **Options:** - `PATH`: Path to the filesystem root (default: `/target`) +- `--erofs-version `: EROFS format for the computed digest (default: `v1`) - `--write-dumpfile-to `: Generate a dumpfile for debugging > **Note**: This command is currently hidden from `--help` output as it's part of the experimental composefs feature set. @@ -201,18 +274,109 @@ Composefs installs using a traditional `vmlinuz`/`initramfs.img` layout instead There is a `--composefs-backend` option for `bootc install` to explicitly select a composefs backend apart from sealed images; this is not as heavily tested yet. -## Known Issues +### Post-install state discovery (design; not implemented) -The composefs backend is experimental; on-disk formats are subject to change. +The narrow proposed API from the latest [#542](https://github.com/bootc-dev/bootc/issues/542) +discussion is `bootc status --sysroot /path --json`. For an unbooted target it +would report `UNMOUNTED` backing `/etc` and `/var` paths so an installer can +apply post-install configuration without kernel mount syscalls. The preferred +representation reports the shared `/var` path directly. + +This command does not exist today: `bootc status` has no `--sysroot` option. +The online status path uses host command-line and ESP information and can +migrate boot entries, so it must not be repurposed for an offline target. The +offline implementation must inspect only target-local state and be strictly +read-only. If the ESP is unavailable and target-local data identifies multiple +deployments, it must reject the ambiguous request rather than guess. -### Stability blockers +The additive JSON shape remains a user decision: either place state paths +under `defaultDeployment.stateDirectories`, or add them to each deployment. +No schema is committed by this document. The existing OSTree-specific +post-install path in [Understanding `bootc install`](bootc-install.md) should +be updated only after this API and JSON shape are implemented. -- [Dual EROFS v1/v2 generation](https://github.com/bootc-dev/bootc/pull/2248) and https://github.com/bootc-dev/bootc/pull/2353 +## Stabilization status -### Important +The composefs backend is experimental; on-disk formats are subject to change. -- Extended install APIs: Ability to cleanly implement anaconda %post and osbuild post mutations and general post-install pre-reboot; right now some tools just mount the deployment directory (note this one also relates to [APIs in general](https://github.com/bootc-dev/bootc/issues/522)) -- [zstd:chunked pull failures](https://github.com/bootc-dev/bootc/issues/2408): Images pushed with `--compression-format zstd:chunked` currently fail to pull on the composefs backend ("unexpected EOF reading tar entry"). A [decode fix](https://github.com/composefs/composefs-rs/pull/381) is in flight in composefs-rs and reaches bootc with the next composefs-rs update; until then publishers should use plain zstd (or gzip). +This isolated candidate contains the V1/V2 repository, UKI, initramfs, and +install changes, and is runtime-verified. The producer container built with +the candidate bootc passed its real-container test. Cache/status and Type-1 +rollback prototypes are intentionally excluded and remain separate pending +integration/VM work. Reported unit-test counts are useful regression evidence, +not a replacement for the end-to-end matrix. + +### Evidence recorded so far + +- Unit tests cover default V1 UKI argument ordering, explicit V2 UKI output, + candidate selection, and V1/V2 digest generation. +- All four selected TMT virt modes passed in the clean + `composefs-compat-verified-validation-20260912-02` verification round. The + payload source was `b9e172…` and the producer container used the real + candidate bootc. +- The bootc 1.16.0 old-stager path was verified: V2 fallback booted the + current dual-digest UKI, a current-client update selected V1, and rollback + plus composefs GC succeeded. The current-stager path with a real 1.16 + initramfs was also verified: automatic selection produced V2-only while + retaining current userspace. Its explicit-V1 negative control failed, and + the final V2 digest was verified. +- These are opt-in, fixture-driven TMT tests, not fully automated CI coverage: + the fixture build recipe currently depends on ignored one-off files. Making + fixture production reproducible remains tracked work before treating this as + generally provisioned regression coverage. +- Sealed CentOS 10 V1/V2 tests and a strict-policy downgrade-rejection control + were reported as passing on the combined tree. The CentOS 9 sealed-upgrade + case was deliberately skipped, so it is not evidence of compatibility. + +The verified paths do not expand the compatibility contract beyond the exact +bootc 1.16.0 fixtures and configurations tested above. + +### Design blockers (not implemented) + +1. **Offline post-install status API:** decide the additive JSON placement for + state directories (`defaultDeployment.stateDirectories` or per-deployment + fields) and the unambiguous-deployment rules without an ESP. Implement the + read-only `--sysroot` path only after that decision, then update the + OSTree-only installation documentation and add post-install tests. +2. **Install-time V2 selection:** design a typed install configuration option + rather than an unrelated environment switch. It must consistently select + repository format, BLS content, and state-directory identity. Acceptance is + a BLS install test that proves all three agree. + +### Remaining blockers before calling this stable + +1. **Make fixture production reproducible.** Replace the ignored one-off + fixture build inputs with a maintained recipe, then run the verified 1.16.0 + bridge paths as provisioned regression coverage. Do not expand the + compatibility claim beyond combinations actually tested. +2. **Exercise recovery and retention failures.** Add the missing xattr + recovery fixture and assess corruption and garbage-collection paths, + including references from both V1 and V2 boot entries. Acceptance requires + a defined, tested outcome for missing/corrupt images and state, with no + deletion of a live fallback image or its required state. +3. **Resolve signature-enforcement persistence.** The required semantics are + still an OSTree/user decision: trust the local source, preserve target + enforcement, and optionally run a fetch check without forcing installation + online. Acceptance requires tests of those semantics and rejection of an + insecure UKI under a strict target policy. + +### Pending work that is not, by itself, a stability blocker + +- **Mount/install API consumers:** anaconda `%post`, osbuild post-mutations, + and other pre-reboot consumers remain blocked on the design above (see also + [#522](https://github.com/bootc-dev/bootc/issues/522)). They should not + depend on mounting deployment directories as a stable API. +- **V2 test controls:** `BOOTC_erofs_version` is a TMT image-build control, + not an install API. The verified historical bridge coverage remains opt-in + until fixture production is reproducible. +- **Signature source work:** the source/persistence investigation is pending; + the policy semantics above are the required behavior, not a claim that all + persistence machinery is complete. +- [zstd:chunked pull failures](https://github.com/bootc-dev/bootc/issues/2408): + images pushed with `--compression-format zstd:chunked` currently fail to + pull on the composefs backend ("unexpected EOF reading tar entry"). Until a + composefs-rs decode fix is incorporated and validated, publishers should use + plain zstd or gzip. ## Related issues diff --git a/docs/src/man/bootc-container-ukify.8.md b/docs/src/man/bootc-container-ukify.8.md index 4542f8bc33..ea244c304f 100644 --- a/docs/src/man/bootc-container-ukify.8.md +++ b/docs/src/man/bootc-container-ukify.8.md @@ -39,8 +39,6 @@ Any additional arguments after `--` are passed through to ukify unchanged. - v1 - v2 - Default: v1 - **--write-dumpfile-to**=*WRITE_DUMPFILE_TO* Write a dumpfile to this path diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 7b52f4f02a..8971cf22e2 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -318,4 +318,17 @@ execute: test: - /tmt/tests/tests/test-48-composefs-uki-dumpfile extra-skip_if_ostree: true + +/plan-49-composefs-1-16-bridge: + summary: Test the bootc 1.16 composefs UKI bridge + enabled: false + discover: + how: fmf + test: + - /tmt/tests/tests/test-49-composefs-1-16-bridge + adjust: + - when: composefs_bridge == true + enabled: true + extra-try_bind_storage: true + extra-skip_if_ostree: true # END GENERATED PLANS diff --git a/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade index a4878c181b..1d7aa0e037 100644 --- a/tmt/tests/Dockerfile.upgrade +++ b/tmt/tests/Dockerfile.upgrade @@ -1,21 +1,22 @@ # Creates a synthetic upgrade image for testing. -# For non-UKI builds, this just adds a marker file on top of localhost/bootc. +# For non-UKI builds, this just adds a marker file on top of the base image. # For UKI builds (boot_type=uki), the image is re-sealed with a new composefs # digest and (optionally signed) UKI. # # Build secrets required (for sealed builds): # secureboot_key, secureboot_cert +ARG base=localhost/bootc ARG boot_type=bls ARG seal_state=unsealed ARG filesystem=ext4 -ARG erofs_version=v1 +ARG erofs_version=auto # Capture contrib/packaging scripts for use in later stages FROM scratch AS packaging COPY contrib/packaging / # Get kernel + initrd from the UKI -FROM localhost/bootc as kernel +FROM ${base} as kernel ARG boot_type RUN <<-EOF if test "${boot_type}" = "uki"; then @@ -27,19 +28,19 @@ EOF # Create the upgrade content (a simple marker file). # For UKI builds, we also remove the existing UKI so that seal-uki can # regenerate it with the correct composefs digest for this derived image. -FROM localhost/bootc AS upgrade-base +FROM ${base} AS upgrade-base ARG boot_type RUN touch --reference=/usr/bin/bash /usr/share/testing-bootc-upgrade-apply && \ if test "${boot_type}" = "uki"; then rm -rf /boot/EFI/Linux/*.efi; fi # Tools for sealing (only meaningfully used for UKI builds) -FROM localhost/bootc AS tools +FROM ${base} AS tools RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packaging,src=/,target=/run/packaging \ /run/packaging/initialize-sealing-tools # Generate a sealed UKI for the upgrade image. -# bootc is already installed in localhost/bootc (our tools base); the +# bootc is already installed in the selected base image (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 erofs_version diff --git a/tmt/tests/booted/README.md b/tmt/tests/booted/README.md index 359bb39628..0f4a0f2a1c 100644 --- a/tmt/tests/booted/README.md +++ b/tmt/tests/booted/README.md @@ -1,3 +1,61 @@ # Booted tests These are intended to run via tmt. + +## Composefs EROFS V1/V2 regression coverage + +The existing `BOOTC_erofs_version` configuration selects the format while the +image is built and is forwarded to tmt by `just test-tmt-nobuild`. It is not +an install flag: installation consumes the UKI already present in the image. +Use the same control for the base and synthetic upgrade images: + +```console +export BOOTC_variant=composefs BOOTC_bootloader=systemd +export BOOTC_boot_type=uki BOOTC_seal_state=sealed +BOOTC_erofs_version=v1 just test-tmt readonly image-upgrade-reboot +BOOTC_erofs_version=v2 just test-tmt readonly image-upgrade-reboot +``` + +`readonly/046-test-erofs-version.nu` checks the booted root identity. Its V1 +assertion requires the V2 fallback argument, and explicit V2 requires only the +legacy argument. This is a format regression check, not an old-client bridge +test. `image-upgrade-reboot` builds its derived UKI with the same selected +format and verifies current-client-to-current-client upgrade behavior. + +`composefs-1-16-bridge` is opt-in historical-client coverage, restricted to +the bootc 1.16.0 fixture. It requires a read-only shared container store and +the three prebuilt fixture images supplied by the coordinator; it does not +build, copy, or SCP images. Run one case at a time: + +```console +cargo xtask run-tmt "$BOOTC_1160_STAGER_IMAGE" composefs-1-16-bridge \ + --composefs-backend --bootloader systemd --boot-type uki --seal-state sealed \ + --context composefs_bridge=true \ + --env BOOTC_composefs_bridge_mode=old-stager \ + --env BOOTC_1160_bootc_sha256="$BOOTC_1160_BOOTC_SHA256" \ + --bridge-image "$BOOTC_CURRENT_DUAL_UKI_IMAGE" \ + --upgrade-image "$BOOTC_CURRENT_DUAL_UKI_UPGRADE_IMAGE" + +cargo xtask run-tmt "$BOOTC_CURRENT_STAGER_IMAGE" composefs-1-16-bridge \ + --composefs-backend --bootloader systemd --boot-type uki --seal-state sealed \ + --context composefs_bridge=true \ + --env BOOTC_composefs_bridge_mode=old-initramfs \ + --bridge-image "$BOOTC_1160_INITRAMFS_AUTO_V2_CURRENT_USERSPACE_IMAGE" +``` + +Required fixture labels are `bootc.test.fixture=bootc-1.16.0-stager`, +`bootc.test.fixture=current-dual-uki`, +`bootc.test.fixture=current-dual-uki-upgrade`, and +`bootc.test.fixture=bootc-1.16.0-initramfs-auto-v2-current-userspace`. +The coordinator supplies the matching pullspecs through the variables above. +For the old-stager case it also supplies the required +`BOOTC_1160_BOOTC_SHA256` value from the pinned fixture build; the test records +the exact `bootc --version`, RPM NEVRA, and `/usr/bin/bootc` checksum before it +stages the bridge image. + +The test checks the public status schema, `/proc/cmdline`, repository image +and deployment-state directories, and `/etc` and `/var` sentinels. There is no +stable public inspection API that labels an on-disk EROFS image as V1 or V2 +independently of its UKI argument, so it does not infer that from filenames. +It performs rollback and `composefs-gc --assert-no-op` only after booting a +current client; no old 1.16 rollback or GC command is assumed. diff --git a/tmt/tests/booted/readonly/046-test-erofs-version.nu b/tmt/tests/booted/readonly/046-test-erofs-version.nu index 14e2270417..0bf99ad2ea 100644 --- a/tmt/tests/booted/readonly/046-test-erofs-version.nu +++ b/tmt/tests/booted/readonly/046-test-erofs-version.nu @@ -20,7 +20,7 @@ if not $is_uki { exit 0 } -let erofs_version = ($env.BOOTC_erofs_version? | default "v1") +let erofs_version = tap selected_erofs_version print $"# Testing EROFS version: ($erofs_version)" # Verify composefs is active and status is healthy @@ -46,8 +46,16 @@ let cfs_digest = if $erofs_version == "v1" { 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 }) + # The default V1 UKI must retain a V2 fallback. V2-only initramfs + # releases ignore the self-describing V1 argument and consume this one. + assert ( + $params | any { |p| $p | str starts-with "composefs=" } + ) $"Expected V2 fallback karg in cmdline, got: ($cmdline)" ($value | split row ":" | last) } else { + assert ( + not ($params | any { |p| $p | str starts-with "composefs.digest=" }) + ) $"Explicit V2 UKI must not contain a V1 karg, got: ($cmdline)" assert ( $cmdline | str contains "composefs=" ) $"Expected composefs= karg in cmdline, got: ($cmdline)" diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index d295a6b556..cbfc52da79 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -19,6 +19,16 @@ export def is_composefs [] { $st.status.booted.composefs? != null } +# Return the EROFS format selected by the tmt configuration. Keep this in the +# harness so derived UKI test images use the same default as the source image. +export def selected_erofs_version [] { + let version = ($env.BOOTC_erofs_version? | default "v1") + if not ($version in ["v1" "v2"]) { + error make { msg: $"Unsupported EROFS version: ($version)" } + } + $version +} + # Get the target image for install tests based on the running OS # This ensures the target image matches the host OS to avoid version mismatches # (e.g., XFS features created by newer mkfs.xfs not recognized by older grub2) @@ -75,7 +85,16 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string, --erofs-version: string = "v1"] { +export def make_uki_containerfile [containerfile: string, --erofs-version: string = ""] { + let erofs_version = if $erofs_version == "" { + selected_erofs_version + } else { + $erofs_version + } + + if not ($erofs_version in ["v1" "v2"]) { + error make { msg: $"Unsupported EROFS version: ($erofs_version)" } + } let is_cfs = (is_composefs) if not $is_cfs { diff --git a/tmt/tests/booted/test-49-composefs-1-16-bridge.nu b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu new file mode 100644 index 0000000000..e0c9980097 --- /dev/null +++ b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu @@ -0,0 +1,201 @@ +# number: 49 +# tmt: +# summary: Test the bootc 1.16 composefs UKI bridge +# duration: 45m +# enabled: false +# adjust: +# - when: composefs_bridge == true +# enabled: true +# extra: +# skip_if_ostree: true +# try_bind_storage: true + +# This deliberately starts disabled. The bridge fixtures are large and are +# supplied from the host's read-only containers-storage mount only on request. +use std assert +use tap.nu + +def bridge-image [] { + let image = ($env.BOOTC_bridge_image? | default "") + if $image == "" { + error make { msg: "BOOTC_bridge_image is required; run with --bridge-image and --bind-storage-ro" } + } + $image +} + +def upgrade-image [] { + let image = ($env.BOOTC_upgrade_image? | default "") + if $image == "" { + error make { msg: "BOOTC_upgrade_image is required for old-stager mode" } + } + $image +} + +def mode [] { + let mode = ($env.BOOTC_composefs_bridge_mode? | default "") + if not ($mode in ["old-stager" "old-initramfs"]) { + error make { msg: "BOOTC_composefs_bridge_mode must be old-stager or old-initramfs" } + } + $mode +} + +def cmdline [] { open /proc/cmdline | str trim | split row " " } + +def required-old-bootc-sha256 [] { + let checksum = ($env.BOOTC_1160_bootc_sha256? | default "") + if ($checksum | str length) != 64 { + error make { msg: "BOOTC_1160_bootc_sha256 must be the required 64-character fixture checksum" } + } + $checksum | str downcase +} + +def assert-old-fixture [] { + let version = (bootc --version | str trim) + assert equal $version "bootc 1.16.0" + let rpm_version = (rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n' bootc | str trim) + let binary_sha256 = (sha256sum /usr/bin/bootc | split row " " | first | str downcase) + assert equal $binary_sha256 (required-old-bootc-sha256) + { bootc_version: $version, rpm_version: $rpm_version, bootc_sha256: $binary_sha256 } + | to json + | save --force /var/composefs-1-16-bootc-proof.json + print $"bootc 1.16 fixture proof: version=($version) rpm=($rpm_version) sha256=($binary_sha256)" +} + +def assert-booted-image [expected: string] { + let st = bootc status --json | from json + let booted = $st.status.booted.image + assert equal $booted.image.transport "containers-storage" + assert equal $booted.image.image $expected +} + +# Verify the identity actually selected by the running initramfs, as well as +# the corresponding repository image and deployment state directory. +def assert-selected-format [format: string, expect_dual: bool] { + if not ($format in ["v1" "v2"]) { + error make { msg: $"Unsupported expected composefs format: ($format)" } + } + let st = bootc status --json | from json + assert ((($st.status.booted.composefs.bootType | into string | str downcase) == "uki")) + let selected = $st.status.booted.composefs.verity + assert equal ($selected | str length) 128 + + let root = findmnt --json --mountpoint / --output SOURCE | from json + let root_source = ($root.filesystems | first | get source | into string) + assert ($root_source | str starts-with "composefs:") "normal bridge boots must mount / directly from composefs" + assert equal $root_source $"composefs:($selected)" + + let params = cmdline + let v2_params = ($params | where { |p| $p | into string | str starts-with "composefs=" }) + assert (($v2_params | length) == 1) "UKI must contain one V2 fallback argument" + let v2_value = ($v2_params | first | str replace "composefs=" "" | into string) + let v2 = ($v2_value | str replace "?" "") + let v1_params = ($params | where { |p| $p | into string | str starts-with "composefs.digest=" }) + let v1 = if $expect_dual { + assert (($v1_params | length) == 1) "current automatic UKI must retain one V1 argument" + let v1_value = ($v1_params | first | str replace "composefs.digest=" "" | into string) + let v1_value = ($v1_value | str replace "?" "") + let parsed_v1 = ($v1_value | split row ":" | last) + assert ($parsed_v1 != $v2) "dual-format UKI must contain distinct V1 and V2 identities" + $parsed_v1 + } else { + assert (($v1_params | length) == 0) "old automatic UKI must be V2-only" + "" + } + + let expected = if $format == "v1" { $v1 } else { $v2 } + assert equal $expected $selected "selected UKI identity must match bootc status" + assert ($"/sysroot/composefs/images/($selected)" | path exists) "selected composefs image must exist" + assert ($"/sysroot/state/deploy/($selected)" | path exists) "selected deployment state must exist" + { selected: $selected, v1: $v1, v2: $v2 } +} + +def write-sentinels [] { + "composefs-1-16-bridge-etc" | save --force /etc/bootc-composefs-bridge-sentinel + "composefs-1-16-bridge-var" | save --force /var/lib/bootc-composefs-bridge-sentinel +} + +def assert-sentinels [] { + assert equal (open /etc/bootc-composefs-bridge-sentinel | str trim) "composefs-1-16-bridge-etc" + assert equal (open /var/lib/bootc-composefs-bridge-sentinel | str trim) "composefs-1-16-bridge-var" +} + +def stage [image: string, save_as: string] { + bootc switch --transport containers-storage $image + let staged = (bootc status --json | from json).status.staged + let staged_image = $staged.image + assert equal $staged_image.image.transport "containers-storage" + assert equal $staged_image.image.image $image + assert (($staged.composefs.verity | str length) == 128) + assert ("/run/composefs/staged-deployment" | path exists) "staging must create transient composefs deployment state" + $staged.composefs.verity | save --force $save_as +} + +def old_stager_boot0 [] { + tap begin "bootc 1.16 stager to current dual-UKI bridge" + assert-old-fixture + write-sentinels + stage (bridge-image) /var/composefs-bridge-v2-identity + tmt-reboot +} + +def old_stager_boot1 [] { + assert-booted-image (bridge-image) + assert (not ((bootc --version) | str starts-with "bootc 1.16.0")) "bridge userspace must be current" + let identity = assert-selected-format v2 true + assert equal $identity.selected (open /var/composefs-bridge-v2-identity | str trim) + assert (not ($"/sysroot/composefs/images/($identity.v1)" | path exists)) "the old-initramfs first hop must not materialize the V1 image" + assert-sentinels + stage (upgrade-image) /var/composefs-bridge-v1-identity + tmt-reboot +} + +def old_stager_boot2 [] { + assert-booted-image (upgrade-image) + assert (not ((bootc --version) | str starts-with "bootc 1.16.0")) "upgraded userspace must be current" + let identity = assert-selected-format v1 true + assert equal $identity.selected (open /var/composefs-bridge-v1-identity | str trim) + assert-sentinels + bootc rollback + assert equal ((bootc status --json | from json).status.rollbackQueued) true + tmt-reboot +} + +def old_stager_boot3 [] { + assert-booted-image (bridge-image) + let identity = assert-selected-format v2 true + assert equal $identity.selected (open /var/composefs-bridge-v2-identity | str trim) + assert-sentinels + assert equal ((bootc status --json | from json).status.rollbackQueued) false + bootc internals composefs-gc --assert-no-op + tap ok +} + +def old_initramfs_boot0 [] { + tap begin "current stager to old-initramfs V2-only UKI" + assert (not ((bootc --version) | str starts-with "bootc 1.16.0")) "the V2-only fixture must retain current userspace" + assert-selected-format v1 true | ignore + write-sentinels + stage (bridge-image) /var/composefs-old-initramfs-v2-identity + tmt-reboot +} + +def old_initramfs_boot1 [] { + assert-booted-image (bridge-image) + assert (not ((bootc --version) | str starts-with "bootc 1.16.0")) "the V2-only fixture must retain current userspace" + let identity = assert-selected-format v2 false + assert equal $identity.selected (open /var/composefs-old-initramfs-v2-identity | str trim) + assert-sentinels + tap ok +} + +def main [] { + match [ (mode) ($env.TMT_REBOOT_COUNT? | default "0") ] { + ["old-stager" "0"] => old_stager_boot0, + ["old-stager" "1"] => old_stager_boot1, + ["old-stager" "2"] => old_stager_boot2, + ["old-stager" "3"] => old_stager_boot3, + ["old-initramfs" "0"] => old_initramfs_boot0, + ["old-initramfs" "1"] => old_initramfs_boot1, + [$selected_mode $count] => { error make { msg: $"Invalid bridge mode/reboot count: ($selected_mode)/($count)" } }, + } +} diff --git a/tmt/tests/booted/test-image-upgrade-reboot.nu b/tmt/tests/booted/test-image-upgrade-reboot.nu index 21bb2ac8b7..94bb59f91c 100644 --- a/tmt/tests/booted/test-image-upgrade-reboot.nu +++ b/tmt/tests/booted/test-image-upgrade-reboot.nu @@ -40,6 +40,23 @@ def parse_cmdline [] { open /proc/cmdline | str trim | split row " " } +def assert_composefs_uki_format [] { + let erofs_version = tap selected_erofs_version + let params = parse_cmdline + let has_v1 = ($params | any { |p| $p | str starts-with "composefs.digest=" }) + let has_v2 = ($params | any { |p| $p | str starts-with "composefs=" }) + + if $erofs_version == "v1" { + assert $has_v1 "V1 UKI must contain composefs.digest=" + assert $has_v2 "V1 UKI must retain a V2 fallback" + } else if $erofs_version == "v2" { + assert $has_v2 "V2 UKI must contain composefs=" + assert (not $has_v1) "Explicit V2 UKI must not contain composefs.digest=" + } else { + error make { msg: $"Unsupported EROFS version: ($erofs_version)" } + } +} + def imgsrc [] { $env.BOOTC_upgrade_image? | default "localhost/bootc-derived-local" } @@ -49,13 +66,14 @@ def initial_build [] { tap begin "local image push + pull + upgrade" let imgsrc = imgsrc + let erofs_version = tap selected_erofs_version # For the packit case, we build locally right now if ($imgsrc | str ends-with "-local") { bootc image copy-to-storage # A simple derived container that adds a file ( - tap make_uki_containerfile " + tap make_uki_containerfile --erofs-version $erofs_version " FROM localhost/bootc as base RUN touch /usr/share/testing-bootc-upgrade-apply ") | save Dockerfile @@ -100,6 +118,7 @@ def second_boot [] { # For UKI boot type, verify both the original and upgrade UKIs exist on the ESP if ($composefs_info.bootType | str downcase) == "uki" { + assert_composefs_uki_format mkdir /var/tmp/efi mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi let boot_dir = "/var/tmp/efi/EFI/Linux/bootc" diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index c2c91422d9..221d278264 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -197,3 +197,12 @@ check: summary: Test composefs UKI dumpfile diff print duration: 30m test: nu booted/test-composefs-uki-dumpfile.nu + +/test-49-composefs-1-16-bridge: + summary: Test the bootc 1.16 composefs UKI bridge + duration: 45m + enabled: false + adjust: + - when: composefs_bridge == true + enabled: true + test: nu booted/test-49-composefs-1-16-bridge.nu