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/Cargo.lock b/Cargo.lock index 07d6c31f94..39f7177607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,8 +198,8 @@ checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" [[package]] name = "bcvk-qemu" -version = "0.1.0" -source = "git+https://github.com/bootc-dev/bcvk?rev=13d860c2a916d0f4cdcc5a817af0042b9cafee86#13d860c2a916d0f4cdcc5a817af0042b9cafee86" +version = "0.20.0" +source = "git+https://github.com/bootc-dev/bcvk?tag=v0.20.0#aaa917ae82086a68941fbb84678b03fe13526093" dependencies = [ "camino", "cap-std-ext", diff --git a/Dockerfile b/Dockerfile index bd8c289a89..963ec12c7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -354,6 +354,7 @@ ARG variant ARG filesystem ARG seal_state ARG boot_type +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 \ @@ -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..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 @@ -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" \ @@ -250,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[@]}" \ @@ -290,7 +294,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 @@ -300,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 \ @@ -508,6 +513,8 @@ _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 \ "${extra_args[@]}" \ 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 diff --git a/contrib/packaging/seal-uki b/contrib/packaging/seal-uki index 7ee03b44c6..7a2ca27bda 100755 --- a/contrib/packaging/seal-uki +++ b/contrib/packaging/seal-uki @@ -4,6 +4,7 @@ set -xeuo pipefail missing_verity=() dumpfile_args=() +erofs_version=auto 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" @@ -89,6 +96,9 @@ 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 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/initramfs/dracut/module-setup.sh b/crates/initramfs/dracut/module-setup.sh index f23c0fd643..42e16e8c14 100755 --- a/crates/initramfs/dracut/module-setup.sh +++ b/crates/initramfs/dracut/module-setup.sh @@ -26,4 +26,6 @@ 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 + + return 0 } diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index 8d2de21911..3a5ec13891 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -26,9 +26,9 @@ use composefs::{ fsverity::{FsVerityHashValue, Sha512HashValue}, mount::FsHandle, 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}; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; @@ -362,6 +362,81 @@ pub fn mount_composefs_image( Ok(rootfs) } +fn parse_composefs_candidates(cmdline: &str) -> Result>> { + let mut candidates = Vec::new(); + let mut seen_legacy = false; + + // The repository policy is deliberately kept separate from the cmdline: + // the '?' marker only requests that policy, and setup_root decides below + // whether the repository permits it. Parse the cmdline with the kernel + // parser so quoted parameters remain a single parameter. + for parameter in Cmdline::from(cmdline).iter() { + let key = parameter.key(); + if &*key != KARG_COMPOSEFS_DIGEST && &*key != KARG_V2 { + continue; + } + + let value = parameter + .value() + .ok_or_else(|| anyhow::anyhow!("{key}= composefs kernel argument has no value"))?; + if &*key == KARG_V2 { + anyhow::ensure!( + !seen_legacy, + "duplicate {KARG_V2}= composefs kernel argument" + ); + seen_legacy = true; + } + + let parameter = format!("{key}={value}"); + if let Some(candidate) = ComposefsCmdline::::from_cmdline(¶meter)? { + candidates.push(candidate); + } + } + Ok(candidates) +} + +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> { + // `allow_missing_fsverity` is repository policy, not a trust decision + // supplied by the kernel command line. A cmdline '?' may only request + // the already-permitted insecure mode; it must never weaken strict policy. + 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,116 @@ 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() { + let malformed = ["composefs=not-a-digest", "composefs.digest=v9-sha512-12:aa"]; + for cmdline in malformed { + assert!( + parse_composefs_candidates(cmdline).is_err(), + "expected parse error for {cmdline:?}" + ); + } + + 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_composefs_candidate_parser_cases() { + let first = Sha512HashValue::EMPTY; + let second = Sha512HashValue::from_hex("aa".repeat(64)).unwrap(); + let cases = [ + ("single v1", v1(first.clone(), false), vec![first.clone()]), + ("single v2", v2(first.clone(), false), vec![first.clone()]), + ( + "dual preserves cmdline order", + format!("{} {}", v2(first.clone(), false), v1(second.clone(), false)), + vec![first.clone(), second.clone()], + ), + ( + "quoted parameter", + format!("quiet \"{}\" rw", v1(first.clone(), false)), + vec![first.clone()], + ), + ( + "skips sha256 descriptor", + format!( + "composefs.digest=v1-sha256-12:{} {} {}", + "aa".repeat(32), + v1(first.clone(), false), + v2(second.clone(), false) + ), + vec![first.clone(), second.clone()], + ), + ]; + + for (name, cmdline, expected_digests) in cases { + let candidates = parse_composefs_candidates(&cmdline) + .unwrap_or_else(|error| panic!("{name} unexpectedly failed: {error:#}")); + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.digest().clone()) + .collect::>(), + expected_digests, + "case {name}" + ); + } + + let duplicate_cases = [ + format!("{} {}", v2(first.clone(), false), v2(second.clone(), false)), + format!("{} {}", v2(first.clone(), false), v2(first.clone(), false)), + ]; + for cmdline in duplicate_cases { + assert!( + parse_composefs_candidates(&cmdline).is_err(), + "duplicate recognized key accepted: {cmdline}" + ); + } + + let irrelevant = [ + "quiet splash rw", + "root=UUID=abc composefs.digest=v1-sha256-12:aa", + ]; + for cmdline in irrelevant { + assert!( + parse_composefs_candidates(cmdline).unwrap().is_empty(), + "irrelevant argument was treated as a candidate: {cmdline}" + ); + } + } } diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 72ff812a8f..2d79b9c117 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -76,13 +76,15 @@ 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}; use composefs_boot::bootloader::{ BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType, - UsrLibModulesVmlinuz, get_boot_resources, + UsrLibModulesVmlinuz, }; +use composefs_boot::cmdline::{KARG_COMPOSEFS_DIGEST, KARG_V2}; use composefs_boot::{ cmdline::ComposefsCmdline as ComposefsBootCmdline, os_release::OsReleaseInfo, uki, }; @@ -90,14 +92,14 @@ 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::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -158,11 +160,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( @@ -171,8 +214,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 { @@ -227,6 +269,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, @@ -259,7 +315,7 @@ fn read_dumpfile_from_fs( pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk - Setup((&'a RootSetup, &'a State, &'a PostFetchState)), + Setup((&'a RootSetup, &'a State, &'a PostFetchState, bool)), /// For `bootc upgrade` Upgrade((&'a Storage, &'a BootedComposefs, &'a Host)), } @@ -657,6 +713,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(KARG_V2)); + cmdline.remove(&ParameterKey::from(KARG_COMPOSEFS_DIGEST)); + 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,13 +731,14 @@ 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 { let id_hex = id.to_hex(); let (root_path, esp_device, mut cmdline_refs, bootloader) = match setup_type { - BootSetupType::Setup((root_setup, state, postfetch)) => { + BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { // root_setup.kargs has [root=UUID=, "rw"] let mut cmdline_options = Cmdline::new(); @@ -685,8 +751,8 @@ 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())); + build_composefs_karg(id.clone(), format_version, allow_missing_fsverity); + 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 +798,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 +1029,189 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, + composefs_digest: Sha512HashValue, +} + +/// The EROFS format a UKI composefs kernel argument claims for its digest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UkiCandidateFormat { + /// `composefs.digest=v1--:` + V1, + /// `composefs.digest=v2--:` + V2, + /// Bare `composefs=`. composefs-rs documents this as shorthand for + /// V2, but bootc has sealed several formats under it: 1.16.0 through + /// 1.16.2 predate format versioning (the original composefs-rs encoding + /// that V2 descends from), 1.16.3 sealed V2, and 1.16.4 through 1.16.13 + /// sealed V1 because composefs-rs switched its default while `ukify` kept + /// emitting only the bare key. Only the digest itself identifies the image. + Unspecified, +} + +impl UkiCandidateFormat { + /// Whether an image serialized as `version` satisfies this argument. + fn accepts(self, version: FormatVersion) -> bool { + match self { + Self::V1 => matches!(version, FormatVersion::V0 | FormatVersion::V1), + Self::V2 => version == FormatVersion::V2, + Self::Unspecified => true, + } + } +} + +impl std::fmt::Display for UkiCandidateFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::V1 => "V1", + Self::V2 => "V2", + Self::Unspecified => "V1 or V2", + }) + } +} + +/// One composefs digest from a UKI command line. +#[derive(Clone, Debug, PartialEq, Eq)] +struct UkiComposefsCandidate { + digest: Sha512HashValue, + insecure: bool, + format: UkiCandidateFormat, +} + +/// Parse every composefs argument emitted by supported bootc UKIs. +fn parse_uki_composefs_candidates(cmdline: &str) -> Result> { + let mut candidates = Vec::with_capacity(2); + let mut seen_legacy = false; + + for parameter in Cmdline::from(cmdline).iter() { + let key = parameter.key(); + let legacy = &*key == KARG_V2; + if !legacy && &*key != KARG_COMPOSEFS_DIGEST { + continue; + } + + let value = parameter + .value() + .ok_or_else(|| anyhow!("{key}= composefs kernel argument has no value"))?; + if legacy { + anyhow::ensure!( + !seen_legacy, + "duplicate {KARG_V2}= composefs kernel argument" + ); + seen_legacy = true; + } + + // Reuse the composefs-rs parser for the digest and insecure marker, + // but don't trust the format it infers for the bare legacy key. + let parsed = + ComposefsBootCmdline::::from_cmdline(&format!("{key}={value}"))?; + let (digest, insecure, format) = match parsed { + None => continue, + Some(ComposefsBootCmdline::V1 { digest, insecure }) => { + (digest, insecure, UkiCandidateFormat::V1) + } + Some(ComposefsBootCmdline::V2 { digest, insecure }) if legacy => { + (digest, insecure, UkiCandidateFormat::Unspecified) + } + Some(ComposefsBootCmdline::V2 { digest, insecure }) => { + (digest, insecure, UkiCandidateFormat::V2) + } + }; + candidates.push(UkiComposefsCandidate { + digest, + insecure, + format, + }); + } + Ok(candidates) +} + +fn uki_candidates_policy(candidates: &[UkiComposefsCandidate]) -> Result { + let first = candidates + .first() + .ok_or_else(|| anyhow!("No composefs digest in UKI cmdline"))? + .insecure; + anyhow::ensure!( + candidates + .iter() + .all(|candidate| candidate.insecure == first), + "UKI composefs candidates have mixed fs-verity policies" + ); + Ok(first) +} + +/// Retain the historical V1 preference for the deployment identity while +/// validating every fallback candidate. This avoids changing the naming of +/// existing dual-format UKIs when their kernel arguments are reordered. +fn primary_uki_candidate(candidates: &[UkiComposefsCandidate]) -> &UkiComposefsCandidate { + candidates + .iter() + .find(|candidate| candidate.format == UkiCandidateFormat::V1) + .or_else(|| candidates.first()) + .expect("UKI candidates were checked as non-empty") +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ExpectedBootImageIds { + v1: Vec, + v2: Vec, +} + +impl ExpectedBootImageIds { + /// The boot images whose format satisfies `candidate`. + fn ids_for_candidate( + &self, + candidate: &UkiComposefsCandidate, + ) -> impl Iterator { + let (v1, v2): (&[_], &[_]) = match candidate.format { + UkiCandidateFormat::V1 => (&self.v1, &[]), + UkiCandidateFormat::V2 => (&[], &self.v2), + UkiCandidateFormat::Unspecified => (&self.v1, &self.v2), + }; + v1.iter().chain(v2) + } + + fn contains(&self, candidate: &UkiComposefsCandidate) -> bool { + self.ids_for_candidate(candidate) + .any(|id| id == &candidate.digest) + } + + fn add(&mut self, version: FormatVersion, digest: Sha512HashValue) { + let ids = match version { + FormatVersion::V0 | FormatVersion::V1 => &mut self.v1, + FormatVersion::V2 => &mut self.v2, + }; + if !ids.contains(&digest) { + ids.push(digest); + } + } +} + +fn validate_uki_candidates( + candidates: &[UkiComposefsCandidate], + expected: &ExpectedBootImageIds, + uki_name: Option, +) -> Result<()> { + for candidate in candidates { + if expected.contains(candidate) { + continue; + } + let expected_digests = expected + .ids_for_candidate(candidate) + .map(Sha512HashValue::to_hex) + .collect::>(); + let expected = if expected_digests.is_empty() { + format!("a {} boot image", candidate.format) + } else { + expected_digests.join(", ") + }; + return Err(UKIDigestMismatch::digest_parameter( + candidate.digest.to_hex(), + expected, + uki_name, + ) + .into()); + } + Ok(()) } /// Determines the directory (under `mounted_efi`) that a PE binary should be written to. @@ -1008,6 +1257,7 @@ fn write_pe_to_esp( file_path: &Utf8Path, pe_type: PEType, uki_id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, missing_fsverity_allowed: bool, mounted_efi: impl AsRef, ) -> Result> { @@ -1031,18 +1281,22 @@ fn write_pe_to_esp( if matches!(pe_type, PEType::Uki) { let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; - let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) - .context("Parsing composefs=")? - .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; - let composefs_cmdline = composefs_info.digest(); - let missing_verity_allowed_cmdline = composefs_info.is_insecure(); + let composefs_candidates = parse_uki_composefs_candidates(&cmdline) + .context("Parsing composefs kernel arguments")?; + let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?; + let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone(); + + anyhow::ensure!( + !missing_verity_allowed_cmdline || missing_fsverity_allowed, + "The UKI requests insecure composefs operation, but this repository requires fs-verity. Use --allow-missing-verity only when missing fs-verity is explicitly supported for this install." + ); // 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 { true if !missing_verity_allowed_cmdline => { tracing::warn!( - "--allow-missing-fsverity passed as option but UKI cmdline does not support it" + "--allow-missing-verity passed as option but UKI cmdline does not support it" ); } @@ -1053,16 +1307,11 @@ fn write_pe_to_esp( _ => { /* no-op */ } } - let file_name = file_path.file_name(); - - if *composefs_cmdline != *uki_id { - return Err(UKIDigestMismatch { - actual: composefs_cmdline.to_hex(), - expected: uki_id.to_hex(), - uki_name: file_name.map(|x| x.to_string()), - } - .into()); - } + validate_uki_candidates( + &composefs_candidates, + boot_ids, + file_path.file_name().map(|name| name.to_string()), + )?; uki_reader.seek(SeekFrom::Start(0))?; let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; @@ -1079,6 +1328,7 @@ fn write_pe_to_esp( version: parsed_osrel.get_version(), os_id: parsed_osrel.get_value(&["ID"]), boot_digest, + composefs_digest, }); } @@ -1088,8 +1338,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() @@ -1112,6 +1366,222 @@ fn write_pe_to_esp( Ok(boot_label) } +fn uki_file_name(file_path: &Path) -> Result { + let file_path = Utf8Path::from_path(file_path) + .ok_or_else(|| anyhow!("UKI path is not valid UTF-8: {file_path:?}"))?; + file_path + .file_name() + .map(str::to_owned) + .ok_or_else(|| anyhow!("Could not get UKI file name from {file_path}")) +} + +/// Inspect primary UKIs to discover their requested fs-verity policy before +/// creating the durable repository. Final artifact validation occurs while +/// writing each UKI to the ESP. +pub(crate) fn uki_fsverity_policy( + repo: &crate::store::ComposefsRepository, + entries: &[ComposefsBootEntry], +) -> Result> { + let mut policy = None; + for entry in entries { + let ComposefsBootEntry::Type2(entry) = entry else { + continue; + }; + if !matches!(entry.pe_type, PEType::Uki) { + continue; + } + let mut 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 reader).context("Getting UKI cmdline")?; + let candidates = parse_uki_composefs_candidates(&cmdline).with_context(|| { + format!( + "Parsing composefs kernel arguments in UKI {}", + entry.file_path.display() + ) + })?; + let current = uki_candidates_policy(&candidates)?; + if let Some(previous) = policy { + anyhow::ensure!( + previous == current, + "Primary UKIs request conflicting composefs fs-verity policies" + ); + } else { + policy = Some(current); + } + } + Ok(policy) +} + +/// The composefs digests embedded in the primary UKI's kernel cmdline, used to +/// reconcile the freshly-generated boot image digest before it is mounted. +struct ExpectedComposefsDigest { + candidates: Vec, + uki_name: String, +} + +/// Scans `entries` for the primary UKI (`PEType::Uki`, not an addon) and +/// extracts the composefs digests 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. +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 candidates = parse_uki_composefs_candidates(&cmdline) + .context("Parsing composefs kernel arguments")?; + uki_candidates_policy(&candidates)?; + let uki_name = uki_file_name(&entry.file_path)?; + return Ok(Some(ExpectedComposefsDigest { + candidates, + uki_name, + })); + } + 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 digest using a different default xattr +/// filtering mode or EROFS format 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, + expected_ids: ExpectedBootImageIds, + 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(RecoveredBootImage { + id: computed_id, + expected_ids, + }); + }; + reconcile_uki_candidates(&expected, expected_ids, |digest| { + composefs_oci::find_matching_boot_image(repo, manifest_digest, digest) + }) +} + +/// The repository-independent part of [`ensure_correct_composefs_digest`]: +/// every UKI candidate must name a boot image of an acceptable format, found +/// either among `expected_ids` or via `find_matching`. +fn reconcile_uki_candidates( + expected: &ExpectedComposefsDigest, + mut expected_ids: ExpectedBootImageIds, + mut find_matching: impl FnMut( + &Sha512HashValue, + ) -> Result>, +) -> Result { + for candidate in &expected.candidates { + if expected_ids.contains(candidate) { + continue; + } + // This is expected when e.g. the repository only generated one of the + // formats in a dual-format UKI, so it isn't worth alarming anyone. + tracing::debug!( + "UKI {} names {} boot image {:?}, which is not among the images generated \ + for this pull; searching other xattr filtering modes and EROFS format versions", + expected.uki_name, + candidate.format, + candidate.digest, + ); + let mismatch = UKIDigestMismatch::unsupported_compatibility( + candidate.digest.clone(), + expected.uki_name.clone(), + 0, + ); + let (version, digest) = + resolve_boot_image_match(mismatch, candidate, find_matching(&candidate.digest))?; + expected_ids.add(version, digest); + } + let selected = primary_uki_candidate(&expected.candidates); + anyhow::ensure!( + expected_ids.contains(selected), + "Recovered boot image does not match the primary UKI composefs candidate" + ); + Ok(RecoveredBootImage { + id: selected.digest.clone(), + expected_ids, + }) +} + +pub(crate) struct RecoveredBootImage { + pub(crate) id: Sha512HashValue, + pub(crate) expected_ids: ExpectedBootImageIds, +} + +/// 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( + mismatch: UKIDigestMismatch, + candidate: &UkiComposefsCandidate, + find_matching_result: Result>, +) -> Result<(FormatVersion, Sha512HashValue)> { + 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" + ); + anyhow::ensure!( + candidate.format.accepts(version), + "UKI composefs candidate format ({}) does not match recovered EROFS format \ + ({version:?}) for {:?}", + candidate.format, + candidate.digest, + ); + Ok((version, digest)) + } + 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"), + }, + } +} + #[context("Writing Grub menuentry")] fn write_grub_uki_menuentry( root_path: Utf8PathBuf, @@ -1200,16 +1670,18 @@ fn write_grub_uki_menuentry( fn write_systemd_uki_config( esp_dir: &Dir, setup_type: &BootSetupType, - boot_label: UKIInfo, + boot_label: String, + version: Option, + os_id: Option, id: &Sha512HashValue, bootloader: &Bootloader, ) -> Result<()> { - let os_id = boot_label.os_id.as_deref().unwrap_or("bootc"); + let os_id = os_id.as_deref().unwrap_or("bootc"); let primary_sort_key = primary_sort_key(os_id); let mut bls_conf = BLSConfig::default(); bls_conf - .with_title(boot_label.boot_label) + .with_title(boot_label) .with_cfg(BLSConfigType::EFI { key: EFIKey::for_bootloader( format!("/{BOOTC_UKI_DIR}/{}", get_uki_name(&id.to_hex())).into(), @@ -1217,7 +1689,7 @@ fn write_systemd_uki_config( ), }) .with_sort_key(primary_sort_key.clone()) - .with_version(boot_label.version.unwrap_or_else(|| id.to_hex())); + .with_version(version.unwrap_or_else(|| id.to_hex())); let (entries_dir, booted_bls) = match setup_type { BootSetupType::Setup(..) => { @@ -1274,11 +1746,12 @@ pub(crate) fn setup_composefs_uki_boot( setup_type: BootSetupType, repo: &crate::store::ComposefsRepository, id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, 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)) => { + BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { state.require_no_kargs_for_uki()?; // Locate ESP partition device by walking up to the root disk(s) @@ -1288,7 +1761,7 @@ pub(crate) fn setup_composefs_uki_boot( root_setup.physical_root_path.clone(), esp_part.path(), postfetch.detected_bootloader.clone(), - state.composefs_options.allow_missing_verity, + allow_missing_fsverity, state.composefs_options.uki_addon.as_ref(), ) } @@ -1361,6 +1834,7 @@ pub(crate) fn setup_composefs_uki_boot( utf8_file_path, entry.pe_type, &id, + boot_ids, missing_fsverity_allowed, esp_mount.dir.path(), )?; @@ -1375,19 +1849,31 @@ pub(crate) fn setup_composefs_uki_boot( let uki_info = 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 UKIInfo { + boot_label, + version, + os_id, + boot_digest, + composefs_digest: deploy_id, + } = uki_info; match bootloader.kind()? { BootloaderKind::GRUBClassic => { - write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)? + write_grub_uki_menuentry(root_path, &setup_type, 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, + boot_label, + version, + os_id, + &deploy_id, + &bootloader, + )?, }; - Ok(boot_digest) + Ok((boot_digest, deploy_id)) } /// A composefs image attached to a temporary directory with the ESP and a @@ -1603,24 +2089,12 @@ 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( - &repo, - &pull_result.manifest_digest, - &Default::default(), - ) - .context("Generating bootable EROFS image")?; - - // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.). - let fs = composefs_oci::image::create_filesystem( - &*repo, - &pull_result.config_digest, - Some(&pull_result.config_verity), - &Default::default(), - ) - .context("Creating composefs filesystem for boot entry discovery")?; - let entries = - get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; + let crate::bootc_composefs::repo::BootImage { + id, + boot_ids, + fs, + entries, + } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; let composefs_mnt_fd = repo .mount(&id.to_hex()) @@ -1727,38 +2201,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(), - )?, - BootType::Uki => { - let uki_setup_result = setup_composefs_uki_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch)), + let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version()); + let (boot_digest, deploy_id) = match boot_type { + BootType::Bls => ( + setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch, allow_missing_fsverity)), + &repo, + &provisional_deploy_id, + provisional_format, + entry, + mounted_root.dir(), + )?, + provisional_deploy_id, + ), + BootType::Uki => print_uki_dumpfile_diff_on_mismatch( + setup_composefs_uki_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch, allow_missing_fsverity)), &repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, - ); - - match uki_setup_result { - Ok(boot_digest) => boot_digest, - 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( &root_setup.physical_root_path, - &id, + &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), None, boot_type, @@ -1771,9 +2242,46 @@ pub(crate) async fn setup_composefs_boot( Ok(()) } +/// Associate every accepted boot image with its EROFS serialization format. +/// A UKI's V1 candidate must never be accepted merely because its digest is a +/// valid V2 image (or vice versa). `selected` is the freshly generated image; +/// recovered images are added with their actual format by the recovery path. +pub(crate) fn expected_boot_image_ids( + v1: Option, + v2: Option, + selected: &Sha512HashValue, + selected_format: FormatVersion, +) -> ExpectedBootImageIds { + let mut ids = ExpectedBootImageIds::default(); + if let Some(v1) = v1 { + ids.add(FormatVersion::V1, v1); + } + if let Some(v2) = v2 { + ids.add(FormatVersion::V2, v2); + } + ids.add(selected_format, selected.clone()); + ids +} + #[cfg(test)] mod tests { use super::*; + use composefs::erofs::format::FormatVersion; + + #[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() { @@ -1928,4 +2436,419 @@ 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() + } + + fn uki_v1(digest: Sha512HashValue, insecure: bool) -> String { + ComposefsBootCmdline::new_v1(digest, insecure).to_cmdline_arg() + } + + /// The bare legacy `composefs=` spelling. + fn uki_v2(digest: Sha512HashValue, insecure: bool) -> String { + ComposefsBootCmdline::new_v2(digest, insecure).to_cmdline_arg() + } + + fn uki_explicit_v2(digest: &Sha512HashValue) -> String { + format!("composefs.digest=v2-sha512-12:{}", digest.to_hex()) + } + + fn candidate(format: UkiCandidateFormat, digest: Sha512HashValue) -> UkiComposefsCandidate { + UkiComposefsCandidate { + digest, + insecure: false, + format, + } + } + + #[test] + fn test_uki_composefs_candidates() { + let v1 = Sha512HashValue::EMPTY; + let v2 = other_digest(); + let secure = format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(v2.clone(), false) + ); + let insecure = format!("{} {}", uki_v1(v1.clone(), true), uki_v2(v2.clone(), true)); + let cases = [ + ("secure dual", secure.clone(), true), + ( + "secure dual reversed", + format!( + "{} {}", + uki_v2(v2.clone(), false), + uki_v1(v1.clone(), false) + ), + true, + ), + ("insecure dual", insecure, true), + ( + "insecure dual reversed", + format!("{} {}", uki_v2(v2.clone(), true), uki_v1(v1.clone(), true)), + true, + ), + ( + "stale secondary", + format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false) + ), + true, + ), + ( + "swapped digests", + format!( + "{} {}", + uki_v1(v2.clone(), false), + uki_v2(v1.clone(), false) + ), + true, + ), + ( + "mixed policy", + format!("{} {}", uki_v1(v1.clone(), false), uki_v2(v2.clone(), true)), + false, + ), + ( + "malformed secondary", + format!("{} composefs=not-a-digest", uki_v1(v1.clone(), false)), + false, + ), + ( + "malformed sha512 descriptor", + "composefs.digest=v1-sha512-12:bad".to_string(), + false, + ), + ( + "explicit V2 descriptor", + format!("composefs.digest=v2-sha512-12:{}", v2.to_hex()), + true, + ), + ( + "duplicate V2 key", + format!( + "{} {}", + uki_v2(v2.clone(), false), + uki_v2(v2.clone(), false) + ), + false, + ), + ("missing value", "composefs".to_string(), false), + ("no candidates", "quiet rw".to_string(), false), + ]; + + for (name, cmdline, should_pass) in cases { + let result = parse_uki_composefs_candidates(&cmdline).and_then(|candidates| { + uki_candidates_policy(&candidates)?; + Ok(candidates) + }); + assert_eq!(result.is_ok(), should_pass, "case {name}: {result:?}"); + } + // Only the explicit descriptor identifies V2; the bare key has + // carried both V1 and V2 digests in released UKIs. + for (cmdline, format) in [ + (uki_explicit_v2(&v2), UkiCandidateFormat::V2), + (uki_v1(v1.clone(), false), UkiCandidateFormat::V1), + (uki_v2(v2.clone(), false), UkiCandidateFormat::Unspecified), + ] { + let candidates = parse_uki_composefs_candidates(&cmdline).unwrap(); + let formats = candidates.iter().map(|c| c.format).collect::>(); + assert_eq!(formats, [format], "{cmdline}"); + } + let candidates = parse_uki_composefs_candidates(&format!( + "composefs.digest=v1-sha256-12:{} {} {}", + "aa".repeat(32), + uki_v1(v1.clone(), false), + uki_v2(v2.clone(), false) + )) + .unwrap(); + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.digest.clone()) + .collect::>(), + vec![v1.clone(), v2.clone()] + ); + + let expected = ExpectedBootImageIds { + v1: vec![v1.clone()], + v2: vec![v2.clone()], + }; + for (name, cmdline, valid) in [ + ("valid dual", secure, true), + ("legacy V1", uki_v2(v1.clone(), false), true), + ("legacy V2", uki_v2(v2.clone(), false), true), + ("explicit V2", uki_explicit_v2(&v2), true), + ("explicit V2 naming V1 image", uki_explicit_v2(&v1), false), + ( + "stale secondary", + format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false) + ), + false, + ), + ( + "swapped digests", + format!( + "{} {}", + uki_v1(v2.clone(), false), + uki_v2(v1.clone(), false) + ), + false, + ), + ] { + let candidates = parse_uki_composefs_candidates(&cmdline).unwrap(); + assert_eq!( + validate_uki_candidates(&candidates, &expected, Some("uki.efi".into())).is_ok(), + valid, + "case {name}" + ); + } + } + + #[test] + 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 { + Found, + NotFound, + Errors, + } + + let cases = [ + (FindMatching::Found, true, vec![]), + ( + FindMatching::NotFound, + 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 \ + the UKI digest" + .to_string(), + ], + ), + ]; + + 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 mismatch = + UKIDigestMismatch::unsupported_compatibility(expected.clone(), uki_name.into(), 0); + let candidate = candidate(UkiCandidateFormat::V2, expected.clone()); + let result = resolve_boot_image_match(mismatch, &candidate, find_matching_result); + if should_succeed { + assert_eq!(result.unwrap(), (FormatVersion::V2, expected.clone())); + 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_expected_boot_image_ids_keep_recovered_ids_by_format() { + let standard_v1 = Sha512HashValue::EMPTY; + let standard_v2 = other_digest(); + let recovered_v1 = Sha512HashValue::from_hex("bb".repeat(64)).unwrap(); + let recovered_v2 = Sha512HashValue::from_hex("cc".repeat(64)).unwrap(); + + let mut ids = expected_boot_image_ids( + Some(standard_v1.clone()), + Some(standard_v2.clone()), + &recovered_v1, + FormatVersion::V1, + ); + ids.add(FormatVersion::V2, recovered_v2.clone()); + + for candidate in [ + candidate(UkiCandidateFormat::V1, standard_v1), + candidate(UkiCandidateFormat::V1, recovered_v1), + candidate(UkiCandidateFormat::V2, standard_v2), + candidate(UkiCandidateFormat::V2, recovered_v2), + ] { + assert!(ids.contains(&candidate), "{candidate:?}"); + } + } + + /// End-to-end candidate reconciliation for the UKI spellings bootc has + /// shipped; `generated` are the boot images this pull produced and + /// `searchable` what the xattr/format search would find. + #[test] + fn test_reconcile_uki_candidates() { + use FormatVersion::{V1, V2}; + let v1 = Sha512HashValue::from_hex("11".repeat(64)).unwrap(); + let v2 = Sha512HashValue::from_hex("22".repeat(64)).unwrap(); + let unknown = Sha512HashValue::from_hex("33".repeat(64)).unwrap(); + let dual = format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(v2.clone(), false) + ); + let dual_reversed = format!( + "{} {}", + uki_v2(v2.clone(), false), + uki_v1(v1.clone(), false) + ); + + /// Boot images as `(format, digest)`. + type Images<'a> = &'a [(FormatVersion, &'a Sha512HashValue)]; + /// `(name, cmdline, generated, searchable, selected digest or error)` + type Case<'a> = ( + &'a str, + String, + Images<'a>, + Images<'a>, + Result<&'a Sha512HashValue, &'a str>, + ); + let cases: &[Case] = &[ + // bootc 1.16.4+: V1 under the bare key. + ( + "legacy V1", + uki_v2(v1.clone(), false), + &[(V1, &v1)], + &[], + Ok(&v1), + ), + ( + "legacy V1 recovered", + uki_v2(v1.clone(), false), + &[(V2, &v2)], + &[(V1, &v1)], + Ok(&v1), + ), + // bootc 1.16.3: V2 under the bare key. + ( + "legacy V2", + uki_v2(v2.clone(), false), + &[(V1, &v1), (V2, &v2)], + &[], + Ok(&v2), + ), + ( + "legacy V2 recovered", + uki_v2(v2.clone(), false), + &[(V1, &v1)], + &[(V2, &v2)], + Ok(&v2), + ), + ("dual", dual.clone(), &[(V1, &v1), (V2, &v2)], &[], Ok(&v1)), + ( + "dual reversed", + dual_reversed, + &[(V1, &v1), (V2, &v2)], + &[], + Ok(&v1), + ), + ( + "dual recovered V2", + dual, + &[(V1, &v1)], + &[(V2, &v2)], + Ok(&v1), + ), + ( + "explicit V2", + uki_explicit_v2(&v2), + &[(V2, &v2)], + &[], + Ok(&v2), + ), + ( + "explicit V2 naming V1 image", + uki_explicit_v2(&v1), + &[(V1, &v1)], + &[(V1, &v1)], + Err("candidate format (V2) does not match recovered EROFS format (V1)"), + ), + ( + "explicit V1 naming V2 image", + uki_v1(v2.clone(), false), + &[(V2, &v2)], + &[(V2, &v2)], + Err("candidate format (V1) does not match recovered EROFS format (V2)"), + ), + ( + "legacy unknown", + uki_v2(unknown.clone(), false), + &[(V1, &v1), (V2, &v2)], + &[], + Err("doesn't match any of"), + ), + ]; + + for (name, cmdline, generated, searchable, want) in cases { + let mut ids = ExpectedBootImageIds::default(); + for (version, digest) in generated.iter() { + ids.add(*version, (*digest).clone()); + } + let expected = ExpectedComposefsDigest { + candidates: parse_uki_composefs_candidates(cmdline).unwrap(), + uki_name: "uki.efi".into(), + }; + let result = reconcile_uki_candidates(&expected, ids, |wanted| { + Ok(searchable + .iter() + .find(|(_, digest)| *digest == wanted) + .map_or(composefs_oci::BootImageMatch::NotFound(4), |(v, d)| { + composefs_oci::BootImageMatch::Found { + mode: composefs_oci::XattrFiltering::KeepUserXattrs, + version: *v, + digest: (*d).clone(), + } + })) + }); + match (result, want) { + (Ok(recovered), Ok(want)) => { + assert_eq!(&recovered.id, *want, "case {name}"); + // Everything the UKI names must now validate at ESP-write time. + validate_uki_candidates(&expected.candidates, &recovered.expected_ids, None) + .unwrap_or_else(|e| panic!("case {name}: {e:#}")); + } + (Err(e), Err(want)) => { + let msg = format!("{e:#}"); + assert!(msg.contains(want), "case {name}: {msg:?} lacks {want:?}"); + } + (result, _) => panic!("case {name}: unexpected {:?}", result.map(|r| r.id)), + } + } + } } diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index 227bbf6c3b..961cc2d169 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-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) /// 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( @@ -77,12 +86,12 @@ 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")?; 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..a8687c741c 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; } } } @@ -502,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 { @@ -592,6 +610,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..6a8d707b3b 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -41,7 +41,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; -use composefs::repository::RepositoryConfig; +use composefs::repository::{EnsureStatus, RepositoryConfig}; use composefs::tree::FileSystem; use composefs_boot::bootloader::{BootEntry as ComposefsBootEntry, get_boot_resources}; use composefs_ctl::composefs; @@ -54,8 +54,17 @@ use composefs_oci::{ use ostree_ext::containers_image_proxy; -use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use cap_std_ext::{ + cap_std::{ambient_authority, fs::Dir}, + cap_tempfile, + dirext::CapStdExtDirExt, +}; +use rustix::fs::{FlockOperation, flock}; +use crate::bootc_composefs::boot::{ + ExpectedBootImageIds, ensure_correct_composefs_digest, expected_boot_image_ids, + print_uki_dumpfile_diff_on_mismatch, +}; use crate::bootc_composefs::progress; use crate::composefs_consts::BOOTC_TAG_PREFIX; use crate::install::{RootSetup, State}; @@ -77,12 +86,121 @@ 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 +} + +/// Open an existing repository without changing its metadata, or create a new +/// one with bootc's current dual-format configuration. +fn ensure_composefs_repository( + rootfs_dir: &Dir, + config: RepositoryConfig, +) -> Result<(crate::store::ComposefsRepository, bool)> { + let (repo, status) = + crate::store::ComposefsRepository::ensure_path(rootfs_dir, "composefs", config) + .context("Failed to initialize composefs repository")?; + Ok((repo, status == EnsureStatus::Created)) +} + +pub(crate) struct InitializedComposefs { + pub(crate) repo: Arc, + pub(crate) created: bool, + pub(crate) repository_insecure: bool, +} + +pub(crate) fn final_repository_policy(uki_policy: Option, requested_relaxed: bool) -> bool { + uki_policy == Some(true) || (uki_policy.is_none() && requested_relaxed) +} + +/// Enforce the durable repository policy after inspecting the imported image. +/// Existing metadata is never rewritten: an explicit relaxation only applies +/// to the session that will install the boot artifacts. +pub(crate) fn validate_repository_policy( + repository_insecure: bool, + requested_relaxed: bool, + allow_missing_verity_explicit: bool, +) -> Result<()> { + if repository_insecure && !requested_relaxed { + anyhow::bail!( + "Existing composefs repository is insecure, but this install requires fs-verity; refusing to continue" + ); + } + if !repository_insecure && requested_relaxed && !allow_missing_verity_explicit { + anyhow::bail!( + "Initial insecure UKI conflicts with the existing strict composefs repository; explicitly pass --allow-missing-verity to permit this session" + ); + } + Ok(()) +} + +/// Replace only the metadata of a repository created by this install. The +/// replacement is generated by composefs-rs itself, so format and feature +/// settings cannot drift from the normal initialization path. +pub(crate) fn finalize_fresh_repository_policy( + rootfs: &Dir, + initial_config: RepositoryConfig, +) -> Result<()> { + let repo_dir = rootfs.open_dir(crate::store::COMPOSEFS)?; + let temp_root = + cap_tempfile::TempDir::new_in(rootfs).context("Creating temporary metadata directory")?; + let relaxed = initial_config.set_insecure(); + let (temporary_repo, _) = + crate::store::ComposefsRepository::init_path(&*temp_root, ".", relaxed)?; + let replacement_metadata = temporary_repo.metadata().clone(); + drop(temporary_repo); + let replacement_bytes = serde_json::to_vec(&replacement_metadata)?; + + // Repository handles use LOCK_SH. They must all be gone before this + // short exclusive critical section, otherwise flock would self-deadlock. + let lock = repo_dir + .reopen_as_ownedfd() + .context("Opening composefs repository lock")?; + flock(&lock, FlockOperation::LockExclusive).context("Taking composefs repository lock")?; + let target_metadata: composefs::repository::RepoMetadata = + serde_json::from_reader(repo_dir.open("meta.json")?)?; + anyhow::ensure!( + target_metadata == replacement_metadata, + "composefs metadata generated for policy replacement does not match the repository" + ); + + repo_dir + .atomic_replace_with("meta.json", |writer| -> Result<()> { + use std::io::Write; + + writer.write_all(&replacement_bytes)?; + writer.flush()?; + writer.get_ref().as_file().sync_all()?; + Ok(()) + }) + .context("Atomically replacing composefs metadata")?; + Ok(()) +} + +pub(crate) fn tag_pulled_image( + rootfs_dir: &Dir, + pull_result: &PullResult, +) -> Result<()> { + let repo = crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs")?; + let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string()); + tag_image(&repo, &pull_result.manifest_digest, &tag) + .context("Tagging pulled image as bootc GC root")?; + Ok(()) +} + +pub(crate) fn initialize_composefs_repository( state: &State, root_setup: &RootSetup, allow_missing_fsverity: bool, - use_unified: bool, -) -> Result> { +) -> Result { const COMPOSEFS_REPO_INIT_JOURNAL_ID: &str = "5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9"; let rootfs_dir = &root_setup.physical_root; @@ -95,7 +213,6 @@ pub(crate) async fn initialize_composefs_repository( bootc.source_image = %image_name, bootc.transport = %transport, bootc.allow_missing_fsverity = allow_missing_fsverity, - bootc.unified_storage = use_unified, "Initializing composefs repository for image {}:{}", transport, image_name @@ -103,23 +220,43 @@ pub(crate) async fn initialize_composefs_repository( crate::store::ensure_composefs_dir(rootfs_dir)?; - let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); - let config = if allow_missing_fsverity { - config.set_insecure() - } else { - config - }; - let (repo, _created) = - crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config) - .context("Failed to initialize composefs repository")?; + let config = composefs_repository_config(allow_missing_fsverity); + let (mut repo, created) = ensure_composefs_repository(rootfs_dir, config)?; + let repository_insecure = repo.is_insecure(); + // `set_insecure()` is an explicit per-handle relaxation. It must also be + // applied when ensure_path opened an existing strict repository; ensure_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 && (created || repository_insecure) { + repo.set_insecure(); + } - let imgref: containers_image_proxy::ImageReference = state + let repo = Arc::new(repo); + + Ok(InitializedComposefs { + repo, + created, + repository_insecure, + }) +} + +/// Pull or import an install image into an initialized composefs repository. +pub(crate) async fn pull_install_composefs_repository( + state: &State, + root_setup: &RootSetup, + repo: &Arc, + use_unified: bool, + fetch_imgref: Option<&containers_image_proxy::ImageReference>, +) -> Result> { + let rootfs_dir = &root_setup.physical_root; + let original_imgref: containers_image_proxy::ImageReference = state .source .imageref .to_string() .as_str() .try_into() .context("Parsing source image reference")?; + let imgref = fetch_imgref.unwrap_or(&original_imgref); // Ensure the compatibility symlink ostree/bootc -> ../composefs/bootc // exists. This is needed for LBI and (when unified storage is enabled) @@ -128,8 +265,6 @@ pub(crate) async fn initialize_composefs_repository( // ostree/bootc/storage depend on this link. crate::store::ensure_composefs_bootc_link(rootfs_dir)?; - let repo = Arc::new(repo); - let pull_result = if use_unified { // Unified path: first into containers-storage on the target // rootfs, then cstor zero-copy into composefs. This ensures the image @@ -162,22 +297,6 @@ pub(crate) async fn initialize_composefs_repository( pull_composefs_direct(&repo, &imgref, false, ProgressWriter::default()).await? }; - // Tag the manifest as a bootc-owned GC root. - let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string()); - tag_image(&*repo, &pull_result.manifest_digest, &tag) - .context("Tagging pulled image as bootc GC root")?; - - tracing::info!( - message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID, - bootc.operation = "repository_init", - bootc.manifest_digest = %pull_result.manifest_digest, - bootc.manifest_verity = pull_result.manifest_verity.to_hex(), - bootc.config_digest = %pull_result.config_digest, - bootc.config_verity = pull_result.config_verity.to_hex(), - bootc.tag = tag, - "Pulled image into composefs repository", - ); - Ok(pull_result) } @@ -187,12 +306,93 @@ pub(crate) struct PullRepoResult { pub(crate) repo: crate::store::ComposefsRepository, pub(crate) entries: Vec>, pub(crate) id: Sha512HashValue, + pub(crate) boot_ids: ExpectedBootImageIds, /// The OCI manifest content digest (e.g. "sha256:abc...") pub(crate) manifest_digest: String, /// The untransformed OCI filesystem (still has /boot, /sysroot, etc.) pub(crate) fs: FileSystem, } +/// The boot-facing artifacts reconstructed from a pulled OCI image. +pub(crate) struct BootImage { + pub(crate) id: Sha512HashValue, + pub(crate) boot_ids: ExpectedBootImageIds, + pub(crate) fs: FileSystem, + pub(crate) entries: Vec>, +} + +/// Generate the boot image, reconstruct the untransformed filesystem for boot +/// entry discovery, and reconcile the generated digest with a UKI, if present. +/// +/// Keeping this sequence next to the pull paths is important: install and +/// update must not independently generate images or recover a different digest. +pub(crate) fn prepare_boot_image( + repo: &Arc, + pull_result: &PullResult, +) -> Result { + let generated_id = composefs_oci::generate_boot_image( + repo, + &pull_result.manifest_digest, + &composefs_oci::OciTransformOptions::default(), + ) + .context("Generating bootable EROFS image")?; + + let fs = create_composefs_filesystem( + &**repo, + &pull_result.config_digest, + Some(&pull_result.config_verity), + &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")?; + 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 expected_ids = expected_boot_image_ids( + oci_img.boot_image_ref_v1().cloned(), + oci_img.boot_image_ref_v2().cloned(), + &generated_id, + repo.erofs_version(), + ); + let recovered = print_uki_dumpfile_diff_on_mismatch( + ensure_correct_composefs_digest( + repo, + &pull_result.manifest_digest, + generated_id, + expected_ids, + &entries, + ), + repo, + &fs, + )?; + + Ok(BootImage { + id: recovered.id, + boot_ids: recovered.expected_ids, + fs, + entries, + }) +} + +/// Inspect UKI policy without generating or recovering a boot image. Boot +/// image generation is deferred until the durable repository policy has been +/// finalized and the normal boot setup path runs. +pub(crate) fn inspect_uki_policy( + repo: &Arc, + pull_result: &PullResult, +) -> Result> { + let fs = create_composefs_filesystem( + &**repo, + &pull_result.config_digest, + Some(&pull_result.config_verity), + &composefs_oci::OciTransformOptions::default(), + ) + .context("Creating composefs filesystem for UKI policy inspection")?; + let entries = get_boot_resources(&fs, &**repo).context("Extracting boot entries")?; + crate::bootc_composefs::boot::uki_fsverity_policy(repo, &entries) +} + /// Pull an image directly into the composefs repository via skopeo. /// /// This is the default path: the image is fetched directly from the source @@ -395,24 +595,12 @@ pub(crate) async fn pull_composefs_repo( "Pulled image into composefs repository", ); - // Generate the bootable EROFS image (idempotent). - let id = composefs_oci::generate_boot_image( - &repo, - &pull_result.manifest_digest, - &Default::default(), - ) - .context("Generating bootable EROFS image")?; - - // Get boot entries from the OCI filesystem (untransformed). - let fs = create_composefs_filesystem( - &*repo, - &pull_result.config_digest, - Some(&pull_result.config_verity), - &Default::default(), - ) - .context("Creating composefs filesystem for boot entry discovery")?; - let entries = - get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; + let BootImage { + id, + boot_ids, + fs, + entries, + } = prepare_boot_image(&repo, &pull_result)?; // Unwrap the Arc to get the owned repo back. let mut repo = Arc::try_unwrap(repo).map_err(|_| { @@ -426,6 +614,7 @@ pub(crate) async fn pull_composefs_repo( repo, entries, id, + boot_ids, manifest_digest: pull_result.manifest_digest.to_string(), fs, }) @@ -434,6 +623,7 @@ pub(crate) async fn pull_composefs_repo( #[cfg(test)] mod tests { use super::*; + use std::os::unix::fs::MetadataExt; #[test] fn test_bootc_tag_for_manifest() { @@ -442,4 +632,234 @@ 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 metadata_inode = std::fs::metadata(tempdir.path().join("meta.json")) + .unwrap() + .ino(); + let (relaxed_repo, _) = crate::store::ComposefsRepository::init_path( + &root, + ".", + composefs_repository_config(true), + ) + .unwrap(); + assert!(!relaxed_repo.is_insecure()); + assert_eq!( + metadata_before, + std::fs::read(tempdir.path().join("meta.json")).unwrap() + ); + assert_eq!( + metadata_inode, + std::fs::metadata(tempdir.path().join("meta.json")) + .unwrap() + .ino() + ); + assert!(validate_repository_policy(false, true, 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!(insecure_repo.is_insecure()); + assert!(validate_repository_policy(true, false, false).is_err()); + } + + #[test] + fn test_ensure_existing_v2_repository_preserves_metadata() { + let tempdir = tempfile::tempdir().unwrap(); + let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap(); + crate::store::ensure_composefs_dir(&root).unwrap(); + let mut v2_config = + RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure(); + v2_config.erofs_formats = composefs::erofs::format::FormatConfig::single( + composefs::erofs::format::FormatVersion::V2, + ); + let (repo, _) = + crate::store::ComposefsRepository::init_path(&root, crate::store::COMPOSEFS, v2_config) + .unwrap(); + let repo_path = tempdir.path().join(crate::store::COMPOSEFS); + let metadata_path = repo_path.join("meta.json"); + let metadata_before = std::fs::read(&metadata_path).unwrap(); + let object_path = repo_path.join("objects/sentinel"); + std::fs::create_dir(object_path.parent().unwrap()).unwrap(); + std::fs::write(&object_path, b"retained").unwrap(); + drop(repo); + + let (reopened, created) = + ensure_composefs_repository(&root, composefs_repository_config(true)).unwrap(); + assert!(!created); + assert!(reopened.is_insecure()); + assert_eq!( + reopened.erofs_version(), + composefs::erofs::format::FormatVersion::V2 + ); + assert_eq!(metadata_before, std::fs::read(metadata_path).unwrap()); + assert_eq!(std::fs::read(object_path).unwrap(), b"retained"); + } + + #[test] + fn test_final_repository_policy_matrix() { + for (uki_policy, requested_relaxed, expected) in [ + (Some(false), false, false), + (Some(false), true, false), + (Some(true), false, true), + (Some(true), true, true), + (None, false, false), + (None, true, true), + ] { + assert_eq!( + final_repository_policy(uki_policy, requested_relaxed), + expected + ); + } + } + + #[test] + fn test_existing_repository_policy_matrix() { + for (insecure, uki_policy, requested, explicit, expected) in [ + (false, Some(false), false, false, true), + (false, Some(true), true, false, false), + (false, Some(true), true, true, true), + (false, None, false, false, true), + (false, None, true, false, false), + (false, None, true, true, true), + (true, Some(false), false, false, false), + (true, Some(true), true, false, true), + (true, None, false, false, false), + (true, None, true, false, true), + ] { + let requested_relaxed = final_repository_policy(uki_policy, requested); + assert_eq!( + validate_repository_policy(insecure, requested_relaxed, explicit).is_ok(), + expected, + "insecure={insecure} uki_policy={uki_policy:?} requested={requested} explicit={explicit}" + ); + } + } + + #[test] + fn test_fresh_policy_replacement_preserves_objects() { + let tempdir = tempfile::tempdir().unwrap(); + let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap(); + crate::store::ensure_composefs_dir(&root).unwrap(); + let (repo, _) = match crate::store::ComposefsRepository::init_path( + &root, + crate::store::COMPOSEFS, + composefs_repository_config(false), + ) { + Ok(repo) => repo, + Err(error) if format!("{error:#}").to_ascii_lowercase().contains("verity") => return, + Err(error) => panic!("strict repository initialization failed: {error:#}"), + }; + let metadata_path = tempdir + .path() + .join(crate::store::COMPOSEFS) + .join("meta.json"); + let metadata_inode = std::fs::metadata(&metadata_path).unwrap().ino(); + let object_path = tempdir + .path() + .join(crate::store::COMPOSEFS) + .join("objects/sentinel"); + std::fs::write(&object_path, b"retained").unwrap(); + drop(repo); + + finalize_fresh_repository_policy(&root, composefs_repository_config(false)).unwrap(); + + let reopened = + crate::store::ComposefsRepository::open_path(&root, crate::store::COMPOSEFS).unwrap(); + assert!(reopened.is_insecure()); + assert_ne!( + metadata_inode, + std::fs::metadata(&metadata_path).unwrap().ino() + ); + assert_eq!(std::fs::read(object_path).unwrap(), b"retained"); + } + + #[test] + fn test_fresh_policy_replacement_refuses_feature_mismatch() { + let tempdir = tempfile::tempdir().unwrap(); + let root = Dir::open_ambient_dir(tempdir.path(), ambient_authority()).unwrap(); + crate::store::ensure_composefs_dir(&root).unwrap(); + let (repo, _) = crate::store::ComposefsRepository::init_path( + &root, + crate::store::COMPOSEFS, + composefs_repository_config(true), + ) + .unwrap(); + drop(repo); + + let metadata_path = tempdir + .path() + .join(crate::store::COMPOSEFS) + .join("meta.json"); + let mut metadata: composefs::repository::RepoMetadata = + serde_json::from_slice(&std::fs::read(&metadata_path).unwrap()).unwrap(); + metadata + .features + .compatible + .push("review-only-feature".to_string()); + std::fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap(); + let metadata_inode = std::fs::metadata(&metadata_path).unwrap().ino(); + + let error = finalize_fresh_repository_policy(&root, composefs_repository_config(false)) + .unwrap_err(); + assert!(format!("{error:#}").contains("metadata")); + assert_eq!( + metadata_inode, + std::fs::metadata(&metadata_path).unwrap().ino() + ); + } } 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/state.rs b/crates/lib/src/bootc_composefs/state.rs index e43447a2f5..9f15d31fca 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -83,7 +83,7 @@ pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Re anyhow::bail!("options not found in bls config") }; - let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(opts)) + let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(opts))? .ok_or_else(|| anyhow::anyhow!("composefs param not found in cmdline"))?; if cfs_cmdline.digest == booted_cfs.cmdline.digest { diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 33c323bd07..512608d824 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -2,7 +2,11 @@ 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, KARG_COMPOSEFS_DIGEST, KARG_V2, +}; use composefs_ctl::composefs_oci; use composefs_oci::OciImage; use fn_error_context::context; @@ -18,8 +22,8 @@ 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, + 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,34 +92,60 @@ 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) -> Result> { + let Some(parsed) = BootComposefsCmdline::::from_cmdline(cmdline) + .context("Parsing composefs kernel command line")? + else { + return Ok(None); + }; + 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, +fn reconcile_composefs_cmdline_with_mount_source( + mut cmdline: ComposefsCmdline, + mount_source: &str, +) -> Result { + let (verity, is_transient) = if let Some(v) = mount_source.strip_prefix("composefs:") { + (v, false) + } else if let Some(v) = mount_source.strip_prefix("transient:composefs=") { + (v, true) + } else { + anyhow::bail!("Root not mounted using composefs (source: {mount_source})") + }; + + let mount_cmdline = ComposefsCmdline::new(verity); + if *mount_cmdline.digest != *cmdline.digest { + cmdline.digest = mount_cmdline.digest; + } + cmdline.is_transient = is_transient; + Ok(cmdline) +} + +/// 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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let allow_missing_fsverity = if self.allow_missing_fsverity { "?" } else { "" }; - write!( - f, - "{}={}{}", - COMPOSEFS_CMDLINE, allow_missing_fsverity, self.digest - ) + write!(f, "{}={}{}", KARG_V2, allow_missing_fsverity, self.digest) } } @@ -157,47 +187,17 @@ 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())?; - // The mount source encodes the composefs digest in one of two formats: - // - Normal boot: "composefs:" - // - Transient root: "transient:composefs=" - // Strip either prefix to get the digest and record whether the root is - // transient, then compare the digest with the cmdline value to detect - // soft-reboots into a different deployment. - let (verity_from_mount_src, is_transient) = - if let Some(v) = root_mnt.source.strip_prefix("composefs:") { - (v, false) - } else if let Some(v) = root_mnt.source.strip_prefix("transient:composefs=") { - (v, true) - } else { - anyhow::bail!( - "Root not mounted using composefs (source: {})", - root_mnt.source - ) - }; - - let r = if *verity_from_mount_src != *v.digest { - // soft rebooted into another deployment - CACHED_DIGEST_VALUE.get_or_init(|| { - let mut c = ComposefsCmdline::new(verity_from_mount_src); - c.is_transient = is_transient; - Some(c) - }) - } else { - CACHED_DIGEST_VALUE.get_or_init(|| { - let mut c = v; - c.is_transient = is_transient; - Some(c) - }) - }; + // The mount source is authoritative after a soft reboot, while the + // parsed command line retains policy such as allow-missing-fsverity. + let reconciled = reconcile_composefs_cmdline_with_mount_source(v, &root_mnt.source)?; + let r = CACHED_DIGEST_VALUE.get_or_init(|| Some(reconciled)); Ok(r.as_ref()) } @@ -730,10 +730,10 @@ 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() == KARG_V2.into() || param.key() == KARG_COMPOSEFS_DIGEST.into() { continue; } @@ -1163,7 +1163,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 +1172,32 @@ mod tests { assert_eq!(v.digest.as_ref(), DIGEST); } + #[test] + fn test_build_composefs_karg() -> Result<()> { + 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)? + .expect("composefs argument should be present") + .digest + .as_ref(), + hex + ); + + Ok(()) + } + #[test] fn classify_bootloader_cases() { struct Case { @@ -1549,55 +1575,123 @@ mod tests { } #[test] - fn test_find_in_cmdline() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; - - // Test case: cmdline contains composefs parameter - let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs={}", DIGEST)); - let result = ComposefsCmdline::find_in_cmdline(&cmdline); - assert!(result.is_some()); - let cfs = result.unwrap(); - assert_eq!(cfs.digest.as_ref(), DIGEST); - assert!(!cfs.allow_missing_fsverity); - - // Test case: cmdline contains composefs parameter with allow_missing_fsverity - let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs=?{}", DIGEST)); - let result = ComposefsCmdline::find_in_cmdline(&cmdline); - assert!(result.is_some()); - let cfs = result.unwrap(); - assert_eq!(cfs.digest.as_ref(), DIGEST); - assert!(cfs.allow_missing_fsverity); - - // Test case: cmdline does not contain composefs parameter - let cmdline = Cmdline::from("root=UUID=abc123 rw quiet"); - let result = ComposefsCmdline::find_in_cmdline(&cmdline); - assert!(result.is_none()); - - // Test case: empty cmdline - let cmdline = Cmdline::from(""); - let result = ComposefsCmdline::find_in_cmdline(&cmdline); - assert!(result.is_none()); - - // Test case: cmdline with other parameters and composefs at different positions - let cmdline = Cmdline::from(format!("quiet composefs={} loglevel=3", DIGEST)); - let result = ComposefsCmdline::find_in_cmdline(&cmdline); - assert!(result.is_some()); - let cfs = result.unwrap(); - assert_eq!(cfs.digest.as_ref(), DIGEST); - assert!(!cfs.allow_missing_fsverity); - - // Test case: cmdline with composefs at the beginning - let cmdline = Cmdline::from(format!("composefs=?{} root=UUID=abc123 quiet", DIGEST)); - let result = ComposefsCmdline::find_in_cmdline(&cmdline); - assert!(result.is_some()); - let cfs = result.unwrap(); - assert_eq!(cfs.digest.as_ref(), DIGEST); - assert!(cfs.allow_missing_fsverity); - - // Test case: cmdline with similar parameter names (should not match) - let cmdline = Cmdline::from(format!("composefs_backup={} root=UUID=abc123", DIGEST)); - let result = ComposefsCmdline::find_in_cmdline(&cmdline); - assert!(result.is_none()); + fn test_find_in_cmdline() -> Result<()> { + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + + let cases = [ + ("absent", "root=UUID=abc123 rw quiet", None, false), + ("legacy", "composefs={DIGEST}", Some(DIGEST), false), + ( + "v1", + "composefs.digest=v1-sha512-12:{DIGEST}", + Some(DIGEST), + false, + ), + ( + "dual", + "composefs={DIGEST} composefs.digest=v1-sha512-12:{DIGEST}", + Some(DIGEST), + false, + ), + ("legacy-insecure", "composefs=?{DIGEST}", Some(DIGEST), true), + ("malformed", "composefs=not-a-digest", None, false), + ]; + + for (name, input, expected_digest, insecure) in cases { + let input = input.replace("{DIGEST}", DIGEST); + let result = ComposefsCmdline::find_in_cmdline(&Cmdline::from(input)); + if name == "malformed" { + let error = match result { + Err(error) => error, + Ok(_) => panic!("malformed composefs argument must fail"), + }; + assert!( + format!("{error:#}").contains("Parsing composefs kernel command line"), + "{error:#}" + ); + assert!(format!("{error:#}").contains("composefs="), "{error:#}"); + } else if let Some(expected_digest) = expected_digest { + let cfs = result?.expect("composefs argument should be present"); + assert_eq!(cfs.digest.as_ref(), expected_digest); + assert_eq!(cfs.allow_missing_fsverity, insecure); + } else { + assert!(result?.is_none(), "{name} should not match"); + } + } + + Ok(()) + } + + #[test] + fn test_reconcile_composefs_cmdline_with_mount_source() -> Result<()> { + let digest_v1 = "ab".repeat(64); + let digest_v2 = "cd".repeat(64); + let cases = [ + ( + "preferred V1, actual V2, strict", + format!("composefs.digest=v1-sha512-12:{digest_v1}"), + format!("composefs:{digest_v2}"), + digest_v2.as_str(), + false, + false, + ), + ( + "preferred V2, actual V2, allow missing, transient", + format!("composefs=?{digest_v1}"), + format!("transient:composefs={digest_v2}"), + digest_v2.as_str(), + true, + true, + ), + ( + "matching digest, strict, normal", + format!("composefs={digest_v1}"), + format!("composefs:{digest_v1}"), + digest_v1.as_str(), + false, + false, + ), + ( + "matching digest, allow missing, transient", + format!("composefs=?{digest_v1}"), + format!("transient:composefs={digest_v1}"), + digest_v1.as_str(), + true, + true, + ), + ]; + + for ( + name, + cmdline, + mount_source, + expected_digest, + expected_allow_missing, + expected_transient, + ) in cases + { + let parsed = ComposefsCmdline::find_in_cmdline(&Cmdline::from(cmdline))? + .unwrap_or_else(|| panic!("{name}: composefs argument should be present")); + let reconciled = reconcile_composefs_cmdline_with_mount_source(parsed, &mount_source)?; + assert_eq!(reconciled.digest.as_ref(), expected_digest, "{name}"); + assert_eq!( + reconciled.allow_missing_fsverity, expected_allow_missing, + "{name}" + ); + assert_eq!(reconciled.is_transient, expected_transient, "{name}"); + } + + let parsed = ComposefsCmdline::new(&digest_v1); + let error = match reconcile_composefs_cmdline_with_mount_source(parsed, "ext4") { + Ok(_) => panic!("invalid mount source must fail"), + Err(error) => error, + }; + assert!( + format!("{error:#}").contains("Root not mounted using composefs (source: ext4)"), + "{error:#}" + ); + + Ok(()) } use crate::testutils::fake_digest_version; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index c2b43c8b86..45e4c5ad28 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; @@ -18,8 +19,8 @@ use crate::spec::BootloaderKind; use crate::{ bootc_composefs::{ boot::{ - BootSetupType, BootType, UKIDigestMismatch, print_uki_dumpfile_diff, - setup_composefs_bls_boot, setup_composefs_uki_boot, + BootSetupType, BootType, print_uki_dumpfile_diff_on_mismatch, setup_composefs_bls_boot, + setup_composefs_uki_boot, }, gc::composefs_gc, repo::pull_composefs_repo, @@ -145,16 +146,26 @@ 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()); + 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 +205,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) @@ -265,6 +279,7 @@ pub(crate) async fn do_upgrade( repo, entries, id, + boot_ids, manifest_digest, fs: oci_fs, } = pull_composefs_repo( @@ -315,34 +330,32 @@ 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 (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version()); - BootType::Uki => { - let uki_setup_result = setup_composefs_uki_boot( + 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 => print_uki_dumpfile_diff_on_mismatch( + 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, - 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 @@ -360,13 +373,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 +405,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..013bbc80c8 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; @@ -417,17 +418,31 @@ 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-sha512-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)] 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, + /// 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,15 @@ pub(crate) enum ContainerOpts { #[clap(long)] allow_missing_verity: bool, + /// EROFS format version to use when computing the composefs digest. + /// + /// By default, produce V1 then V2 kargs for compatibility. V1 produces a + /// `composefs.digest=v1-sha512-12:` karg (C-tool compatible), while V2 + /// produces the legacy `composefs=` karg (composefs-rs native). + /// Explicit V2 produces only the V2 karg as a compatibility escape hatch. + #[clap(long)] + erofs_version: Option, + /// Write a dumpfile to this path #[clap(long)] write_dumpfile_to: Option, @@ -517,6 +541,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-sha512-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 +2088,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(); @@ -2095,7 +2144,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")?; @@ -2115,6 +2164,7 @@ async fn run_from_opt(opt: Opt) -> Result { rootfs, kargs, allow_missing_verity, + erofs_version, write_dumpfile_to, kernel_dir, args, @@ -2147,6 +2197,7 @@ async fn run_from_opt(opt: Opt) -> Result { &args, kernel, allow_missing_verity, + erofs_version, write_dumpfile_to.as_deref(), ) .await @@ -2689,6 +2740,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/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 8617f1005b..03ccc53100 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -1,6 +1,3 @@ -/// composefs= parameter in kernel cmdline -pub const COMPOSEFS_CMDLINE: &str = "composefs"; - /// Directory to store transient state, such as staged deployemnts etc pub(crate) const COMPOSEFS_TRANSIENT_STATE_DIR: &str = "/run/composefs"; /// File created in /run/composefs to record a staged-deployment diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs index 46d8f75b2f..3c4d6d973a 100644 --- a/crates/lib/src/generator.rs +++ b/crates/lib/src/generator.rs @@ -90,48 +90,58 @@ pub(crate) fn unit_enablement_impl(sysroot: &Dir, unit_dir: &Dir) -> Result<()> /// Main entrypoint for the generator pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> { - // === Relabel unit: runs for ALL composefs boots (native or ostree) === + // Detect composefs and ostree once, reuse across all blocks below. + // + // composefs always uses overlayfs, so check the filesystem magic first as + // a cheap gate. Then inspect the mount source to distinguish regular + // composefs ("composefs:") from transient ("transient:composefs="). + let is_overlayfs = rustix::fs::fstatfs(root.as_fd())?.f_type == libc::OVERLAYFS_SUPER_MAGIC; + let root_source = if is_overlayfs { + match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) { + Ok(fs) => Some(fs.source), + Err(e) => { + tracing::debug!("Could not inspect root filesystem: {e:#}"); + None + } + } + } else { + None + }; + let root_is_transient = root_source + .as_deref() + .is_some_and(|s| s.starts_with("transient:composefs=")); + let is_composefs = root_is_transient + || root_source + .as_deref() + .is_some_and(|s| s.starts_with("composefs:")); + let is_ostree = root.try_exists(OSTREE_BOOTED)?; + + // === Relabel unit: runs for transient composefs boots (native or ostree) === // Must be before the ostree-booted guard because native composefs boots do // not write /run/ostree-booted, but still need the relabel unit when any // transient overlay is active. // - // Gate on the root being overlayfs (composefs always mounts an overlay, so - // this excludes non-composefs systems without needing the ostree-booted marker). - // // Two triggering conditions, detected independently: // // 1. Transient root: the initramfs sets the overlay source to - // "transient:composefs=" in /proc/self/mountinfo. Detect via - // inspect_filesystem() rather than fstatvfs() because the `ro` kernel - // cmdline flag can make an otherwise-writable overlay appear read-only - // at generator time. + // "transient:composefs=" in /proc/self/mountinfo. Detected + // via root_is_transient above. // // 2. Transient /etc: this is mounted by bootc-root-setup.service // which runs *after* the generator, so fstatvfs would see the read-only // composefs at generator time. Read setup-root-conf.toml directly from // the booted image instead. - { - let st = rustix::fs::fstatfs(root.as_fd())?; - if st.f_type == libc::OVERLAYFS_SUPER_MAGIC { - let root_is_transient = - match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) { - Ok(fs) => fs.source.starts_with("transient:composefs="), - Err(e) => { - tracing::debug!("Could not inspect root filesystem: {e:#}"); - false - } - }; - let submounts_are_transient = bootc_initramfs_setup::config_has_transient_submounts( - std::path::Path::new(bootc_initramfs_setup::SETUP_ROOT_CONF_PATH), + if is_composefs { + let submounts_are_transient = bootc_initramfs_setup::config_has_transient_submounts( + std::path::Path::new(bootc_initramfs_setup::SETUP_ROOT_CONF_PATH), + ); + if root_is_transient || submounts_are_transient { + tracing::debug!( + root_is_transient, + submounts_are_transient, + "Transient overlay detected; generating relabel unit" ); - if root_is_transient || submounts_are_transient { - tracing::debug!( - root_is_transient, - submounts_are_transient, - "Transient overlay detected; generating relabel unit" - ); - generate_transient_overlay_relabel(unit_dir)?; - } + generate_transient_overlay_relabel(unit_dir)?; } } @@ -140,21 +150,28 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> { // not write /run/ostree-booted, but still need the shadow sync to clean up // stale shadow/gshadow entries (the rechunk scenario). Gate on either a // composefs mount source (native composefs boot) or the ostree-booted marker. - { - let is_composefs = match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) { - Ok(fs) => { - fs.source.starts_with("composefs:") || fs.source.starts_with("transient:composefs=") - } - Err(e) => { - tracing::debug!("Could not inspect root filesystem: {e:#}"); - false - } - }; - let is_ostree = root.try_exists(OSTREE_BOOTED)?; - if is_composefs || is_ostree { - let updated = shadow_sync_generator_impl(root, unit_dir)?; - tracing::trace!("Enabled shadow sync: {updated}"); - } + if is_composefs || is_ostree { + let updated = shadow_sync_generator_impl(root, unit_dir)?; + tracing::trace!("Enabled shadow sync: {updated}"); + } + + // === tmpfiles ordering fix: native composefs boots only === + // On ostree boots, ostree-remount.service provides + // Before=systemd-tmpfiles-setup.service + // Before=systemd-random-seed.service + // which (together with the generated var.mount unit) ensures tmpfiles + // creates /var/lib/systemd before any service tries to use it. + // + // On native composefs boots neither ostree-remount nor a var.mount unit + // exist, so systemd-tmpfiles-setup and systemd-random-seed race. If + // the container image ships a sparse /var (common after the var-tmpfiles + // lint), /var/lib/systemd may not exist yet when random-seed starts, + // causing an SELinux denial or ENOENT. + // + // Fix: generate a drop-in that orders tmpfiles-setup before the services + // that need its output. + if is_composefs && !is_ostree { + generate_tmpfiles_ordering(unit_dir)?; } // === Ostree-specific generator logic === @@ -236,6 +253,33 @@ ExecStart=bootc internals fixup-etc-fstab\n\ Ok(()) } +/// On native composefs boots (no ostree-remount), ensure +/// systemd-tmpfiles-setup runs before services that need /var/lib/systemd. +/// +/// Without this, systemd-tmpfiles-setup and systemd-random-seed race: both +/// are wanted by sysinit.target with no ordering between them. If the +/// container image has a sparse /var (e.g. after `rm -rf /var/lib` for the +/// var-tmpfiles lint), /var/lib/systemd won't exist when random-seed starts. +/// A .mount unit (e.g. var-lib-nfs-rpc_pipefs.mount) may even create +/// /var/lib with an unlabeled_t SELinux context before policy loads, causing +/// an AVC denial when random-seed tries to mkdir /var/lib/systemd. +/// +/// On ostree boots, ostree-remount.service already provides this ordering. +fn generate_tmpfiles_ordering(unit_dir: &Dir) -> Result<()> { + let dropin_dir = "systemd-tmpfiles-setup.service.d"; + unit_dir.create_dir_all(dropin_dir)?; + unit_dir.atomic_write( + &format!("{dropin_dir}/bootc-ordering.conf"), + "# Generated by bootc generator for native composefs boots.\n\ +# Ensure /var/lib/systemd and other tmpfiles-managed directories\n\ +# exist before services that depend on them.\n\ +[Unit]\n\ +Before=systemd-random-seed.service systemd-tpm2-setup.service\n", + )?; + tracing::debug!("Generated tmpfiles-setup ordering drop-in for composefs boot"); + Ok(()) +} + /// Generate a oneshot service that relabels the transient overlay inode /// after SELinux policy loads, fixing the tmpfs_t label SELinux assigns to /// overlay upper-dir inodes at policy-load time. @@ -466,6 +510,34 @@ UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot xfs defaul Ok(()) } + #[test] + fn test_tmpfiles_ordering_dropin() -> Result<()> { + let tempdir = fixture()?; + let unit_dir = &tempdir.open_dir("run/systemd/system")?; + + generate_tmpfiles_ordering(unit_dir)?; + + let dropin_dir = unit_dir.open_dir("systemd-tmpfiles-setup.service.d")?; + let content = dropin_dir.read_to_string("bootc-ordering.conf")?; + assert!( + content.contains("\n[Unit]\n"), + "drop-in [Unit] header must be left-aligned: {content}" + ); + assert!( + content.contains("\nBefore=systemd-random-seed.service"), + "drop-in must order tmpfiles before random-seed: {content}" + ); + assert!( + content.contains("systemd-tpm2-setup.service"), + "drop-in must order tmpfiles before tpm2-setup: {content}" + ); + + // Calling again must succeed (idempotent) + generate_tmpfiles_ordering(unit_dir)?; + + Ok(()) + } + #[test] fn test_shadow_sync_no_shadow() -> Result<()> { // No /etc/shadow => should not enable (boot detection is caller's job) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index c82d857f7e..c00aa65f45 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -189,7 +189,11 @@ use serde::{Deserialize, Serialize}; use self::baseline::InstallBlockDeviceOpts; use crate::bootc_composefs::status::ComposefsCmdline; use crate::bootc_composefs::{ - boot::setup_composefs_boot, repo::initialize_composefs_repository, + boot::setup_composefs_boot, + repo::{ + finalize_fresh_repository_policy, initialize_composefs_repository, + pull_install_composefs_repository, tag_pulled_image, validate_repository_policy, + }, status::get_container_manifest_and_config, }; use crate::bootc_kargs::{INITRD_ARG_PREFIX, ROOTFLAGS_KEY}; @@ -204,7 +208,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 @@ -607,10 +610,13 @@ pub(crate) struct InstallPrintConfigurationOpts { /// Global state captured from the container. #[derive(Debug, Clone)] pub(crate) struct SourceInfo { - /// Image reference we'll pull from (today always containers-storage: type) + /// Original image reference, retained for user-facing output and origin data. pub(crate) imageref: ostree_container::ImageReference, - /// The digest to use for pulls + /// Manifest digest reported by `podman inspect`, if this is a running + /// container. This is not the image's containers-storage config ID. pub(crate) digest: Option, + /// Running container's immutable containers-storage config ID, if any. + pub(crate) host_image_id: Option, /// Whether or not SELinux appears to be enabled in the source commit pub(crate) selinux: bool, /// Whether the source is available in the host mount namespace @@ -644,6 +650,8 @@ pub(crate) struct State { // If Some, then --composefs_native is passed pub(crate) composefs_options: InstallComposefsOpts, + pub(crate) composefs_fsverity_supported: bool, + pub(crate) allow_missing_verity_explicit: bool, } // Shared read-only global state @@ -816,6 +824,12 @@ impl FromStr for MountSpec { } impl SourceInfo { + /// Return the source reference used by composefs. Host-container imports + /// must use the config ID rather than the mutable human-facing tag. + pub(crate) fn composefs_fetch_reference(&self) -> ostree_container::ImageReference { + composefs_fetch_reference(&self.imageref, self.host_image_id.as_deref()) + } + // Inspect container information and convert it to an ostree image reference // that pulls from containers-storage. #[context("Gathering source info from container env")] @@ -835,14 +849,19 @@ impl SourceInfo { }; tracing::debug!("Finding digest for image ID {}", container_info.imageid); let digest = crate::podman::imageid_to_digest(&container_info.imageid)?; - - Self::new(imageref, Some(digest), root, true) + Self::new( + imageref, + Some(digest), + Some(container_info.imageid.clone()), + root, + true, + ) } #[context("Creating source info from a given imageref")] pub(crate) fn from_imageref(imageref: &str, root: &Dir) -> Result { let imageref = ostree_container::ImageReference::try_from(imageref)?; - Self::new(imageref, None, root, false) + Self::new(imageref, None, None, root, false) } fn have_selinux_from_repo(root: &Dir) -> Result { @@ -865,6 +884,7 @@ impl SourceInfo { fn new( imageref: ostree_container::ImageReference, digest: Option, + host_image_id: Option, root: &Dir, in_host_mountns: bool, ) -> Result { @@ -876,12 +896,25 @@ impl SourceInfo { Ok(Self { imageref, digest, + host_image_id, selinux, in_host_mountns, }) } } +fn composefs_fetch_reference( + imageref: &ostree_container::ImageReference, + host_image_id: Option<&str>, +) -> ostree_container::ImageReference { + host_image_id + .map(|config_id| ostree_container::ImageReference { + transport: ostree_container::Transport::ContainerStorage, + name: config_id.to_owned(), + }) + .unwrap_or_else(|| imageref.clone()) +} + pub(crate) fn print_configuration(opts: InstallPrintConfigurationOpts) -> Result<()> { let mut install_config = config::load_config()?.unwrap_or_default(); if !opts.all { @@ -1550,6 +1583,7 @@ async fn prepare_install( target_fs: Option, ) -> Result> { tracing::trace!("Preparing install"); + let allow_missing_verity_explicit = composefs_options.allow_missing_verity; let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) .context("Opening /")?; @@ -1713,6 +1747,7 @@ async fn prepare_install( .and_then(|c| c.filesystem_root()) .and_then(|r| r.fstype)) .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?; + let composefs_fsverity_supported = root_filesystem.supports_fsverity(); let mut is_uki = false; @@ -1726,10 +1761,12 @@ async fn prepare_install( match kernel { Some(k) => match k.k_type { crate::kernel::KernelType::Uki { cmdline, .. } => { - let allow_missing_fsverity = cmdline.is_some_and(|cmd| { - ComposefsCmdline::find_in_cmdline(&cmd) + let allow_missing_fsverity = if let Some(cmdline) = cmdline { + ComposefsCmdline::find_in_cmdline(&cmdline)? .is_some_and(|cfs_cmdline| cfs_cmdline.allow_missing_fsverity) - }); + } else { + false + }; if !allow_missing_fsverity { anyhow::ensure!( @@ -1804,6 +1841,8 @@ async fn prepare_install( host_is_container, composefs_required, composefs_options, + composefs_fsverity_supported, + allow_missing_verity_explicit, }); Ok(state) @@ -2022,45 +2061,62 @@ async fn install_to_filesystem_impl( } if state.composefs_options.composefs_backend { - // Pre-flight disk space check for native composefs install path. - { - 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 = - RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512) - .set_insecure(); - let (cfs_repo, _created) = crate::store::ComposefsRepository::init_path( - &rootfs.physical_root, - crate::store::COMPOSEFS, - config, - )?; - crate::deploy::check_disk_space_composefs( - &cfs_repo, - &img_manifest_config.manifest, - &crate::spec::ImageReference { - image: imgref.name.clone(), - transport: imgref.transport.to_string(), - signature: None, - }, - )?; - } - let pull_result = initialize_composefs_repository( + let fetch_ref = state.source.composefs_fetch_reference(); + let manifest = get_container_manifest_and_config(&fetch_ref).await?; + // A capable filesystem gets a strict provisional repository. The + // imported image is inspected below; only a fresh repository may then + // atomically adopt the image's explicit relaxed policy. + let provisional_relaxed = !state.composefs_fsverity_supported; + let fetch_imgref = Some(fetch_ref.clone().into()); + let initialized = initialize_composefs_repository(state, rootfs, provisional_relaxed)?; + crate::deploy::check_disk_space_composefs( + &initialized.repo, + &manifest.manifest, + &ImageReference { + image: fetch_ref.name.clone(), + transport: fetch_ref.transport.to_string(), + signature: None, + }, + )?; + let pull_result = pull_install_composefs_repository( state, rootfs, - state.composefs_options.allow_missing_verity, + &initialized.repo, state.target_opts.unified_storage_exp, + fetch_imgref.as_ref(), ) .await?; + let uki_policy = + crate::bootc_composefs::repo::inspect_uki_policy(&initialized.repo, &pull_result)?; - setup_composefs_boot( - rootfs, - state, - &pull_result, + let requested_relaxed = crate::bootc_composefs::repo::final_repository_policy( + uki_policy, state.composefs_options.allow_missing_verity, - ) - .await?; + ); + if !initialized.created { + validate_repository_policy( + initialized.repository_insecure, + requested_relaxed, + state.allow_missing_verity_explicit, + )?; + } else if !requested_relaxed && provisional_relaxed { + anyhow::bail!( + "Initial UKI requires fs-verity, but the target filesystem does not support it" + ); + } + // Repository handles retain LOCK_SH, so no Arc may remain before the + // fresh-policy replacement below takes LOCK_EX. + drop(initialized.repo); + if initialized.created && requested_relaxed && !provisional_relaxed { + finalize_fresh_repository_policy( + &rootfs.physical_root, + crate::bootc_composefs::repo::composefs_repository_config(false), + )?; + } + let allow_missing_verity = requested_relaxed; + tag_pulled_image(&rootfs.physical_root, &pull_result)?; + + setup_composefs_boot(rootfs, state, &pull_result, allow_missing_verity).await?; // Label composefs objects as /usr so they get usr_t rather than // default_t (which has no policy match). @@ -2972,6 +3028,28 @@ mod tests { assert_eq!(c.block_opts.device, "/dev/vda"); } + #[test] + fn source_fetch_reference_uses_config_id_for_host_images() { + let cases = [ + ( + "containers-storage:localhost/os:latest", + Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + "containers-storage:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ( + "docker://quay.io/example/os:latest", + None, + "docker://quay.io/example/os:latest", + ), + ("oci:/tmp/image", None, "oci:/tmp/image"), + ]; + for (original, config_id, expected) in cases { + let original = original.parse().unwrap(); + let fetched = composefs_fetch_reference(&original, config_id); + assert_eq!(fetched.to_string(), expected); + } + } + #[test] fn test_mountspec() { let mut ms = MountSpec::new("/dev/vda4", "/boot"); diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index c796ffdab1..157e0f3632 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -233,8 +233,8 @@ impl BLSConfig { .as_ref() .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"))?; + let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(&options))? + .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..669876919f 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -1,7 +1,8 @@ //! 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; @@ -13,10 +14,40 @@ 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::cli::ErofsVersionArg; use crate::kernel::KernelInternal; +fn resolve_erofs_version(requested: Option) -> FormatVersion { + requested.map(Into::into).unwrap_or(FormatVersion::V1) +} + +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: @@ -33,6 +64,7 @@ pub(crate) async fn build_ukify( args: &[OsString], kernel: Option, allow_missing_fsverity: bool, + erofs_version: Option, write_dumpfile_to: Option<&Utf8Path>, ) -> Result<()> { // Warn if --karg is used (temporary workaround) @@ -43,13 +75,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}"))?; @@ -96,16 +121,41 @@ pub(crate) async fn build_ukify( } } - // Compute the composefs digest - let composefs_digest = compute_composefs_digest(rootfs, write_dumpfile_to).await?; + let erofs_version = resolve_erofs_version(erofs_version); + + 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)?; - // 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`). + 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 { @@ -143,16 +193,16 @@ pub(crate) async fn build_ukify( #[cfg(test)] mod tests { use bootc_utils::create_minimal_pe; - - use super::*; use std::fs; + use super::*; #[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, 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!( @@ -174,7 +224,8 @@ mod tests { ) .unwrap(); - let result = build_ukify(path, &[], &[], None, false, 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!( @@ -182,4 +233,41 @@ 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 (requested, expected_version, fallback, expected_len, expected_prefixes) in [ + ( + None, + FormatVersion::V1, + Some(v2.clone()), + 2, + ["composefs.digest=v1-sha512-12:", "composefs="], + ), + ( + Some(ErofsVersionArg::V1), + FormatVersion::V1, + Some(v2.clone()), + 2, + ["composefs.digest=v1-sha512-12:", "composefs="], + ), + ( + Some(ErofsVersionArg::V2), + FormatVersion::V2, + None, + 1, + ["composefs=", ""], + ), + ] { + let version = resolve_erofs_version(requested); + 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}"); + } + assert_eq!(version, expected_version); + } + } } diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index 14396107b3..08716f475a 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}; @@ -351,6 +351,179 @@ 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). +/// - the default producer passes both V1 and V2 composefs kargs to ukify. +/// - explicit V2 selection passes only the V2 karg, including for an old-style +/// initramfs artifact. +pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { + use std::{io::Write, os::unix::fs::PermissionsExt}; + + fn write_old_cpio_initramfs(path: &Path) -> Result<()> { + let source = tempfile::tempdir()?; + fs::create_dir_all(source.path().join("etc"))?; + fs::write(source.path().join("etc/legacy"), b"legacy\n")?; + let filenames = b"etc/legacy\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 + // 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")?; + let initramfs = mod_dir.join("initramfs.img"); + write_old_cpio_initramfs(&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)" + ); + + 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")?); + + // The default producer emits both kargs without probing the initramfs. + // This checks producer output only; it does not assert that either karg + // identifies an image booted with a particular initramfs. + let default_auto = Command::new("bootc") + .env("PATH", &path_with_fake_ukify) + .env("UKIFY_ARGS", &ukify_args) + .args(["container", "ukify", "--rootfs", root_str]) + .output()?; + assert!( + default_auto.status.success(), + "default producer failed: {}", + String::from_utf8_lossy(&default_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}"); + + // An old initramfs has no composefs capability marker. Explicit V2 is + // still usable because selecting V2 does not depend on that artifact. + 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!( + explicit_v2.status.success(), + "explicit V2 failed for an old initramfs: {}", + String::from_utf8_lossy(&explicit_v2.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 for explicit V2")?; + assert!( + cmdline.contains("composefs=") && !cmdline.contains("composefs.digest=v1-sha512-12:"), + "explicit V2 should pass only the V2 karg: {cmdline}" + ); + + 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 +537,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 b129b213f9..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"; @@ -557,8 +559,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 +571,13 @@ 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" + ); } else if try_bind_storage && !supports_bind_storage_ro { println!( "Note: Test wants bind storage but skipping on {} (missing systemd.extra-unit.* support)", @@ -1304,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 17feee550f..5a55b45ea7 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,28 @@ 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 + /// 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 +808,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/experimental-composefs.md b/docs/src/experimental-composefs.md index c4f04eca74..4218d016f4 100644 --- a/docs/src/experimental-composefs.md +++ b/docs/src/experimental-composefs.md @@ -7,28 +7,104 @@ 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. - -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. +The composefs backend has two independent integrity controls: + +- **fs-verity enforcement.** By default every object in the composefs + repository must have fs-verity enabled, and the root filesystem is only + mounted if its digest matches the one on the kernel command line. Building a + UKI with `--allow-missing-verity` adds a `?` marker to that argument, which + makes fs-verity optional (for filesystems such as XFS that lack it). Both UKI + and traditional kernel/initramfs installs can enforce fs-verity. +- **Boot authentication.** In a *sealed* deployment fs-verity is enforced and + the expected root digest is embedded in a UKI signed for Secure Boot, so + firmware authenticates the digest and the digest authenticates the root + filesystem. A BLS entry or an + unsigned UKI still has fs-verity checked at mount time, but nothing + authenticates the digest itself. + +## EROFS formats + +composefs-rs can encode the EROFS image for a root filesystem in two formats, +which produce different digests for the same content: + +- **V1** is compatible with the C composefs tools and is the default for new + repositories. Its kernel argument is + `composefs.digest=v1-sha512-12:`. +- **V2** is the older composefs-rs format, kept as a fallback. bootc writes + its kernel argument as the bare `composefs=`. + +By default `bootc container ukify` computes both digests and writes the V1 +argument followed by the V2 one. `--erofs-version=v2` writes only the V2 +argument. + +Each argument names one exact image. Staging fails unless every digest in the +UKI matches an image bootc generated for that container image. At boot, +bootc's initramfs tries the arguments in order and moves on to the next one if +an image is missing, but an image that fails fs-verity checks stops the boot. +bootc never substitutes a different digest. + +Existing repositories keep the format configuration recorded in their +metadata; opening one with a newer bootc doesn't convert it. + +### The bare `composefs=` argument + +Released UKIs have used the bare `composefs=` argument for different +formats: + +- bootc 1.16.0 through 1.16.2 predate format versioning and use the original + composefs-rs encoding that V2 descends from. +- bootc 1.16.3 writes a V2 digest. +- bootc 1.16.4 through 1.16.13 write a **V1** digest, because composefs-rs + switched its default while `ukify` kept emitting only the bare argument. +- Releases after 1.16.13 write V2 there again, after an explicit V1 argument. + +So bootc accepts a bare `composefs=` digest that matches either a V1 or a V2 +image, and only enforces the format for the explicit +`composefs.digest=v1-…`/`composefs.digest=v2-…` form. + +### Upgrading from bootc 1.16 + +When you update bootc in an image, **regenerate the initramfs before +generating the UKI**. The initramfs contains bootc's own mount logic, and +keeping an old initramfs with a newer bootc is not supported. + +For a sealed deployment, sign the new UKI with a key the existing machine +trusts. If the deployment was built with `--allow-missing-verity`, keep that +flag. Then publish the image and run `bootc upgrade` as usual. + +What happens next depends on the bootc version doing the staging. A client +that only understands `composefs=`, such as 1.16.0, stages the V2 fallback; +the new initramfs boots it, and the next upgrade (now staged by the new bootc) +moves the system to V1. bootc 1.16.4 and later already understand +`composefs.digest=` and stage V1 directly. + +The 1.16.0 path is covered by the `test-49-composefs-1-16-bridge` TMT test for +both sealed and `--allow-missing-verity` UKIs, including rollback and garbage +collection. Upgrades from other releases, and from BLS (non-UKI) composefs +installs, are not yet tested. ## 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 + - `objects/`: content-addressed file storage, keyed by SHA-512 fs-verity digest and shared via reflink (`FICLONE`) where the filesystem supports it + - `images/`: EROFS images describing each deployment's root filesystem metadata, possibly in both [formats](#erofs-formats) - `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 (see below for how it is named): - `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 fs-verity hash algorithms, bootc currently hardcodes `SHA-512` for the repository. This is why EROFS image IDs and object identifiers are 128-character hex strings. + +Three kinds of digest show up here and are easy to confuse. The OCI manifest +digest names the pulled container image (see the origin file above). An EROFS +digest names one bootable image under `images/` and is what the kernel command +line refers to; a deployment may have one of each format. The deployment ID names the +state directory; it is the digest of the boot image selected when the +deployment was staged. 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. @@ -39,7 +115,7 @@ Transient, not-yet-finalized deployment state (used while staging an update befo A sealed image is a cryptographically signed and verified bootc image that provides end-to-end integrity protection. This is achieved through: - **Unified Kernel Images (UKIs)**: Combining kernel, initramfs, and boot parameters into a single signed binary -- **Composefs integration**: Using composefs with fsverity for content-addressed filesystem verification +- **Composefs integration**: Using composefs with fs-verity for content-addressed filesystem verification - **Secure Boot**: Cryptographic signatures on both the UKI and systemd-boot loader A sealed image includes: @@ -48,7 +124,7 @@ A sealed image includes: 2. **Unified Kernel Image (UKI)**: A single EFI binary containing the kernel, initramfs, and kernel command line with the composefs digest embedded 3. **Secure Boot signature**: The UKI is signed with your private key -At boot time, the composefs digest in the kernel command line (e.g., `composefs=`) is verified against the mounted root filesystem. This creates a chain of trust from firmware to userspace, ensuring the system will only boot if the root filesystem matches exactly what was signed. +At boot time, the composefs digest in the kernel command line (e.g. `composefs.digest=v1-sha512-12:`) is verified against the mounted root filesystem. This creates a chain of trust from firmware to userspace, ensuring the system will only boot if the root filesystem matches exactly what was signed. ## Building Sealed Images @@ -63,12 +139,12 @@ For sealed images, the container must: Sealed images also require: - Secure Boot support in the target system firmware -- A filesystem with fsverity support (e.g., ext4, btrfs) for the root partition +- A filesystem with fs-verity support (e.g., ext4, btrfs) for the root partition #### Using without Secure Boot You can use a sealed UKI without Secure Boot enabled. The composefs and mounting -code is fully orthogonal to Secure Boot - the fsverity digest of the root filesystem +code is fully orthogonal to Secure Boot - the fs-verity digest of the root filesystem and all of its contents will still be validated at runtime, which does provide an increased level of integrity. @@ -81,12 +157,8 @@ valid use case is to temporarily disable it in order to test a change locally on e.g. one machine, then re-enable it later. However at the current time it is not yet streamlined to regenerate the UKI locally. -Note this is a different, independent weakening from `--allow-missing-verity` -(see [Overview](#overview) above): disabling Secure Boot only removes firmware -verification of the UKI's own signature, while the fsverity digest of the root -filesystem is still enforced. Building with `--allow-missing-verity` instead -disables that digest enforcement itself, which is what actually makes an image -unsealed regardless of whether Secure Boot is enabled. +This is independent of `--allow-missing-verity` (see [Overview](#overview)), +which instead makes fs-verity on the root filesystem optional. ### Build Pattern: Split the Kernel, Then Generate the UKI in a Separate Stage @@ -151,7 +223,9 @@ 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) +- `--allow-missing-verity`: Make fs-verity validation optional, for filesystems that don't support it (e.g. XFS) +- `--erofs-version `: `v1` (the default) writes a V1 argument followed + by a V2 fallback; `v2` writes only V2. See [EROFS formats](#erofs-formats). - `--write-dumpfile-to `: Write a composefs dumpfile for debugging ### The `bootc container compute-composefs-digest` Command @@ -165,6 +239,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. @@ -193,26 +268,32 @@ See [CONTRIBUTING.md](https://github.com/bootc-dev/bootc/blob/main/CONTRIBUTING. ## Bootloader Support -Whenever the container image has a UKI, bootc automatically selects the composefs backend during installation (see [Prerequisites](#prerequisites) above for the currently-supported UKI + systemd-boot configuration for building sealed images). Note that having a UKI does not by itself make an install sealed — that also depends on whether fsverity enforcement is on, per [Overview](#overview) above. +Whenever the container image has a UKI, bootc automatically selects the composefs backend during installation (see [Prerequisites](#prerequisites) above for the currently-supported UKI + systemd-boot configuration for building sealed images). Note that having a UKI does not by itself make an install sealed — that also depends on whether fs-verity enforcement is on, per [Overview](#overview) above. -Composefs installs using a traditional `vmlinuz`/`initramfs.img` layout instead of a UKI are always unsealed, and can use either `bootupd` (GRUB) or systemd-boot, the same as the ostree backend. See [bootloaders.md](bootloaders.md) for the general bootloader selection rules. Under the hood, bootc writes standard BLS boot entries for both UKI and traditional kernels; see the [composefs boot module documentation](https://github.com/bootc-dev/bootc/blob/main/crates/lib/src/bootc_composefs/boot.rs) for details on how entry filenames and sort-keys are chosen to sort correctly on both GRUB and systemd-boot. +Composefs installs using a traditional `vmlinuz`/`initramfs.img` layout instead of a UKI can enforce fs-verity, but are never sealed, since nothing authenticates the root digest. They can use either `bootupd` (GRUB) or systemd-boot, the same as the ostree backend. See [bootloaders.md](bootloaders.md) for the general bootloader selection rules. Under the hood, bootc writes standard BLS boot entries for both UKI and traditional kernels; see the [composefs boot module documentation](https://github.com/bootc-dev/bootc/blob/main/crates/lib/src/bootc_composefs/boot.rs) for details on how entry filenames and sort-keys are chosen to sort correctly on both GRUB and systemd-boot. ## Installation 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 +## Known issues The composefs backend is experimental; on-disk formats are subject to change. -### Stability blockers - -- [Dual EROFS v1/v2 generation](https://github.com/bootc-dev/bootc/pull/2248) and https://github.com/bootc-dev/bootc/pull/2353 - -### Important - +- Upgrades are tested only from bootc 1.16.0 UKI installs (see + [Upgrading from bootc 1.16](#upgrading-from-bootc-116)), and that test + doesn't yet run in CI. +- Recovery from missing or corrupt images and deployment state is not yet + tested, nor is garbage collection when deployments are referenced by both V1 + and V2 boot entries (for example, that GC keeps a V2 fallback image a + rollback deployment still boots from). +- How container signature enforcement carries over from installation into the + installed system is not settled yet. - 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). +- [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, publish with 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 d98e325894..5ba01d3a6b 100644 --- a/docs/src/man/bootc-container-ukify.8.md +++ b/docs/src/man/bootc-container-ukify.8.md @@ -14,6 +14,12 @@ This command computes the necessary arguments from the container image (kernel, initrd, cmdline, os-release) and invokes ukify with them. Any additional arguments after `--` are passed through to ukify unchanged. +Unless `--erofs-version=v2` is specified, the generated UKI contains the V1 +composefs argument followed by a V2 fallback. The command does not inspect the +initramfs to choose a format. Explicit V2 emits only the legacy V2 argument. +When using the default output, the initramfs in the image must be regenerated +with a bootc version that supports V1 before the UKI is built. + # OPTIONS @@ -31,6 +37,14 @@ 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 + **--write-dumpfile-to**=*WRITE_DUMPFILE_TO* Write a dumpfile to this path diff --git a/hack/packages.txt b/hack/packages.txt index 8a1f51b517..cbec4a75bf 100644 --- a/hack/packages.txt +++ b/hack/packages.txt @@ -11,5 +11,7 @@ parted lvm2 dosfstools e2fsprogs +# Required by composefs UKI target-policy inspection before UKI creation +binutils # Required by bib-build test qemu-img diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 7b52f4f02a..06dadb2cf3 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 bootc 1.16 old-stager migration for sealed and unsealed UKIs + 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 b66393114e..1d7aa0e037 100644 --- a/tmt/tests/Dockerfile.upgrade +++ b/tmt/tests/Dockerfile.upgrade @@ -1,20 +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=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 @@ -26,22 +28,22 @@ 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 +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 +67,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/README.md b/tmt/tests/booted/README.md index 359bb39628..bc54513520 100644 --- a/tmt/tests/booted/README.md +++ b/tmt/tests/booted/README.md @@ -1,3 +1,87 @@ # 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. The +default `v1` case is dual-format (V1 then V2); it is not an initramfs +capability probe. + +`composefs-1-16-bridge` is opt-in historical-client coverage, restricted to +the bootc 1.16.0 old-stager fixture. It requires a read-only shared container +store and prebuilt fixture images supplied by the coordinator; it does not +build, copy, or SCP images. The covered path is an old-stager migration where +the old client is followed by current bootc and a newly generated initramfs. +bootc 1.16.0 stages the V2 fallback of the dual-digest UKI; 1.16.4 and later +clients would stage V1 directly, which this test does not cover. +Both sealed and unsealed UKIs are covered; 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_integrity_mode=sealed \ + --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_1160_STAGER_IMAGE" composefs-1-16-bridge \ + --composefs-backend --bootloader systemd --boot-type uki --seal-state unsealed \ + --context composefs_bridge=true \ + --env BOOTC_composefs_bridge_integrity_mode=unsealed \ + --env BOOTC_1160_bootc_sha256="$BOOTC_1160_BOOTC_SHA256" \ + --bridge-image "$BOOTC_CURRENT_DUAL_UKI_UNSEALED_IMAGE" \ + --upgrade-image "$BOOTC_CURRENT_DUAL_UKI_UPGRADE_UNSEALED_IMAGE" + +``` + +Required fixture labels are `bootc.test.fixture=bootc-1.16.0-stager`, +`bootc.test.fixture=current-dual-uki-sealed`, and +`bootc.test.fixture=current-dual-uki-upgrade-sealed` for the sealed run. +The old-stager label is intentionally unchanged in both runs; its booted +status image is read from `.status.booted.image.image.image`. +The unsealed run requires separately built fixtures labeled +`bootc.test.fixture=current-dual-uki-unsealed` and +`bootc.test.fixture=current-dual-uki-upgrade-unsealed`; it never reuses a +strict fixture. 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 bridge and upgrade fixtures must be independently generated with current +userspace and a newly generated dual-format UKI/initramfs for the requested +integrity mode. The sealed run must use Secure Boot firmware; the unsealed run +uses the explicitly insecure firmware context selected by `--seal-state +unsealed`. The test validates the image labels, the `?` marker in the V2 UKI +argument, and `missingVerityAllowed` in bootc status rather than treating +unsealed as a relaxed version of the sealed fixture. + +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. + +This intentionally does not cover stale initramfs, old-initramfs-mode, or BLS +installs. The test is opt-in through `composefs_bridge=true`, so these large +fixtures are not added to the normal matrix. diff --git a/tmt/tests/booted/readonly/014-test-no-failed-units.nu b/tmt/tests/booted/readonly/014-test-no-failed-units.nu new file mode 100644 index 0000000000..af71c86c97 --- /dev/null +++ b/tmt/tests/booted/readonly/014-test-no-failed-units.nu @@ -0,0 +1,43 @@ +# Verify no systemd or bootc units are in a failed state. +# +# This catches first-boot ordering issues such as the race between +# systemd-tmpfiles-setup and systemd-random-seed/systemd-tpm2-setup +# when /var/lib/systemd does not yet exist (the bootc generator +# emits a drop-in to prevent this). +use std assert +use tap.nu + +tap begin "verify no failed systemd/bootc units" + +let selinux_enabled = ("/sys/fs/selinux/enforce" | path exists) + +let failed_candidates = (systemctl list-units --failed --no-legend --plain + | lines + | filter { |l| $l != "" } + | filter { |l| + let unit = ($l | split row " " | first) + (($unit | str starts-with "systemd-") or ($unit | str starts-with "bootc-")) + }) + +let failed = if $selinux_enabled { + # https://bugzilla.redhat.com/show_bug.cgi?id=2507393 + $failed_candidates | filter { |l| + let unit = ($l | split row " " | first) + if $unit == "systemd-tpm2-setup.service" { + print "# SELinux workaround: ignoring systemd-tpm2-setup.service failure (SELinux denies writing the NvPCR credential to dosfs_t)" + false + } else { + true + } + } +} else { + $failed_candidates +} + +if ($failed | length) > 0 { + print $"Failed units:\n($failed | str join "\n")" + assert equal ($failed | length) 0 "Expected zero failed systemd-*/bootc-* units" +} + +print "No failed systemd-*/bootc-* units" +tap ok 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..a5af0f763d --- /dev/null +++ b/tmt/tests/booted/readonly/046-test-erofs-version.nu @@ -0,0 +1,75 @@ +# extra: +# skip_if_ostree: true +# +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 = tap selected_erofs_version +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 }) + # 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)" + + 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..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] { +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 { @@ -121,7 +140,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-49-composefs-1-16-bridge.nu b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu new file mode 100644 index 0000000000..0f209313c2 --- /dev/null +++ b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu @@ -0,0 +1,192 @@ +# number: 49 +# tmt: +# summary: Test bootc 1.16 old-stager migration for sealed and unsealed UKIs +# 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. +# +# The initial fixture is pinned to bootc 1.16.0, which only understands the +# bare composefs= argument and so stages the V2 fallback of a dual-digest UKI. +# bootc 1.16.4 and later parse composefs.digest= first and stage V1 directly; +# that path is not exercised here. +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 integrity-mode [] { + let mode = ($env.BOOTC_composefs_bridge_integrity_mode? | default "") + if not ($mode in ["sealed" "unsealed"]) { + error make { msg: "BOOTC_composefs_bridge_integrity_mode must be sealed or unsealed" } + } + $mode +} + +def assert-fixture-label [image: string, expected: string] { + let label = (podman image inspect --format '{{ index .Config.Labels "bootc.test.fixture" }}' $image | str trim) + assert equal $label $expected $"($image) has the wrong bridge fixture label" +} + +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] { + 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")) + assert equal $st.status.booted.composefs.missingVerityAllowed ((integrity-mode) == "unsealed") "booted composefs policy must match the requested integrity mode" + 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_is_unsealed = ($v2_value | str starts-with "?") + assert equal $v2_is_unsealed ((integrity-mode) == "unsealed") "UKI integrity marker must match the fixture mode" + let v2 = ($v2_value | str replace --regex "^\\?" "") + let v1_params = ($params | where { |p| $p | into string | str starts-with "composefs.digest=" }) + 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) + # Both arguments carry the same fs-verity policy marker; only the digest + # is compared here. + let v1 = ($v1_value | split row ":" | last) + assert ($v1 != $v2) "dual-format UKI must contain distinct V1 and V2 identities" + + 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 ((integrity-mode))" + assert-old-fixture + let initial = (bootc status --json | from json).status.booted.image.image.image + assert-fixture-label $initial "bootc-1.16.0-stager" + assert-fixture-label (bridge-image) $"current-dual-uki-((integrity-mode))" + 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 + assert equal $identity.selected (open /var/composefs-bridge-v2-identity | str trim) + assert-sentinels + assert-fixture-label (upgrade-image) $"current-dual-uki-upgrade-((integrity-mode))" + 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 + 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 + 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 main [] { + match ($env.TMT_REBOOT_COUNT? | default "0") { + "0" => old_stager_boot0, + "1" => old_stager_boot1, + "2" => old_stager_boot2, + "3" => old_stager_boot3, + $count => { error make { msg: $"Invalid bridge reboot count: ($count)" } }, + } +} 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)" } }, } } - 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/booted/test-install-outside-container.nu b/tmt/tests/booted/test-install-outside-container.nu index 8c88cf17aa..9107235e39 100644 --- a/tmt/tests/booted/test-install-outside-container.nu +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -14,6 +14,27 @@ bootc image copy-to-storage skopeo copy containers-storage:localhost/bootc oci:/var/tmp/bootc-oci let target_image = "oci:/var/tmp/bootc-oci" +# Keep the policy observed from the image we are actually testing. In +# particular, do not infer it from the fact that the target is ext4: an +# unsealed UKI is a valid result on an fs-verity-capable filesystem. +let source_status = bootc status --json | from json +let source_composefs = $source_status.status.booted.composefs? +let source_is_uki = $source_composefs != null and (($source_composefs.bootType | str downcase) == "uki") +let source_cmdline = (open /proc/cmdline | str trim | split row " ") +let source_composefs_args = ($source_cmdline | where { |arg| + ($arg | str starts-with "composefs=") or ($arg | str starts-with "composefs.digest=") +}) +let source_requests_missing_verity = if $source_is_uki { + assert (($source_composefs_args | length) > 0) "source UKI must have a composefs kernel argument" + let requested = ($source_composefs_args | any { |arg| + ($arg | str starts-with "composefs=?") or ($arg | str starts-with "composefs.digest=?") + }) + assert equal $source_composefs.missingVerityAllowed $requested "bootc status must reflect the source UKI composefs policy" + $requested +} else { + false +} + # setup filesystem mkdir /var/mnt truncate -s 10G disk.img @@ -40,4 +61,120 @@ let install_cmd = if (tap is_composefs) { tap run_install $install_cmd +def discover_target_partitions [loop: string] { + mut last_listing = "" + for attempt in 1..20 { + # partscan creates the kernel partition devices synchronously, but + # lsblk's udev-backed LABEL cache can lag behind filesystem creation. + udevadm settle + let listed = (do { lsblk --json --tree --paths --output PATH,LABEL,TYPE $loop } | complete) + $last_listing = $listed.stdout + if $listed.exit_code == 0 { + let parsed = (try { $listed.stdout | from json } catch { null }) + if $parsed != null { + let partitions = ($parsed.blockdevices.0.children? | default []) + let esp = ($partitions | where type == "part" and label == "EFI-SYSTEM" | get path) + let root = ($partitions | where type == "part" and label == "root" | get path) + if (($esp | length) == 1 and ($root | length) == 1) { + return { esp: ($esp | first), root: ($root | first) } + } + + # Query the filesystem superblocks directly as a fallback for + # a stale lsblk LABEL cache. blkid -p does not consult udev. + let direct = ($partitions | each { |partition| + let label = (do { blkid -p -s LABEL -o value $partition.path } | complete) + $partition | upsert label ($label.stdout | str trim) + }) + let direct_esp = ($direct | where type == "part" and label == "EFI-SYSTEM" | get path) + let direct_root = ($direct | where type == "part" and label == "root" | get path) + if (($direct_esp | length) == 1 and ($direct_root | length) == 1) { + return { esp: ($direct_esp | first), root: ($direct_root | first) } + } + } + } + if $attempt < 20 { + sleep 100ms + } + } + error make { msg: $"target partition labels did not become visible after 20 attempts; last lsblk JSON: ($last_listing)" } +} + +# Inspect the disk produced by this test, rather than the running system. +# The installer creates an architecture-specific BIOS/boot partition before +# the ESP, so locate partitions by their discoverable labels instead of using +# fixed partition numbers. +if $source_is_uki { + let loop = (losetup --find --show --partscan ./disk.img | str trim) + let partitions = try { + discover_target_partitions $loop + } catch {|err| + do { losetup -d $loop } | complete | ignore + error make { msg: $"installed target partition discovery failed: ($err)" } + } + let esp = $partitions.esp + let root = $partitions.root + + let target_root = "/var/mnt/plan23-target" + let target_esp = "/var/mnt/plan23-esp" + mkdir $target_root + mkdir $target_esp + mount -o ro $root $target_root + mount -o ro $esp $target_esp + + # Always tear down the temporary mounts and loop device, while retaining + # the original assertion/inspection error for tmt to report. + let inspection_error = try { + let meta = $"($target_root)/composefs/meta.json" + assert ($meta | path exists) "installed target must contain composefs/meta.json" + assert (not (which objcopy | is-empty)) "binutils package must provide objcopy" + + # lsattr reads the typed ext4 FS_VERITY_FL attribute. This is + # deliberately not a check of the composefs metadata JSON (which only + # describes the hash algorithm), and works on bases without the + # separately packaged fsverity utility. + let attributes = (do { lsattr -d $meta } | complete) + assert equal $attributes.exit_code 0 "could not read target metadata fs-verity attribute" + let flags = ($attributes.stdout | split row " " | first) + let has_verity = ($flags | str contains "V") + if $source_requests_missing_verity { + assert (not $has_verity) "an unsealed source UKI must not make target composefs metadata require fs-verity" + } else { + assert $has_verity "a strict source UKI must produce fs-verity-protected target composefs metadata" + } + + let ukis = (glob $"($target_esp)/EFI/Linux/bootc/*.efi") + assert (($ukis | length) > 0) "installed target ESP must contain a composefs UKI" + let inspect_dir = "/var/tmp/plan23-uki-cmdline" + mkdir $inspect_dir + for uki in $ukis { + let dump = $"($inspect_dir)/($uki | path basename).cmdline" + # Some objcopy versions open the input for writing even when only + # dumping a section. Keep the installed ESP read-only. + let uki_copy = $"($inspect_dir)/($uki | path basename)" + cp $uki $uki_copy + let dump_result = (do { objcopy --dump-section $".cmdline=($dump)" $uki_copy } | complete) + assert equal $dump_result.exit_code 0 $"could not inspect installed UKI ($uki): ($dump_result.stderr)" + let cmdline = (open --raw $dump | into binary | decode utf-8 | str replace -a (char nul) "" | str trim | split row " ") + let args = ($cmdline | where { |arg| + ($arg | str starts-with "composefs=") or ($arg | str starts-with "composefs.digest=") + }) + assert (($args | length) > 0) $"installed UKI ($uki) must have a composefs kernel argument" + let requests_missing = ($args | each { |arg| + ($arg | str starts-with "composefs=?") or ($arg | str starts-with "composefs.digest=?") + }) + assert (($requests_missing | uniq | length) == 1) $"installed UKI ($uki) contains inconsistent composefs policies" + assert equal ($requests_missing | first) $source_requests_missing_verity $"installed UKI ($uki) policy must match the initial UKI" + } + print $"Verified installed target policy: missing-verity=($source_requests_missing_verity), metadata-verity=($has_verity)" + null + } catch {|err| $err } + + do { umount $target_esp } | complete | ignore + do { umount $target_root } | complete | ignore + do { losetup -d $loop } | complete | ignore + if $inspection_error != null { + error make { msg: $"installed target inspection failed: ($inspection_error)" } + } +} + tap ok 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..c64d70c181 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 "${BOOTC_erofs_version:-v1}" \ "${allow_missing_verity[@]}" RUNEOF diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index c2c91422d9..f27e9addd5 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 bootc 1.16 old-stager migration for sealed and unsealed UKIs + duration: 45m + enabled: false + adjust: + - when: composefs_bridge == true + enabled: true + test: nu booted/test-49-composefs-1-16-bridge.nu