From 2c13a601d762b15c405bd3c1a97cb4a4737d22d2 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 25 Jul 2026 11:22:33 -0400 Subject: [PATCH] composefs+UKI: Try to generate image with matching digest We had two changes in composefs-rs: - xattr filtering - v1 vs v2 EROFS When we switched to v1 for the EROFS generation by default, it broke UKI upgrades when the system was deployed with an older bootc. Unfortunately, there's no easy way to fix this (short of reverting the change to v1 by default). But at least with this approach, one can more easily temporarily use a newer bootc on the client, and it will be able to adapt to e.g. having an *older* bootc (perhaps using v2) in the target environment. 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/repo.rs | 17 +- crates/lib/src/bootc_composefs/update.rs | 7 +- crates/lib/src/cli.rs | 2 +- 5 files changed, 220 insertions(+), 9 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index c87110ba5..3d2bfbd99 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1112,6 +1112,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, @@ -1604,10 +1720,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")?; @@ -1616,12 +1732,22 @@ pub(crate) async fn setup_composefs_boot( &*repo, &pull_result.config_digest, None, - &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")?; @@ -1774,6 +1900,7 @@ pub(crate) async fn setup_composefs_boot( #[cfg(test)] mod tests { use super::*; + use composefs::erofs::format::FormatVersion; #[test] fn test_pe_output_dir() { @@ -1928,4 +2055,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 227bbf6c3..057f60fe4 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -77,7 +77,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/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 9454e61c5..9545762cc 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}; @@ -396,10 +397,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")?; @@ -408,12 +409,22 @@ pub(crate) async fn pull_composefs_repo( &*repo, &pull_result.config_digest, None, - &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 c2b43c8b8..683b537b4 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -145,7 +145,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_id = fs.compute_image_id(repo.erofs_version()); diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 86b4eb999..59a536aec 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -2039,7 +2039,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")?;