diff --git a/Cargo.lock b/Cargo.lock index 416625ce1..5f6324670 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -97,6 +97,30 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -109,6 +133,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -493,7 +535,7 @@ dependencies = [ "maybe-owned", "rustix", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -571,7 +613,7 @@ dependencies = [ "rustix", "rustix-linux-procfs", "uuid", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -769,8 +811,8 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" -version = "0.4.0" -source = "git+https://github.com/composefs/composefs-rs?rev=e2770757762ec5091bb183bf0e778fe97c8d5694#e2770757762ec5091bb183bf0e778fe97c8d5694" +version = "0.7.0" +source = "git+https://github.com/composefs/composefs-rs?rev=469f6d635abcd5604ac99ca65ab37f58c516045d#469f6d635abcd5604ac99ca65ab37f58c516045d" dependencies = [ "anyhow", "composefs-ioctls", @@ -782,6 +824,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "serde_repr", "sha2 0.11.0", "tempfile", "thiserror 2.0.18", @@ -789,19 +832,20 @@ dependencies = [ "tokio-stream", "xxhash-rust", "zerocopy", + "zlink-core", "zstd", ] [[package]] name = "composefs-boot" -version = "0.4.0" -source = "git+https://github.com/composefs/composefs-rs?rev=e2770757762ec5091bb183bf0e778fe97c8d5694#e2770757762ec5091bb183bf0e778fe97c8d5694" +version = "0.7.0" +source = "git+https://github.com/composefs/composefs-rs?rev=469f6d635abcd5604ac99ca65ab37f58c516045d#469f6d635abcd5604ac99ca65ab37f58c516045d" dependencies = [ "anyhow", "composefs", "fn-error-context", "hex", - "regex-automata", + "pcre2", "rustix", "thiserror 2.0.18", "zerocopy", @@ -809,8 +853,8 @@ dependencies = [ [[package]] name = "composefs-ctl" -version = "0.4.0" -source = "git+https://github.com/composefs/composefs-rs?rev=e2770757762ec5091bb183bf0e778fe97c8d5694#e2770757762ec5091bb183bf0e778fe97c8d5694" +version = "0.7.0" +source = "git+https://github.com/composefs/composefs-rs?rev=469f6d635abcd5604ac99ca65ab37f58c516045d#469f6d635abcd5604ac99ca65ab37f58c516045d" dependencies = [ "anyhow", "clap", @@ -823,16 +867,19 @@ dependencies = [ "fn-error-context", "hex", "indicatif 0.17.11", + "libsystemd", + "log", "rustix", "serde", "serde_json", "tokio", + "zlink", ] [[package]] name = "composefs-ioctls" -version = "0.4.0" -source = "git+https://github.com/composefs/composefs-rs?rev=e2770757762ec5091bb183bf0e778fe97c8d5694#e2770757762ec5091bb183bf0e778fe97c8d5694" +version = "0.7.0" +source = "git+https://github.com/composefs/composefs-rs?rev=469f6d635abcd5604ac99ca65ab37f58c516045d#469f6d635abcd5604ac99ca65ab37f58c516045d" dependencies = [ "rustix", "thiserror 2.0.18", @@ -840,8 +887,8 @@ dependencies = [ [[package]] name = "composefs-oci" -version = "0.4.0" -source = "git+https://github.com/composefs/composefs-rs?rev=e2770757762ec5091bb183bf0e778fe97c8d5694#e2770757762ec5091bb183bf0e778fe97c8d5694" +version = "0.7.0" +source = "git+https://github.com/composefs/composefs-rs?rev=469f6d635abcd5604ac99ca65ab37f58c516045d#469f6d635abcd5604ac99ca65ab37f58c516045d" dependencies = [ "anyhow", "async-compression", @@ -852,6 +899,7 @@ dependencies = [ "composefs-boot", "composefs-storage", "containers-image-proxy", + "flate2", "fn-error-context", "hex", "indicatif 0.17.11", @@ -865,12 +913,14 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "zlink-core", + "zstd", ] [[package]] name = "composefs-storage" -version = "0.4.0" -source = "git+https://github.com/composefs/composefs-rs?rev=e2770757762ec5091bb183bf0e778fe97c8d5694#e2770757762ec5091bb183bf0e778fe97c8d5694" +version = "0.7.0" +source = "git+https://github.com/composefs/composefs-rs?rev=469f6d635abcd5604ac99ca65ab37f58c516045d#469f6d635abcd5604ac99ca65ab37f58c516045d" dependencies = [ "anyhow", "base64 0.22.1", @@ -911,6 +961,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -1032,6 +1091,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.29.0" @@ -1271,7 +1336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1296,6 +1361,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1436,6 +1522,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1701,6 +1800,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2321,7 +2426,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2548,6 +2653,12 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2571,6 +2682,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pcre2" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e970b0fcce0c7ee6ef662744ff711f21ccd6f11b7cf03cd187a80e89797fc67" +dependencies = [ + "libc", + "log", + "pcre2-sys", +] + +[[package]] +name = "pcre2-sys" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18b9073c1a2549bd409bf4a32c94d903bb1a09bf845bc306ae148897fa0760a4" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2615,6 +2748,20 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2930,7 +3077,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3061,6 +3208,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3228,7 +3386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3378,10 +3536,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3391,7 +3549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3491,6 +3649,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4419,6 +4578,73 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zlink" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b895b99588dceb73f4d349b8323eabad9a97d48ce83698d475c7223727c6148" +dependencies = [ + "zlink-smol", + "zlink-tokio", +] + +[[package]] +name = "zlink-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd12701bd1d42a982b931f0159cf5054bf13d90e7828a8377dfc02ed4b00342d" +dependencies = [ + "futures-util", + "itoa", + "libc", + "pin-project-lite", + "rustix", + "ryu", + "serde", + "serde_json", + "tracing", + "zlink-macros", +] + +[[package]] +name = "zlink-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f2416a5f504dfd7e04fee49f31abafe3314a3f62b4ddaa8e9a5fd496d4dd50" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zlink-smol" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bc53cd0d636ad753f759aab0abb1f456e985c3938a279d11ebda92340ae37b1" +dependencies = [ + "async-broadcast", + "async-channel", + "async-io", + "futures-lite", + "futures-util", + "pin-project-lite", + "zlink-core", +] + +[[package]] +name = "zlink-tokio" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cbf366ac77ab41bf9a8d43535d3d620a072f7957813e03355d3d010c16cc4f" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", + "tokio-stream", + "zlink-core", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 92ee2b357..467c37d27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ clap_mangen = { version = "0.3.0" } # [patch."https://github.com/composefs/composefs-rs"] # composefs-ctl = { path = "/path/to/composefs-rs/crates/composefs-ctl" } # The Justfile will auto-detect these and bind-mount them into container builds. -composefs-ctl = { git = "https://github.com/composefs/composefs-rs", rev = "e2770757762ec5091bb183bf0e778fe97c8d5694" } +composefs-ctl = { git = "https://github.com/composefs/composefs-rs", rev = "469f6d635abcd5604ac99ca65ab37f58c516045d" } fn-error-context = "0.2.1" futures-util = "0.3" hex = "0.4.3" diff --git a/Dockerfile b/Dockerfile index 9831efcca..df1c0f764 100644 --- a/Dockerfile +++ b/Dockerfile @@ -277,6 +277,7 @@ ARG variant ARG filesystem ARG seal_state ARG boot_type +ARG erofs_version=v2 # 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 \ @@ -295,7 +296,7 @@ if [[ $filesystem == "xfs" ]]; then fi if test "${boot_type}" = "uki"; then - /run/packaging/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state + /run/packaging/seal-uki /run/target /out /run/secrets $allow_missing_verity $seal_state $erofs_version fi EORUN diff --git a/Justfile b/Justfile index 6ba80d0c7..9450507d1 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,8 @@ filesystem := env("BOOTC_filesystem", "ext4") boot_type := env("BOOTC_boot_type", "bls") # Only used for composefs tests seal_state := env("BOOTC_seal_state", "unsealed") +# Only used for composefs UKI tests: "v1" or "v2" +erofs_version := env("BOOTC_erofs_version", "v2") # 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 @@ -72,6 +74,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" \ @@ -271,7 +274,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 @@ -489,6 +492,7 @@ _build-upgrade-image: --build-arg "boot_type={{boot_type}}" \ --build-arg "seal_state={{seal_state}}" \ --build-arg "filesystem={{filesystem}}" \ + --build-arg "erofs_version={{erofs_version}}" \ --secret=id=secureboot_key,src=target/test-secureboot/db.key \ --secret=id=secureboot_cert,src=target/test-secureboot/db.crt \ "${extra_args[@]}" \ diff --git a/contrib/packaging/seal-uki b/contrib/packaging/seal-uki index 66de92ffd..47510a388 100755 --- a/contrib/packaging/seal-uki +++ b/contrib/packaging/seal-uki @@ -15,6 +15,9 @@ allow_missing_verity=$1 shift seal_state=$1 shift +# EROFS format version to pass to bootc container ukify (optional, default: v2) +erofs_version=${1:-v2} +shift || true if [[ $seal_state == "sealed" && $allow_missing_verity == "true" ]]; then echo "Cannot have missing verity with sealed UKI" >&2 @@ -53,4 +56,4 @@ fi # Build the UKI using bootc container ukify # This computes the composefs digest, reads kargs from kargs.d, and invokes ukify -bootc container ukify "${containerukifyargs[@]}" "${missing_verity[@]}" -- "${ukifyargs[@]}" +bootc container ukify "${containerukifyargs[@]}" "${missing_verity[@]}" --erofs-version="${erofs_version}" -- "${ukifyargs[@]}" diff --git a/crates/etc-merge/src/lib.rs b/crates/etc-merge/src/lib.rs index 0743435a2..0b78a0af5 100644 --- a/crates/etc-merge/src/lib.rs +++ b/crates/etc-merge/src/lib.rs @@ -52,6 +52,7 @@ impl From<(&cap_std::fs::Metadata, Xattrs)> for MyStat { st_uid: value.0.uid(), st_gid: value.0.gid(), st_mtim_sec: value.0.mtime(), + st_mtim_nsec: value.0.mtime_nsec() as u32, xattrs: value.1, }) } diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index c15dae95d..f01b7fc43 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -28,7 +28,7 @@ use composefs::{ mountcompat::{overlayfs_set_fd, overlayfs_set_lower_and_data_fds, prepare_mount}, repository::Repository, }; -use composefs_boot::cmdline::get_cmdline_composefs; +use composefs_boot::cmdline::ComposefsCmdline; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; @@ -463,11 +463,17 @@ pub fn setup_root(args: Args) -> Result<()> { config }; - let (image, insecure) = get_cmdline_composefs::(&cmdline)?; + 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, &image.to_hex(), insecure)?, + None => mount_composefs_image( + &sysroot, + &composefs_info.digest().to_hex(), + composefs_info.is_insecure(), + )?, }; // we need to clone this before the next step to make sure we get the old one @@ -497,7 +503,7 @@ pub fn setup_root(args: Args) -> Result<()> { let transient_overlay_fd: Option = if config.root.transient { let overlay_fd = overlay_transient( &new_root, - &format!("transient:composefs={}", image.to_hex()), + &format!("transient:composefs={}", composefs_info.digest().to_hex()), None, )?; @@ -533,7 +539,10 @@ pub fn setup_root(args: Args) -> Result<()> { } // etc + var - let state = open_dir(open_dir(&sysroot, "state/deploy")?, image.to_hex())?; + let state = open_dir( + open_dir(&sysroot, "state/deploy")?, + composefs_info.digest().to_hex(), + )?; mount_subdir(visible_root, &state, "etc", config.etc, MountType::Bind)?; // /var is bind-mounted from the deployment state directory by default. // The systemd.volatile=state cmdline detection above (or an explicit diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index f4a020c5e..891a2a4f6 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -82,7 +82,9 @@ use composefs_boot::bootloader::{ BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType, UsrLibModulesVmlinuz, get_boot_resources, }; -use composefs_boot::{cmdline::get_cmdline_composefs, os_release::OsReleaseInfo, uki}; +use composefs_boot::{ + cmdline::ComposefsCmdline as BootComposefsCmdline, os_release::OsReleaseInfo, uki, +}; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; use composefs_ctl::composefs_oci; @@ -783,6 +785,12 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, + /// The composefs image digest parsed from (and validated against) the UKI's + /// own cmdline. This is the authoritative deployment key for UKI boots: + /// setup-root opens `state/deploy/` using the karg baked into the UKI, + /// so the deploy directory must be named after exactly this value regardless + /// of which EROFS format (V1 or V2) was sealed. + composefs_cmdline: Sha512HashValue, } /// Writes a PortableExecutable to ESP along with any PE specific or Global addons @@ -793,6 +801,7 @@ fn write_pe_to_esp( file_path: &Utf8Path, pe_type: PEType, uki_id: &Sha512HashValue, + boot_ids: &[&Sha512HashValue], missing_fsverity_allowed: bool, mounted_efi: impl AsRef, ) -> Result> { @@ -811,8 +820,11 @@ 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_cmdline, missing_verity_allowed_cmdline) = - get_cmdline_composefs::(&cmdline).context("Parsing composefs=")?; + let composefs_info = BootComposefsCmdline::::from_cmdline(&cmdline) + .context("Parsing composefs=")? + .ok_or_else(|| anyhow::anyhow!("No composefs= or composefs.digest.v1= karg found in UKI cmdline"))?; + let composefs_cmdline = composefs_info.digest().clone(); + let missing_verity_allowed_cmdline = composefs_info.is_insecure(); // If the UKI cmdline does not match what the user has passed as cmdline option // NOTE: This will only be checked for new installs and now upgrades/switches @@ -830,11 +842,9 @@ fn write_pe_to_esp( _ => { /* no-op */ } } - if composefs_cmdline != *uki_id { - anyhow::bail!( - "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})" - ); - } + composefs_info + .validate_digest(boot_ids.iter().copied()) + .context("Validating UKI composefs digest")?; uki_reader.seek(SeekFrom::Start(0))?; let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; @@ -851,6 +861,7 @@ fn write_pe_to_esp( version: parsed_osrel.get_version(), os_id: parsed_osrel.get_value(&["ID"]), boot_digest, + composefs_cmdline, }); } @@ -884,8 +895,18 @@ fn write_pe_to_esp( let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) .with_context(|| format!("Opening {final_pe_path:?}"))?; + // For UKIs, name the .efi file after the composefs cmdline digest (the + // deploy key that setup-root uses), NOT the provisional uki_id. When the + // UKI was sealed with --erofs-version=v1, uki_id (v2) and the cmdline + // digest (v1) differ; using the cmdline digest keeps the filename, BLS + // config, and state directory in agreement. + let pe_name_owned; let pe_name = match pe_type { - PEType::Uki => &get_uki_name(&uki_id.to_hex()), + PEType::Uki => { + let deploy_digest = &boot_label.as_ref().unwrap().composefs_cmdline; + pe_name_owned = get_uki_name(&deploy_digest.to_hex()); + &pe_name_owned + } PEType::UkiAddon => file_path .components() .last() @@ -1066,8 +1087,9 @@ pub(crate) fn setup_composefs_uki_boot( setup_type: BootSetupType, repo: crate::store::ComposefsRepository, id: &Sha512HashValue, + boot_ids: &[&Sha512HashValue], entries: Vec>, -) -> Result { +) -> Result<(String, Sha512HashValue)> { let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch)) => { @@ -1147,7 +1169,8 @@ pub(crate) fn setup_composefs_uki_boot( &entry.file, utf8_file_path, entry.pe_type, - &id, + id, + boot_ids, missing_fsverity_allowed, esp_mount.dir.path(), )?; @@ -1164,17 +1187,30 @@ pub(crate) fn setup_composefs_uki_boot( let boot_digest = uki_info.boot_digest.clone(); + // The deploy key for a UKI boot is the composefs digest baked into the UKI + // cmdline (already validated against `boot_ids` in `write_pe_to_esp`). + // setup-root opens `state/deploy/` using that same karg, so we must + // key the deployment off exactly this value -- whether the UKI was sealed + // with the V2 (default) or V1 EROFS digest. + let deploy_id = uki_info.composefs_cmdline.clone(); + match bootloader { - Bootloader::Grub => { - write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)? - } + Bootloader::Grub => write_grub_uki_menuentry( + root_path, + &setup_type, + uki_info.boot_label, + &deploy_id, + &esp_device, + )?, - Bootloader::Systemd => write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_info, id)?, + Bootloader::Systemd => { + write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_info, &deploy_id)? + } Bootloader::None => unreachable!("Checked at install time"), }; - Ok(boot_digest) + Ok((boot_digest, deploy_id)) } /// A composefs image attached to a temporary directory with the ESP and a @@ -1345,6 +1381,15 @@ pub(crate) async fn setup_composefs_boot( let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) .context("Generating bootable EROFS image")?; + // Open the OCI image to read both stored boot EROFS digests. The UKI may + // have been sealed with either the V1 or V2 boot image digest, so we need + // both for verification. + let oci_img = + composefs_oci::oci_image::OciImage::open(&*repo, &pull_result.manifest_digest, None) + .context("Opening OCI image to read boot image refs")?; + let boot_id_v1 = oci_img.boot_image_ref_v1().cloned(); + let boot_id_v2 = oci_img.boot_image_ref_v2().cloned(); + // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.). let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None) .context("Creating composefs filesystem for boot entry discovery")?; @@ -1397,25 +1442,49 @@ 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(), - )?, + // The deployment key is the hash that setup-root looks for in + // state/deploy/, derived from the composefs karg. The two boot types + // establish that karg differently: + // + // * BLS: bootc writes the karg itself, so we are free to choose the key. + // Prefer the V2 digest (matching the default EROFS format), falling back + // to the primary id for legacy repos with no separate V2 boot ref. + // + // * UKI: the karg is baked into the UKI at seal time, so the key is + // whatever digest the UKI carries. `setup_composefs_uki_boot` parses and + // validates that against the boot refs and returns it, so we override the + // provisional value below. This keeps us correct whether the UKI was + // sealed with the V2 (default) or V1 EROFS digest. + let provisional_deploy_id = boot_id_v2.as_ref().cloned().unwrap_or_else(|| id.clone()); + + // Collect whichever boot image refs exist; the UKI cmdline may carry either. + let boot_ids_owned: Vec = + [boot_id_v1, boot_id_v2].into_iter().flatten().collect(); + let boot_ids: Vec<&Sha512HashValue> = boot_ids_owned.iter().collect(); + + let (boot_digest, deploy_id) = match boot_type { + BootType::Bls => ( + setup_composefs_bls_boot( + BootSetupType::Setup((&root_setup, &state, &postfetch)), + repo, + &provisional_deploy_id, + entry, + mounted_root.dir(), + )?, + provisional_deploy_id, + ), BootType::Uki => setup_composefs_uki_boot( BootSetupType::Setup((&root_setup, &state, &postfetch)), repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, )?, }; write_composefs_state( &root_setup.physical_root_path, - &id, + &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), None, boot_type, diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index 074423e25..99111a49c 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -10,7 +10,9 @@ 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; use composefs_ctl::composefs_boot; @@ -20,21 +22,27 @@ use crate::store::ComposefsRepository; /// Creates a temporary composefs repository for computing digests. /// +/// The `erofs_version` controls which EROFS format the digest is computed for: +/// use `FormatVersion::V1` to get a `composefs.digest=v1-sha256-12:` karg (V1 EROFS, +/// C-tool compatible) or `FormatVersion::V2` for the legacy `composefs=` karg. +/// /// Returns the TempDir guard (must be kept alive for the repo to remain valid) /// and the repository wrapped in Arc. #[fn_error_context::context("Creating new temp composefs repo")] -pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc)> { +pub(crate) fn new_temp_composefs_repo( + erofs_version: FormatVersion, +) -> Result<(TempDir, Arc)> { let td_guard = tempfile::tempdir_in("/var/tmp")?; let td_path = td_guard.path(); let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?; td_dir.create_dir("repo")?; let repo_dir = td_dir.open_dir("repo")?; - let (mut repo, _created) = - ComposefsRepository::init_path(&repo_dir, ".", Algorithm::SHA512, false) - .context("Init cfs repo")?; // We don't need to hard require verity on the *host* system, we're just computing a checksum here - repo.set_insecure(); + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure(); + config.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version); + let (repo, _created) = + ComposefsRepository::init_path(&repo_dir, ".", config).context("Init cfs repo")?; Ok((td_guard, Arc::new(repo))) } @@ -58,13 +66,14 @@ pub(crate) fn new_temp_composefs_repo() -> 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( @@ -81,7 +90,7 @@ pub(crate) async fn compute_composefs_digest( .await .context("Reading container root")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; - let id = fs.compute_image_id(); + let id = fs.compute_image_id(erofs_version); let digest = id.to_hex(); if let Some(dumpfile_path) = write_dumpfile_to { @@ -135,7 +144,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!( @@ -150,7 +161,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" @@ -159,7 +172,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 d8bafd467..95f234de0 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -402,16 +402,20 @@ pub(crate) async fn composefs_gc( ref_digest, None, ) { - if let Some(img_ref) = img.image_ref() { - 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. + let img_ref_hex = img + .image_ref_v1() + .or_else(|| img.image_ref_v2()) + .map(|id| id.to_hex()); + if img_ref_hex.as_deref() == Some(verity.as_str()) { + 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; } } } diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 3c34c0a4a..e788a6672 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -41,6 +41,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs::repository::RepositoryConfig; use composefs_boot::bootloader::{BootEntry as ComposefsBootEntry, get_boot_resources}; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; @@ -99,16 +100,24 @@ pub(crate) async fn initialize_composefs_repository( crate::store::ensure_composefs_dir(rootfs_dir)?; - let (mut repo, _created) = crate::store::ComposefsRepository::init_path( - rootfs_dir, - "composefs", - composefs::fsverity::Algorithm::SHA512, - !allow_missing_fsverity, - ) - .context("Failed to initialize composefs repository")?; - if allow_missing_fsverity { - repo.set_insecure(); - } + let mut config = { + let c = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + if allow_missing_fsverity { + c.set_insecure() + } else { + c + } + }; + // Generate both V1 and V2 EROFS images so a deployment can be booted via + // either the composefs= legacy shorthand (V2) or composefs.digest= (V1/V2) + // karg. This makes both digests available for any boot path. + config.erofs_formats = composefs::erofs::format::FormatConfig { + default: composefs::erofs::format::FormatVersion::V1, + extra: [composefs::erofs::format::FormatVersion::V2].into(), + }; + let (repo, _created) = + crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config) + .context("Failed to initialize composefs repository")?; let imgref: containers_image_proxy::ImageReference = state .source diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 16b469206..e588c8e68 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -3,7 +3,8 @@ use std::{io::Read, sync::OnceLock}; use anyhow::{Context, Result}; use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::inspect_filesystem; -use composefs_ctl::composefs::fsverity::Sha512HashValue; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_ctl::composefs_boot::cmdline::ComposefsCmdline as BootComposefsCmdline; use composefs_ctl::composefs_oci; use composefs_oci::OciImage; use fn_error_context::context; @@ -95,15 +96,16 @@ impl ComposefsCmdline { } } - /// Search for the `composefs=` parameter in the passed in kernel command line + /// Search for `composefs=` (V2) or `composefs.digest=` (V1) in the kernel + /// command line. Delegates to the composefs-boot library which handles + /// both formats, extracting the raw hex hash in either case. 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, - } + let parsed = BootComposefsCmdline::::from_cmdline(cmdline).ok()??; + Some(ComposefsCmdline { + allow_missing_fsverity: parsed.is_insecure(), + digest: parsed.digest().to_hex().into(), + is_transient: false, + }) } } @@ -149,18 +151,17 @@ pub(crate) struct BootloaderEntry { pub(crate) boot_artifact_name: String, } -/// Detect if we have `composefs=` in `/proc/cmdline` +/// Detect if we have `composefs=` or `composefs.digest=:` +/// in `/proc/cmdline`. pub(crate) fn composefs_booted() -> Result> { static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); if let Some(v) = CACHED_DIGEST_VALUE.get() { 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())?; @@ -1172,7 +1173,8 @@ mod tests { #[test] fn test_find_in_cmdline() { - const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + // Must be 128 hex chars (SHA-512) to match Sha512HashValue + const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; // Test case: cmdline contains composefs parameter let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs={}", DIGEST)); diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 79c823159..2488bd307 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -141,13 +141,18 @@ pub(crate) fn validate_update( let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity))?; fs.transform_for_boot(&repo)?; - let image_id = fs.compute_image_id(); + // Match against both EROFS format ids: a deployment committed by a repo + // that generates both formats may be recorded under either the V1 + // (composefs.digest=v1-...) or V2 (composefs=, legacy shorthand) digest. + // compute_image_id is a cheap in-memory EROFS generation. + let id_v1 = fs.compute_image_id(composefs::erofs::format::FormatVersion::V1); + let id_v2 = fs.compute_image_id(composefs::erofs::format::FormatVersion::V2); let all_deployments = host.all_composefs_deployments()?; let found_depl = all_deployments .iter() - .find(|d| d.deployment.verity == image_id.to_hex()); + .find(|d| d.deployment.verity == id_v1.to_hex() || d.deployment.verity == id_v2.to_hex()); if let Some(collision) = found_depl { if is_switch { @@ -195,10 +200,13 @@ pub(crate) fn validate_update( .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")?; + // New deployments are keyed by V1; check both in case an older deployment + // was recorded under V2. + for id in [&id_v1, &id_v2] { + let hex = id.to_hex(); + if state_dir.exists(&hex) { + state_dir.remove_dir_all(&hex).context("Removing state")?; + } } Ok(UpdateAction::Proceed) @@ -237,6 +245,34 @@ async fn apply_upgrade( Ok(()) } +/// Refuse to deploy if `deploy_id` collides with an existing deployment +/// (booted, staged, rollback, or pinned). +/// +/// `deploy_id` names the `state/deploy/` directory whose `/etc` is +/// seeded from the deploying image. Two images from different sources can +/// produce identical content (hence the same fs-verity digest); silently +/// reusing such a directory would graft one image's `/etc` onto another, so we +/// bail instead. +/// +/// This complements `validate_update`, which only checks `switch` (and `Skip`s +/// for `upgrade`); the `img_pulled == None` path also reaches `do_upgrade` +/// without going through it, so the guard is enforced here against the real +/// deploy key. +fn ensure_no_deploy_collision(host: &Host, deploy_id: &Sha512HashValue) -> Result<()> { + let deploy_hex = deploy_id.to_hex(); + if let Some(collision) = host + .all_composefs_deployments()? + .iter() + .find(|d| d.deployment.verity == deploy_hex) + { + anyhow::bail!( + "Target image has the same fs-verity digest as the existing {:?} deployment.", + collision.ty, + ); + } + Ok(()) +} + /// Performs the Update or Switch operation #[context("Performing Upgrade Operation")] pub(crate) async fn do_upgrade( @@ -264,56 +300,83 @@ pub(crate) async fn do_upgrade( ) .await?; - // If the target image produces the same fs-verity digest as any existing - // deployment (booted, staged, rollback, or pinned), error out. Two images - // from different sources can have identical content; we cannot silently reuse - // an existing state directory whose /etc was seeded from a different image. - let all_deployments = host.all_composefs_deployments()?; - if let Some(collision) = all_deployments - .iter() - .find(|d| d.deployment.verity == id.to_hex()) - { - anyhow::bail!( - "Target image has the same fs-verity digest as the existing {:?} deployment.", - collision.ty, - ); - } - let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); }; + let boot_type = BootType::from(entry); + + // Mounting just needs *a* valid bootable EROFS to read boot resources from; + // V1 and V2 are byte-distinct serializations of the same filesystem, so `id` + // (the repo-default boot image from generate_boot_image) always works and is + // guaranteed to exist. let mounted_fs = Dir::reopen_dir( &repo .mount(&id.to_hex()) .context("Failed to mount composefs image")?, )?; - 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, - )?, + // Open the OCI image to read both stored boot EROFS digests. The UKI may + // have been sealed with either the V1 or V2 boot image digest, so we need + // both for verification. + let manifest_oci_digest: composefs_oci::OciDigest = manifest_digest + .parse() + .with_context(|| format!("Parsing manifest digest {manifest_digest}"))?; + let oci_img = composefs_oci::oci_image::OciImage::open(&repo, &manifest_oci_digest, None) + .context("Opening OCI image to read boot image refs")?; + let boot_id_v1 = oci_img.boot_image_ref_v1().cloned(); + let boot_id_v2 = oci_img.boot_image_ref_v2().cloned(); + let boot_ids_owned: Vec = [boot_id_v1, boot_id_v2.clone()] + .into_iter() + .flatten() + .collect(); + let boot_ids: Vec<&Sha512HashValue> = boot_ids_owned.iter().collect(); + + // The deployment key must equal the digest in the next-boot composefs karg + // (see setup_composefs_boot for the full rationale). Provisional value for + // BLS (where bootc writes the karg from this same id); overridden for UKI by + // the digest the UKI cmdline actually carries. + let provisional_deploy_id = boot_id_v2.clone().unwrap_or_else(|| id.clone()); + + // Early collision check against the provisional key, before any ESP or + // bootloader writes. This catches BLS (where the provisional is the final + // key) and the common V2-sealed UKI case, avoiding leaving orphaned ESP + // entries behind on a bail. A UKI sealed with a non-default digest is still + // caught by the authoritative re-check below, once its real key is known. + ensure_no_deploy_collision(host, &provisional_deploy_id)?; + + let (boot_digest, deploy_id) = match boot_type { + BootType::Bls => ( + setup_composefs_bls_boot( + BootSetupType::Upgrade((storage, booted_cfs, &host)), + repo, + &provisional_deploy_id, + entry, + &mounted_fs, + )?, + provisional_deploy_id, + ), BootType::Uki => setup_composefs_uki_boot( BootSetupType::Upgrade((storage, booted_cfs, &host)), repo, - &id, + &provisional_deploy_id, + &boot_ids, entries, )?, }; + // Authoritative collision check against the final deploy key. For UKI this + // may differ from the provisional checked above (the UKI may carry a + // non-default digest), so this is the load-bearing guarantee. + ensure_no_deploy_collision(host, &deploy_id)?; + write_composefs_state( &Utf8PathBuf::from("/sysroot"), - &id, + &deploy_id, imgref, Some(StagedDeployment { - depl_id: id.to_hex(), + depl_id: deploy_id.to_hex(), finalization_locked: opts.download_only, }), boot_type, @@ -335,7 +398,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("Upgrading composefs")] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 9a7c307ff..72192c545 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; @@ -404,6 +405,13 @@ pub(crate) enum ContainerOpts { /// Additionally generate a dumpfile written to the target path #[clap(long)] write_dumpfile_to: Option, + + /// EROFS format version to use when computing the composefs digest. + /// + /// V1 produces a `composefs.digest=v1-sha256-12:` karg (C-tool compatible). + /// V2 produces the legacy `composefs=` karg (composefs-rs native). + #[clap(long, default_value = "v2")] + erofs_version: ErofsVersionArg, }, /// Output the bootable composefs digest from container storage. #[clap(hide = true)] @@ -438,6 +446,14 @@ pub(crate) enum ContainerOpts { #[clap(long)] allow_missing_verity: bool, + /// EROFS format version to use when computing the composefs digest. + /// + /// V1 produces a `composefs.digest=v1-sha256-12:` karg (C-tool compatible). + /// V2 produces the legacy `composefs=` karg (composefs-rs native). + /// Must match the format version used when images were committed to the repository. + #[clap(hide = true, long, default_value = "v2")] + erofs_version: ErofsVersionArg, + /// Write a dumpfile to this path #[clap(long)] write_dumpfile_to: Option, @@ -484,6 +500,24 @@ pub(crate) enum ContainerOpts { }, } +/// EROFS format version for `bootc container ukify --erofs-version`. +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub(crate) enum ErofsVersionArg { + /// V1 EROFS (C-tool compatible, `composefs.digest=v1-sha256-12:` karg). + V1, + /// V2 EROFS (composefs-rs native, `composefs=` karg). Default. + V2, +} + +impl From for FormatVersion { + fn from(v: ErofsVersionArg) -> Self { + match v { + ErofsVersionArg::V1 => FormatVersion::V1, + ErofsVersionArg::V2 => FormatVersion::V2, + } + } +} + #[derive(Debug, Clone, ValueEnum, PartialEq, Eq)] pub(crate) enum ExportFormat { /// Export as tar archive @@ -1840,8 +1874,14 @@ 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(()) } @@ -1849,7 +1889,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { write_dumpfile_to, image, } => { - let (_td_guard, repo) = new_temp_composefs_repo()?; + let (_td_guard, repo) = new_temp_composefs_repo(FormatVersion::V2)?; let mut proxycfg = crate::deploy::new_proxy_config(); @@ -1892,7 +1932,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { ) .context("Populating fs")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; - let id = fs.compute_image_id(); + let id = fs.compute_image_id(repo.erofs_version()); println!("{}", id.to_hex()); if let Some(path) = write_dumpfile_to.as_deref() { @@ -1908,6 +1948,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { rootfs, kargs, allow_missing_verity, + erofs_version, write_dumpfile_to, kernel_dir, args, @@ -1940,6 +1981,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { &args, kernel, allow_missing_verity, + erofs_version.into(), write_dumpfile_to.as_deref(), ) .await diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index c15f6cb76..1efcbd711 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -1409,7 +1409,7 @@ mod tests { ); // Verify the version is present (not just "bootc/") assert!( - prefix.len() > "bootc/".len(), + prefix.strip_prefix("bootc/").is_some_and(|v| !v.is_empty()), "Version should be present after bootc/" ); } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index d49c6a350..e4da24126 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -204,6 +204,7 @@ use crate::task::Task; use crate::utils::sigpolicy_from_opt; use bootc_kernel_cmdline::{INITRD_ARG_PREFIX, ROOTFLAGS, bytes, utf8}; use bootc_mount::Filesystem; +use composefs_ctl::composefs::repository::RepositoryConfig; /// The toplevel boot directory pub(crate) const BOOT: &str = "boot"; @@ -2019,12 +2020,20 @@ async fn install_to_filesystem_impl( let imgref = &state.source.imageref; let img_manifest_config = get_container_manifest_and_config(&imgref).await?; crate::store::ensure_composefs_dir(&rootfs.physical_root)?; - // Use init_path since the repo may not exist yet during install + // Use init_path since the repo may not exist yet during install. + // Generate both V1 and V2 EROFS images (see initialize_composefs_repository); + // this config must match the one used there since it re-inits the same repo. + let mut config = + RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512) + .set_insecure(); + config.erofs_formats = composefs_ctl::composefs::erofs::format::FormatConfig { + default: composefs_ctl::composefs::erofs::format::FormatVersion::V1, + extra: [composefs_ctl::composefs::erofs::format::FormatVersion::V2].into(), + }; let (cfs_repo, _created) = crate::store::ComposefsRepository::init_path( &rootfs.physical_root, crate::store::COMPOSEFS, - composefs_ctl::composefs::fsverity::Algorithm::SHA512, - false, + config, )?; crate::deploy::check_disk_space_composefs( &cfs_repo, diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index c72674a08..6dc18b99f 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -197,7 +197,7 @@ impl BLSConfig { .ok_or_else(|| anyhow::anyhow!("No options"))?; let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(&options)) - .ok_or_else(|| anyhow::anyhow!("No composefs= param"))?; + .ok_or_else(|| anyhow::anyhow!("No composefs= or composefs.digest= param"))?; Ok(cfs_cmdline.digest.to_string()) } diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 653fd2a25..06f62671a 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -111,6 +111,7 @@ use ostree_ext::{gio, ostree}; use rustix::fs::Mode; use composefs::fsverity::Sha512HashValue; +use composefs::repository::{RepositoryConfig, RepositoryOpenError}; use composefs_ctl::composefs; use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix; @@ -618,16 +619,42 @@ impl Storage { let ostree = self.get_ostree()?; let ostree_repo = &ostree.repo(); let ostree_verity = ostree_ext::fsverity::is_verity_enabled(ostree_repo)?; - let (mut composefs, _created) = ComposefsRepository::init_path( - self.physical_root.open_dir(COMPOSEFS)?, - ".", - composefs::fsverity::Algorithm::SHA512, - ostree_verity.enabled, - )?; if !ostree_verity.enabled { tracing::debug!("Setting insecure mode for composefs repo"); - composefs.set_insecure(); } + + // This is a runtime open-or-create path: the repository was almost + // always already initialized at install time, with a format + // (V2_ONLY or BOTH) chosen then. init_path() asserts that the + // requested config exactly matches the on-disk meta.json, so a + // static config here would fail to open repos created with the + // other format. Prefer open_path(), which adopts whatever format + // is on disk, and only fall back to init_path() (with BOTH, to + // match the install path) when the repository does not yet exist. + let composefs_dir = self.physical_root.open_dir(COMPOSEFS)?; + let composefs = match ComposefsRepository::open_path(&composefs_dir, ".") { + Ok(mut repo) => { + if !ostree_verity.enabled { + repo.set_insecure(); + } + repo + } + Err(RepositoryOpenError::MetadataMissing) => { + let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512); + config.erofs_formats = composefs::erofs::format::FormatConfig { + default: composefs::erofs::format::FormatVersion::V1, + extra: [composefs::erofs::format::FormatVersion::V2].into(), + }; + let config = if !ostree_verity.enabled { + config.set_insecure() + } else { + config + }; + let (repo, _created) = ComposefsRepository::init_path(&composefs_dir, ".", config)?; + repo + } + Err(e) => return Err(e.into()), + }; let composefs = Arc::new(composefs); let r = Arc::clone(self.composefs.get_or_init(|| composefs)); Ok(r) diff --git a/crates/lib/src/testutils.rs b/crates/lib/src/testutils.rs index 3712c7e44..9b682d3d4 100644 --- a/crates/lib/src/testutils.rs +++ b/crates/lib/src/testutils.rs @@ -10,6 +10,7 @@ use anyhow::{Context, Result}; use cap_std_ext::cap_std::{self, fs::Dir}; use cap_std_ext::cap_tempfile; use cap_std_ext::dirext::CapStdExtDirExt; +use composefs_ctl::composefs::repository::RepositoryConfig; use crate::bootc_composefs::boot::{ FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, get_type1_dir_name, primary_sort_key, @@ -24,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) } @@ -164,14 +165,10 @@ impl TestRoot { // Initialize the composefs repo (creates meta.json) let repo_dir = root.open_dir("composefs")?; - let (mut repo, _created) = ComposefsRepository::init_path( - &repo_dir, - ".", - composefs_ctl::composefs::fsverity::Algorithm::SHA512, - false, - ) - .context("Initializing composefs repo")?; - repo.set_insecure(); + let config = RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512) + .set_insecure(); + let (repo, _created) = ComposefsRepository::init_path(&repo_dir, ".", config) + .context("Initializing composefs repo")?; let mut test_root = Self { root, @@ -502,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); } } @@ -545,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 523836217..9a85ef863 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -13,6 +13,9 @@ use camino::Utf8Path; use cap_std_ext::cap_std::fs::Dir; use fn_error_context::context; +use composefs::erofs::format::FormatVersion; +use composefs_ctl::composefs; + use crate::bootc_composefs::digest::compute_composefs_digest; use crate::bootc_composefs::status::ComposefsCmdline; use crate::kernel::KernelInternal; @@ -33,6 +36,7 @@ pub(crate) async fn build_ukify( args: &[OsString], kernel: Option, allow_missing_fsverity: bool, + erofs_version: FormatVersion, write_dumpfile_to: Option<&Utf8Path>, ) -> Result<()> { // Warn if --karg is used (temporary workaround) @@ -97,7 +101,8 @@ pub(crate) async fn build_ukify( } // Compute the composefs digest - let composefs_digest = compute_composefs_digest(rootfs, write_dumpfile_to).await?; + let composefs_digest = + compute_composefs_digest(rootfs, erofs_version, write_dumpfile_to).await?; // Get kernel arguments from kargs.d let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; @@ -152,7 +157,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let path = Utf8Path::from_path(tempdir.path()).unwrap(); - let result = build_ukify(path, &[], &[], None, false, None).await; + let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( @@ -174,7 +179,7 @@ mod tests { ) .unwrap(); - let result = build_ukify(path, &[], &[], None, false, None).await; + let result = build_ukify(path, &[], &[], None, false, FormatVersion::V2, None).await; assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index a7d8e89d6..41e1da311 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -330,6 +330,119 @@ pub(crate) fn test_compute_composefs_digest() -> Result<()> { Ok(()) } +/// Test that `bootc container ukify --erofs-version` is plumbed correctly. +/// +/// Verifies that: +/// - `compute-composefs-digest --erofs-version=v1` and `=v2` produce distinct, +/// valid 128-char SHA-512 hex digests (different EROFS layouts → different IDs). +/// - `bootc container ukify --erofs-version=v1` either invokes ukify (skipping +/// gracefully if ukify is absent) or fails with a clear error before ukify. +pub(crate) fn test_container_ukify_erofs_versions() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + // Build a minimal rootfs that satisfies find_kernel() and build_ukify()'s + // existence checks. The files don't need to be real ELF/CPIO — bootc only + // stat-checks them before handing them off to ukify. + let td = tempfile::tempdir()?; + let root = td.path(); + + fs::create_dir_all(root.join("boot"))?; + fs::create_dir_all(root.join("sysroot"))?; + + let usr_bin = root.join("usr/bin"); + fs::create_dir_all(&usr_bin)?; + let hello = usr_bin.join("hello"); + fs::write(&hello, b"#!/bin/sh\necho hello\n")?; + fs::set_permissions(&hello, fs::Permissions::from_mode(0o755))?; + + // Kernel layout that find_kernel() expects + let kver = "6.1.0-test"; + let mod_dir = root.join("usr/lib/modules").join(kver); + fs::create_dir_all(&mod_dir)?; + fs::write(mod_dir.join("vmlinuz"), b"fake-vmlinuz")?; + fs::write(mod_dir.join("initramfs.img"), b"fake-initramfs")?; + + // ukify reads --os-release @usr/lib/os-release relative to the rootfs cwd + let os_release_dir = root.join("usr/lib"); + fs::create_dir_all(&os_release_dir)?; + fs::write( + os_release_dir.join("os-release"), + b"ID=test\nNAME=Test\nVERSION_ID=1\n", + )?; + + let root_str = root.to_str().unwrap(); + + // ── Part 1: compare V1 vs V2 digest via compute-composefs-digest ────────── + let sh = Shell::new()?; + + let digest_v2 = cmd!( + sh, + "bootc container compute-composefs-digest {root_str} --erofs-version=v2" + ) + .read()?; + let digest_v1 = cmd!( + sh, + "bootc container compute-composefs-digest {root_str} --erofs-version=v1" + ) + .read()?; + + let digest_v2 = digest_v2.trim(); + let digest_v1 = digest_v1.trim(); + + assert_eq!(digest_v2.as_bytes().len(), 128, "V2 digest must be 128 hex chars"); + assert_eq!(digest_v1.as_bytes().len(), 128, "V1 digest must be 128 hex chars"); + assert!( + digest_v2.chars().all(|c| c.is_ascii_hexdigit()), + "V2 digest contains non-hex chars: {digest_v2}" + ); + assert!( + digest_v1.chars().all(|c| c.is_ascii_hexdigit()), + "V1 digest contains non-hex chars: {digest_v1}" + ); + assert_ne!( + digest_v1, digest_v2, + "V1 and V2 EROFS digests must differ (they use different on-disk layouts)" + ); + + // ── Part 2: smoke-test the full ukify CLI path with --erofs-version=v1 ──── + // + // We don't assert success because ukify will fail on fake kernel blobs. + // What we're testing is that bootc reaches the ukify invocation stage — + // i.e. the --erofs-version plumbing is wired correctly all the way through. + let output = Command::new("bootc") + .args([ + "container", + "ukify", + "--rootfs", + root_str, + "--erofs-version=v1", + "--allow-missing-verity", + "--", + "--output=/dev/null", + ]) + .output()?; + + let stderr = String::from_utf8_lossy(&output.stderr); + + if stderr.contains("ukify executable not found in PATH") { + // ukify binary absent: the CLI plumbing still ran up to that check. + eprintln!("note: ukify not found, skipping ukify invocation check"); + return Ok(()); + } + + // ukify was found and invoked. It will fail because of the fake kernel + // blobs, but bootc must have reached the `ukify build` invocation, which + // means the V1 digest was computed and the cmdline assembled. Assert that + // no *bootc* logic bailed before reaching ukify (i.e. no "No kernel found", + // "already contains a UKI", or similar early exits). + assert!( + !stderr.contains("No kernel found") && !stderr.contains("already contains a UKI"), + "bootc bailed before reaching ukify; stderr:\n{stderr}" + ); + + Ok(()) +} + /// Tests that should be run in a default container image. #[context("Container tests")] pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { @@ -343,6 +456,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 728eec97f..64edf745e 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -475,8 +475,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 @@ -484,6 +487,10 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { if let Some(ref upgrade_img) = args.upgrade_image { tmt_env_vars.push(format!("{}={}", ENV_BOOTC_UPGRADE_IMAGE, upgrade_img)); } + } else if try_bind_storage && args.skip_bind_storage { + println!( + "Note: Test requests bind storage but --skip-bind-storage was set; running without host container-storage mount" + ); } else if try_bind_storage && !supports_bind_storage_ro { println!( "Note: Test wants bind storage but skipping on {} (missing systemd.extra-unit.* support)", diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index be0a2c235..c6e1a628a 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")] @@ -228,6 +240,24 @@ pub(crate) struct RunTmtArgs { #[clap(long)] pub(crate) upgrade_image: Option, + /// Skip the `--bind-storage-ro` host container-storage virtiofs mount even for + /// plans that request it. Useful where libvirt-managed virtiofsd cannot run + /// (nested user namespaces, cloud/non-qemu). Plans that depend on a locally + /// built upgrade image being available in-VM via bind-storage will not be able + /// to perform the upgrade/switch step. + /// + /// Takes `0`/`1`/`true`/`false` so it can be driven from the Justfile via + /// `BOOTC_skip_bind_storage=1`. A bare `--skip-bind-storage` means `1`. + #[arg( + long, + env = "BOOTC_skip_bind_storage", + num_args = 0..=1, + default_value_t = false, + default_missing_value = "1", + value_parser = parse_cli_bool, + )] + pub(crate) skip_bind_storage: bool, + /// Preserve VMs after test completion (useful for debugging) #[arg(long)] pub(crate) preserve_vm: bool, @@ -771,3 +801,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/tmt/tests/Dockerfile.upgrade b/tmt/tests/Dockerfile.upgrade index 561e2e0a7..893db8aa3 100644 --- a/tmt/tests/Dockerfile.upgrade +++ b/tmt/tests/Dockerfile.upgrade @@ -8,6 +8,7 @@ ARG boot_type=bls ARG seal_state=unsealed ARG filesystem=ext4 +ARG erofs_version=v2 # Capture contrib/packaging scripts for use in later stages FROM scratch AS packaging @@ -31,7 +32,7 @@ RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ # bootc is already installed in localhost/bootc (our tools base); the # container ukify command it provides is needed for seal-uki. FROM tools AS sealed-upgrade-uki -ARG boot_type seal_state filesystem +ARG boot_type seal_state filesystem erofs_version RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ --mount=type=secret,id=secureboot_key \ --mount=type=secret,id=secureboot_cert \ @@ -45,7 +46,7 @@ if [ "${filesystem}" = "xfs" ]; then fi if test "${boot_type}" = "uki"; then - /run/packaging/seal-uki /run/target /out /run/secrets "${allow_missing_verity}" "${seal_state}" + /run/packaging/seal-uki /run/target /out /run/secrets "${allow_missing_verity}" "${seal_state}" "${erofs_version}" fi EORUN diff --git a/tmt/tests/booted/readonly/046-test-erofs-version.nu b/tmt/tests/booted/readonly/046-test-erofs-version.nu new file mode 100644 index 000000000..e5b188c08 --- /dev/null +++ b/tmt/tests/booted/readonly/046-test-erofs-version.nu @@ -0,0 +1,50 @@ +use std assert +use tap.nu + +tap begin "verify composefs UKI EROFS version boots correctly" + +let is_composefs = (tap is_composefs) + +if not $is_composefs { + print "# Skipping: not a composefs system" + tap ok + exit 0 +} + +let st = bootc status --json | from json +let is_uki = ($st.status.booted.composefs.bootType | str downcase) == "uki" + +if not $is_uki { + print "# Skipping: not a UKI boot" + tap ok + exit 0 +} + +let erofs_version = ($env.BOOTC_erofs_version? | default "v2") +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 composefs= karg is always in the v2 format (composefs=), +# regardless of --erofs-version; the difference is which EROFS image id +# is computed. Verify the karg is present and well-formed. +let cmdline = open /proc/cmdline | str trim +assert ( + $cmdline | str contains "composefs=" +) $"Expected composefs= karg in cmdline, got: ($cmdline)" + +# Verify the composefs karg value matches the booted verity digest +let cfs_param = ($cmdline | split row " " | where { |p| $p | str starts-with "composefs=" } | first) +let cfs_value = ($cfs_param | str replace "composefs=" "") +# Strip optional leading '?' for insecure mode +let cfs_digest = (if ($cfs_value | str starts-with "?") { $cfs_value | str substring 1.. } else { $cfs_value }) +assert equal $cfs_digest $verity "composefs= karg must match booted verity digest" +print $"# Verified composefs karg matches verity" + +tap ok diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index b8b0b3f7e..0f5ed1495 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -75,7 +75,16 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string] { +# Append UKI-sealing stages to a Containerfile string. +# +# On non-composefs or non-UKI systems the input is returned unchanged. +# erofs_version controls which EROFS format the composefs digest is computed +# in: "v1" produces a composefs.digest=v1-sha256-12: karg (RHEL9-compatible), +# "v2" (default) produces the legacy composefs= karg. +export def make_uki_containerfile [ + containerfile: string + --erofs-version: string = "v2" +] { let is_cfs = (is_composefs) if not $is_cfs { @@ -100,7 +109,7 @@ export def make_uki_containerfile [containerfile: string] { FROM base as sealed-uki RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \\ --mount=type=bind,from=base-final,src=/,target=/run/target \\ - /usr/bin/seal-uki /run/target /out /run/secrets ($allow_missing_verity) ($seal_state) + /usr/bin/seal-uki /run/target /out /run/secrets ($allow_missing_verity) ($seal_state) ($erofs_version) FROM base-final