diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36e269ce15..a23345d7e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,9 +302,14 @@ jobs: seal_state: ["sealed", "unsealed"] exclude: - # https://github.com/bootc-dev/bootc/issues/1812 + # centos-9 composefs: only sealed UKI is supported (V1 EROFS). + # BLS and unsealed modes require newer dracut/systemd features. - test_os: centos-9 variant: composefs + boot_type: bls + - test_os: centos-9 + variant: composefs + seal_state: unsealed - seal_state: "sealed" boot_type: bls - seal_state: "sealed" diff --git a/Dockerfile b/Dockerfile index bd8c289a89..8c68ba7687 100644 --- a/Dockerfile +++ b/Dockerfile @@ -354,6 +354,7 @@ ARG variant ARG filesystem ARG seal_state ARG boot_type +ARG erofs_version=v1 # Install our bootc package (only needed for the compute-composefs-digest command) RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=bind,from=packages,src=/,target=/run/packages \ @@ -381,7 +382,8 @@ if test "${boot_type}" = "uki"; then --secrets /run/secrets \ "${allow_missing_verity[@]}" \ --kernel-dir "/run/kernel/$kver" \ - --seal-state $seal_state + --seal-state $seal_state \ + --erofs-version $erofs_version fi EORUN diff --git a/Justfile b/Justfile index 86f14dd278..692af2d109 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,8 @@ filesystem := env("BOOTC_filesystem", "ext4") boot_type := env("BOOTC_boot_type", "bls") # Only used for composefs tests seal_state := env("BOOTC_seal_state", "unsealed") +# Only used for composefs UKI tests: "v1" or "v2" +erofs_version := env("BOOTC_erofs_version", "v1") # Baseconfigs to inject into the image for testing (e.g. "etc-transient" or "root-transient") baseconfigs := env("BOOTC_baseconfigs", "") # Base container image to build from @@ -75,6 +77,7 @@ base_buildargs := generic_buildargs + " " + _extra_src_args \ + " --build-arg=boot_type=" + boot_type \ + " --build-arg=seal_state=" + seal_state \ + " --build-arg=filesystem=" + filesystem \ + + " --build-arg=erofs_version=" + erofs_version \ + " --build-arg=baseconfigs=" + baseconfigs buildargs := base_buildargs \ + " --cap-add=all --security-opt=label=type:container_runtime_t --device /dev/fuse" \ @@ -290,7 +293,7 @@ test-container-export: build # Run tmt tests without rebuilding (for fast iteration) [group('testing')] test-tmt-nobuild *ARGS: - cargo xtask run-tmt --env=BOOTC_variant={{variant}} {{_baseconfigs_env}} --upgrade-image={{upgrade_img}} {{base_img}} {{ARGS}} + cargo xtask run-tmt --env=BOOTC_variant={{variant}} --env=BOOTC_erofs_version={{erofs_version}} {{_baseconfigs_env}} --upgrade-image={{upgrade_img}} {{base_img}} {{ARGS}} # Run readonly tests with a baseconfig baked into the image at build time. # Requires composefs variant. Example: just variant=composefs test-tmt-baseconfig root-transient @@ -508,6 +511,7 @@ _build-upgrade-image: --build-arg "boot_type={{boot_type}}" \ --build-arg "seal_state={{seal_state}}" \ --build-arg "filesystem={{filesystem}}" \ + --build-arg "erofs_version={{erofs_version}}" \ --secret=id=secureboot_key,src=target/test-secureboot/db.key \ --secret=id=secureboot_cert,src=target/test-secureboot/db.crt \ "${extra_args[@]}" \ diff --git a/contrib/packaging/seal-uki b/contrib/packaging/seal-uki index 7ee03b44c6..d83c5e72f6 100755 --- a/contrib/packaging/seal-uki +++ b/contrib/packaging/seal-uki @@ -4,6 +4,7 @@ set -xeuo pipefail missing_verity=() dumpfile_args=() +erofs_version=v1 while [ ! -z "${1:-}" ]; do case "$1" in @@ -45,6 +46,12 @@ while [ ! -z "${1:-}" ]; do shift ;; + "--erofs-version") + erofs_version="$2" + shift + shift + ;; + # Path to the directory containing kernel and initramfs "--kernel-dir") kernel_dir="$2" @@ -92,4 +99,4 @@ containerukifyargs=(--rootfs "${target}") # Build the UKI using bootc container ukify # This computes the composefs digest, reads kargs from kargs.d, and invokes ukify -bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" "${dumpfile_args[@]}" -- "${ukifyargs[@]}" +bootc container ukify "${containerukifyargs[@]}" "${kernel_params[@]}" "${missing_verity[@]}" "${dumpfile_args[@]}" --erofs-version="${erofs_version}" -- "${ukifyargs[@]}" diff --git a/crates/initramfs/bootc-root-setup.service b/crates/initramfs/bootc-root-setup.service index 23525c7bc2..99c442f532 100644 --- a/crates/initramfs/bootc-root-setup.service +++ b/crates/initramfs/bootc-root-setup.service @@ -2,7 +2,8 @@ Description=bootc setup root Documentation=man:bootc(1) DefaultDependencies=no -ConditionKernelCommandLine=composefs +ConditionKernelCommandLine=|composefs +ConditionKernelCommandLine=|composefs.digest ConditionPathExists=/etc/initrd-release After=sysroot.mount After=ostree-prepare-root.service diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index c87110ba55..327f4c1729 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -76,6 +76,7 @@ use cap_std_ext::{ dirext::CapStdExtDirExt, }; use clap::ValueEnum; +use composefs::erofs::format::FormatVersion; use composefs::fs::read_file; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs::tree::{FileSystem, RegularFile}; @@ -90,16 +91,19 @@ use composefs_ctl::composefs; use composefs_ctl::composefs_boot; use composefs_ctl::composefs_oci; use fn_error_context::context; -use linux_kernel_cmdline::utf8::{Cmdline, Parameter}; +use linux_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey}; use ostree_ext::composefs::dumpfile; use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; -use crate::bootc_composefs::status::ComposefsCmdline; +use crate::bootc_composefs::status::build_composefs_karg; use crate::bootc_kargs::compute_new_kargs; -use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; +use crate::composefs_consts::{ + COMPOSEFS_CMDLINE, COMPOSEFS_DIGEST_CMDLINE, TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, + TYPE1_ENT_PATH_STAGED, +}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; use crate::spec::BootloaderKind; use crate::task::Task; @@ -657,6 +661,15 @@ struct BLSEntryPath { config_path: Utf8PathBuf, } +/// Replace either karg spelling to ensure only the selected EROFS format remains. +fn replace_composefs_karg(cmdline: &mut Cmdline, new_karg: &str) -> Result<()> { + cmdline.remove(&ParameterKey::from(COMPOSEFS_CMDLINE)); + cmdline.remove(&ParameterKey::from(COMPOSEFS_DIGEST_CMDLINE)); + let parameter = Parameter::parse(new_karg).context("Parsing composefs kernel parameter")?; + cmdline.add_or_modify(¶meter); + Ok(()) +} + /// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk /// /// # Returns @@ -666,6 +679,7 @@ pub(crate) fn setup_composefs_bls_boot( setup_type: BootSetupType, repo: &crate::store::ComposefsRepository, id: &Sha512HashValue, + format_version: FormatVersion, entry: &ComposefsBootEntry, mounted_erofs: &Dir, ) -> Result { @@ -684,9 +698,12 @@ pub(crate) fn setup_composefs_bls_boot( } } - let composefs_cmdline = - ComposefsCmdline::build(&id_hex, state.composefs_options.allow_missing_verity); - cmdline_options.extend(&Cmdline::from(&composefs_cmdline.to_string())); + let composefs_cmdline = build_composefs_karg( + id.clone(), + format_version, + state.composefs_options.allow_missing_verity, + ); + cmdline_options.extend(&Cmdline::from(&composefs_cmdline)); // If there's a separate /boot partition, add a systemd.mount-extra // karg so systemd mounts it after reboot. This avoids writing to @@ -732,14 +749,14 @@ pub(crate) fn setup_composefs_bls_boot( _ => anyhow::bail!("Found NonEFI config"), }; - // Copy all cmdline args, replacing only `composefs=` - let cfs_cmdline = - ComposefsCmdline::build(&id_hex, booted_cfs.cmdline.allow_missing_fsverity) - .to_string(); - - let param = Parameter::parse(&cfs_cmdline) - .context("Failed to create 'composefs=' parameter")?; - cmdline.add_or_modify(¶m); + replace_composefs_karg( + &mut cmdline, + &build_composefs_karg( + id.clone(), + format_version, + booted_cfs.cmdline.allow_missing_fsverity, + ), + )?; // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; @@ -963,6 +980,7 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, + composefs_digest: Sha512HashValue, } /// Determines the directory (under `mounted_efi`) that a PE binary should be written to. @@ -1008,6 +1026,7 @@ fn write_pe_to_esp( file_path: &Utf8Path, pe_type: PEType, uki_id: &Sha512HashValue, + boot_ids: &[Sha512HashValue], missing_fsverity_allowed: bool, mounted_efi: impl AsRef, ) -> Result> { @@ -1034,7 +1053,7 @@ fn write_pe_to_esp( let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) .context("Parsing composefs=")? .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; - let composefs_cmdline = composefs_info.digest(); + let composefs_digest = composefs_info.digest().clone(); let missing_verity_allowed_cmdline = composefs_info.is_insecure(); // If the UKI cmdline does not match what the user has passed as cmdline option @@ -1053,16 +1072,17 @@ fn write_pe_to_esp( _ => { /* no-op */ } } - let file_name = file_path.file_name(); - - if *composefs_cmdline != *uki_id { + if !boot_ids.contains(&composefs_digest) { return Err(UKIDigestMismatch { - actual: composefs_cmdline.to_hex(), + actual: composefs_digest.to_hex(), expected: uki_id.to_hex(), - uki_name: file_name.map(|x| x.to_string()), + uki_name: file_path.file_name().map(|name| name.to_string()), } .into()); } + composefs_info + .validate_digest(boot_ids) + .context("Validating UKI composefs digest")?; uki_reader.seek(SeekFrom::Start(0))?; let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; @@ -1079,6 +1099,7 @@ fn write_pe_to_esp( version: parsed_osrel.get_version(), os_id: parsed_osrel.get_value(&["ID"]), boot_digest, + composefs_digest, }); } @@ -1088,8 +1109,12 @@ fn write_pe_to_esp( let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) .with_context(|| format!("Opening {final_pe_path:?}"))?; + let pe_name_owned; let pe_name = match pe_type { - PEType::Uki => &get_uki_name(&uki_id.to_hex()), + PEType::Uki => { + pe_name_owned = get_uki_name(&boot_label.as_ref().unwrap().composefs_digest.to_hex()); + &pe_name_owned + } PEType::UkiAddon | PEType::GlobalUkiAddon => file_path .components() .last() @@ -1274,8 +1299,9 @@ pub(crate) fn setup_composefs_uki_boot( setup_type: BootSetupType, repo: &crate::store::ComposefsRepository, id: &Sha512HashValue, + boot_ids: &[Sha512HashValue], entries: Vec>, -) -> Result { +) -> Result<(String, Sha512HashValue)> { let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch)) => { @@ -1361,6 +1387,7 @@ pub(crate) fn setup_composefs_uki_boot( utf8_file_path, entry.pe_type, &id, + boot_ids, missing_fsverity_allowed, esp_mount.dir.path(), )?; @@ -1376,18 +1403,27 @@ pub(crate) fn setup_composefs_uki_boot( uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?; let boot_digest = uki_info.boot_digest.clone(); + let deploy_id = uki_info.composefs_digest.clone(); match bootloader.kind()? { - BootloaderKind::GRUBClassic => { - write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)? - } + BootloaderKind::GRUBClassic => write_grub_uki_menuentry( + root_path, + &setup_type, + uki_info.boot_label, + &deploy_id, + &esp_device, + )?, - BootloaderKind::BLSCompatible => { - write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_info, id, &bootloader)? - } + BootloaderKind::BLSCompatible => write_systemd_uki_config( + &esp_mount.fd, + &setup_type, + uki_info, + &deploy_id, + &bootloader, + )?, }; - Ok(boot_digest) + Ok((boot_digest, deploy_id)) } /// A composefs image attached to a temporary directory with the ESP and a @@ -1611,6 +1647,12 @@ pub(crate) async fn setup_composefs_boot( ) .context("Generating bootable EROFS image")?; + let oci_img = + composefs_oci::oci_image::OciImage::open(&*repo, &pull_result.manifest_digest, None) + .context("Opening OCI image to read boot image refs")?; + let boot_id_v1 = oci_img.boot_image_ref_v1().cloned(); + let boot_id_v2 = oci_img.boot_image_ref_v2().cloned(); + // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.). let fs = composefs_oci::image::create_filesystem( &*repo, @@ -1727,24 +1769,35 @@ pub(crate) async fn setup_composefs_boot( ) })?; - let boot_digest = match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch)), - &repo, - &id, - entry, - mounted_root.dir(), - )?, + let (provisional_deploy_id, provisional_format) = match boot_id_v1.as_ref() { + Some(v1) => (v1.clone(), FormatVersion::V1), + None => (id.clone(), repo.erofs_version()), + }; + let boot_ids: Vec = [boot_id_v1, boot_id_v2].into_iter().flatten().collect(); + + let (boot_digest, deploy_id) = match boot_type { + BootType::Bls => ( + setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch)), + &repo, + &provisional_deploy_id, + provisional_format, + entry, + mounted_root.dir(), + )?, + provisional_deploy_id, + ), BootType::Uki => { let uki_setup_result = setup_composefs_uki_boot( BootSetupType::Setup((&root_setup, &state, &postfetch)), &repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, ); match uki_setup_result { - Ok(boot_digest) => boot_digest, + Ok(result) => result, Err(e) => match e.downcast::() { Ok(mismatch) => { print_uki_dumpfile_diff(&mismatch, &repo, &fs); @@ -1758,7 +1811,7 @@ pub(crate) async fn setup_composefs_boot( write_composefs_state( &root_setup.physical_root_path, - &id, + &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), None, boot_type, @@ -1775,6 +1828,21 @@ pub(crate) async fn setup_composefs_boot( mod tests { use super::*; + #[test] + fn test_replace_composefs_karg() { + let mut cmdline = + Cmdline::from("root=UUID=abc composefs=old composefs.digest=v1-sha512-12:stale"); + replace_composefs_karg( + &mut cmdline, + "composefs.digest=v1-sha512-12:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ) + .unwrap(); + let rendered = cmdline.to_string(); + assert!(!rendered.contains("composefs=old")); + assert!(!rendered.contains(":stale")); + assert!(rendered.contains("root=UUID=abc")); + } + #[test] fn test_pe_output_dir() { let mounted_efi = Path::new("/esp"); diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index 227bbf6c3b..c72e98bd10 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -10,7 +10,8 @@ use camino::Utf8Path; use cap_std_ext::cap_std; use cap_std_ext::cap_std::fs::Dir; use composefs::dumpfile; -use composefs::fsverity::{Algorithm, FsVerityHashValue}; +use composefs::erofs::format::FormatVersion; +use composefs::fsverity::FsVerityHashValue; use composefs::repository::RepositoryConfig; use composefs_boot::BootOps as _; use composefs_ctl::composefs; @@ -21,10 +22,16 @@ use crate::store::ComposefsRepository; /// Creates a temporary composefs repository for computing digests. /// +/// The `erofs_version` controls which EROFS format the digest is computed for: +/// use `FormatVersion::V1` to get a `composefs.digest=v1-sha256-12:` karg (V1 EROFS, +/// C-tool compatible) or `FormatVersion::V2` for the legacy `composefs=` karg. +/// /// Returns the TempDir guard (must be kept alive for the repo to remain valid) /// and the repository wrapped in Arc. #[fn_error_context::context("Creating new temp composefs repo")] -pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc)> { +pub(crate) fn new_temp_composefs_repo( + erofs_version: FormatVersion, +) -> Result<(TempDir, Arc)> { let td_guard = tempfile::tempdir_in("/var/tmp")?; let td_path = td_guard.path(); let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?; @@ -32,7 +39,8 @@ pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc Result<(TempDir, Arc, ) -> Result { if path.as_str() == "/" { anyhow::bail!("Cannot operate on active root filesystem; mount separate target instead"); } - let (_td_guard, repo) = new_temp_composefs_repo()?; + let (_td_guard, repo) = new_temp_composefs_repo(erofs_version)?; // Read filesystem from path, transform for boot, compute digest let dirfd: OwnedFd = rustix::fs::open( @@ -82,7 +91,7 @@ pub(crate) async fn compute_composefs_digest( .await .context("Reading container root")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; - let id = fs.compute_image_id(repo.erofs_version()); + let id = fs.compute_image_id(erofs_version); let digest = id.to_hex(); if let Some(dumpfile_path) = write_dumpfile_to { @@ -136,7 +145,9 @@ mod tests { // Compute the digest let path = Utf8Path::from_path(td.path()).unwrap(); - let digest = compute_composefs_digest(path, None).await.unwrap(); + let digest = compute_composefs_digest(path, FormatVersion::V2, None) + .await + .unwrap(); // Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars) assert_eq!( @@ -151,7 +162,9 @@ mod tests { ); // Verify consistency - computing twice on the same filesystem produces the same result - let digest2 = compute_composefs_digest(path, None).await.unwrap(); + let digest2 = compute_composefs_digest(path, FormatVersion::V2, None) + .await + .unwrap(); assert_eq!( digest, digest2, "Digest should be consistent across multiple computations" @@ -160,7 +173,7 @@ mod tests { #[tokio::test] async fn test_compute_composefs_digest_rejects_root() { - let result = compute_composefs_digest(Utf8Path::new("/"), None).await; + let result = compute_composefs_digest(Utf8Path::new("/"), FormatVersion::V2, None).await; assert!(result.is_err()); let err = result.unwrap_err(); let found = err.chain().any(|e| { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 5c1b5bf275..6bf50bb5c8 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -51,6 +51,17 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { type BootBinary = (BootType, String); +fn image_refs_match( + image_ref_v1: Option<&composefs::fsverity::Sha512HashValue>, + image_ref_v2: Option<&composefs::fsverity::Sha512HashValue>, + verity: &str, +) -> bool { + [image_ref_v1, image_ref_v2] + .into_iter() + .flatten() + .any(|image_ref| image_ref.to_hex() == verity) +} + /// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem /// /// Returns a vector of binary type (UKI/Type1) + name of all boot binaries @@ -403,16 +414,16 @@ pub(crate) async fn composefs_gc( ref_digest, None, ) { - if let Some(img_ref) = img.image_ref(booted_cfs.repo.erofs_version()) { - if img_ref.to_hex() == *verity { - tracing::info!( - "Deployment {verity} has no manifest_digest in origin; \ - found matching manifest {ref_digest} via image_ref" - ); - live_manifest_digests.push(ref_digest.clone()); - found_manifest = true; - break; - } + // Check both V1 and V2 slots: the deployment verity + // may have been produced under either format. + if image_refs_match(img.image_ref_v1(), img.image_ref_v2(), verity) { + tracing::info!( + "Deployment {verity} has no manifest_digest in origin; \ + found matching manifest {ref_digest} via image_ref" + ); + live_manifest_digests.push(ref_digest.clone()); + found_manifest = true; + break; } } } @@ -508,6 +519,14 @@ mod tests { use crate::bootc_composefs::status::list_type1_entries; use crate::testutils::{ChangeType, TestRoot}; + #[test] + fn test_image_refs_match_v2_when_v1_is_present() { + let v1 = composefs::fsverity::Sha512HashValue::from_hex(&"11".repeat(64)).unwrap(); + let v2 = composefs::fsverity::Sha512HashValue::from_hex(&"22".repeat(64)).unwrap(); + + assert!(image_refs_match(Some(&v1), Some(&v2), &v2.to_hex())); + } + /// Reproduce the shared-entry GC bug from issue #2102. /// /// Scenario with both shared and non-shared kernels: diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 9454e61c5d..375db93a79 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -103,12 +103,13 @@ pub(crate) async fn initialize_composefs_repository( crate::store::ensure_composefs_dir(rootfs_dir)?; - let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); - let config = if allow_missing_fsverity { + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + config = if allow_missing_fsverity { config.set_insecure() } else { config }; + crate::store::set_dual_erofs_formats(&mut config); let (repo, _created) = crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config) .context("Failed to initialize composefs repository")?; diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 1d8ecfc223..393ae84f64 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -1,7 +1,7 @@ use crate::{ bootc_composefs::{ service::start_finalize_stated_svc, - status::{ComposefsCmdline, get_composefs_status}, + status::{build_composefs_karg, get_composefs_status}, }, cli::SoftRebootMode, store::{BootedComposefs, Storage}, @@ -13,6 +13,7 @@ use camino::Utf8Path; use cap_std_ext::cap_std::ambient_authority; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use fn_error_context::context; use linux_kernel_cmdline::utf8::Cmdline; use ostree_ext::systemd_has_soft_reboot; @@ -108,14 +109,25 @@ pub(crate) async fn prepare_soft_reboot_composefs( create_dir_all(NEXTROOT).context("Creating nextroot")?; - let cmdline = ComposefsCmdline::build(deployment_id, booted_cfs.cmdline.allow_missing_fsverity); + let deployment_digest = Sha512HashValue::from_hex(deployment_id) + .with_context(|| format!("Parsing deployment id '{deployment_id}'"))?; + // We don't persist which EROFS format each deployment was written with, so + // fall back to the repo's currently configured default. This only affects + // the karg's self-description, not whether the soft-reboot actually + // succeeds: `setup_root` (below) resolves the deployment purely from the + // digest, independent of the composefs=/composefs.digest= tag. + let cmdline = build_composefs_karg( + deployment_digest, + booted_cfs.repo.erofs_version(), + booted_cfs.cmdline.allow_missing_fsverity, + ); let args = bootc_initramfs_setup::Args { cmd: vec![], sysroot: PathBuf::from("/sysroot"), config: Default::default(), root_fs: None, - cmdline: Some(Cmdline::from(cmdline.to_string())), + cmdline: Some(Cmdline::from(cmdline)), target: Some(NEXTROOT.into()), }; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 7badda8fa3..b76c139704 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -2,7 +2,9 @@ use std::{io::Read, sync::OnceLock}; use anyhow::{Context, Result}; use bootc_mount::inspect_filesystem; -use composefs_ctl::composefs::fsverity::Sha512HashValue; +use composefs_ctl::composefs::erofs::format::FormatVersion; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_ctl::composefs_boot::cmdline::ComposefsCmdline as BootComposefsCmdline; use composefs_ctl::composefs_oci; use composefs_oci::OciImage; use fn_error_context::context; @@ -18,8 +20,9 @@ use crate::{ utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ - COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, - TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, USER_CFG_STAGED, + COMPOSEFS_CMDLINE, COMPOSEFS_DIGEST_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, + ORIGIN_KEY_MANIFEST_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, + USER_CFG_STAGED, }, install::EFI_LOADER_INFO, parsers::{ @@ -88,24 +91,30 @@ impl ComposefsCmdline { } } - pub(crate) fn build(digest: &str, allow_missing_fsverity: bool) -> Self { - ComposefsCmdline { - allow_missing_fsverity, - digest: digest.into(), + /// Search for either supported composefs kernel command line parameter. + pub(crate) fn find_in_cmdline(cmdline: &Cmdline) -> Option { + let parsed = BootComposefsCmdline::::from_cmdline(cmdline).ok()??; + Some(Self { + allow_missing_fsverity: parsed.is_insecure(), + digest: parsed.digest().to_hex().into(), is_transient: false, - } + }) } +} - /// Search for the `composefs=` parameter in the passed in kernel command line - pub(crate) fn find_in_cmdline(cmdline: &Cmdline) -> Option { - match cmdline.find(COMPOSEFS_CMDLINE) { - Some(param) => { - let value = param.value()?; - Some(Self::new(value)) - } - None => None, +/// Render a composefs karg that identifies the EROFS format of `digest`. +pub(crate) fn build_composefs_karg( + digest: Sha512HashValue, + format_version: FormatVersion, + allow_missing_fsverity: bool, +) -> String { + match format_version { + FormatVersion::V0 | FormatVersion::V1 => { + BootComposefsCmdline::new_v1(digest, allow_missing_fsverity) } + FormatVersion::V2 => BootComposefsCmdline::new_v2(digest, allow_missing_fsverity), } + .to_cmdline_arg() } impl std::fmt::Display for ComposefsCmdline { @@ -157,11 +166,9 @@ pub(crate) fn composefs_booted() -> Result> { return Ok(v.as_ref()); } let cmdline = Cmdline::from_proc()?; - let Some(kv) = cmdline.find(COMPOSEFS_CMDLINE) else { + let Some(v) = ComposefsCmdline::find_in_cmdline(&cmdline) else { return Ok(None); }; - let Some(v) = kv.value() else { return Ok(None) }; - let v = ComposefsCmdline::new(v); // Find the source of / mountpoint as the cmdline doesn't change on soft-reboot let root_mnt = inspect_filesystem("/".into())?; @@ -730,10 +737,11 @@ fn find_bls_entry<'a>( Ok(None) } -/// Compares cmdline `first` and `second` skipping `composefs=` +/// Compares cmdline `first` and `second` skipping either composefs karg spelling. fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool { for param in first { - if param.key() == COMPOSEFS_CMDLINE.into() { + if param.key() == COMPOSEFS_CMDLINE.into() || param.key() == COMPOSEFS_DIGEST_CMDLINE.into() + { continue; } @@ -1154,7 +1162,7 @@ mod tests { #[test] fn test_composefs_parsing() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; let v = ComposefsCmdline::new(DIGEST); assert!(!v.allow_missing_fsverity); assert_eq!(v.digest.as_ref(), DIGEST); @@ -1163,6 +1171,30 @@ mod tests { assert_eq!(v.digest.as_ref(), DIGEST); } + #[test] + fn test_build_composefs_karg() { + let hex = "ab".repeat(64); + let digest = || Sha512HashValue::from_hex(&hex).unwrap(); + + assert_eq!( + build_composefs_karg(digest(), FormatVersion::V1, false), + format!("composefs.digest=v1-sha512-12:{hex}") + ); + assert_eq!( + build_composefs_karg(digest(), FormatVersion::V2, true), + format!("composefs=?{hex}") + ); + + let cmdline = Cmdline::from(format!("composefs.digest=v1-sha512-12:{hex}")); + assert_eq!( + ComposefsCmdline::find_in_cmdline(&cmdline) + .unwrap() + .digest + .as_ref(), + hex + ); + } + #[test] fn classify_bootloader_cases() { struct Case { @@ -1387,7 +1419,7 @@ mod tests { #[test] fn test_find_in_cmdline() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; // Test case: cmdline contains composefs parameter let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs={}", DIGEST)); diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index c2b43c8b86..6d1652d152 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use camino::Utf8PathBuf; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs::erofs::format::FormatVersion; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; use composefs_ctl::composefs; @@ -148,13 +149,18 @@ pub(crate) fn validate_update( let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity), &Default::default())?; fs.transform_for_boot(&repo)?; - let image_id = fs.compute_image_id(repo.erofs_version()); + let image_ids = [ + fs.compute_image_id(FormatVersion::V1), + fs.compute_image_id(FormatVersion::V2), + ]; let all_deployments = host.all_composefs_deployments()?; - let found_depl = all_deployments - .iter() - .find(|d| d.deployment.verity == image_id.to_hex()); + let found_depl = all_deployments.iter().find(|d| { + image_ids + .iter() + .any(|id| d.deployment.verity == id.to_hex()) + }); if let Some(collision) = found_depl { if is_switch { @@ -194,16 +200,19 @@ pub(crate) fn validate_update( BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, } - // Remove state directory + // Remove state directories for either serialisation of the same rootfs. let state_dir = storage .physical_root .open_dir(STATE_DIR_RELATIVE) .context("Opening state dir")?; - if state_dir.exists(image_id.to_hex()) { - state_dir - .remove_dir_all(image_id.to_hex()) - .context("Removing state")?; + for image_id in image_ids { + let image_id = image_id.to_hex(); + if state_dir.exists(&image_id) { + state_dir + .remove_dir_all(&image_id) + .context("Removing state")?; + } } Ok(UpdateAction::Proceed) @@ -315,25 +324,43 @@ pub(crate) async fn do_upgrade( let boot_type = BootType::from(entry); - let boot_digest = match boot_type { - BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), - &repo, - &id, - entry, - &mounted_fs, - )?, + let manifest_oci_digest: composefs_oci::OciDigest = manifest_digest + .parse() + .with_context(|| format!("Parsing manifest digest {manifest_digest}"))?; + let oci_img = composefs_oci::oci_image::OciImage::open(&repo, &manifest_oci_digest, None) + .context("Opening OCI image to read boot image refs")?; + let boot_id_v1 = oci_img.boot_image_ref_v1().cloned(); + let boot_id_v2 = oci_img.boot_image_ref_v2().cloned(); + let (provisional_deploy_id, provisional_format) = match boot_id_v1.as_ref() { + Some(v1) => (v1.clone(), FormatVersion::V1), + None => (id.clone(), repo.erofs_version()), + }; + let boot_ids: Vec = [boot_id_v1, boot_id_v2].into_iter().flatten().collect(); + + let (boot_digest, deploy_id) = match boot_type { + BootType::Bls => ( + setup_composefs_bls_boot( + BootSetupType::Upgrade((storage, booted_cfs, &host)), + &repo, + &provisional_deploy_id, + provisional_format, + entry, + &mounted_fs, + )?, + provisional_deploy_id, + ), BootType::Uki => { let uki_setup_result = setup_composefs_uki_boot( BootSetupType::Upgrade((storage, booted_cfs, &host)), &repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, ); match uki_setup_result { - Ok(boot_digest) => boot_digest, + Ok(result) => result, Err(e) => match e.downcast::() { Ok(mismatch) => { print_uki_dumpfile_diff(&mismatch, &repo, &oci_fs); @@ -360,13 +387,13 @@ pub(crate) async fn do_upgrade( drop(repo); let staged_state = StagedDeployment { - depl_id: id.to_hex(), + depl_id: deploy_id.to_hex(), finalization_locked: opts.download_only, }; write_composefs_state( &Utf8PathBuf::from("/sysroot"), - &id, + &deploy_id, imgref, Some(staged_state), boot_type, @@ -392,7 +419,7 @@ pub(crate) async fn do_upgrade( ) .await?; - apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await + apply_upgrade(storage, booted_cfs, &deploy_id.to_hex(), opts).await } #[context("Applying downloaded upgrade")] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 86b4eb9998..f2f2d296cb 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -17,6 +17,7 @@ use clap::CommandFactory; use clap::Parser; use clap::ValueEnum; use composefs::dumpfile; +use composefs::erofs::format::FormatVersion; use composefs::fsverity; use composefs::fsverity::FsVerityHashValue; use composefs_ctl::composefs; @@ -420,6 +421,13 @@ pub(crate) enum ContainerOpts { /// Additionally generate a dumpfile written to the target path #[clap(long)] write_dumpfile_to: Option, + + /// EROFS format version to use when computing the composefs digest. + /// + /// V1 produces a `composefs.digest=v1-sha256-12:` karg (C-tool compatible). + /// V2 produces the legacy `composefs=` karg (composefs-rs native). + #[clap(long, default_value = "v1")] + erofs_version: ErofsVersionArg, }, /// Output the bootable composefs digest from container storage. #[clap(hide = true)] @@ -428,6 +436,13 @@ pub(crate) enum ContainerOpts { #[clap(long)] write_dumpfile_to: Option, + /// EROFS format version to use when computing the composefs digest. + /// + /// Must match the format used by `compute-composefs-digest` (and by + /// `container ukify`) for the two views to be comparable. + #[clap(long, default_value = "v1")] + erofs_version: ErofsVersionArg, + /// Identifier for image; if not provided, the running image will be used. image: Option, }, @@ -471,6 +486,14 @@ pub(crate) enum ContainerOpts { #[clap(long)] allow_missing_verity: bool, + /// EROFS format version to use when computing the composefs digest. + /// + /// V1 produces a `composefs.digest=v1-sha256-12:` karg (C-tool compatible). + /// V2 produces the legacy `composefs=` karg (composefs-rs native). + /// Must match the format version used when images were committed to the repository. + #[clap(long, default_value = "v1")] + erofs_version: ErofsVersionArg, + /// Write a dumpfile to this path #[clap(long)] write_dumpfile_to: Option, @@ -517,6 +540,24 @@ pub(crate) enum ContainerOpts { }, } +/// EROFS format version for `bootc container ukify --erofs-version`. +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub(crate) enum ErofsVersionArg { + /// V1 EROFS (C-tool compatible, `composefs.digest=v1-sha256-12:` karg). Default. + V1, + /// V2 EROFS (composefs-rs native, `composefs=` karg). + V2, +} + +impl From for FormatVersion { + fn from(v: ErofsVersionArg) -> Self { + match v { + ErofsVersionArg::V1 => FormatVersion::V1, + ErofsVersionArg::V2 => FormatVersion::V2, + } + } +} + #[derive(Debug, Clone, ValueEnum, PartialEq, Eq)] pub(crate) enum ExportFormat { /// Export as tar archive @@ -1990,16 +2031,23 @@ async fn run_from_opt(opt: Opt) -> Result<()> { ContainerOpts::ComputeComposefsDigest { path, write_dumpfile_to, + erofs_version, } => { - let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref()).await?; + let digest = compute_composefs_digest( + &path, + erofs_version.into(), + write_dumpfile_to.as_deref(), + ) + .await?; println!("{digest}"); Ok(()) } ContainerOpts::ComputeComposefsDigestFromStorage { write_dumpfile_to, + erofs_version, image, } => { - let (_td_guard, repo) = new_temp_composefs_repo()?; + let (_td_guard, repo) = new_temp_composefs_repo(erofs_version.into())?; let mut proxycfg = crate::deploy::new_proxy_config(); @@ -2059,6 +2107,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { rootfs, kargs, allow_missing_verity, + erofs_version, write_dumpfile_to, kernel_dir, args, @@ -2091,6 +2140,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { &args, kernel, allow_missing_verity, + erofs_version.into(), write_dumpfile_to.as_deref(), ) .await diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 8617f1005b..6e455980ad 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -1,5 +1,7 @@ -/// composefs= parameter in kernel cmdline +/// composefs= parameter in kernel cmdline (V2 format) pub const COMPOSEFS_CMDLINE: &str = "composefs"; +/// composefs.digest= parameter in kernel cmdline (V1 format) +pub const COMPOSEFS_DIGEST_CMDLINE: &str = "composefs.digest"; /// Directory to store transient state, such as staged deployemnts etc pub(crate) const COMPOSEFS_TRANSIENT_STATE_DIR: &str = "/run/composefs"; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 3c5182fd75..c431938ebd 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2027,10 +2027,13 @@ async fn install_to_filesystem_impl( let imgref = &state.source.imageref; let img_manifest_config = get_container_manifest_and_config(&imgref).await?; crate::store::ensure_composefs_dir(&rootfs.physical_root)?; - // Use init_path since the repo may not exist yet during install - let config = + // Use init_path since the repo may not exist yet during install. + // Generate both V1 and V2 EROFS images (see initialize_composefs_repository); + // this config must match the one used there since it re-inits the same repo. + let mut config = RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512) .set_insecure(); + crate::store::set_dual_erofs_formats(&mut config); let (cfs_repo, _created) = crate::store::ComposefsRepository::init_path( &rootfs.physical_root, crate::store::COMPOSEFS, diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index c796ffdab1..f13b67e232 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -234,7 +234,7 @@ impl BLSConfig { .ok_or_else(|| anyhow::anyhow!("No options"))?; let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(&options)) - .ok_or_else(|| anyhow::anyhow!("No composefs= param"))?; + .ok_or_else(|| anyhow::anyhow!("No composefs= or composefs.digest= param"))?; Ok(cfs_cmdline.digest.to_string()) } diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index d417b17dec..6fa6793d52 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -125,6 +125,14 @@ use crate::utils::{deployment_fd, open_dir_remount_rw}; /// See pub type ComposefsRepository = composefs::repository::Repository; +/// Configure new repositories to retain boot images for both supported formats. +pub(crate) fn set_dual_erofs_formats(config: &mut RepositoryConfig) { + config.erofs_formats = composefs::erofs::format::FormatConfig { + default: composefs::erofs::format::FormatVersion::V1, + extra: [composefs::erofs::format::FormatVersion::V2].into(), + }; +} + /// Path to the physical root pub const SYSROOT: &str = "sysroot"; @@ -722,9 +730,10 @@ impl Storage { repo } Err(RepositoryOpenError::MetadataMissing) => { - // No meta.json — this is a fresh directory. Initialize a new - // repository with the current defaults. - let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + // No meta.json — this is a fresh directory. Existing repositories + // above retain their recorded format configuration. + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + set_dual_erofs_formats(&mut config); let config = if ostree_verity.enabled { config } else { diff --git a/crates/lib/src/testutils.rs b/crates/lib/src/testutils.rs index e24a80a5fa..9b682d3d4d 100644 --- a/crates/lib/src/testutils.rs +++ b/crates/lib/src/testutils.rs @@ -25,16 +25,16 @@ use crate::store::ComposefsRepository; use ostree_ext::container::deploy::ORIGIN_CONTAINER; -/// Return a deterministic SHA-256 hex digest for a test build version. +/// Return a deterministic SHA-512 hex digest for a test build version. /// -/// Computes `sha256("build-{n}")`, producing a realistic 64-char hex digest +/// Computes `sha512("build-{n}")`, producing a realistic 128-char hex digest /// that is stable across runs. pub(crate) fn fake_digest_version(n: u32) -> String { let hash = openssl::hash::hash( - openssl::hash::MessageDigest::sha256(), + openssl::hash::MessageDigest::sha512(), format!("build-{n}").as_bytes(), ) - .expect("sha256"); + .expect("sha512"); hex::encode(hash) } @@ -499,10 +499,10 @@ impl TestRoot { } } LayoutMode::Legacy => { - // Legacy dirs are just the raw hex digest (64 chars). + // Legacy dirs are just the raw hex digest (128 chars for SHA-512). // Only include entries that look like hex digests to // avoid accidentally counting "loader" or other dirs. - if name.len() == 64 && name.chars().all(|c| c.is_ascii_hexdigit()) { + if name.len() == 128 && name.chars().all(|c| c.is_ascii_hexdigit()) { names.push(name); } } @@ -542,7 +542,7 @@ impl TestRoot { // compared to the real migration in PR #2128 which also // handles UKI PE files and GRUB configs. if !name.starts_with(TYPE1_BOOT_DIR_PREFIX) - && name.len() == 64 + && name.len() == 128 && name.chars().all(|c| c.is_ascii_hexdigit()) { to_rename.push(name); diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index cd434a3960..d4f3f62d8e 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -13,8 +13,12 @@ use cap_std_ext::cap_std::fs::Dir; use fn_error_context::context; use linux_kernel_cmdline::utf8::Cmdline; +use composefs::erofs::format::FormatVersion; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_ctl::composefs; + use crate::bootc_composefs::digest::compute_composefs_digest; -use crate::bootc_composefs::status::ComposefsCmdline; +use crate::bootc_composefs::status::build_composefs_karg; use crate::kernel::KernelInternal; /// Build a UKI from the given rootfs. @@ -33,6 +37,7 @@ pub(crate) async fn build_ukify( args: &[OsString], kernel: Option, allow_missing_fsverity: bool, + erofs_version: FormatVersion, write_dumpfile_to: Option<&Utf8Path>, ) -> Result<()> { // Warn if --karg is used (temporary workaround) @@ -97,15 +102,22 @@ pub(crate) async fn build_ukify( } // Compute the composefs digest - let composefs_digest = compute_composefs_digest(rootfs, write_dumpfile_to).await?; + let composefs_digest = + compute_composefs_digest(rootfs, erofs_version, write_dumpfile_to).await?; + let composefs_digest = Sha512HashValue::from_hex(&composefs_digest) + .context("Parsing computed composefs digest")?; // Get kernel arguments from kargs.d let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; - // Add the composefs digest - cmdline.extend(&Cmdline::from( - ComposefsCmdline::build(&composefs_digest, allow_missing_fsverity).to_string(), - )); + // Add the composefs digest, tagging the karg with the same EROFS format + // version used to compute it so it stays boot-compatible (see + // `build_composefs_karg`). + cmdline.extend(&Cmdline::from(build_composefs_karg( + composefs_digest, + erofs_version, + allow_missing_fsverity, + ))); // Add any extra kargs provided via --karg for karg in extra_kargs { @@ -152,7 +164,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); - let result = build_ukify(path, &[], &[], None, false, None).await; + let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -174,7 +186,7 @@ mod tests { ) .unwrap(); - let result = build_ukify(path, &[], &[], None, false, None).await; + let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index 14396107b3..951acc24eb 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -351,6 +351,127 @@ pub(crate) fn test_compute_composefs_digest() -> Result<()> { Ok(()) } +/// Test that `bootc container ukify --erofs-version` is plumbed correctly. +/// +/// Verifies that: +/// - `compute-composefs-digest --erofs-version=v1` and `=v2` produce distinct, +/// valid 128-char SHA-512 hex digests (different EROFS layouts → different IDs). +/// - `bootc container ukify --erofs-version=v1` either invokes ukify (skipping +/// gracefully if ukify is absent) or fails with a clear error before ukify. +pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + // Build a minimal rootfs that satisfies find_kernel() and build_ukify()'s + // existence checks. The files don't need to be real ELF/CPIO — bootc only + // stat-checks them before handing them off to ukify. + let td = tempfile::tempdir()?; + let root = td.path(); + + fs::create_dir_all(root.join("boot"))?; + fs::create_dir_all(root.join("sysroot"))?; + + let usr_bin = root.join("usr/bin"); + fs::create_dir_all(&usr_bin)?; + let hello = usr_bin.join("hello"); + fs::write(&hello, b"#!/bin/sh\necho hello\n")?; + fs::set_permissions(&hello, fs::Permissions::from_mode(0o755))?; + + // Kernel layout that find_kernel() expects + let kver = "6.1.0-test"; + let mod_dir = root.join("usr/lib/modules").join(kver); + fs::create_dir_all(&mod_dir)?; + fs::write(mod_dir.join("vmlinuz"), b"fake-vmlinuz")?; + fs::write(mod_dir.join("initramfs.img"), b"fake-initramfs")?; + + // ukify reads --os-release @usr/lib/os-release relative to the rootfs cwd + let os_release_dir = root.join("usr/lib"); + fs::create_dir_all(&os_release_dir)?; + fs::write( + os_release_dir.join("os-release"), + b"ID=test\nNAME=Test\nVERSION_ID=1\n", + )?; + + let root_str = root.to_str().unwrap(); + + // ── Part 1: compare V1 vs V2 digest via compute-composefs-digest ────────── + let sh = Shell::new()?; + + let digest_v2 = cmd!( + sh, + "bootc container compute-composefs-digest {root_str} --erofs-version=v2" + ) + .read()?; + let digest_v1 = cmd!( + sh, + "bootc container compute-composefs-digest {root_str} --erofs-version=v1" + ) + .read()?; + + let digest_v2 = digest_v2.trim(); + let digest_v1 = digest_v1.trim(); + + assert_eq!( + digest_v2.as_bytes().len(), + 128, + "V2 digest must be 128 hex chars" + ); + assert_eq!( + digest_v1.as_bytes().len(), + 128, + "V1 digest must be 128 hex chars" + ); + assert!( + digest_v2.chars().all(|c| c.is_ascii_hexdigit()), + "V2 digest contains non-hex chars: {digest_v2}" + ); + assert!( + digest_v1.chars().all(|c| c.is_ascii_hexdigit()), + "V1 digest contains non-hex chars: {digest_v1}" + ); + assert_ne!( + digest_v1, digest_v2, + "V1 and V2 EROFS digests must differ (they use different on-disk layouts)" + ); + + // ── Part 2: smoke-test the full ukify CLI path with --erofs-version=v1 ──── + // + // We don't assert success because ukify will fail on fake kernel blobs. + // What we're testing is that bootc reaches the ukify invocation stage — + // i.e. the --erofs-version plumbing is wired correctly all the way through. + let output = Command::new("bootc") + .args([ + "container", + "ukify", + "--rootfs", + root_str, + "--erofs-version=v1", + "--allow-missing-verity", + "--", + "--output=/dev/null", + ]) + .output()?; + + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.contains("ukify executable not found in PATH") { + // ukify binary absent: the CLI plumbing still ran up to that check. + eprintln!("note: ukify not found, skipping ukify invocation check"); + return Ok(()); + } + + // ukify was found and invoked. It will fail because of the fake kernel + // blobs, but bootc must have reached the `ukify build` invocation, which + // means the V1 digest was computed and the cmdline assembled. Assert that + // no *bootc* logic bailed before reaching ukify (i.e. no "No kernel found", + // "already contains a UKI", or similar early exits). + assert!( + !stderr.contains("No kernel found") && !stderr.contains("already contains a UKI"), + "bootc bailed before reaching ukify; stderr:\n{stderr}" + ); + + Ok(()) +} + /// Tests that should be run in a default container image. #[context("Container tests")] pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { @@ -364,6 +485,10 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { new_test("system-reinstall --help", test_system_reinstall_help), new_test("container export tar", test_container_export_tar), new_test("compute-composefs-digest", test_compute_composefs_digest), + new_test( + "container-ukify-erofs-versions", + test_container_ukify_erofs_versions, + ), ]; libtest_mimic::run(&testargs, tests.into()).exit() diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 12111f46e5..2d2c5eaa19 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -542,8 +542,11 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { let mut opts = Vec::new(); - // If test wants bind storage and distro supports it, add --bind-storage-ro - if try_bind_storage && supports_bind_storage_ro { + // If test wants bind storage, the distro supports it, and it wasn't + // explicitly disabled, add --bind-storage-ro + let use_bind_storage = + try_bind_storage && supports_bind_storage_ro && !args.skip_bind_storage; + if use_bind_storage { opts.push(BCVK_OPT_BIND_STORAGE_RO.to_string()); // If upgrade image is provided, set it as an environment variable for tmt @@ -551,6 +554,10 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { if let Some(ref upgrade_img) = args.upgrade_image { tmt_env_vars.push(format!("{}={}", ENV_BOOTC_UPGRADE_IMAGE, upgrade_img)); } + } else if try_bind_storage && args.skip_bind_storage { + println!( + "Note: Test requests bind storage but --skip-bind-storage was set; running without host container-storage mount" + ); } else if try_bind_storage && !supports_bind_storage_ro { println!( "Note: Test wants bind storage but skipping on {} (missing systemd.extra-unit.* support)", diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 17feee550f..e09567b863 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -45,6 +45,18 @@ fn out_of_sync_error(message: &str) -> Result<()> { anyhow::bail!("{}; run `just update-generated` to update it", message) } +/// Parse a `0`/`1` boolean from a CLI/env value so the flag can be driven from +/// the Justfile (e.g. `BOOTC_skip_bind_storage=1`). +fn parse_cli_bool(s: &str) -> std::result::Result { + match s { + "1" | "true" => Ok(true), + "0" | "false" => Ok(false), + other => Err(format!( + "invalid value '{other}' (expected 0, 1, true, or false)" + )), + } +} + /// Build tasks for bootc #[derive(Debug, Parser)] #[command(name = "xtask")] @@ -231,6 +243,24 @@ pub(crate) struct RunTmtArgs { #[clap(long)] pub(crate) upgrade_image: Option, + /// Skip the `--bind-storage-ro` host container-storage virtiofs mount even for + /// plans that request it. Useful where libvirt-managed virtiofsd cannot run + /// (nested user namespaces, cloud/non-qemu). Plans that depend on a locally + /// built upgrade image being available in-VM via bind-storage will not be able + /// to perform the upgrade/switch step. + /// + /// Takes `0`/`1`/`true`/`false` so it can be driven from the Justfile via + /// `BOOTC_skip_bind_storage=1`. A bare `--skip-bind-storage` means `1`. + #[arg( + long, + env = "BOOTC_skip_bind_storage", + num_args = 0..=1, + default_value_t = false, + default_missing_value = "1", + value_parser = parse_cli_bool, + )] + pub(crate) skip_bind_storage: bool, + /// Preserve VMs after test completion (useful for debugging) #[arg(long)] pub(crate) preserve_vm: bool, @@ -774,3 +804,18 @@ fn validate_composefs_digest(sh: &Shell, args: &ValidateComposefsDigestArgs) -> anyhow::bail!("Composefs digest mismatch"); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cli_bool() { + assert_eq!(parse_cli_bool("1"), Ok(true)); + assert_eq!(parse_cli_bool("true"), Ok(true)); + assert_eq!(parse_cli_bool("0"), Ok(false)); + assert_eq!(parse_cli_bool("false"), Ok(false)); + assert!(parse_cli_bool("").is_err()); + assert!(parse_cli_bool("maybe").is_err()); + } +} diff --git a/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-composefs-corruped-state-resilience.nu b/tmt/tests/booted/test-composefs-corruped-state-resilience.nu index cbd10f67be..d0c40fd791 100644 --- a/tmt/tests/booted/test-composefs-corruped-state-resilience.nu +++ b/tmt/tests/booted/test-composefs-corruped-state-resilience.nu @@ -35,6 +35,7 @@ def first_boot [] { } let booted_verity = $st.status.booted.composefs.verity + let missing_verity = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" # Add some random entry in /boot/loader/entries to simulate # https://github.com/bootc-dev/bootc/issues/2208 @@ -43,7 +44,7 @@ def first_boot [] { cd ($entries_dir) cp * new-entry.conf - sed -i 's;($booted_verity);bad-verity;' new-entry.conf + sed -i 's;($booted_verity);($missing_verity);' new-entry.conf " # This should work but log a warning in journal @@ -51,7 +52,7 @@ def first_boot [] { assert ( journalctl F_MESSAGE_ID=d264f924dadb4c31bff0412107d391fb - | str contains $"No origin file for deployment bad-verity" + | str contains $"No origin file for deployment ($missing_verity)" ) # Create a simple derived image to switch to 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