From 18cac3b305249c8de4c4c097951744c5664a6621 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 28 Sep 2026 22:20:39 +0200 Subject: [PATCH 01/11] Bump composefs-rs to 0.9.3 This adds support for android boot. Signed-off-by: Alexander Larsson --- Cargo.lock | 53 ++++++++++++++++++++++++++++++++++------------------- Cargo.toml | 2 +- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2986bb9f2..c960871ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,8 +780,8 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "anyhow", "composefs-ioctls", @@ -802,13 +802,13 @@ dependencies = [ "xxhash-rust", "zerocopy", "zlink-core", - "zstd 0.13.3", + "zstd 0.14.0", ] [[package]] name = "composefs-boot" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "anyhow", "composefs", @@ -823,8 +823,8 @@ dependencies = [ [[package]] name = "composefs-ctl" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "anyhow", "cap-std-ext", @@ -852,8 +852,8 @@ dependencies = [ [[package]] name = "composefs-fuse" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "anyhow", "composefs", @@ -867,8 +867,8 @@ dependencies = [ [[package]] name = "composefs-ioctls" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "rustix", "thiserror 2.0.20", @@ -876,8 +876,8 @@ dependencies = [ [[package]] name = "composefs-oci" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "anyhow", "async-compression", @@ -893,6 +893,7 @@ dependencies = [ "fn-error-context", "hex", "indicatif", + "oci-delta", "ocidir", "rustix", "serde", @@ -904,13 +905,13 @@ dependencies = [ "tracing", "zlink", "zlink-core", - "zstd 0.13.3", + "zstd 0.14.0", ] [[package]] name = "composefs-splitdirfdstream" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "openssl", "rand 0.10.3", @@ -922,8 +923,8 @@ dependencies = [ [[package]] name = "composefs-storage" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.3" +source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.3#5ecf9f76231ef5d3d55e037e780cb18be35ba5d9" dependencies = [ "anyhow", "base64 0.23.0", @@ -942,7 +943,7 @@ dependencies = [ "toml", "tracing", "zlink", - "zstd 0.13.3", + "zstd 0.14.0", ] [[package]] @@ -2396,6 +2397,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "oci-delta" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc185feabe86ac78e5c4ae8b800078f257621ba652cd426d516a846aed93b543" +dependencies = [ + "anyhow", + "flate2", + "hex", + "oci-spec", + "openssl", + "zstd 0.13.3", +] + [[package]] name = "oci-spec" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 76245127a..eb2f7d753 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ clap_mangen = { version = "0.3.0" } # pulls), which drags in rustls-webpki/untrusted/webpki-root-certs whose # licenses aren't in our cargo-deny allow list. Re-enable once that's sorted. # See: https://github.com/bootc-dev/bootc/pull/2295 -composefs-ctl = { git = "https://github.com/composefs/composefs-rs", tag = "v0.9.2", default-features = false, features = [ +composefs-ctl = { git = "https://github.com/composefs/composefs-rs", tag = "v0.9.3", default-features = false, features = [ "pre-6.15", "pre-6.16", "oci", From 6234f442b62bad38704ccc433c11bf8f7216bc27 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Fri, 18 Sep 2026 16:56:24 +0200 Subject: [PATCH 02/11] composefs: recognize aboot boot artifacts Add the Aboot boot type and map composefs-boot aboot entries to it. Reject aboot operations before modifying bootloader state until the deployment and lifecycle paths are implemented. Assisted-by: AI Signed-off-by: Alexander Larsson --- .../backwards_compat/bcompat_boot.rs | 1 + crates/lib/src/bootc_composefs/boot.rs | 22 ++++++++++++++----- crates/lib/src/bootc_composefs/delete.rs | 1 + crates/lib/src/bootc_composefs/finalize.rs | 1 + crates/lib/src/bootc_composefs/gc.rs | 1 + crates/lib/src/bootc_composefs/rollback.rs | 1 + crates/lib/src/bootc_composefs/state.rs | 2 +- crates/lib/src/bootc_composefs/status.rs | 2 ++ crates/lib/src/bootc_composefs/update.rs | 8 +++++-- docs/src/host-v1.schema.json | 3 ++- 10 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs index bf203c0d4..bbc18c6f9 100644 --- a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs +++ b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs @@ -392,6 +392,7 @@ pub(crate) async fn prepend_custom_prefix( handle_bls_conf(storage, cfs_cmdline, boot_dir, true)?; } }, + BootType::Aboot => anyhow::bail!("aboot backwards compatibility is not implemented"), }; Ok(()) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a10d41bf1..a7a927e1c 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -327,6 +327,7 @@ pub enum BootType { #[default] Bls, Uki, + Aboot, } impl ::std::fmt::Display for BootType { @@ -334,6 +335,7 @@ impl ::std::fmt::Display for BootType { let s = match self { BootType::Bls => "bls", BootType::Uki => "uki", + BootType::Aboot => "aboot", }; write!(f, "{}", s) @@ -347,6 +349,7 @@ impl TryFrom<&str> for BootType { match value { "bls" => Ok(Self::Bls), "uki" => Ok(Self::Uki), + "aboot" => Ok(Self::Aboot), unrecognized => Err(anyhow::anyhow!( "Unrecognized boot option: '{unrecognized}'" )), @@ -360,6 +363,7 @@ impl From<&ComposefsBootEntry> for BootType { ComposefsBootEntry::Type1(..) => Self::Bls, ComposefsBootEntry::Type2(..) => Self::Uki, ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, + ComposefsBootEntry::Aboot(..) => Self::Aboot, } } } @@ -882,6 +886,7 @@ pub(crate) fn setup_composefs_bls_boot( let (bls_config, boot_digest, os_id) = match &entry { ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"), ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"), + ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"), ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) @@ -1805,6 +1810,7 @@ pub(crate) fn setup_composefs_uki_boot( ComposefsBootEntry::UsrLibModulesVmLinuz(..) => { tracing::debug!("Skipping vmlinuz in /usr/lib/modules") } + ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"), ComposefsBootEntry::Type2(entry) => { // If --uki-addon is not passed, we don't install any addon (whether @@ -2103,6 +2109,15 @@ pub(crate) async fn setup_composefs_boot( entries, } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; + let Some(entry) = entries.iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(entry); + if boot_type == BootType::Aboot { + bail!("aboot boot setup is not implemented"); + } + let composefs_mnt_fd = repo .mount(&id.to_hex()) .context("Failed to mount composefs image")?; @@ -2196,12 +2211,6 @@ pub(crate) async fn setup_composefs_boot( })?; } - let Some(entry) = entries.iter().next() else { - anyhow::bail!("No boot entries!"); - }; - - let boot_type = BootType::from(entry); - let repo = Arc::try_unwrap(repo).map_err(|_| { anyhow::anyhow!( "BUG: Arc still has other references after boot image generation" @@ -2232,6 +2241,7 @@ pub(crate) async fn setup_composefs_boot( &repo, &fs, )?, + BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), }; write_composefs_state( diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 97295f389..fc9a3ae8c 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -154,6 +154,7 @@ fn delete_depl_boot_entries( BootType::Uki => { remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } + BootType::Aboot => anyhow::bail!("aboot deletion is not implemented"), }, BootloaderKind::BLSCompatible => { diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index eec182594..845a448fe 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -145,6 +145,7 @@ pub(crate) async fn composefs_backend_finalize( rename_exchange_bls_entries(&entries_dir)?; } BootType::Uki => finalize_staged_grub_uki(boot_dir)?, + BootType::Aboot => anyhow::bail!("aboot finalization is not implemented"), }, BootloaderKind::BLSCompatible => { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 5e0e77158..652216943 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -312,6 +312,7 @@ pub(crate) async fn composefs_gc( delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)? } BootType::Uki => delete_uki(storage, verity, gc_opts.dry_run)?, + BootType::Aboot => anyhow::bail!("aboot garbage collection is not implemented"), } } diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 307545463..51f64aebf 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -260,6 +260,7 @@ pub(crate) async fn composefs_rollback( BootType::Uki => { rollback_grub_uki_entries(boot_dir)?; } + BootType::Aboot => anyhow::bail!("aboot rollback is not implemented"), }, BootloaderKind::BLSCompatible => { diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 9f15d31fc..ec1df048b 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -227,7 +227,7 @@ pub(crate) fn update_boot_digest_in_origin( /// * `deployment_id` - Unique SHA512 hash identifier for this deployment /// * `imgref` - Container image reference for the deployment /// * `staged` - Whether this is a staged deployment (writes to transient state dir) -/// * `boot_type` - Boot loader type (`Bls` or `Uki`) +/// * `boot_type` - Boot artifact type (`Bls`, `Uki`, or `Aboot`) /// * `boot_digest` - Optional boot digest for verification /// * `manifest_digest` - OCI manifest content digest, stored in the origin file so the /// manifest+config can be retrieved from the composefs repo later diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 512608d82..253a2cc4b 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -714,6 +714,7 @@ fn set_soft_reboot_capability( } BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host), + BootType::Aboot => Ok(()), } } @@ -1074,6 +1075,7 @@ async fn composefs_deployment_status_from( (is_rollback_queued, None, Some(menuentries)) } + BootType::Aboot => anyhow::bail!("aboot status is not implemented"), }, // We will have BLS stuff and the UKI stuff in the same DIR diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 45e4c5ad2..336403c9a 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -200,6 +200,7 @@ pub(crate) fn validate_update( .context("Removing staged grub user config")?; } } + BootType::Aboot => anyhow::bail!("aboot update cleanup is not implemented"), }, BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, @@ -309,6 +310,10 @@ pub(crate) async fn do_upgrade( let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); }; + let boot_type = BootType::from(entry); + if boot_type == BootType::Aboot { + anyhow::bail!("aboot boot setup is not implemented"); + } let mounted_fs = Dir::reopen_dir( &repo @@ -328,8 +333,6 @@ pub(crate) async fn do_upgrade( anyhow::bail!("Merge conflicts found in etc"); } - let boot_type = BootType::from(entry); - let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version()); let (boot_digest, deploy_id) = match boot_type { @@ -356,6 +359,7 @@ pub(crate) async fn do_upgrade( &repo, &oci_fs, )?, + BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), }; // `repo` holds its own flock(LOCK_SH) on /sysroot/composefs, taken out by diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 686b71e04..451875957 100644 --- a/docs/src/host-v1.schema.json +++ b/docs/src/host-v1.schema.json @@ -204,7 +204,8 @@ "type": "string", "enum": [ "Bls", - "Uki" + "Uki", + "Aboot" ] }, "Bootloader": { From 3dbb0f4dc41751fab2b93dfeb6ad9cda49e772d4 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Fri, 18 Sep 2026 17:10:26 +0200 Subject: [PATCH 03/11] container: Share boot artifact preparation Move kernel selection, composefs digest calculation, and kernel command-line assembly out of the ukify implementation. This prepares the same inputs for reuse by the aboot image builder. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/boot_artifact.rs | 209 +++++++++++++++++++++++++++++ crates/lib/src/cli.rs | 24 +--- crates/lib/src/lib.rs | 1 + crates/lib/src/ukify.rs | 231 +++----------------------------- 4 files changed, 227 insertions(+), 238 deletions(-) create mode 100644 crates/lib/src/boot_artifact.rs diff --git a/crates/lib/src/boot_artifact.rs b/crates/lib/src/boot_artifact.rs new file mode 100644 index 000000000..4e86f86ad --- /dev/null +++ b/crates/lib/src/boot_artifact.rs @@ -0,0 +1,209 @@ +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::cap_std::fs::Dir; +use composefs::erofs::format::FormatVersion; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_ctl::composefs; +use linux_kernel_cmdline::utf8::Cmdline; + +use crate::bootc_composefs::digest::compute_composefs_digest; +use crate::bootc_composefs::status::build_composefs_karg; +use crate::cli::ErofsVersionArg; +use crate::kernel::KernelType; + +fn resolve_erofs_version(requested: Option) -> FormatVersion { + requested.map(Into::into).unwrap_or(FormatVersion::V1) +} + +fn composefs_kargs_for_boot_artifact( + 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 +} + +#[derive(Debug)] +pub(crate) struct BootArtifactInputs { + pub(crate) kernel_version: String, + pub(crate) vmlinuz: Utf8PathBuf, + pub(crate) initramfs: Utf8PathBuf, + pub(crate) cmdline: String, +} + +pub(crate) async fn prepare_boot_artifact( + rootfs: &Utf8Path, + kernel_dir: Option<&Utf8Path>, + extra_kargs: &[String], + allow_missing_fsverity: bool, + erofs_version: Option, + write_dumpfile_to: Option<&Utf8Path>, +) -> Result { + let root = Dir::open_ambient_dir(rootfs, cap_std_ext::cap_std::ambient_authority()) + .with_context(|| format!("Opening rootfs {rootfs}"))?; + + let (kernel_version, vmlinuz, initramfs, external_paths) = match kernel_dir { + Some(kernel_dir) => { + let kver = kernel_dir + .components() + .next_back() + .ok_or_else(|| anyhow::anyhow!("Could not determine kernel version"))?; + ( + kver.to_string(), + kernel_dir.join("vmlinuz"), + kernel_dir.join("initramfs.img"), + true, + ) + } + None => { + let kernel = crate::kernel::find_kernel(&root)? + .ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?; + match kernel.k_type { + KernelType::Vmlinuz { path, initramfs } => { + (kernel.kernel.version, path, initramfs, false) + } + KernelType::Uki { path, .. } => anyhow::bail!( + "Cannot build boot artifact: rootfs already contains a UKI at {path}" + ), + } + } + }; + + if external_paths { + if !vmlinuz.exists() { + anyhow::bail!("Kernel not found at {vmlinuz}"); + } + if !initramfs.exists() { + anyhow::bail!("Initramfs not found at {initramfs}"); + } + } else { + if !root.try_exists(&vmlinuz).context("Checking for vmlinuz")? { + anyhow::bail!("Kernel not found at {vmlinuz}"); + } + if !root + .try_exists(&initramfs) + .context("Checking for initramfs")? + { + anyhow::bail!("Initramfs not found at {initramfs}"); + } + } + + let erofs_version = resolve_erofs_version(erofs_version); + 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 + }; + + let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; + for karg in composefs_kargs_for_boot_artifact( + composefs_digest, + erofs_version, + compatibility_v2_digest, + allow_missing_fsverity, + ) { + cmdline.extend(&Cmdline::from(karg)); + } + for karg in extra_kargs { + cmdline.extend(&Cmdline::from(karg)); + } + + Ok(BootArtifactInputs { + kernel_version, + vmlinuz, + initramfs, + cmdline: cmdline.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use bootc_utils::create_minimal_pe; + + use super::*; + + #[tokio::test] + async fn no_kernel() { + let tempdir = tempfile::tempdir().unwrap(); + let path = Utf8Path::from_path(tempdir.path()).unwrap(); + + let err = prepare_boot_artifact(path, None, &[], false, None, None) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("No kernel found")); + } + + #[tokio::test] + async fn rejects_uki() { + let tempdir = tempfile::tempdir().unwrap(); + let path = Utf8Path::from_path(tempdir.path()).unwrap(); + fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap(); + fs::write( + tempdir.path().join("boot/EFI/Linux/test.efi"), + &create_minimal_pe(), + ) + .unwrap(); + + let err = prepare_boot_artifact(path, None, &[], false, None, None) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("already contains a UKI")); + } + + #[test] + fn test_composefs_kargs_for_boot_artifact() { + 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_boot_artifact(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/lib/src/cli.rs b/crates/lib/src/cli.rs index 9d25ef748..015b8323d 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -2238,33 +2238,11 @@ async fn run_from_opt(opt: Opt) -> Result { kernel_dir, args, } => { - let kernel = match kernel_dir { - Some(kernel_dir) => { - let kver = kernel_dir - .components() - .last() - .ok_or_else(|| anyhow::anyhow!("Could not determine kernel version"))?; - - Some(crate::kernel::KernelInternal { - kernel: crate::kernel::Kernel { - unified: false, - version: kver.to_string(), - }, - k_type: crate::kernel::KernelType::Vmlinuz { - path: kernel_dir.join("vmlinuz"), - initramfs: kernel_dir.join("initramfs.img"), - }, - }) - } - - None => None, - }; - crate::ukify::build_ukify( &rootfs, &kargs, &args, - kernel, + kernel_dir.as_deref(), allow_missing_verity, erofs_version, write_dumpfile_to.as_deref(), diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 09a646c75..166887a4f 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -63,6 +63,7 @@ //! - [`linux-kernel-cmdline`](../linux_kernel_cmdline/index.html) - Cmdline parsing //! - [`etc-merge`](../etc_merge/index.html) - `/etc` three-way merge +mod boot_artifact; mod bootc_composefs; pub(crate) mod bootc_kargs; mod bootloader; diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 669876919..4615275f7 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -10,64 +10,25 @@ use std::process::Command; use anyhow::{Context, Result}; use bootc_utils::CommandRunExt; use camino::Utf8Path; -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::build_composefs_karg; +use crate::boot_artifact::prepare_boot_artifact; 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: -/// 1. Verifies that ukify is available -/// 2. Finds the kernel in the rootfs -/// 3. Computes the composefs digest -/// 4. Reads kernel arguments from kargs.d -/// 5. Appends any additional kargs provided via --karg -/// 6. Invokes ukify with computed arguments plus any pass-through args +/// Finds the kernel, computes the composefs digest, reads kernel arguments from +/// kargs.d, then invokes ukify with any additional pass-through arguments. #[context("Building UKI")] pub(crate) async fn build_ukify( rootfs: &Utf8Path, extra_kargs: &[String], args: &[OsString], - kernel: Option, + kernel_dir: Option<&Utf8Path>, allow_missing_fsverity: bool, erofs_version: Option, write_dumpfile_to: Option<&Utf8Path>, ) -> Result<()> { - // Warn if --karg is used (temporary workaround) if !extra_kargs.is_empty() { tracing::warn!( "The --karg flag is temporary and will be removed as soon as possible \ @@ -75,199 +36,39 @@ pub(crate) async fn build_ukify( ); } - // Open the rootfs directory - let root = Dir::open_ambient_dir(rootfs, cap_std_ext::cap_std::ambient_authority()) - .with_context(|| format!("Opening rootfs {rootfs}"))?; - - let kernel_final = match kernel { - Some(ref kernel) => kernel, - None => &crate::kernel::find_kernel(&root)? - .ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?, - }; - - // Extract vmlinuz and initramfs paths, or bail if this is already a UKI - let (vmlinuz_path, initramfs_path) = match &kernel_final.k_type { - crate::kernel::KernelType::Vmlinuz { path, initramfs } => (path, initramfs), - crate::kernel::KernelType::Uki { path, .. } => { - anyhow::bail!("Cannot build UKI: rootfs already contains a UKI at {path}"); - } - }; - - // Verify kernel and initramfs exist - // - // NOTE: Not using cap_std here as the vmlinuz/initramfs path from - // args can be outside of "rootfs" - if kernel.is_some() { - if !vmlinuz_path.exists() { - anyhow::bail!("Kernel not found at {vmlinuz_path}"); - } - - if !initramfs_path.exists() { - anyhow::bail!("Initramfs not found at {initramfs_path}"); - } - } else { - if !root - .try_exists(&vmlinuz_path) - .context("Checking for vmlinuz")? - { - anyhow::bail!("Kernel not found at {vmlinuz_path}"); - } - - if !root - .try_exists(&initramfs_path) - .context("Checking for initramfs")? - { - anyhow::bail!("Initramfs not found at {initramfs_path}"); - } - } - - 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, 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, + let inputs = prepare_boot_artifact( + rootfs, + kernel_dir, + extra_kargs, allow_missing_fsverity, - ) { - cmdline.extend(&Cmdline::from(karg)); - } - - // Add any extra kargs provided via --karg - for karg in extra_kargs { - cmdline.extend(&Cmdline::from(karg)); - } - - let cmdline_str = cmdline.to_string(); + erofs_version, + write_dumpfile_to, + ) + .await?; - // Build the ukify command with cwd set to rootfs so paths can be relative let mut cmd = Command::new("ukify"); cmd.current_dir(rootfs); cmd.arg("build") .arg("--linux") - .arg(&vmlinuz_path) + .arg(&inputs.vmlinuz) .arg("--initrd") - .arg(&initramfs_path) + .arg(&inputs.initramfs) .arg("--uname") - .arg(&kernel_final.kernel.version) + .arg(&inputs.kernel_version) .arg("--cmdline") - .arg(&cmdline_str) + .arg(&inputs.cmdline) .arg("--os-release") .arg("@usr/lib/os-release"); - - // Add pass-through arguments cmd.args(args); tracing::debug!("Executing ukify: {:?}", cmd); - - // Run ukify cmd.run_inherited().context("Running ukify")?; Ok(()) } - -#[cfg(test)] -mod tests { - use bootc_utils::create_minimal_pe; - 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, Some(ErofsVersionArg::V2), None).await; - assert!(result.is_err()); - let err = format!("{:#}", result.unwrap_err()); - assert!( - err.contains("No kernel found") || err.contains("ukify executable not found"), - "Unexpected error message: {err}" - ); - } - - #[tokio::test] - async fn test_build_ukify_already_uki() { - let tempdir = tempfile::tempdir().unwrap(); - let path = Utf8Path::from_path(tempdir.path()).unwrap(); - - // Create a UKI structure - fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap(); - fs::write( - tempdir.path().join("boot/EFI/Linux/test.efi"), - &create_minimal_pe(), - ) - .unwrap(); - - let result = - build_ukify(path, &[], &[], None, false, Some(ErofsVersionArg::V2), None).await; - assert!(result.is_err()); - let err = format!("{:#}", result.unwrap_err()); - assert!( - err.contains("already contains a UKI") || err.contains("ukify executable not found"), - "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); - } - } -} From e229afa4702fc42738bf362f24cbcb355662c788 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Fri, 18 Sep 2026 17:35:47 +0200 Subject: [PATCH 04/11] Add bootc container aboot to create aboot images This is similar to `bootc container ukify`, but builds aboot images using aboot-update. Signed-off-by: Alexander Larsson --- crates/lib/src/aboot.rs | 135 ++++++++++++++++++++++++ crates/lib/src/boot_artifact.rs | 13 ++- crates/lib/src/cli.rs | 61 +++++++++++ crates/lib/src/lib.rs | 1 + docs/src/man/bootc-container-aboot.8.md | 56 ++++++++++ docs/src/man/bootc-container.8.md | 2 +- 6 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 crates/lib/src/aboot.rs create mode 100644 docs/src/man/bootc-container-aboot.8.md diff --git a/crates/lib/src/aboot.rs b/crates/lib/src/aboot.rs new file mode 100644 index 000000000..e09f0228e --- /dev/null +++ b/crates/lib/src/aboot.rs @@ -0,0 +1,135 @@ +//! Build Android boot or ukiboot images using aboot-update. + +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use composefs_ctl::composefs_boot::{android_boot::AndroidBootImage, uki}; +use fn_error_context::context; +use linux_kernel_cmdline::utf8::Cmdline; + +use crate::boot_artifact::prepare_boot_artifact; + +#[context("Building aboot image")] +pub(crate) async fn build_aboot( + rootfs: &Utf8Path, + extra_kargs: &[String], + kernel_dir: Option<&Utf8Path>, + out: Option<&Utf8Path>, + allow_missing_fsverity: bool, + write_dumpfile_to: Option<&Utf8Path>, +) -> Result<()> { + if !extra_kargs.is_empty() { + tracing::warn!( + "The --karg flag is temporary and will be removed as soon as possible \ + (https://github.com/bootc-dev/bootc/issues/1826)" + ); + } + + if !crate::utils::have_executable("aboot-update")? { + anyhow::bail!("aboot-update executable not found in PATH"); + } + + let inputs = prepare_boot_artifact( + rootfs, + kernel_dir, + extra_kargs, + allow_missing_fsverity, + None, + write_dumpfile_to, + ) + .await?; + + let mut cmd = Command::new("aboot-update"); + cmd.arg("--root") + .arg(rootfs) + .arg("--cmdline") + .arg(&inputs.cmdline); + if let Some(kernel_dir) = kernel_dir { + cmd.arg("--kernel-dir").arg(kernel_dir); + } + if let Some(out) = out { + cmd.arg("--out").arg(out); + } + cmd.arg(&inputs.kernel_version); + + tracing::debug!("Executing aboot-update: {:?}", cmd); + cmd.run_inherited().context("Running aboot-update")?; + + let output_dir = out + .map(ToOwned::to_owned) + .unwrap_or_else(|| rootfs.join("boot")); + let image_path = output_dir.join(format!("aboot-{}.img", inputs.kernel_version)); + let actual_cmdline = read_cmdline(&image_path)?; + validate_composefs_cmdline(&inputs.cmdline, &actual_cmdline) +} + +fn read_cmdline(path: &Utf8Path) -> Result { + let image = File::open(path).with_context(|| format!("Opening generated image {path}"))?; + let mut image = BufReader::new(image); + let mut magic = [0; 8]; + image + .read_exact(&mut magic) + .with_context(|| format!("Reading generated image {path}"))?; + image.seek(SeekFrom::Start(0))?; + + if magic == *b"ANDROID!" { + let header = AndroidBootImage::parse(&mut image) + .with_context(|| format!("Parsing generated Android boot image {path}"))?; + Ok(header.cmdline()?.to_owned()) + } else { + uki::get_cmdline_buffered(&mut image) + .with_context(|| format!("Parsing generated ukiboot image {path}")) + } +} + +fn validate_composefs_cmdline(expected: &str, actual: &str) -> Result<()> { + let composefs_args = |cmdline: &str| -> Vec { + Cmdline::from(cmdline) + .iter_str() + .filter(|arg| arg.starts_with("composefs=") || arg.starts_with("composefs.digest=")) + .map(str::to_owned) + .collect() + }; + let mut expected = composefs_args(expected); + ensure!( + !expected.is_empty(), + "Generated command line has no composefs parameter" + ); + let mut actual = composefs_args(actual); + expected.sort_unstable(); + actual.sort_unstable(); + ensure!( + actual == expected, + "Generated image has the wrong composefs parameters" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_composefs_cmdline() { + validate_composefs_cmdline("quiet composefs=?abcd", "bootconfig composefs=?abcd").unwrap(); + validate_composefs_cmdline( + "composefs.digest=v1-sha512-12:abcd composefs=efgh", + "composefs=efgh composefs.digest=v1-sha512-12:abcd", + ) + .unwrap(); + + for actual in [ + "quiet", + "composefs=efgh", + "composefs=abcd composefs=abcd", + "composefs=?abcd composefs=?abcd", + "composefs.digest=v1-sha512-12:abcd composefs=efgh composefs=efgh", + ] { + assert!(validate_composefs_cmdline("composefs=?abcd", actual).is_err()); + } + } +} diff --git a/crates/lib/src/boot_artifact.rs b/crates/lib/src/boot_artifact.rs index 4e86f86ad..7cd8b9355 100644 --- a/crates/lib/src/boot_artifact.rs +++ b/crates/lib/src/boot_artifact.rs @@ -101,6 +101,11 @@ pub(crate) async fn prepare_boot_artifact( } } + let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; + for karg in extra_kargs { + cmdline.extend(&Cmdline::from(karg)); + } + let erofs_version = resolve_erofs_version(erofs_version); let composefs_digest = compute_composefs_digest(rootfs, erofs_version, write_dumpfile_to).await?; @@ -113,17 +118,15 @@ pub(crate) async fn prepare_boot_artifact( None }; - let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; for karg in composefs_kargs_for_boot_artifact( composefs_digest, erofs_version, compatibility_v2_digest, allow_missing_fsverity, ) { - cmdline.extend(&Cmdline::from(karg)); - } - for karg in extra_kargs { - cmdline.extend(&Cmdline::from(karg)); + for param in &Cmdline::from(karg) { + cmdline.add_or_modify(¶m); + } } Ok(BootArtifactInputs { diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 015b8323d..57f88ec12 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -527,6 +527,35 @@ pub(crate) enum ContainerOpts { #[clap(last = true)] args: Vec, }, + /// Build an Android boot or ukiboot image using aboot-update. + Aboot { + /// Operate on the provided rootfs. + #[clap(long, default_value = "/")] + rootfs: Utf8PathBuf, + + /// Additional kernel arguments to append to the cmdline. + /// Can be specified multiple times. + /// This is a temporary workaround and will be removed. + #[clap(long = "karg", hide = true)] + kargs: Vec, + + /// Make fs-verity validation optional in case the filesystem doesn't support it + #[clap(long)] + allow_missing_verity: bool, + + /// Write a dumpfile to this path + #[clap(long)] + write_dumpfile_to: Option, + + /// The directory containing vmlinuz and initramfs.img. + /// Must be of the format /parent/$kernel_version. + #[clap(long)] + kernel_dir: Option, + + /// Output directory. Defaults to $rootfs/boot. + #[clap(long)] + out: Option, + }, /// Export container filesystem as a tar archive. /// /// This command exports the container filesystem in a bootable format with proper @@ -2249,6 +2278,24 @@ async fn run_from_opt(opt: Opt) -> Result { ) .await } + ContainerOpts::Aboot { + rootfs, + kargs, + allow_missing_verity, + write_dumpfile_to, + kernel_dir, + out, + } => { + crate::aboot::build_aboot( + &rootfs, + &kargs, + kernel_dir.as_deref(), + out.as_deref(), + allow_missing_verity, + write_dumpfile_to.as_deref(), + ) + .await + } ContainerOpts::Export { format, target, @@ -2860,6 +2907,20 @@ mod tests { "{args:?}" ); } + + assert!(matches!( + Opt::parse_including_static([ + "bootc", + "container", + "aboot", + "--karg", + "root=LABEL=root", + "--out", + "/out", + ]), + Opt::Container(ContainerOpts::Aboot { kargs, out, .. }) + if kargs == ["root=LABEL=root"] && out.as_deref() == Some(Utf8Path::new("/out")) + )); } #[test] diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 166887a4f..a1025e60b 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -63,6 +63,7 @@ //! - [`linux-kernel-cmdline`](../linux_kernel_cmdline/index.html) - Cmdline parsing //! - [`etc-merge`](../etc_merge/index.html) - `/etc` three-way merge +mod aboot; mod boot_artifact; mod bootc_composefs; pub(crate) mod bootc_kargs; diff --git a/docs/src/man/bootc-container-aboot.8.md b/docs/src/man/bootc-container-aboot.8.md new file mode 100644 index 000000000..2162df475 --- /dev/null +++ b/docs/src/man/bootc-container-aboot.8.md @@ -0,0 +1,56 @@ +# NAME + +bootc-container-aboot - Build an Android boot or ukiboot image using aboot-update + +# SYNOPSIS + +bootc container aboot [OPTIONS] + +# DESCRIPTION + +Build an Android boot image or ukiboot UKI from a container root filesystem using +`aboot-update`. The image type and configuration details depends on the `aboot.cfg`. This +image includes kernel, initrd, cmdline, and (optionally) dtb. + +The image is written to `aboot-.img` in the output directory. By default, +that directory is `/boot` within the supplied root filesystem. `aboot-update` must be +available in the build environment. + +# OPTIONS + + +**--rootfs**=*ROOTFS* + + Operate on the provided rootfs + + Default: / + +**--allow-missing-verity** + + Make fs-verity validation optional in case the filesystem doesn't support it + +**--write-dumpfile-to**=*WRITE_DUMPFILE_TO* + + Write a dumpfile to this path + +**--kernel-dir**=*KERNEL_DIR* + + The directory containing vmlinuz and initramfs.img. Must be of the format /parent/$kernel_version + +**--out**=*OUT* + + Output directory. Defaults to $rootfs/boot + + + +# EXAMPLES + + bootc container aboot --rootfs /target --kernel-dir /kernel/KVER --out /out + +# SEE ALSO + +**bootc**(8), **bootc-container-ukify**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-container.8.md b/docs/src/man/bootc-container.8.md index 1b1fce62f..94d0975aa 100644 --- a/docs/src/man/bootc-container.8.md +++ b/docs/src/man/bootc-container.8.md @@ -23,10 +23,10 @@ Operations which can be executed as part of a container build | **bootc container lint** | Perform relatively inexpensive static analysis checks as part of a container build | | **bootc container split-kernel-and-rootfs** | Split kernel and rootfs from a container image | | **bootc container ukify** | Build a Unified Kernel Image (UKI) using ukify | +| **bootc container aboot** | Build an Android boot or ukiboot image using aboot-update | # VERSION - From 93bb3c1e64e440f735eb4adfc1dea54e517f811b Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 21 Sep 2026 12:43:36 +0200 Subject: [PATCH 05/11] bootc: Break out install_composefs_bootloader() from setup_composefs_boot() This is just some code motion that will make later changes easier to do. Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/boot.rs | 98 ++++++++++++++------------ 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a7a927e1c..02fd0324a 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2078,53 +2078,12 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } -#[context("Setting up composefs boot")] -pub(crate) async fn setup_composefs_boot( +fn install_composefs_bootloader( root_setup: &RootSetup, state: &State, - pull_result: &composefs_oci::PullResult, - allow_missing_fsverity: bool, + postfetch: &PostFetchState, + mounted_root: &MountedImageRoot, ) -> Result<()> { - const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; - - tracing::info!( - message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID, - bootc.operation = "boot_setup", - bootc.config_digest = %pull_result.config_digest, - bootc.allow_missing_fsverity = allow_missing_fsverity, - "Setting up composefs boot", - ); - - let mut repo = open_composefs_repo(&root_setup.physical_root)?; - if allow_missing_fsverity { - repo.set_insecure(); - } - - let repo = Arc::new(repo); - - let crate::bootc_composefs::repo::BootImage { - id, - boot_ids, - fs, - entries, - } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; - - let Some(entry) = entries.iter().next() else { - anyhow::bail!("No boot entries!"); - }; - - let boot_type = BootType::from(entry); - if boot_type == BootType::Aboot { - bail!("aboot boot setup is not implemented"); - } - - let composefs_mnt_fd = repo - .mount(&id.to_hex()) - .context("Failed to mount composefs image")?; - let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; - - let postfetch = PostFetchState::new(state, mounted_root.dir())?; - let boot_uuid = root_setup .get_boot_uuid()? .or(root_setup.rootfs_uuid.as_deref()) @@ -2210,6 +2169,57 @@ pub(crate) async fn setup_composefs_boot( ) })?; } + Ok(()) +} + +#[context("Setting up composefs boot")] +pub(crate) async fn setup_composefs_boot( + root_setup: &RootSetup, + state: &State, + pull_result: &composefs_oci::PullResult, + allow_missing_fsverity: bool, +) -> Result<()> { + const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; + + tracing::info!( + message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID, + bootc.operation = "boot_setup", + bootc.config_digest = %pull_result.config_digest, + bootc.allow_missing_fsverity = allow_missing_fsverity, + "Setting up composefs boot", + ); + + let mut repo = open_composefs_repo(&root_setup.physical_root)?; + if allow_missing_fsverity { + repo.set_insecure(); + } + + let repo = Arc::new(repo); + + let crate::bootc_composefs::repo::BootImage { + id, + boot_ids, + fs, + entries, + } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; + + let Some(entry) = entries.iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(entry); + if boot_type == BootType::Aboot { + bail!("aboot boot setup is not implemented"); + } + + let composefs_mnt_fd = repo + .mount(&id.to_hex()) + .context("Failed to mount composefs image")?; + let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; + + let postfetch = PostFetchState::new(state, mounted_root.dir())?; + + install_composefs_bootloader(root_setup, state, &postfetch, &mounted_root)?; let repo = Arc::try_unwrap(repo).map_err(|_| { anyhow::anyhow!( From 90fdbd51ea6d229c02d0cf5042cba9eb91ad2fc8 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 21 Sep 2026 13:17:17 +0200 Subject: [PATCH 06/11] container inspect: Add aboot and aboot-efi to "type" If the container has an aboot-*.img file, then its type is aboot or aboot-efi (depending on the payload type). We also add this to the json output. This will be read by e.g. image-builder. Signed-off-by: Alexander Larsson --- crates/lib/src/kernel.rs | 112 ++++++++++++++++++++++++++++++++++++++- crates/lib/src/spec.rs | 3 ++ crates/lib/src/status.rs | 50 +++++++++++++++-- 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/crates/lib/src/kernel.rs b/crates/lib/src/kernel.rs index 83e7cc24c..86fc61d59 100644 --- a/crates/lib/src/kernel.rs +++ b/crates/lib/src/kernel.rs @@ -4,9 +4,9 @@ //! images, supporting both traditional kernels (with separate vmlinuz/initrd) and //! Unified Kernel Images (UKI). -use std::path::Path; +use std::{io::Read, path::Path}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use camino::Utf8PathBuf; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; @@ -213,6 +213,79 @@ fn find_uki_path(root: &Dir) -> Result> { .map(|filename| Utf8PathBuf::from(format!("boot/{EFI_LINUX}/{filename}")))) } +/// The boot artifact type reported by `bootc container inspect`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ContainerImageType { + Aboot, + AbootEfi, + Uki, + Vmlinuz, +} + +impl ContainerImageType { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Aboot => "aboot", + Self::AbootEfi => "aboot-efi", + Self::Uki => "UKI", + Self::Vmlinuz => "vmlinuz", + } + } +} + +fn aboot_type_at(dir: &Dir, path: &Path) -> Result { + let mut file = dir + .open(path) + .with_context(|| format!("Opening aboot payload {}", path.display()))?; + let mut magic = [0; 8]; + file.read_exact(&mut magic) + .with_context(|| format!("Reading aboot payload {}", path.display()))?; + if &magic == b"ANDROID!" { + Ok(ContainerImageType::Aboot) + } else if magic.starts_with(b"MZ") { + Ok(ContainerImageType::AbootEfi) + } else { + bail!("Unknown aboot payload format at {}", path.display()) + } +} + +pub(crate) fn find_aboot_type(root: &Dir) -> Result> { + if let Some(modules) = root.open_dir_optional("usr/lib/modules")? { + for entry in modules.entries()? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let path = Path::new(&entry.file_name()).join("aboot.img"); + if modules + .symlink_metadata_optional(&path)? + .is_some_and(|metadata| metadata.is_file()) + { + return aboot_type_at(&modules, &path).map(Some); + } + } + } + let Some(boot) = root.open_dir_optional("boot")? else { + return Ok(None); + }; + for entry in boot.entries()? { + let entry = entry?; + let name = entry.file_name(); + let Some(version) = name + .to_str() + .and_then(|name| name.strip_prefix("aboot-")) + .and_then(|name| name.strip_suffix(".img")) + else { + continue; + }; + if !version.is_empty() && entry.file_type()?.is_file() { + return aboot_type_at(&boot, Path::new(&name)).map(Some); + } + } + Ok(None) +} + #[cfg(test)] mod tests { use super::*; @@ -226,6 +299,41 @@ mod tests { Ok(()) } + #[test] + fn test_find_aboot_type() -> Result<()> { + let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert_eq!(find_aboot_type(&tempdir)?, None); + + tempdir.create_dir_all("boot/aboot-directory.img")?; + tempdir.atomic_write("boot/aboot-.img", b"")?; + tempdir.atomic_write("boot/aboot-1.0", b"")?; + assert_eq!(find_aboot_type(&tempdir)?, None); + + tempdir.atomic_write("boot/aboot-1.0.img", b"ANDROID!")?; + assert_eq!(find_aboot_type(&tempdir)?, Some(ContainerImageType::Aboot)); + tempdir.atomic_write("boot/aboot-1.0.img", b"MZpayload")?; + assert_eq!( + find_aboot_type(&tempdir)?, + Some(ContainerImageType::AbootEfi) + ); + tempdir.atomic_write("boot/aboot-1.0.img", b"invalid!")?; + assert!(find_aboot_type(&tempdir).is_err()); + Ok(()) + } + + #[test] + fn test_find_aboot_type_modules() -> Result<()> { + let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; + tempdir.create_dir_all("usr/lib/modules/1.0")?; + assert_eq!(find_aboot_type(&tempdir)?, None); + tempdir.atomic_write("usr/lib/modules/1.0/aboot.img", b"MZpayload")?; + assert_eq!( + find_aboot_type(&tempdir)?, + Some(ContainerImageType::AbootEfi) + ); + Ok(()) + } + #[test] fn test_find_kernel_traditional() -> Result<()> { let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 15ad1014f..b62edfd74 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -469,6 +469,9 @@ pub(crate) struct DeploymentEntry<'a> { #[derive(Debug, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct ContainerInspect { + /// The boot artifact type in the image, if present. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub(crate) image_type: Option, /// Kernel arguments embedded in the container image. pub(crate) kargs: Vec, /// Information about the kernel in the container image. diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 4525f60c7..d1746917c 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -949,12 +949,14 @@ fn container_inspect_print_human( if let Some(kernel) = &inspect.kernel { rows.push(("Kernel", kernel.version.clone())); - let kernel_type = if kernel.unified { "UKI" } else { "vmlinuz" }; - rows.push(("Type", kernel_type.to_string())); } else { rows.push(("Kernel", "".to_string())); } + if let Some(image_type) = inspect.image_type { + rows.push(("Type", image_type.as_str().to_string())); + } + let kargs = if inspect.kargs.is_empty() { "".to_string() } else { @@ -989,8 +991,21 @@ pub(crate) fn container_inspect( )?; let kargs = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; let kargs: Vec = kargs.iter_str().map(|s| s.to_owned()).collect(); - let kernel = crate::kernel::find_kernel(&root)?.map(Into::into); - let inspect = crate::spec::ContainerInspect { kargs, kernel }; + let kernel: Option = crate::kernel::find_kernel(&root)?.map(Into::into); + let image_type = crate::kernel::find_aboot_type(&root)?.or_else(|| { + kernel.as_ref().map(|kernel| { + if kernel.unified { + crate::kernel::ContainerImageType::Uki + } else { + crate::kernel::ContainerImageType::Vmlinuz + } + }) + }); + let inspect = crate::spec::ContainerInspect { + kargs, + kernel, + image_type, + }; // Determine output format: explicit --format wins, then --json, then default to human-readable let format = format.unwrap_or(if json { @@ -1290,6 +1305,7 @@ mod tests { #[test] fn test_container_inspect_human_readable() { let inspect = crate::spec::ContainerInspect { + image_type: Some(crate::kernel::ContainerImageType::Vmlinuz), kargs: vec!["console=ttyS0".into(), "quiet".into()], kernel: Some(crate::kernel::Kernel { version: "6.12.0-100.fc41.x86_64".into(), @@ -1310,6 +1326,7 @@ mod tests { #[test] fn test_container_inspect_human_readable_uki() { let inspect = crate::spec::ContainerInspect { + image_type: Some(crate::kernel::ContainerImageType::Uki), kargs: vec![], kernel: Some(crate::kernel::Kernel { version: "6.12.0-100.fc41.x86_64".into(), @@ -1330,6 +1347,7 @@ mod tests { #[test] fn test_container_inspect_human_readable_no_kernel() { let inspect = crate::spec::ContainerInspect { + image_type: None, kargs: vec!["console=ttyS0".into()], kernel: None, }; @@ -1343,6 +1361,30 @@ mod tests { similar_asserts::assert_eq!(output, expected); } + #[test] + fn test_container_inspect_human_readable_aboot() { + for (image_type, label) in [ + (crate::kernel::ContainerImageType::Aboot, "aboot"), + (crate::kernel::ContainerImageType::AbootEfi, "aboot-efi"), + ] { + let inspect = crate::spec::ContainerInspect { + image_type: Some(image_type), + kargs: vec!["console=ttyS0".into()], + kernel: None, + }; + let mut w = Vec::new(); + container_inspect_print_human(&inspect, &mut w).unwrap(); + let output = String::from_utf8(w).unwrap(); + similar_asserts::assert_eq!( + output, + format!("Kernel: \n Type: {label}\n Kargs: console=ttyS0\n") + ); + let json = serde_json::to_value(&inspect).unwrap(); + assert_eq!(json["type"], label); + assert!(json.get("aboot").is_none()); + } + } + #[test] fn test_human_readable_booted_usroverlay() { let w = From ee5f96c8e9040f6c4aab5f56f0bf27267dbf7e7f Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 23 Sep 2026 19:25:11 +0200 Subject: [PATCH 07/11] composefs: Support initial installation of aboot images Allow composefs installation with Android boot v2 and ukiboot payloads. Validate the embedded composefs command line and record a boot digest covering the kernel, initrd, and optional DTB. This automatically Use bootloader=none for aboot v2 images without requiring an ESP. For ukiboot images it uses bootloader=ukiboot and provisions the EFI loader and slot addons. Prefer files from /boot in the untransformed source image, allowing signed binaries to override the packaged copies under /usr. Partition payloads remain the responsibility of the image builder. Updates, slot tracking, and post-boot management are left for later. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/boot.rs | 425 ++++++++++++++++-- crates/lib/src/bootc_composefs/repo.rs | 17 +- crates/lib/src/bootc_composefs/status.rs | 4 +- crates/lib/src/install.rs | 78 +++- crates/lib/src/spec.rs | 8 +- docs/src/host-v1.schema.json | 5 + docs/src/man/bootc-install-to-disk.8.md | 1 + .../man/bootc-install-to-existing-root.8.md | 1 + docs/src/man/bootc-install-to-filesystem.8.md | 1 + 9 files changed, 458 insertions(+), 82 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 02fd0324a..677aaa0e1 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -63,12 +63,12 @@ use std::cell::Cell; use std::fs::create_dir_all; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::os::fd::AsFd; use std::path::Path; use std::sync::Arc; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use bootc_mount::tempmount::TempMount; use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::{ @@ -79,14 +79,17 @@ 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::tree::{FileSystem, ImageError, RegularFile}; use composefs_boot::bootloader::{ - BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType, - UsrLibModulesVmlinuz, + AbootEncoding, AbootEntry, BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, + EFI_ADDON_FILE_EXT, EFI_EXT, PEType, UsrLibModulesVmlinuz, }; use composefs_boot::cmdline::{KARG_COMPOSEFS_DIGEST, KARG_V2}; use composefs_boot::{ - cmdline::ComposefsCmdline as ComposefsBootCmdline, os_release::OsReleaseInfo, uki, + android_boot::{AndroidBootError, AndroidBootImage, Component}, + cmdline::ComposefsCmdline as ComposefsBootCmdline, + os_release::OsReleaseInfo, + uki, }; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; @@ -608,6 +611,24 @@ pub(crate) fn compute_boot_digest_uki(uki_reader: &mut R) -> Res Ok(hex::encode(digest)) } +#[context("Computing Android boot digest")] +pub(crate) fn compute_boot_digest_aboot( + reader: &mut R, + metadata: &AndroidBootImage, +) -> Result { + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256()) + .context("Creating hasher")?; + for component in [Component::Kernel, Component::Ramdisk] { + hasher.update(&metadata.component(reader, component)?)?; + } + match metadata.component(reader, Component::Dtb) { + Ok(dtb) => hasher.update(&dtb)?, + Err(AndroidBootError::MissingComponent("dtb")) => {} + Err(error) => return Err(error.into()), + } + Ok(hex::encode(hasher.finish()?)) +} + /// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum /// /// # Returns @@ -1140,13 +1161,13 @@ fn parse_uki_composefs_candidates(cmdline: &str) -> Result Result { let first = candidates .first() - .ok_or_else(|| anyhow!("No composefs digest in UKI cmdline"))? + .ok_or_else(|| anyhow!("No composefs digest in boot artifact cmdline"))? .insecure; anyhow::ensure!( candidates .iter() .all(|candidate| candidate.insecure == first), - "UKI composefs candidates have mixed fs-verity policies" + "Boot artifact composefs candidates have mixed fs-verity policies" ); Ok(first) } @@ -1387,41 +1408,40 @@ fn uki_file_name(file_path: &Path) -> Result { .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( +/// Inspect primary boot artifacts to discover their requested fs-verity policy +/// before creating the durable repository. Final artifact validation occurs +/// while installing the boot artifact. +pub(crate) fn boot_artifact_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)?) + let cmdline = match entry { + ComposefsBootEntry::Type2(entry) if matches!(entry.pe_type, PEType::Uki) => { + 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") + } + }; + uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")? } - RegularFile::Inline(..) | RegularFile::Sparse(..) => { - anyhow::bail!("UKI file is not a regular external object") + ComposefsBootEntry::Aboot(entry) => { + let payload = read_aboot_payload(entry, repo)?; + compute_aboot_boot_digest(entry.encoding, &payload)?.0 } + _ => continue, }; - 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 candidates = parse_uki_composefs_candidates(&cmdline) + .context("Parsing composefs kernel arguments in boot artifact")?; let current = uki_candidates_policy(&candidates)?; if let Some(previous) = policy { anyhow::ensure!( previous == current, - "Primary UKIs request conflicting composefs fs-verity policies" + "Primary boot artifacts request conflicting composefs fs-verity policies" ); } else { policy = Some(current); @@ -2078,12 +2098,170 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } +fn read_aboot_payload( + entry: &AbootEntry, + repo: &ComposefsRepository, +) -> Result> { + read_file(&entry.payload.file, repo).context("Reading aboot payload") +} + +fn compute_aboot_boot_digest(encoding: AbootEncoding, payload: &[u8]) -> Result<(String, String)> { + let mut image = Cursor::new(payload); + match encoding { + AbootEncoding::Uki => { + let cmdline = uki::get_cmdline(payload) + .context("Getting UKI command line")? + .to_string(); + let digest = compute_boot_digest_uki(&mut image)?; + Ok((cmdline, digest)) + } + AbootEncoding::AndroidV2 => { + let metadata = AndroidBootImage::parse(&mut image)?; + let cmdline = metadata.cmdline()?.to_string(); + let digest = compute_boot_digest_aboot(&mut image, &metadata)?; + Ok((cmdline, digest)) + } + } +} + +fn install_ukiboot( + root: &Dir, + source: &FileSystem, + repo: &ComposefsRepository, + esp: &Dir, +) -> Result<()> { + let (efi_arch, fallback_name) = match std::env::consts::ARCH { + "x86_64" => ("x64", "BOOTX64.EFI"), + "aarch64" => ("aa64", "BOOTAA64.EFI"), + arch => bail!("ukiboot is not supported on {arch}"), + }; + let (vendor, ..) = + parse_os_release(root)?.ok_or_else(|| anyhow!("Failed to parse os-release"))?; + let get_file = |path: &str| { + source + .as_dir() + .split_ref(path.as_ref()) + .and_then(|(dir, name)| dir.get_file(name)) + }; + + for (vendor_path, fallback_path, packaged_name) in [ + ( + format!("ukiboot{efi_arch}.efi"), + fallback_name.to_string(), + format!("ukiboot{efi_arch}.efi"), + ), + ( + "ukiboot_a.efi.extra.d/slot_a.addon.efi".into(), + "ukiboot_a.efi.extra.d/slot_a.addon.efi".into(), + "slot_a.addon.efi".into(), + ), + ( + "ukiboot_b.efi.extra.d/slot_b.addon.efi".into(), + "ukiboot_b.efi.extra.d/slot_b.addon.efi".into(), + "slot_b.addon.efi".into(), + ), + ] { + // We prefer files in /boot, because they can be signed without affecting the boot-transformed cfs digest + let preferred = format!("boot/efi/EFI/{vendor}/{vendor_path}"); + let fallback = format!("usr/libexec/ukiboot/efi/{packaged_name}"); + let file = match get_file(&preferred) { + Err(ImageError::NotFound(..)) => get_file(&fallback), + result => result, + } + .with_context(|| format!("Finding {preferred} or {fallback}"))?; + let data = read_file(file, repo).context("Reading ukiboot EFI file")?; + for target in [ + format!("EFI/{vendor}/{vendor_path}"), + format!("EFI/BOOT/{fallback_path}"), + ] { + let target = Utf8Path::new(&target); + if let Some(parent) = target.parent() { + esp.create_dir_all(parent)?; + } + esp.atomic_write(target, &data) + .with_context(|| format!("Writing ukiboot EFI file to ESP at {target}"))?; + } + } + Ok(()) +} + +fn validate_bootloader( + entry: &ComposefsBootEntry, + bootloader: Bootloader, +) -> Result<()> { + if let ComposefsBootEntry::Aboot(entry) = entry { + match (entry.encoding, bootloader) { + (AbootEncoding::AndroidV2, Bootloader::None) => {} + (AbootEncoding::Uki, Bootloader::Ukiboot) => {} + (encoding, bootloader) => { + bail!("aboot encoding {encoding:?} cannot be used with bootloader {bootloader}") + } + } + } else { + ensure!( + bootloader != Bootloader::None, + "Bootloader set to none is not supported with the composefs backend unless using an aboot artifact" + ); + ensure!( + bootloader != Bootloader::Ukiboot, + "Bootloader ukiboot requires an aboot artifact with the composefs backend" + ); + } + Ok(()) +} + +fn image_bootloader(entry: &ComposefsBootEntry) -> Option { + match entry { + ComposefsBootEntry::Aboot(entry) => Some(match entry.encoding { + AbootEncoding::AndroidV2 => Bootloader::None, + AbootEncoding::Uki => Bootloader::Ukiboot, + }), + _ => None, + } +} + +fn setup_composefs_aboot_boot( + repo: &ComposefsRepository, + id: &Sha512HashValue, + entry: &ComposefsBootEntry, + allow_missing_fsverity: bool, +) -> Result { + let ComposefsBootEntry::Aboot(entry) = entry else { + bail!("Expected an aboot boot entry") + }; + let payload = read_aboot_payload(entry, repo)?; + let (cmdline, boot_digest) = compute_aboot_boot_digest(entry.encoding, &payload)?; + let composefs = ComposefsBootCmdline::::from_cmdline(&cmdline) + .context("Parsing composefs=")? + .ok_or_else(|| anyhow!("No composefs image in aboot command line"))?; + composefs.validate_digest([id])?; + match (allow_missing_fsverity, composefs.is_insecure()) { + (true, false) => tracing::warn!( + "--allow-missing-fsverity was requested but the aboot image requires fs-verity" + ), + (false, true) => tracing::warn!("aboot image permits booting without fs-verity"), + _ => {} + } + + Ok(boot_digest) +} + fn install_composefs_bootloader( root_setup: &RootSetup, state: &State, - postfetch: &PostFetchState, - mounted_root: &MountedImageRoot, + bootloader: Bootloader, + composefs_mnt_fd: std::os::fd::OwnedFd, + repo: &ComposefsRepository, + fs: &FileSystem, ) -> Result<()> { + if bootloader == Bootloader::None { + return Ok(()); + } + let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; + + if bootloader == Bootloader::Ukiboot { + return mounted_root.with_esp(|esp| install_ukiboot(mounted_root.dir(), fs, repo, esp)); + } let boot_uuid = root_setup .get_boot_uuid()? .or(root_setup.rootfs_uuid.as_deref()) @@ -2095,10 +2273,7 @@ fn install_composefs_bootloader( &root_setup.device_info.require_single_root()?, boot_uuid, )?; - } else if matches!( - postfetch.detected_bootloader, - Bootloader::Grub | Bootloader::GrubCC - ) { + } else if matches!(bootloader, Bootloader::Grub | Bootloader::GrubCC) { let chroot_target = Utf8Path::from_path(mounted_root.root_path()) .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?; // Like the ostree backend, bind the physical root's real /boot (an @@ -2118,7 +2293,7 @@ fn install_composefs_bootloader( )?; // FIXME: Remove this hack once we have support in bootupd - if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) { + if matches!(bootloader, Bootloader::GrubCC) { // bootupctl wrote this under the physical root's real /boot (via // the bind mount above), not under the composefs root. root_setup @@ -2202,24 +2377,28 @@ pub(crate) async fn setup_composefs_boot( fs, entries, } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; - let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); }; - let boot_type = BootType::from(entry); - if boot_type == BootType::Aboot { - bail!("aboot boot setup is not implemented"); - } let composefs_mnt_fd = repo .mount(&id.to_hex()) .context("Failed to mount composefs image")?; - let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; + let image_root = Dir::reopen_dir(&composefs_mnt_fd)?; - let postfetch = PostFetchState::new(state, mounted_root.dir())?; + let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?; - install_composefs_bootloader(root_setup, state, &postfetch, &mounted_root)?; + validate_bootloader(entry, postfetch.detected_bootloader)?; + + install_composefs_bootloader( + root_setup, + state, + postfetch.detected_bootloader, + composefs_mnt_fd, + &repo, + &fs, + )?; let repo = Arc::try_unwrap(repo).map_err(|_| { anyhow::anyhow!( @@ -2236,7 +2415,7 @@ pub(crate) async fn setup_composefs_boot( &provisional_deploy_id, provisional_format, entry, - mounted_root.dir(), + &image_root, )?, provisional_deploy_id, ), @@ -2251,7 +2430,10 @@ pub(crate) async fn setup_composefs_boot( &repo, &fs, )?, - BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), + BootType::Aboot => ( + setup_composefs_aboot_boot(&repo, &id, entry, allow_missing_fsverity)?, + provisional_deploy_id, + ), }; write_composefs_state( @@ -2294,6 +2476,7 @@ pub(crate) fn expected_boot_image_ids( mod tests { use super::*; use composefs::erofs::format::FormatVersion; + use composefs_boot::bootloader::{AbootArtifact, AbootEncoding, AbootEntry, Type2Entry}; #[test] fn test_replace_composefs_karg() { @@ -2310,6 +2493,152 @@ mod tests { assert!(rendered.contains("root=UUID=abc")); } + #[tokio::test] + async fn test_install_ukiboot() -> Result<()> { + use composefs::fs::read_filesystem; + use composefs::repository::RepositoryConfig; + + let temp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure(); + let (repo, _) = ComposefsRepository::init_path(&*temp, "repo", config)?; + let repo = Arc::new(repo); + let root = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + root.create_dir_all("usr/lib")?; + root.write("usr/lib/os-release", "ID=testos\n")?; + + let (loader, fallback) = match std::env::consts::ARCH { + "x86_64" => ("ukibootx64.efi", "BOOTX64.EFI"), + "aarch64" => ("ukibootaa64.efi", "BOOTAA64.EFI"), + _ => return Ok(()), + }; + let files = [ + (loader, loader, fallback), + ( + "slot_a.addon.efi", + "ukiboot_a.efi.extra.d/slot_a.addon.efi", + "ukiboot_a.efi.extra.d/slot_a.addon.efi", + ), + ( + "slot_b.addon.efi", + "ukiboot_b.efi.extra.d/slot_b.addon.efi", + "ukiboot_b.efi.extra.d/slot_b.addon.efi", + ), + ]; + for signed_mask in 0..8 { + let source = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + let esp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + source.create_dir_all("usr/libexec/ukiboot/efi")?; + for (index, (name, vendor_path, _)) in files.iter().enumerate() { + source.write( + format!("usr/libexec/ukiboot/efi/{name}"), + format!("unsigned-{name}").repeat(if index == 0 { 512 } else { 1 }), + )?; + if signed_mask & (1 << index) != 0 { + let path = format!("boot/efi/EFI/testos/{vendor_path}"); + let path = Utf8Path::new(&path); + source.create_dir_all(path.parent().unwrap())?; + source.write( + path, + format!("signed-{name}").repeat(if index == 0 { 512 } else { 1 }), + )?; + } + } + let fs = read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone())) + .await?; + install_ukiboot(&root, &fs, &repo, &esp)?; + for (index, (name, vendor_path, fallback_path)) in files.iter().enumerate() { + let expected = if signed_mask & (1 << index) != 0 { + format!("signed-{name}") + } else { + format!("unsigned-{name}") + } + .repeat(if index == 0 { 512 } else { 1 }); + for path in [ + format!("EFI/testos/{vendor_path}"), + format!("EFI/BOOT/{fallback_path}"), + ] { + assert_eq!(esp.read_to_string(path)?, expected); + } + } + + let preferred = format!("boot/efi/EFI/testos/{loader}"); + source.remove_file_optional(&preferred)?; + source.create_dir_all(&preferred)?; + let fs = read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone())) + .await?; + assert!(install_ukiboot(&root, &fs, &repo, &esp).is_err()); + source.remove_dir(&preferred)?; + source.remove_file(format!("usr/libexec/ukiboot/efi/{loader}"))?; + let fs = read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone())) + .await?; + assert!(install_ukiboot(&root, &fs, &repo, &esp).is_err()); + } + Ok(()) + } + + fn fake_aboot_entry() -> ComposefsBootEntry { + ComposefsBootEntry::Aboot(AbootEntry { + kver: "1.0".into(), + encoding: AbootEncoding::AndroidV2, + payload: AbootArtifact { + path: "/boot/aboot-1.0.img".into(), + file: RegularFile::External(Sha512HashValue::EMPTY, 4096), + }, + vbmeta: None, + }) + } + + #[test] + fn test_validate_bootloader() { + let bls = ComposefsBootEntry::UsrLibModulesVmLinuz(UsrLibModulesVmlinuz { + kver: "1.0".into(), + vmlinuz: RegularFile::Inline(Default::default()), + initramfs: Some(RegularFile::Inline(Default::default())), + os_release: None, + }); + let android = fake_aboot_entry(); + let mut ukiboot = fake_aboot_entry(); + if let ComposefsBootEntry::Aboot(entry) = &mut ukiboot { + entry.encoding = AbootEncoding::Uki; + } + let uki = ComposefsBootEntry::Type2(Type2Entry { + kver: None, + file_path: "test.efi".into(), + file: RegularFile::Inline(Default::default()), + pe_type: PEType::Uki, + }); + assert_eq!(image_bootloader(&android), Some(Bootloader::None)); + assert_eq!(image_bootloader(&ukiboot), Some(Bootloader::Ukiboot)); + assert_eq!(image_bootloader(&uki), None); + assert_eq!(image_bootloader(&bls), None); + for bootloader in [ + Bootloader::None, + Bootloader::Ukiboot, + Bootloader::Grub, + Bootloader::GrubCC, + Bootloader::Systemd, + ] { + for (entry, expected) in [ + (&android, bootloader == Bootloader::None), + (&ukiboot, bootloader == Bootloader::Ukiboot), + ( + &uki, + !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot), + ), + ( + &bls, + !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot), + ), + ] { + assert_eq!( + validate_bootloader(entry, bootloader).is_ok(), + expected, + "{entry:?}, {bootloader:?}" + ); + } + } + } + #[test] fn test_pe_output_dir() { let mounted_efi = Path::new("/esp"); diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 6a8d707b3..9b098fa26 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -117,8 +117,11 @@ pub(crate) struct InitializedComposefs { 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) +pub(crate) fn final_repository_policy( + artifact_policy: Option, + requested_relaxed: bool, +) -> bool { + artifact_policy == Some(true) || (artifact_policy.is_none() && requested_relaxed) } /// Enforce the durable repository policy after inspecting the imported image. @@ -136,7 +139,7 @@ pub(crate) fn validate_repository_policy( } 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" + "Initial insecure boot artifact conflicts with the existing strict composefs repository; explicitly pass --allow-missing-verity to permit this session" ); } Ok(()) @@ -375,10 +378,10 @@ pub(crate) fn prepare_boot_image( }) } -/// Inspect UKI policy without generating or recovering a boot image. Boot +/// Inspect boot artifact 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( +pub(crate) fn inspect_boot_artifact_policy( repo: &Arc, pull_result: &PullResult, ) -> Result> { @@ -388,9 +391,9 @@ pub(crate) fn inspect_uki_policy( Some(&pull_result.config_verity), &composefs_oci::OciTransformOptions::default(), ) - .context("Creating composefs filesystem for UKI policy inspection")?; + .context("Creating composefs filesystem for boot artifact policy inspection")?; let entries = get_boot_resources(&fs, &**repo).context("Extracting boot entries")?; - crate::bootc_composefs::boot::uki_fsverity_policy(repo, &entries) + crate::bootc_composefs::boot::boot_artifact_fsverity_policy(repo, &entries) } /// Pull an image directly into the composefs repository via skopeo. diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 253a2cc4b..d700c9f28 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -333,8 +333,8 @@ fn get_sorted_type1_boot_entries_helper( // For grub and grub-cc, sort by filename in descending order // See: https://github.com/bootc-dev/bootc/issues/2221 Bootloader::Grub | Bootloader::GrubCC => b.filename.cmp(&a.filename), - Bootloader::None => { - unreachable!("Bootloader checked during installation should not have been none") + Bootloader::Ukiboot | Bootloader::None => { + unreachable!("Bootloader checked during installation does not use BLS entries") } }; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 4dec3d02b..ad4be0fdf 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1724,11 +1724,10 @@ async fn prepare_install( composefs_options.composefs_backend = true; } - if composefs_options.composefs_backend - && matches!(config_opts.bootloader, Some(Bootloader::None)) - { - anyhow::bail!("Bootloader set to none is not supported with the composefs backend"); - } + ensure!( + config_opts.bootloader != Some(Bootloader::Ukiboot) || composefs_options.composefs_backend, + "ukiboot requires --composefs-backend" + ); // We need to access devices that are set up by the host udev bootc_mount::ensure_mirrored_host_mount("/dev")?; @@ -1878,20 +1877,15 @@ async fn prepare_install( } impl PostFetchState { - pub(crate) fn new(state: &State, d: &Dir) -> Result { + pub(crate) fn new( + state: &State, + d: &Dir, + image_bootloader: Option, + ) -> Result { // Determine bootloader type for the target system - // Priority: user-specified > bootupd availability > systemd-boot fallback - let detected_bootloader = { - if let Some(bootloader) = state.config_opts.bootloader.clone() { - bootloader - } else { - if crate::bootloader::supports_bootupd(d)? { - crate::spec::Bootloader::Grub - } else { - crate::spec::Bootloader::Systemd - } - } - }; + // Priority: user-specified > image-required > bootupd availability > systemd-boot fallback + let detected_bootloader = + select_bootloader(state.config_opts.bootloader, image_bootloader, d)?; println!("Bootloader: {detected_bootloader}"); let r = Self { detected_bootloader, @@ -1900,6 +1894,21 @@ impl PostFetchState { } } +fn select_bootloader( + configured: Option, + image: Option, + root: &Dir, +) -> Result { + if let Some(bootloader) = configured.or(image) { + return Ok(bootloader); + } + if crate::bootloader::supports_bootupd(root)? { + Ok(Bootloader::Grub) + } else { + Ok(Bootloader::Systemd) + } +} + /// Given a baseline root filesystem with an ostree sysroot initialized: /// - install the container to that root /// - install the bootloader @@ -1927,7 +1936,7 @@ async fn install_with_sysroot( .physical_root .open_dir(&deployment_path) .context("Opening deployment dir")?; - let postfetch = PostFetchState::new(state, &deployment_dir)?; + let postfetch = PostFetchState::new(state, &deployment_dir, None)?; if cfg!(target_arch = "s390x") { // TODO: Integrate s390x support into install_via_bootupd @@ -1950,7 +1959,7 @@ async fn install_with_sysroot( Some(bind_boot_path.as_path()), )?; } - Bootloader::Systemd | Bootloader::GrubCC => { + Bootloader::Systemd | Bootloader::GrubCC | Bootloader::Ukiboot => { anyhow::bail!("bootupd is required for ostree-based installs"); } Bootloader::None => { @@ -2115,11 +2124,13 @@ async fn install_to_filesystem_impl( fetch_imgref.as_ref(), ) .await?; - let uki_policy = - crate::bootc_composefs::repo::inspect_uki_policy(&initialized.repo, &pull_result)?; + let artifact_policy = crate::bootc_composefs::repo::inspect_boot_artifact_policy( + &initialized.repo, + &pull_result, + )?; let requested_relaxed = crate::bootc_composefs::repo::final_repository_policy( - uki_policy, + artifact_policy, state.composefs_options.allow_missing_verity, ); if !initialized.created { @@ -2130,7 +2141,7 @@ async fn install_to_filesystem_impl( )?; } else if !requested_relaxed && provisional_relaxed { anyhow::bail!( - "Initial UKI requires fs-verity, but the target filesystem does not support it" + "Initial boot artifact requires fs-verity, but the target filesystem does not support it" ); } // Repository handles retain LOCK_SH, so no Arc may remain before the @@ -3048,6 +3059,25 @@ pub(crate) async fn install_finalize(target: &Utf8Path) -> Result<()> { mod tests { use super::*; + #[test] + fn test_select_bootloader() -> Result<()> { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert_eq!(select_bootloader(None, None, &root)?, Bootloader::Systemd); + assert_eq!( + select_bootloader(None, Some(Bootloader::None), &root)?, + Bootloader::None + ); + assert_eq!( + select_bootloader(None, Some(Bootloader::Ukiboot), &root)?, + Bootloader::Ukiboot + ); + assert_eq!( + select_bootloader(Some(Bootloader::Grub), Some(Bootloader::None), &root)?, + Bootloader::Grub + ); + Ok(()) + } + #[test] fn install_opts_serializable() { let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({ diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index b62edfd74..419a6802f 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -242,6 +242,8 @@ pub enum Bootloader { GrubCC, /// Use SystemdBoot as the bootloader Systemd, + /// Use ukiboot as the bootloader + Ukiboot, /// Don't use a bootloader managed by bootc None, } @@ -261,6 +263,7 @@ impl Display for Bootloader { Bootloader::Grub => "grub", Bootloader::GrubCC => "grub-cc", Bootloader::Systemd => "systemd", + Bootloader::Ukiboot => "ukiboot", Bootloader::None => "none", }; @@ -276,6 +279,7 @@ impl FromStr for Bootloader { "grub" => Ok(Self::Grub), "grub-cc" => Ok(Self::GrubCC), "systemd" => Ok(Self::Systemd), + "ukiboot" => Ok(Self::Ukiboot), "none" => Ok(Self::None), unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")), } @@ -289,7 +293,9 @@ impl Bootloader { match self { Bootloader::Grub => Ok(BootloaderKind::GRUBClassic), Bootloader::Systemd | Bootloader::GrubCC => Ok(BootloaderKind::BLSCompatible), - Bootloader::None => anyhow::bail!("Bootloader was None"), + Bootloader::Ukiboot | Bootloader::None => { + anyhow::bail!("Bootloader {self} does not use BLS entries") + } } } } diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 451875957..2263078c6 100644 --- a/docs/src/host-v1.schema.json +++ b/docs/src/host-v1.schema.json @@ -226,6 +226,11 @@ "type": "string", "const": "systemd" }, + { + "description": "Use ukiboot as the bootloader", + "type": "string", + "const": "ukiboot" + }, { "description": "Don't use a bootloader managed by bootc", "type": "string", diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index 9c1173948..d119b3b67 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -168,6 +168,7 @@ set `discoverable-partitions = true` in their install configuration - grub - grub-cc - systemd + - ukiboot - none **--via-loopback** diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 168478fd8..729ecfbc6 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -205,6 +205,7 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo - grub - grub-cc - systemd + - ukiboot - none **--acknowledge-destructive** diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 2d2b8b6f6..bae39e799 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -122,6 +122,7 @@ is currently expected to be empty by default. - grub - grub-cc - systemd + - ukiboot - none **--composefs-backend** From 1efc9c600806594ce62d9948659c40d28aa9fb4b Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 21 Sep 2026 16:41:51 +0200 Subject: [PATCH 08/11] composefs: Track aboot slot state at boot Aboot systems do not use BLS entries or necessarily have an ESP, so identify them from the booted deployment origin before initializing boot storage. Record the deployment observed in the active A/B slot using a generated reconciliation service. Add a mutation lock for future update operations and helpers to atomically record or invalidate slot mappings. An absent mapping represents an invalid slot and does not retain a deployment. Skip ESP discovery and legacy boot-entry migration for aboot systems. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 248 ++++++++++++++++++++++++ crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/state.rs | 11 ++ crates/lib/src/cli.rs | 10 + crates/lib/src/composefs_consts.rs | 2 + crates/lib/src/generator.rs | 99 ++++++++++ crates/lib/src/store/mod.rs | 85 +++++--- systemd/bootc-aboot-reconcile.service | 11 ++ 8 files changed, 440 insertions(+), 27 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/aboot.rs create mode 100644 systemd/bootc-aboot-reconcile.service diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs new file mode 100644 index 000000000..ec2d0628d --- /dev/null +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -0,0 +1,248 @@ +use anyhow::{Context, Result, ensure}; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use linux_kernel_cmdline::utf8::Cmdline; +use rustix::{ + fd::OwnedFd, + fs::{FlockOperation, Mode, OFlags, flock, fsync, openat}, +}; + +use crate::composefs_consts::ABOOT_STATE_DIR; + +use super::{boot::BootType, state::read_boot_type}; + +const SLOT_SUFFIX: &str = "androidboot.slot_suffix"; +const LOCK_FILE: &str = "bootc-aboot.lock"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Slot { + A, + B, +} + +impl Slot { + pub(crate) fn from_cmdline(cmdline: &Cmdline<'_>) -> Option { + let mut suffixes = cmdline + .iter() + .filter(|param| param.key() == SLOT_SUFFIX.into()); + let suffix = suffixes.next()?; + if suffixes.next().is_some() { + return None; + } + match suffix.value()? { + "_a" => Some(Self::A), + "_b" => Some(Self::B), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::A => "a", + Self::B => "b", + } + } +} + +pub(crate) fn lock(run: &Dir) -> Result { + let fd = openat( + run, + LOCK_FILE, + OFlags::CREATE | OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::RUSR | Mode::WUSR, + ) + .context("Opening aboot mutation lock")?; + flock(&fd, FlockOperation::LockExclusive).context("Locking aboot state")?; + Ok(fd) +} + +pub(crate) fn read_slot(sysroot: &Dir, slot: Slot) -> Result> { + let path = format!("{ABOOT_STATE_DIR}/slots/{}", slot.name()); + let Some(contents) = sysroot.read_optional(&path)? else { + return Ok(None); + }; + let digest = match Sha512HashValue::from_hex(contents.trim_ascii()) { + Ok(digest) => digest, + Err(err) => { + tracing::warn!(%path, %err, "Invalid aboot slot mapping"); + return Ok(None); + } + }; + if read_boot_type(sysroot, &digest.to_hex())? != Some(BootType::Aboot) { + tracing::warn!(%path, "Aboot slot mapping has no corresponding aboot deployment"); + return Ok(None); + } + Ok(Some(digest)) +} + +pub(crate) fn record_booted(sysroot: &Dir, cmdline: &Cmdline<'_>, digest: &str) -> Result<()> { + let digest = Sha512HashValue::from_hex(digest).context("Invalid booted deployment digest")?; + ensure!( + read_boot_type(sysroot, &digest.to_hex())? == Some(BootType::Aboot), + "Booted deployment is not aboot" + ); + let Some(slot) = Slot::from_cmdline(cmdline) else { + tracing::warn!("No unambiguous aboot slot suffix; leaving slot mappings unchanged"); + return Ok(()); + }; + if read_slot(sysroot, slot)?.as_ref() == Some(&digest) { + return Ok(()); + } + + let mut dir = sysroot.try_clone()?; + for component in ABOOT_STATE_DIR.split('/').chain(["slots"]) { + dir.create_dir_all(component)?; + fsync(dir.reopen_as_ownedfd()?).context("Syncing aboot state directory")?; + dir = dir.open_dir(component)?; + } + dir.atomic_write(slot.name(), format!("{}\n", digest.to_hex())) + .context("Recording booted aboot slot")?; + Ok(()) +} + +#[cfg_attr( + not(test), + expect(dead_code, reason = "Used by aboot update finalization in a follow-up") +)] +pub(crate) fn invalidate_other(sysroot: &Dir, booted_slot: Slot) -> Result<()> { + let other = match booted_slot { + Slot::A => Slot::B, + Slot::B => Slot::A, + }; + let Some(slots) = sysroot.open_dir_optional(format!("{ABOOT_STATE_DIR}/slots"))? else { + return Ok(()); + }; + slots.remove_file_optional(other.name())?; + fsync(slots.reopen_as_ownedfd()?).context("Syncing invalidated aboot slot")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::composefs_consts::STATE_DIR_RELATIVE; + use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir}; + + fn deployment(root: &Dir, digest: &str, boot_type: &str) -> Result<()> { + let path = format!("{STATE_DIR_RELATIVE}/{digest}"); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + format!("[boot]\nboot_type={boot_type}\n"), + )?; + Ok(()) + } + + #[test] + fn slot_suffix() { + for (cmdline, expected) in [ + ("androidboot.slot_suffix=_a", Some(Slot::A)), + ("quiet androidboot.slot_suffix=_b rw", Some(Slot::B)), + ("", None), + ("androidboot.slot_suffix", None), + ("androidboot.slot_suffix=", None), + ("androidboot.slot_suffix=a", None), + ("androidboot.slot_suffix=_c", None), + ( + "androidboot.slot_suffix=_a androidboot.slot_suffix=_b", + None, + ), + ( + "androidboot.slot_suffix=_a androidboot.slot_suffix=_a", + None, + ), + ] { + assert_eq!( + Slot::from_cmdline(&Cmdline::from(cmdline)), + expected, + "{cmdline}" + ); + } + } + + #[test] + fn slot_lifecycle() -> Result<()> { + let root = tempdir(ambient_authority())?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + deployment(&root, &a, "aboot")?; + deployment(&root, &b, "aboot")?; + let cmdline_a = Cmdline::from("androidboot.slot_suffix=_a"); + let cmdline_b = Cmdline::from("androidboot.slot_suffix=_b"); + + assert_eq!(read_slot(&root, Slot::A)?, None); + invalidate_other(&root, Slot::A)?; + record_booted(&root, &cmdline_a, &a)?; + record_booted(&root, &cmdline_a, &a)?; + assert_eq!(read_slot(&root, Slot::B)?, None); + record_booted(&root, &cmdline_b, &b)?; + assert_eq!( + read_slot(&root, Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + assert_eq!( + read_slot(&root, Slot::B)?, + Some(Sha512HashValue::from_hex(&b)?) + ); + + for slot in [Slot::A, Slot::B] { + record_booted(&root, &cmdline_a, &a)?; + record_booted(&root, &cmdline_b, &b)?; + invalidate_other(&root, slot)?; + invalidate_other(&root, slot)?; + assert!(read_slot(&root, slot)?.is_some()); + let other = if slot == Slot::A { Slot::B } else { Slot::A }; + assert_eq!(read_slot(&root, other)?, None); + assert!(!root.try_exists(format!("{ABOOT_STATE_DIR}/slots/{}", other.name()))?); + } + + record_booted(&root, &cmdline_a, &a)?; + record_booted(&root, &Cmdline::from(""), &b)?; + assert_eq!( + read_slot(&root, Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + record_booted(&root, &cmdline_a, &b)?; + assert_eq!( + read_slot(&root, Slot::A)?, + Some(Sha512HashValue::from_hex(&b)?) + ); + Ok(()) + } + + #[test] + fn invalid_mappings() -> Result<()> { + let root = tempdir(ambient_authority())?; + let slots = format!("{ABOOT_STATE_DIR}/slots"); + root.create_dir_all(&slots)?; + for contents in [String::new(), "invalid".into(), "aa".repeat(64)] { + root.atomic_write(format!("{slots}/a"), contents)?; + assert_eq!(read_slot(&root, Slot::A)?, None); + } + root.atomic_write(format!("{slots}/a"), [0xff; 128])?; + assert_eq!(read_slot(&root, Slot::A)?, None); + let digest = "aa".repeat(64); + root.atomic_write(format!("{slots}/a"), &digest)?; + deployment(&root, &digest, "uki")?; + assert_eq!(read_slot(&root, Slot::A)?, None); + assert!( + record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_a"), &digest).is_err() + ); + assert!(record_booted(&root, &Cmdline::from(""), "../invalid").is_err()); + Ok(()) + } + + #[test] + fn mutation_lock() -> Result<()> { + let run = tempdir(ambient_authority())?; + let guard = lock(&run)?; + let other = run.open(LOCK_FILE)?; + assert_eq!( + flock(&other, FlockOperation::NonBlockingLockExclusive), + Err(rustix::io::Errno::WOULDBLOCK) + ); + drop(guard); + flock(&other, FlockOperation::NonBlockingLockExclusive)?; + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 42d521150..22d7a3a27 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod aboot; pub(crate) mod backwards_compat; pub(crate) mod boot; pub(crate) mod delete; diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index ec1df048b..8f7c82623 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -64,6 +64,17 @@ pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result Result> { + Sha512HashValue::from_hex(deployment_id).context("Invalid deployment digest")?; + let Some(origin) = read_origin(sysroot, deployment_id)? else { + return Ok(None); + }; + origin + .get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) + .map(|value| BootType::try_from(value.as_str())) + .transpose() +} + pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Result { let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true)?; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 57f88ec12..db01f9391 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -845,6 +845,8 @@ pub(crate) enum InternalsOpts { }, /// Ensure that a composefs repository is initialized TestComposefs, + /// Record the booted aboot slot in persistent deployment state. + ComposefsAbootReconcile, /// Loopback device cleanup helper (internal use only) LoopbackCleanupHelper { /// Device path to clean up @@ -2423,6 +2425,14 @@ async fn run_from_opt(opt: Opt) -> Result { } Opt::Status(opts) => super::status::status(opts).await, Opt::Internals(opts) => match opts { + InternalsOpts::ComposefsAbootReconcile => { + let storage = get_storage().await?; + ensure!( + matches!(storage.kind()?, BootedStorageKind::Composefs(_)), + "Aboot reconciliation requires the composefs backend" + ); + Ok(()) + } InternalsOpts::SystemdGenerator { normal_dir, early_dir: _, diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 03ccc5310..34293e703 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -7,6 +7,8 @@ pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_FNAME: &str = "staged-deployment"; pub(crate) const STATE_DIR_ABS: &str = "/sysroot/state/deploy"; /// Relative path to composefs-backend state directory. Relative to /sysroot pub(crate) const STATE_DIR_RELATIVE: &str = "state/deploy"; +pub(crate) const ABOOT_STATE_DIR: &str = "state/boot/aboot"; +pub(crate) const ABOOT_RECONCILE_SERVICE: &str = "bootc-aboot-reconcile.service"; /// Relative path to the shared 'var' directory. Relative to /sysroot pub(crate) const SHARED_VAR_PATH: &str = "state/os/default/var"; diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs index 3c4d6d973..9cdfff777 100644 --- a/crates/lib/src/generator.rs +++ b/crates/lib/src/generator.rs @@ -9,7 +9,10 @@ use ostree_ext::container_utils::{OSTREE_BOOTED, is_ostree_booted_in}; use ostree_ext::{gio, ostree}; use rustix::{fd::AsFd, fs::StatVfsMountFlags}; +use crate::bootc_composefs::{boot::BootType, state::read_boot_type, status::ComposefsCmdline}; +use crate::composefs_consts::ABOOT_RECONCILE_SERVICE; use crate::install::DESTRUCTIVE_CLEANUP; +use linux_kernel_cmdline::utf8::Cmdline; const STATUS_ONBOOT_UNIT: &str = "bootc-status-updated-onboot.target"; const STATUS_PATH_UNIT: &str = "bootc-status-updated.path"; @@ -174,6 +177,8 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> { generate_tmpfiles_ordering(unit_dir)?; } + aboot_generator_impl(root, unit_dir)?; + // === Ostree-specific generator logic === // Only run on ostree systems (native composefs boots skip below). if !root.try_exists(OSTREE_BOOTED)? { @@ -231,6 +236,23 @@ pub(crate) fn shadow_sync_generator_impl(root: &Dir, unit_dir: &Dir) -> Result Result { + let Some(cmdline) = root.read_to_string_optional("proc/cmdline")? else { + return Ok(false); + }; + let Some(cmdline) = ComposefsCmdline::find_in_cmdline(&Cmdline::from(cmdline.as_str()))? else { + return Ok(false); + }; + let Some(sysroot) = root.open_dir_optional("sysroot")? else { + return Ok(false); + }; + if read_boot_type(&sysroot, &cmdline.digest)? != Some(BootType::Aboot) { + return Ok(false); + } + enable_unit(unit_dir, ABOOT_RECONCILE_SERVICE, MULTI_USER_TARGET)?; + Ok(true) +} + /// Parse /etc/fstab and check if the root mount is out of sync with the composefs /// state, and if so, fix it. fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> { @@ -347,6 +369,83 @@ mod tests { Ok(tempdir) } + #[test] + fn aboot_reconcile_enablement() -> Result<()> { + let digest = "ab".repeat(64); + for boot_type in [None, Some("bls"), Some("uki"), Some("aboot")] { + let root = fixture()?; + let units = root.open_dir("run/systemd/system")?; + assert!(!aboot_generator_impl(&root, &units)?); + root.create_dir("proc")?; + root.atomic_write( + "proc/cmdline", + format!("composefs=?{digest} androidboot.slot_suffix=_a"), + )?; + if let Some(boot_type) = boot_type { + let path = format!( + "sysroot/{}/{digest}", + crate::composefs_consts::STATE_DIR_RELATIVE + ); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + format!("[boot]\nboot_type={boot_type}\n"), + )?; + } + let expected = boot_type == Some("aboot"); + for _ in 0..2 { + assert_eq!(aboot_generator_impl(&root, &units)?, expected); + } + let link = format!("{MULTI_USER_TARGET}.wants/{ABOOT_RECONCILE_SERVICE}"); + if expected { + assert_eq!( + units.read_link_contents(link)?, + std::path::Path::new("/usr/lib/systemd/system").join(ABOOT_RECONCILE_SERVICE) + ); + } else { + assert_eq!(units.entries()?.count(), 0); + } + } + Ok(()) + } + + #[test] + fn aboot_reconcile_with_dual_format_cmdline() -> Result<()> { + use composefs_ctl::composefs::erofs::format::FormatVersion; + use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; + + let root = fixture()?; + let units = root.open_dir("run/systemd/system")?; + root.create_dir("proc")?; + let v1 = Sha512HashValue::from_hex("ab".repeat(64))?; + let v2 = Sha512HashValue::from_hex("cd".repeat(64))?; + root.atomic_write( + "proc/cmdline", + format!( + "{} {} androidboot.slot_suffix=_a", + crate::bootc_composefs::status::build_composefs_karg( + v1.clone(), + FormatVersion::V1, + false, + ), + crate::bootc_composefs::status::build_composefs_karg(v2, FormatVersion::V2, false,), + ), + )?; + let digest = v1.to_hex(); + let path = format!( + "sysroot/{}/{digest}", + crate::composefs_consts::STATE_DIR_RELATIVE + ); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + "[boot]\nboot_type=aboot\n", + )?; + + assert!(aboot_generator_impl(&root, &units)?); + Ok(()) + } + #[test] fn test_generator_no_fstab() -> Result<()> { let tempdir = fixture()?; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index e25ac67c1..f2ca6bfa0 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -117,8 +117,9 @@ use composefs::repository::{RepositoryConfig, RepositoryOpenError}; use composefs_ctl::composefs; use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix; -use crate::bootc_composefs::boot::{EFI_LINUX, mount_esp_readonly, mount_esp_writable}; +use crate::bootc_composefs::boot::{BootType, EFI_LINUX, mount_esp_readonly, mount_esp_writable}; use crate::bootc_composefs::status::{ComposefsCmdline, composefs_booted, get_bootloader}; +use crate::bootc_composefs::{aboot, state::read_boot_type}; use crate::lsm; use crate::podstorage::CStorage; use crate::spec::{BootloaderKind, ImageStatus}; @@ -230,6 +231,7 @@ pub(crate) fn ensure_composefs_bootc_link(physical_root: &Dir) -> Result<()> { /// via ostree or composefs, providing a unified interface for both. pub(crate) struct BootedStorage { pub(crate) storage: Storage, + _aboot_lock: Option, } impl Deref for BootedStorage { @@ -468,31 +470,52 @@ impl BootedStorage { let r = match &env { Environment::ComposefsBooted(cmdline) => { let (physical_root, run, is_ro) = get_physical_root_and_run()?; + let is_aboot = + read_boot_type(&physical_root, &cmdline.digest)? == Some(BootType::Aboot); + let aboot_lock = if is_aboot && !is_ro && matches!(esp_access, EspAccess::ReadWrite) + { + let lock = aboot::lock(&run)?; + let kernel_cmdline = std::fs::read_to_string("/proc/cmdline") + .context("Reading kernel command line")?; + aboot::record_booted( + &physical_root, + &linux_kernel_cmdline::utf8::Cmdline::from(kernel_cmdline.as_str()), + &cmdline.digest, + )?; + Some(lock) + } else { + None + }; let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?; if cmdline.allow_missing_fsverity { composefs.set_insecure(); } let composefs = Arc::new(composefs); - // Locate ESP by walking up to the root disk(s). Both mount - // variants transparently reuse an already-mounted ESP when - // present (e.g. auto-mounted at /boot ro via - // `systemd.mount-extra` in the deployment cmdline). - let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?; - let esp_dev = root_dev.find_first_colocated_esp()?; - let esp_path = esp_dev.path(); - let esp_mount = match esp_access { - EspAccess::ReadOnly => mount_esp_readonly(&esp_path)?, - EspAccess::ReadWrite => mount_esp_writable(&esp_path)?, - }; - - let boot_dir = match get_bootloader()?.kind()? { - // We can have a separate /boot and not /sysroot/boot - BootloaderKind::GRUBClassic => get_boot_dir_for_grub(&physical_root)?, - // NOTE: Handle XBOOTLDR partitions here if and when we use it - BootloaderKind::BLSCompatible => { - esp_mount.fd.try_clone().context("Cloning fd")? - } + let (boot_dir, esp) = if is_aboot { + (None, None) + } else { + // Locate ESP by walking up to the root disk(s). Both mount + // variants transparently reuse an already-mounted ESP when + // present (e.g. auto-mounted at /boot ro via + // `systemd.mount-extra` in the deployment cmdline). + let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?; + let esp_dev = root_dev.find_first_colocated_esp()?; + let esp_path = esp_dev.path(); + let esp_mount = match esp_access { + EspAccess::ReadOnly => mount_esp_readonly(&esp_path)?, + EspAccess::ReadWrite => mount_esp_writable(&esp_path)?, + }; + + let boot_dir = match get_bootloader()?.kind()? { + // We can have a separate /boot and not /sysroot/boot + BootloaderKind::GRUBClassic => get_boot_dir_for_grub(&physical_root)?, + // NOTE: Handle XBOOTLDR partitions here if and when we use it + BootloaderKind::BLSCompatible => { + esp_mount.fd.try_clone().context("Cloning fd")? + } + }; + (Some(boot_dir), Some(esp_mount)) }; let storage = Storage { @@ -500,8 +523,8 @@ impl BootedStorage { physical_root_path: Utf8PathBuf::from("/sysroot"), is_ro, run, - boot_dir: Some(boot_dir), - esp: Some(esp_mount), + boot_dir, + esp, ostree: Default::default(), composefs: OnceCell::from(composefs.clone()), imgstore: Default::default(), @@ -513,11 +536,16 @@ impl BootedStorage { // lacked the prefix — we can't use meta.json presence as a trigger // because open_upgrade() in the initramfs writes meta.json before // userspace ever runs. - let cmdline = composefs_booted()? - .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?; - prepend_custom_prefix(&storage, &cmdline).await?; + if !is_aboot { + let cmdline = composefs_booted()? + .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?; + prepend_custom_prefix(&storage, &cmdline).await?; + } - Some(Self { storage }) + Some(Self { + storage, + _aboot_lock: aboot_lock, + }) } Environment::OstreeBooted => { // The caller must have entered a private mount namespace before @@ -552,7 +580,10 @@ impl BootedStorage { imgstore: Default::default(), }; - Some(Self { storage }) + Some(Self { + storage, + _aboot_lock: None, + }) } // For container or non-bootc environments, there's no storage Environment::Container | Environment::Other => None, diff --git a/systemd/bootc-aboot-reconcile.service b/systemd/bootc-aboot-reconcile.service new file mode 100644 index 000000000..bc23864f1 --- /dev/null +++ b/systemd/bootc-aboot-reconcile.service @@ -0,0 +1,11 @@ +[Unit] +Description=Record Booted Aboot Slot +Documentation=man:bootc(1) +RequiresMountsFor=/sysroot +After=local-fs.target +Before=bootc-fetch-apply-updates.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/bootc internals composefs-aboot-reconcile From 1d7cba6d5c1153cf7d8ebe27efb095023ae5359b Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 21 Sep 2026 17:38:08 +0200 Subject: [PATCH 09/11] composefs: Add aboot status and safe GC Build aboot status from the running deployment, transient staged state, and valid observed slot mappings without inspecting BLS or ESP files. Use the resulting deployment list as the GC roots for aboot. Treat missing or malformed slot mappings as invalid, skip bootloader artifact cleanup, and leave shared objects to repository reachability GC. Keep soft reboot disabled and explicitly reject rollback and deployment deletion until controller-aware implementations are available. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 27 +++ crates/lib/src/bootc_composefs/delete.rs | 4 + crates/lib/src/bootc_composefs/gc.rs | 154 ++++++++++++++-- crates/lib/src/bootc_composefs/rollback.rs | 4 + crates/lib/src/bootc_composefs/status.rs | 205 +++++++++++++++++++-- crates/lib/src/store/mod.rs | 15 ++ 6 files changed, 374 insertions(+), 35 deletions(-) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index ec2d0628d..8880d187a 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -44,6 +44,19 @@ impl Slot { } } +pub(crate) fn slot_deployments(sysroot: &Dir) -> Result> { + let mut deployments = Vec::new(); + for slot in [Slot::A, Slot::B] { + let Some(deployment) = read_slot(sysroot, slot)? else { + continue; + }; + if !deployments.contains(&deployment) { + deployments.push(deployment); + } + } + Ok(deployments) +} + pub(crate) fn lock(run: &Dir) -> Result { let fd = openat( run, @@ -232,6 +245,20 @@ mod tests { Ok(()) } + #[test] + fn deployments_deduplicates_slots() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + deployment(&root, &digest, "aboot")?; + record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_a"), &digest)?; + record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_b"), &digest)?; + assert_eq!( + slot_deployments(&root)?, + vec![Sha512HashValue::from_hex(digest)?] + ); + Ok(()) + } + #[test] fn mutation_lock() -> Result<()> { let run = tempdir(ambient_authority())?; diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index fc9a3ae8c..0dae7bd0b 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -229,6 +229,10 @@ pub(crate) async fn delete_composefs_deployment( anyhow::bail!("Cannot delete currently booted deployment"); } + if booted.boot_type == BootType::Aboot { + anyhow::bail!("aboot deployment deletion is not implemented"); + } + let all_depls = host.all_composefs_deployments()?; let depl_to_del = all_depls diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 652216943..2dc4816ac 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -217,6 +217,26 @@ pub(crate) struct GCOpts { pub(crate) prune_repo: bool, } +fn orphaned_state_dirs<'a>( + state_dirs: &'a [String], + live_deployments: &[&str], + bootloader_entries: &[BootloaderEntry], + is_aboot: bool, +) -> Vec<&'a String> { + state_dirs + .iter() + .filter(|state| { + if is_aboot { + !live_deployments.contains(&state.as_str()) + } else { + !bootloader_entries + .iter() + .any(|entry| &entry.fsverity == *state) + } + }) + .collect() +} + /// 1. List all bootloader entries /// 2. List all EROFS images /// 3. List all state directories @@ -284,8 +304,15 @@ pub(crate) async fn composefs_gc( let sysroot = &storage.physical_root; - let bootloader_entries = list_bootloader_entries(storage)?; - let boot_binaries = collect_boot_binaries(storage)?; + let is_aboot = booted_cfs_status.boot_type == BootType::Aboot; + let (bootloader_entries, boot_binaries) = if is_aboot { + (Vec::new(), Vec::new()) + } else { + ( + list_bootloader_entries(storage)?, + collect_boot_binaries(storage)?, + ) + }; tracing::debug!("bootloader_entries: {bootloader_entries:?}"); tracing::debug!("boot_binaries: {boot_binaries:?}"); @@ -328,18 +355,34 @@ pub(crate) async fn composefs_gc( let staged = &host.status.staged; - // State dirs without a bootloader entry are from interrupted deployments. - let orphaned_state_dirs: Vec<_> = state_dirs - .iter() - .filter(|s| !bootloader_entries.iter().any(|entry| &entry.fsverity == *s)) - .collect(); + let live_deployments = host + .list_deployments() + .into_iter() + .map(|deployment| { + deployment + .require_composefs() + .map(|cfs| cfs.verity.as_str()) + }) + .collect::>>()?; + + // State dirs without a live reference are from interrupted deployments. + let orphaned_state_dirs = orphaned_state_dirs( + &state_dirs, + &live_deployments, + &bootloader_entries, + is_aboot, + ); // Bootloader entries without a state dir are from interrupted cleanups. - let orphaned_boot_entries: Vec<_> = bootloader_entries - .iter() - .map(|entry| &entry.fsverity) - .filter(|verity| !state_dirs.contains(verity)) - .collect(); + let orphaned_boot_entries: Vec<_> = if is_aboot { + Vec::new() + } else { + bootloader_entries + .iter() + .map(|entry| &entry.fsverity) + .filter(|verity| !state_dirs.contains(verity)) + .collect() + }; let all_orphans: Vec<_> = orphaned_state_dirs .iter() @@ -506,10 +549,10 @@ pub(crate) async fn composefs_gc( let (mut objects_bytes, mut objects_removed) = (0, 0); - // Now GC the UKI/UKI Addons from `.boot` EROFS if we have them - // These won't be GC'd by the above `repo.gc` as the EROFS have - // `/boot` masked - for verity in all_orphans { + // Now GC the UKI/UKI Addons from `.boot` EROFS if we have them. + // Aboot artifacts may share repository objects and are left to repository GC. + // These won't be GC'd by the above `repo.gc` as the EROFS have `/boot` masked. + for verity in all_orphans.iter().copied().filter(|_| !is_aboot) { let verity_to_sha = Sha512HashValue::from_hex(verity); let Ok(verity_to_sha) = verity_to_sha else { @@ -613,7 +656,86 @@ pub(crate) async fn composefs_gc( mod tests { use super::*; use crate::bootc_composefs::status::list_type1_entries; + use crate::bootc_composefs::{aboot, status::ComposefsCmdline}; + use crate::composefs_consts::STATE_DIR_RELATIVE; + use crate::store::ComposefsRepository; use crate::testutils::{ChangeType, TestRoot}; + use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir}; + use composefs_ctl::composefs::repository::RepositoryConfig; + + fn write_aboot_origin(root: &Dir, digest: &str) -> Result<()> { + let path = format!("{STATE_DIR_RELATIVE}/{digest}"); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + "[boot]\nboot_type=aboot\ndigest=boot-digest\n", + )?; + Ok(()) + } + + #[test] + fn aboot_orphans_only_unreferenced_state() { + let state = ["booted".into(), "rollback".into(), "invalid".into()]; + let live = ["booted", "rollback"]; + let orphans = orphaned_state_dirs(&state, &live, &[], true); + assert_eq!( + orphans.iter().map(|s| s.as_str()).collect::>(), + ["invalid"] + ); + + let live = ["booted"]; + let orphans = orphaned_state_dirs(&state, &live, &[], true); + assert_eq!( + orphans.iter().map(|s| s.as_str()).collect::>(), + ["rollback", "invalid"] + ); + } + + #[tokio::test] + async fn aboot_gc_uses_status_roots() -> Result<()> { + let root = tempdir(ambient_authority())?; + root.create_dir_all("composefs")?; + root.create_dir("run")?; + let repo_dir = root.open_dir("composefs")?; + let config = RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512) + .set_insecure(); + let (repo, _) = ComposefsRepository::init_path(&repo_dir, ".", config)?; + let repo = std::sync::Arc::new(repo); + let storage = Storage::new_composefs_for_test(&root, &root.open_dir("run")?)?; + let booted = "aa".repeat(64); + let rollback = "bb".repeat(64); + let orphan = "cc".repeat(64); + for digest in [&booted, &rollback, &orphan] { + write_aboot_origin(&root, digest)?; + } + aboot::record_booted( + &root, + &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_a"), + &booted, + )?; + aboot::record_booted( + &root, + &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_b"), + &rollback, + )?; + let cmdline = Box::leak(Box::new(ComposefsCmdline::new(&booted))); + let booted_cfs = BootedComposefs { repo, cmdline }; + + composefs_gc( + &storage, + &booted_cfs, + GCOpts { + dry_run: false, + prune_repo: true, + }, + ) + .await?; + + assert!(root.try_exists(format!("{STATE_DIR_RELATIVE}/{booted}"))?); + assert!(root.try_exists(format!("{STATE_DIR_RELATIVE}/{rollback}"))?); + assert!(!root.try_exists(format!("{STATE_DIR_RELATIVE}/{orphan}"))?); + Ok(()) + } #[test] fn test_image_refs_match_v2_when_v1_is_present() { diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 51f64aebf..0b8a8026a 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -226,6 +226,10 @@ pub(crate) async fn composefs_rollback( let host = get_composefs_status(storage, booted_cfs).await?; + if host.require_composefs_booted()?.boot_type == BootType::Aboot { + anyhow::bail!("aboot rollback is not implemented"); + } + let new_spec = { let mut new_spec = host.spec.clone(); new_spec.boot_order = new_spec.boot_order.swap(); diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index d700c9f28..5b035a5c6 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -1,6 +1,6 @@ use std::{io::Read, sync::OnceLock}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use bootc_mount::inspect_filesystem; use composefs_ctl::composefs::erofs::format::FormatVersion; use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; @@ -16,9 +16,10 @@ use serde::{Deserialize, Serialize}; use crate::{ bootc_composefs::{ + aboot, boot::BootType, selinux::are_selinux_policies_compatible, - state::{get_composefs_usr_overlay_status, read_origin}, + state::{get_composefs_usr_overlay_status, read_boot_type, read_origin}, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ @@ -619,6 +620,7 @@ fn boot_entry_from_composefs_deployment( origin: tini::Ini, verity: &str, missing_verity_allowed: bool, + bootloader: Bootloader, ) -> Result { let image = match origin.get::("origin", ORIGIN_CONTAINER) { Some(img_name_from_config) => { @@ -668,7 +670,7 @@ fn boot_entry_from_composefs_deployment( composefs: Some(crate::spec::BootEntryComposefs { verity: verity.into(), boot_type, - bootloader: get_bootloader()?, + bootloader, boot_digest, missing_verity_allowed, }), @@ -921,6 +923,91 @@ fn rollback_queued_from_first_entry( } } +fn read_staged_deployment() -> Result> { + let path = format!("{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("Reading {path}")), + }; + serde_json::from_str(&contents) + .with_context(|| format!("Parsing {path}")) + .map(Some) +} + +fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> Result { + let origin = read_origin(&storage.physical_root, verity)? + .ok_or_else(|| anyhow::anyhow!("No origin file for aboot deployment {verity}"))?; + let entry = boot_entry_from_composefs_deployment( + storage, + origin, + verity, + missing_verity_allowed, + Bootloader::None, + )?; + ensure!( + entry.require_composefs()?.boot_type == BootType::Aboot, + "Slot references non-aboot deployment {verity}" + ); + Ok(entry) +} + +fn composefs_aboot_status(storage: &Storage, cmdline: &ComposefsCmdline) -> Result { + let staged = read_staged_deployment()?; + composefs_aboot_status_from(storage, cmdline, staged.as_ref()) +} + +fn composefs_aboot_status_from( + storage: &Storage, + cmdline: &ComposefsCmdline, + staged: Option<&StagedDeployment>, +) -> Result { + let mut host = Host::new(HostSpec { + image: None, + boot_order: BootOrder::Default, + }); + + host.status.booted = Some(aboot_entry( + storage, + &cmdline.digest, + cmdline.allow_missing_fsverity, + )?); + + if let Some(staged) = staged.filter(|s| s.depl_id != *cmdline.digest) { + let mut entry = aboot_entry(storage, &staged.depl_id, cmdline.allow_missing_fsverity)?; + entry.download_only = staged.finalization_locked; + host.status.staged = Some(entry); + } + + let mut seen = vec![cmdline.digest.to_string()]; + if let Some(staged) = host.status.staged.as_ref() { + seen.push(staged.require_composefs()?.verity.clone()); + } + for deployment in aboot::slot_deployments(&storage.physical_root)? { + let verity = deployment.to_hex(); + if seen.contains(&verity) { + continue; + } + seen.push(verity.clone()); + let entry = aboot_entry(storage, &verity, cmdline.allow_missing_fsverity)?; + if host.status.rollback.is_none() { + host.status.rollback = Some(entry); + } else { + host.status.other_deployments.push(entry); + } + } + + host.spec.image = host + .status + .staged + .as_ref() + .or(host.status.booted.as_ref()) + .and_then(|entry| entry.image.as_ref()) + .map(|image| image.image.clone()); + host.status.usr_overlay = get_composefs_usr_overlay_status().ok().flatten(); + Ok(host) +} + #[context("Getting composefs deployment status")] async fn composefs_deployment_status_from( storage: &Storage, @@ -928,6 +1015,10 @@ async fn composefs_deployment_status_from( ) -> Result { let booted_composefs_digest = &cmdline.digest; + if read_boot_type(&storage.physical_root, booted_composefs_digest)? == Some(BootType::Aboot) { + return composefs_aboot_status(storage, cmdline); + } + let boot_dir = storage.require_boot_dir()?; // This is our source of truth @@ -940,20 +1031,10 @@ async fn composefs_deployment_status_from( let mut host = Host::new(host_spec); - let staged_deployment = match std::fs::File::open(format!( - "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" - )) { - Ok(mut f) => { - let mut s = String::new(); - f.read_to_string(&mut s)?; - - Ok(Some(s)) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - }?; + let staged_deployment = read_staged_deployment()?; let mut boot_type: Option = None; + let bootloader = get_bootloader()?; // Boot entries from deployments that are neither booted nor staged deployments // Rollback deployment is in here, but may also contain stale deployment entries @@ -983,6 +1064,7 @@ async fn composefs_deployment_status_from( ini, &verity_digest, cmdline.allow_missing_fsverity, + bootloader, )?; // SAFETY: boot_entry.composefs will always be present @@ -1006,10 +1088,8 @@ async fn composefs_deployment_status_from( } if let Some(staged_deployment) = &staged_deployment { - let staged_depl = serde_json::from_str::(&staged_deployment)?; - - if verity_digest == staged_depl.depl_id { - boot_entry.download_only = staged_depl.finalization_locked; + if verity_digest == staged_deployment.depl_id { + boot_entry.download_only = staged_deployment.finalization_locked; host.status.staged = Some(boot_entry); continue; } @@ -1155,14 +1235,101 @@ async fn composefs_deployment_status_from( mod tests { use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; + use crate::bootc_composefs::aboot; use crate::bootc_composefs::boot::{ FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key, secondary_sort_key, type1_entry_conf_file_name, }; + use crate::composefs_consts::{ABOOT_STATE_DIR, STATE_DIR_RELATIVE}; use crate::parsers::grub_menuconfig::MenuentryBody; use super::*; + fn write_aboot_origin(root: &Dir, digest: &str) -> Result<()> { + let path = format!("{STATE_DIR_RELATIVE}/{digest}"); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + "[boot]\nboot_type=aboot\ndigest=boot-digest\n", + )?; + Ok(()) + } + + #[test] + fn aboot_status_from_slots() -> Result<()> { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir("run")?; + let run = root.open_dir("run")?; + let storage = Storage::new_composefs_for_test(&root, &run)?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + let staged_id = "cc".repeat(64); + for digest in [&a, &b, &staged_id] { + write_aboot_origin(&root, digest)?; + } + aboot::record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + aboot::record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + + let cmdline = ComposefsCmdline::new(&a); + let host = composefs_aboot_status_from(&storage, &cmdline, None)?; + let booted = host.require_composefs_booted()?; + assert_eq!(booted.verity, a); + assert_eq!(booted.boot_type, BootType::Aboot); + assert_eq!(booted.bootloader, Bootloader::None); + assert_eq!( + host.status + .rollback + .as_ref() + .unwrap() + .require_composefs()? + .verity, + b + ); + assert!(host.status.staged.is_none()); + assert!(host.status.other_deployments.is_empty()); + assert!(!host.status.rollback_queued); + + let staged = StagedDeployment { + depl_id: staged_id.clone(), + finalization_locked: true, + }; + let host = composefs_aboot_status_from(&storage, &cmdline, Some(&staged))?; + let staged_entry = host.status.staged.as_ref().unwrap(); + assert_eq!(staged_entry.require_composefs()?.verity, staged_id); + assert!(staged_entry.download_only); + assert_eq!( + host.status + .rollback + .as_ref() + .unwrap() + .require_composefs()? + .verity, + b + ); + + let staged = StagedDeployment { + depl_id: b.clone(), + finalization_locked: false, + }; + let host = composefs_aboot_status_from(&storage, &cmdline, Some(&staged))?; + assert_eq!( + host.status + .staged + .as_ref() + .unwrap() + .require_composefs()? + .verity, + b + ); + assert!(host.status.rollback.is_none()); + + root.atomic_write(format!("{ABOOT_STATE_DIR}/slots/b"), "invalid")?; + let host = composefs_aboot_status_from(&storage, &cmdline, None)?; + assert!(host.status.rollback.is_none()); + assert_eq!(host.list_deployments().len(), 1); + Ok(()) + } + #[test] fn test_composefs_parsing() { const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index f2ca6bfa0..d65cb2477 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -659,6 +659,21 @@ pub(crate) struct CachedImageStatus { } impl Storage { + #[cfg(test)] + pub(crate) fn new_composefs_for_test(physical_root: &Dir, run: &Dir) -> Result { + Ok(Self { + physical_root: physical_root.try_clone()?, + physical_root_path: Utf8PathBuf::from("/sysroot"), + is_ro: false, + boot_dir: None, + esp: None, + run: run.try_clone()?, + ostree: Default::default(), + composefs: Default::default(), + imgstore: Default::default(), + }) + } + /// Create a new storage accessor from an existing ostree sysroot. /// /// This is used for non-booted scenarios (e.g., `bootc install`) where From 58b91b73e0a42b4820c224a22f651de7fdda6d23 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Tue, 22 Sep 2026 11:33:23 +0200 Subject: [PATCH 10/11] composefs: Support aboot updates and rollbacks Implement staging, finalization, and boot-time reconciliation for aboot deployments. Stage the boot image and optional vbmeta alongside the deployment and record their hashes in persistent pending state. At shutdown, verify the payloads, record the attempt, invalidate the non-booted slot mapping when known, and invoke aboot-deploy. On the following boot, use the active deployment to determine whether the attempt succeeded. Clean up successful attempts while retaining failed candidates without automatically retrying them. Persist download-only state, support applying previously downloaded updates, and reconstruct the transient staged state during reconciliation. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 812 ++++++++++++++++++--- crates/lib/src/bootc_composefs/boot.rs | 129 +++- crates/lib/src/bootc_composefs/delete.rs | 17 +- crates/lib/src/bootc_composefs/finalize.rs | 64 +- crates/lib/src/bootc_composefs/gc.rs | 11 +- crates/lib/src/bootc_composefs/rollback.rs | 54 +- crates/lib/src/bootc_composefs/state.rs | 47 +- crates/lib/src/bootc_composefs/status.rs | 52 +- crates/lib/src/bootc_composefs/update.rs | 148 ++-- crates/lib/src/cli.rs | 17 +- crates/lib/src/composefs_consts.rs | 6 + crates/lib/src/store/mod.rs | 3 +- 12 files changed, 1126 insertions(+), 234 deletions(-) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index 8880d187a..f9cff390a 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -1,4 +1,38 @@ +//! Persistent Aboot state relative to the physical root: +//! +//! ```text +//! state/boot/aboot/ +//! slots/a observed deployment ID for slot A +//! slots/b observed deployment ID for slot B +//! pending staged deployment, payload hashes, and finalization lock +//! attempted deployment attempt and the boot ID which initiated it +//! rollback queued rollback deployment and originating boot ID +//! +//! state/deploy//aboot/ +//! aboot.img staged boot partition payload +//! vbmeta.img optional staged vbmeta payload +//! ``` +//! +//! A missing slot file means that slot has no known valid mapping. Pending and +//! attempted state retain an update until a later boot observes its result. +//! `/run/composefs/staged-deployment` is a transient version of pending state +//! used by the common status and finalization code. +//! +//! Update staging first creates the deployment state, then writes and hashes the boot +//! (and optional vbmeta payloads) below that deployment. Only after those writes are +//! durable does it publish `pending` and the transient staged view. No boot partitions +//! are written during staging. +//! +//! At shutdown the finalizer verifies the staged payload hashes, writes `attempted`, +//! invalidates the non-booted slot mapping, and invokes aboot-deploy. On the next boot, +//! observing the candidate consumes `pending`, its staged payload directory, and +//! `attempted`. Observing another deployment retains them without automatically retrying +//! the write. An unattempted pending update is projected back into `/run` and rearmed +//! unless it was staged download-only. + use anyhow::{Context, Result, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use canon_json::CanonJsonSerialize; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use linux_kernel_cmdline::utf8::Cmdline; @@ -6,20 +40,381 @@ use rustix::{ fd::OwnedFd, fs::{FlockOperation, Mode, OFlags, flock, fsync, openat}, }; +use serde::{Deserialize, Serialize}; -use crate::composefs_consts::ABOOT_STATE_DIR; +use crate::composefs_consts::{ + ABOOT_ARTIFACT_DIR, ABOOT_ATTEMPTED_FNAME, ABOOT_IMAGE_FNAME, ABOOT_PENDING_FNAME, + ABOOT_ROLLBACK_FNAME, ABOOT_STATE_DIR, ABOOT_VBMETA_FNAME, STATE_DIR_RELATIVE, +}; -use super::{boot::BootType, state::read_boot_type}; +use super::{ + boot::BootType, + state::{read_boot_type, remove_staged_deployment, write_staged_deployment}, + status::StagedDeployment, +}; const SLOT_SUFFIX: &str = "androidboot.slot_suffix"; const LOCK_FILE: &str = "bootc-aboot.lock"; +/// Logical A/B slot observed from the running kernel command line. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Slot { A, B, } +/// Durable staged-deployment state and hashes of the payloads to flash to the boot partitions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PendingDeployment { + pub(crate) depl_id: String, + pub(crate) finalization_locked: bool, + boot_image_sha256: String, + vbmeta_image_sha256: Option, +} + +impl PendingDeployment { + pub(crate) fn staged(&self) -> StagedDeployment { + StagedDeployment { + depl_id: self.depl_id.clone(), + finalization_locked: self.finalization_locked, + } + } +} + +/// Records an attempted deployment and the system boot id which initiated it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AttemptedDeployment { + pub(crate) depl_id: String, + boot_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct QueuedRollback { + depl_id: String, + boot_id: String, +} + +/// Absolute paths to staged payloads after their hashes have been verified. +pub(crate) struct ArtifactPaths { + pub(crate) boot: Utf8PathBuf, + pub(crate) vbmeta: Option, +} + +fn boot_id() -> Result { + Ok(std::fs::read_to_string("/proc/sys/kernel/random/boot_id")? + .trim() + .to_string()) +} + +enum ReconcileAction { + None, + RemoveTransient, + Publish(PendingDeployment), +} + +/// An open fd to the persistent Aboot state +pub(crate) struct AbootState<'a> { + sysroot: &'a Dir, + state: Option, + slots: Option, +} + +impl<'a> AbootState<'a> { + pub(crate) fn open(sysroot: &'a Dir) -> Result { + let state = sysroot.open_dir_optional(ABOOT_STATE_DIR)?; + let slots = match state.as_ref() { + Some(state) => state.open_dir_optional("slots")?, + None => None, + }; + Ok(Self { + sysroot, + state, + slots, + }) + } + + fn ensure_state(&mut self) -> Result<&Dir> { + if self.state.is_none() { + let mut dir = self.sysroot.try_clone()?; + for component in ABOOT_STATE_DIR.split('/') { + dir.create_dir_all(component)?; + fsync(dir.reopen_as_ownedfd()?).context("Syncing aboot state directory")?; + dir = dir.open_dir(component)?; + } + self.state = Some(dir); + } + Ok(self.state.as_ref().unwrap()) + } + + fn ensure_slots(&mut self) -> Result<&Dir> { + if self.slots.is_none() { + { + let state = self.ensure_state()?; + state.create_dir_all("slots")?; + fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state directory")?; + } + self.slots = Some(self.state.as_ref().unwrap().open_dir("slots")?); + } + Ok(self.slots.as_ref().unwrap()) + } + + fn read_json Deserialize<'de>>(&self, name: &str) -> Result> { + let Some(state) = self.state.as_ref() else { + return Ok(None); + }; + let Some(contents) = state.read_optional(name)? else { + return Ok(None); + }; + serde_json::from_slice(&contents) + .with_context(|| format!("Parsing aboot {name}")) + .map(Some) + } + + fn write_json(&mut self, name: &str, value: &impl CanonJsonSerialize) -> Result<()> { + let state = self.ensure_state()?; + state.atomic_write(name, value.to_canon_json_vec()?)?; + fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state")?; + Ok(()) + } + + fn remove_state_file(&self, name: &str) -> Result<()> { + let Some(state) = self.state.as_ref() else { + return Ok(()); + }; + state.remove_file_optional(name)?; + fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state")?; + Ok(()) + } + + pub(crate) fn read_pending(&self) -> Result> { + self.read_json(ABOOT_PENDING_FNAME) + } + + /// Publish or replace a pending deployment unless one was already attempted. + pub(crate) fn write_pending(&mut self, pending: &PendingDeployment) -> Result<()> { + ensure!( + self.read_attempted()?.is_none(), + "Cannot replace an attempted aboot deployment" + ); + self.write_json(ABOOT_PENDING_FNAME, pending) + } + + pub(crate) fn read_attempted(&self) -> Result> { + self.read_json(ABOOT_ATTEMPTED_FNAME) + } + + fn read_rollback(&self) -> Result> { + self.read_json(ABOOT_ROLLBACK_FNAME) + } + + pub(crate) fn queued_rollback(&self) -> Result> { + let Some(rollback) = self.read_rollback()? else { + return Ok(None); + }; + if rollback.boot_id != boot_id()? { + return Ok(None); + } + Ok(Some(rollback.depl_id)) + } + + pub(crate) fn queue_rollback(&mut self, deployment: &str) -> Result<()> { + let digest = Sha512HashValue::from_hex(deployment).context("Invalid rollback digest")?; + ensure!( + self.slot_deployments()?.contains(&digest), + "Rollback deployment is not mapped to an aboot slot" + ); + self.write_json( + ABOOT_ROLLBACK_FNAME, + &QueuedRollback { + depl_id: deployment.to_string(), + boot_id: boot_id()?, + }, + ) + } + + pub(crate) fn clear_rollback(&self) -> Result<()> { + self.remove_state_file(ABOOT_ROLLBACK_FNAME) + } + + /// Make a download-only pending deployment eligible for finalization. + pub(crate) fn unlock_pending(&mut self, deployment: &str) -> Result { + let mut pending = self + .read_pending()? + .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?; + ensure!( + pending.depl_id == deployment, + "Pending aboot deployment changed" + ); + pending.finalization_locked = false; + self.write_pending(&pending)?; + Ok(pending) + } + + /// Store the exact partition payloads before publishing the pending deployment. + pub(crate) fn stage_artifacts( + &self, + deployment: &str, + finalization_locked: bool, + boot: &[u8], + vbmeta: Option<&[u8]>, + ) -> Result { + Sha512HashValue::from_hex(deployment).context("Invalid staged deployment digest")?; + ensure!( + read_boot_type(self.sysroot, deployment)? == Some(BootType::Aboot), + "Staged deployment is not aboot" + ); + let deployment_dir = self + .sysroot + .open_dir(format!("{STATE_DIR_RELATIVE}/{deployment}")) + .context("Opening staged deployment")?; + deployment_dir.create_dir_all(ABOOT_ARTIFACT_DIR)?; + fsync(deployment_dir.reopen_as_ownedfd()?).context("Syncing staged deployment")?; + let artifacts = deployment_dir.open_dir(ABOOT_ARTIFACT_DIR)?; + artifacts.atomic_write(ABOOT_IMAGE_FNAME, boot)?; + match vbmeta { + Some(vbmeta) => artifacts.atomic_write(ABOOT_VBMETA_FNAME, vbmeta)?, + None => { + artifacts.remove_file_optional(ABOOT_VBMETA_FNAME)?; + } + } + fsync(artifacts.reopen_as_ownedfd()?).context("Syncing staged aboot artifacts")?; + Ok(PendingDeployment { + depl_id: deployment.to_string(), + finalization_locked, + boot_image_sha256: hex::encode(openssl::sha::sha256(boot)), + vbmeta_image_sha256: vbmeta.map(|data| hex::encode(openssl::sha::sha256(data))), + }) + } + + /// Verify staged payloads and return paths suitable for passing to aboot-deploy. + pub(crate) fn verify_artifacts( + &self, + root_path: &Utf8Path, + pending: &PendingDeployment, + ) -> Result { + let relative = Utf8PathBuf::from(STATE_DIR_RELATIVE) + .join(&pending.depl_id) + .join(ABOOT_ARTIFACT_DIR); + let artifacts = self.sysroot.open_dir(&relative)?; + let boot = artifacts.read(ABOOT_IMAGE_FNAME)?; + ensure!( + hex::encode(openssl::sha::sha256(&boot)) == pending.boot_image_sha256, + "Staged aboot image was modified" + ); + let vbmeta = match pending.vbmeta_image_sha256.as_ref() { + Some(expected) => { + let data = artifacts.read(ABOOT_VBMETA_FNAME)?; + ensure!( + hex::encode(openssl::sha::sha256(&data)) == *expected, + "Staged vbmeta image was modified" + ); + Some(root_path.join(&relative).join(ABOOT_VBMETA_FNAME)) + } + None => None, + }; + Ok(ArtifactPaths { + boot: root_path.join(relative).join(ABOOT_IMAGE_FNAME), + vbmeta, + }) + } + + fn remove_artifacts(&self, deployment: &str) -> Result<()> { + let deployment_dir = self + .sysroot + .open_dir(format!("{STATE_DIR_RELATIVE}/{deployment}")) + .context("Opening deployed state")?; + if deployment_dir + .open_dir_optional(ABOOT_ARTIFACT_DIR)? + .is_some() + { + deployment_dir + .remove_dir_all(ABOOT_ARTIFACT_DIR) + .context("Removing staged aboot artifacts")?; + } + fsync(deployment_dir.reopen_as_ownedfd()?).context("Syncing deployed state")?; + Ok(()) + } + + pub(crate) fn discard_pending(&self) -> Result<()> { + ensure!( + self.read_attempted()?.is_none(), + "An attempted aboot update has not been reconciled" + ); + if let Some(pending) = self.read_pending()? { + self.remove_state_file(ABOOT_PENDING_FNAME)?; + self.remove_artifacts(&pending.depl_id)?; + } + remove_staged_deployment()?; + Ok(()) + } + + /// Mark an update attempted before invalidating a slot or writing partitions. + pub(crate) fn record_attempt(&mut self, deployment: &str) -> Result<()> { + ensure!( + self.read_attempted()?.is_none(), + "Aboot deployment was already attempted" + ); + let pending = self + .read_pending()? + .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?; + ensure!( + pending.depl_id == deployment, + "Pending aboot deployment changed" + ); + self.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: deployment.to_string(), + boot_id: boot_id()?, + }, + ) + } + + fn reconcile_state(&mut self, booted: &str, current_boot_id: &str) -> Result { + if self + .read_rollback()? + .is_some_and(|rollback| rollback.boot_id != current_boot_id) + { + self.remove_state_file(ABOOT_ROLLBACK_FNAME)?; + } + if let Some(attempted) = self.read_attempted()? { + if attempted.boot_id == current_boot_id { + // The still-running system cannot tell us whether the new payload will boot. + return Ok(ReconcileAction::None); + } + if attempted.depl_id == booted { + // Remove pending first so interruption cannot make a successful write retryable. + self.remove_state_file(ABOOT_PENDING_FNAME)?; + self.remove_artifacts(&attempted.depl_id)?; + self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?; + return Ok(ReconcileAction::RemoveTransient); + } + // Firmware booted another deployment; retain the candidate without retrying it. + return Ok(ReconcileAction::None); + } + + let Some(pending) = self.read_pending()? else { + return Ok(ReconcileAction::RemoveTransient); + }; + Ok(ReconcileAction::Publish(pending)) + } + + /// Restore the transient staged view and return whether finalization should be armed. + pub(crate) fn reconcile(&mut self, booted: &str) -> Result { + match self.reconcile_state(booted, &boot_id()?)? { + ReconcileAction::None => Ok(false), + ReconcileAction::RemoveTransient => { + remove_staged_deployment()?; + Ok(false) + } + ReconcileAction::Publish(pending) => { + write_staged_deployment(&pending.staged())?; + Ok(!pending.finalization_locked) + } + } + } +} + impl Slot { pub(crate) fn from_cmdline(cmdline: &Cmdline<'_>) -> Option { let mut suffixes = cmdline @@ -44,19 +439,7 @@ impl Slot { } } -pub(crate) fn slot_deployments(sysroot: &Dir) -> Result> { - let mut deployments = Vec::new(); - for slot in [Slot::A, Slot::B] { - let Some(deployment) = read_slot(sysroot, slot)? else { - continue; - }; - if !deployments.contains(&deployment) { - deployments.push(deployment); - } - } - Ok(deployments) -} - +/// Serialize aboot state mutations and partition deployment for the guard's lifetime. pub(crate) fn lock(run: &Dir) -> Result { let fd = openat( run, @@ -69,65 +452,132 @@ pub(crate) fn lock(run: &Dir) -> Result { Ok(fd) } -pub(crate) fn read_slot(sysroot: &Dir, slot: Slot) -> Result> { - let path = format!("{ABOOT_STATE_DIR}/slots/{}", slot.name()); - let Some(contents) = sysroot.read_optional(&path)? else { - return Ok(None); - }; - let digest = match Sha512HashValue::from_hex(contents.trim_ascii()) { - Ok(digest) => digest, - Err(err) => { - tracing::warn!(%path, %err, "Invalid aboot slot mapping"); +impl AbootState<'_> { + pub(crate) fn slot_deployments(&self) -> Result> { + let mut deployments = Vec::new(); + for slot in [Slot::A, Slot::B] { + let Some(deployment) = self.read_slot(slot)? else { + continue; + }; + if !deployments.contains(&deployment) { + deployments.push(deployment); + } + } + Ok(deployments) + } + + fn read_slot(&self, slot: Slot) -> Result> { + let path = format!("{ABOOT_STATE_DIR}/slots/{}", slot.name()); + let Some(slots) = self.slots.as_ref() else { + return Ok(None); + }; + let Some(contents) = slots.read_optional(slot.name())? else { + return Ok(None); + }; + let digest = match Sha512HashValue::from_hex(contents.trim_ascii()) { + Ok(digest) => digest, + Err(err) => { + tracing::warn!(%path, %err, "Invalid aboot slot mapping"); + return Ok(None); + } + }; + if read_boot_type(self.sysroot, &digest.to_hex())? != Some(BootType::Aboot) { + tracing::warn!(%path, "Aboot slot mapping has no corresponding aboot deployment"); return Ok(None); } - }; - if read_boot_type(sysroot, &digest.to_hex())? != Some(BootType::Aboot) { - tracing::warn!(%path, "Aboot slot mapping has no corresponding aboot deployment"); - return Ok(None); + Ok(Some(digest)) } - Ok(Some(digest)) -} -pub(crate) fn record_booted(sysroot: &Dir, cmdline: &Cmdline<'_>, digest: &str) -> Result<()> { - let digest = Sha512HashValue::from_hex(digest).context("Invalid booted deployment digest")?; - ensure!( - read_boot_type(sysroot, &digest.to_hex())? == Some(BootType::Aboot), - "Booted deployment is not aboot" - ); - let Some(slot) = Slot::from_cmdline(cmdline) else { - tracing::warn!("No unambiguous aboot slot suffix; leaving slot mappings unchanged"); - return Ok(()); - }; - if read_slot(sysroot, slot)?.as_ref() == Some(&digest) { - return Ok(()); - } - - let mut dir = sysroot.try_clone()?; - for component in ABOOT_STATE_DIR.split('/').chain(["slots"]) { - dir.create_dir_all(component)?; - fsync(dir.reopen_as_ownedfd()?).context("Syncing aboot state directory")?; - dir = dir.open_dir(component)?; - } - dir.atomic_write(slot.name(), format!("{}\n", digest.to_hex())) - .context("Recording booted aboot slot")?; - Ok(()) -} + /// Record a slot mapping only after that deployment has been observed running. + pub(crate) fn record_booted(&mut self, cmdline: &Cmdline<'_>, digest: &str) -> Result<()> { + let digest = + Sha512HashValue::from_hex(digest).context("Invalid booted deployment digest")?; + ensure!( + read_boot_type(self.sysroot, &digest.to_hex())? == Some(BootType::Aboot), + "Booted deployment is not aboot" + ); + let Some(slot) = Slot::from_cmdline(cmdline) else { + tracing::warn!("No unambiguous aboot slot suffix; leaving slot mappings unchanged"); + return Ok(()); + }; + if self.read_slot(slot)?.as_ref() == Some(&digest) { + return Ok(()); + } -#[cfg_attr( - not(test), - expect(dead_code, reason = "Used by aboot update finalization in a follow-up") -)] -pub(crate) fn invalidate_other(sysroot: &Dir, booted_slot: Slot) -> Result<()> { - let other = match booted_slot { - Slot::A => Slot::B, - Slot::B => Slot::A, - }; - let Some(slots) = sysroot.open_dir_optional(format!("{ABOOT_STATE_DIR}/slots"))? else { - return Ok(()); - }; - slots.remove_file_optional(other.name())?; - fsync(slots.reopen_as_ownedfd()?).context("Syncing invalidated aboot slot")?; - Ok(()) + self.ensure_slots()? + .atomic_write(slot.name(), format!("{}\n", digest.to_hex())) + .context("Recording booted aboot slot")?; + Ok(()) + } + + /// Invalidate only the non-booted slot mapping before deployment. + pub(crate) fn invalidate_other(&self, booted_slot: Slot) -> Result<()> { + let other = match booted_slot { + Slot::A => Slot::B, + Slot::B => Slot::A, + }; + let Some(slots) = self.slots.as_ref() else { + return Ok(()); + }; + slots.remove_file_optional(other.name())?; + fsync(slots.reopen_as_ownedfd()?).context("Syncing invalidated aboot slot")?; + Ok(()) + } + + pub(crate) fn delete_deployment(&self, deployment: &str) -> Result<()> { + let digest = Sha512HashValue::from_hex(deployment).context("Invalid deployment digest")?; + let current_boot_id = boot_id()?; + + if let Some(rollback) = self.read_rollback()? { + if rollback.boot_id == current_boot_id { + ensure!( + rollback.depl_id != deployment, + "Cannot delete a queued aboot rollback deployment" + ); + } else { + self.clear_rollback()?; + } + } + + if let Some(attempted) = self + .read_attempted()? + .filter(|attempted| attempted.depl_id == deployment) + { + ensure!( + attempted.boot_id != current_boot_id, + "Cannot delete an aboot deployment attempted during this boot" + ); + if self + .read_pending()? + .is_some_and(|pending| pending.depl_id == deployment) + { + self.remove_state_file(ABOOT_PENDING_FNAME)?; + remove_staged_deployment()?; + self.remove_artifacts(deployment)?; + } + self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?; + } else if self + .read_pending()? + .is_some_and(|pending| pending.depl_id == deployment) + { + self.remove_state_file(ABOOT_PENDING_FNAME)?; + remove_staged_deployment()?; + self.remove_artifacts(deployment)?; + } + + let mut removed = false; + for slot in [Slot::A, Slot::B] { + if self.read_slot(slot)?.as_ref() == Some(&digest) { + self.slots.as_ref().unwrap().remove_file(slot.name())?; + removed = true; + } + } + if removed { + fsync(self.slots.as_ref().unwrap().reopen_as_ownedfd()?) + .context("Syncing aboot slot mappings")?; + } + Ok(()) + } } #[cfg(test)] @@ -182,42 +632,43 @@ mod tests { deployment(&root, &b, "aboot")?; let cmdline_a = Cmdline::from("androidboot.slot_suffix=_a"); let cmdline_b = Cmdline::from("androidboot.slot_suffix=_b"); + let mut state = AbootState::open(&root)?; - assert_eq!(read_slot(&root, Slot::A)?, None); - invalidate_other(&root, Slot::A)?; - record_booted(&root, &cmdline_a, &a)?; - record_booted(&root, &cmdline_a, &a)?; - assert_eq!(read_slot(&root, Slot::B)?, None); - record_booted(&root, &cmdline_b, &b)?; + assert_eq!(state.read_slot(Slot::A)?, None); + state.invalidate_other(Slot::A)?; + state.record_booted(&cmdline_a, &a)?; + state.record_booted(&cmdline_a, &a)?; + assert_eq!(state.read_slot(Slot::B)?, None); + state.record_booted(&cmdline_b, &b)?; assert_eq!( - read_slot(&root, Slot::A)?, + state.read_slot(Slot::A)?, Some(Sha512HashValue::from_hex(&a)?) ); assert_eq!( - read_slot(&root, Slot::B)?, + state.read_slot(Slot::B)?, Some(Sha512HashValue::from_hex(&b)?) ); for slot in [Slot::A, Slot::B] { - record_booted(&root, &cmdline_a, &a)?; - record_booted(&root, &cmdline_b, &b)?; - invalidate_other(&root, slot)?; - invalidate_other(&root, slot)?; - assert!(read_slot(&root, slot)?.is_some()); + state.record_booted(&cmdline_a, &a)?; + state.record_booted(&cmdline_b, &b)?; + state.invalidate_other(slot)?; + state.invalidate_other(slot)?; + assert!(state.read_slot(slot)?.is_some()); let other = if slot == Slot::A { Slot::B } else { Slot::A }; - assert_eq!(read_slot(&root, other)?, None); + assert_eq!(state.read_slot(other)?, None); assert!(!root.try_exists(format!("{ABOOT_STATE_DIR}/slots/{}", other.name()))?); } - record_booted(&root, &cmdline_a, &a)?; - record_booted(&root, &Cmdline::from(""), &b)?; + state.record_booted(&cmdline_a, &a)?; + state.record_booted(&Cmdline::from(""), &b)?; assert_eq!( - read_slot(&root, Slot::A)?, + state.read_slot(Slot::A)?, Some(Sha512HashValue::from_hex(&a)?) ); - record_booted(&root, &cmdline_a, &b)?; + state.record_booted(&cmdline_a, &b)?; assert_eq!( - read_slot(&root, Slot::A)?, + state.read_slot(Slot::A)?, Some(Sha512HashValue::from_hex(&b)?) ); Ok(()) @@ -228,20 +679,27 @@ mod tests { let root = tempdir(ambient_authority())?; let slots = format!("{ABOOT_STATE_DIR}/slots"); root.create_dir_all(&slots)?; + let mut state = AbootState::open(&root)?; for contents in [String::new(), "invalid".into(), "aa".repeat(64)] { root.atomic_write(format!("{slots}/a"), contents)?; - assert_eq!(read_slot(&root, Slot::A)?, None); + assert_eq!(state.read_slot(Slot::A)?, None); } root.atomic_write(format!("{slots}/a"), [0xff; 128])?; - assert_eq!(read_slot(&root, Slot::A)?, None); + assert_eq!(state.read_slot(Slot::A)?, None); let digest = "aa".repeat(64); root.atomic_write(format!("{slots}/a"), &digest)?; deployment(&root, &digest, "uki")?; - assert_eq!(read_slot(&root, Slot::A)?, None); + assert_eq!(state.read_slot(Slot::A)?, None); assert!( - record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_a"), &digest).is_err() + state + .record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &digest) + .is_err() + ); + assert!( + state + .record_booted(&Cmdline::from(""), "../invalid") + .is_err() ); - assert!(record_booted(&root, &Cmdline::from(""), "../invalid").is_err()); Ok(()) } @@ -250,15 +708,177 @@ mod tests { let root = tempdir(ambient_authority())?; let digest = "aa".repeat(64); deployment(&root, &digest, "aboot")?; - record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_a"), &digest)?; - record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_b"), &digest)?; + let mut state = AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &digest)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &digest)?; assert_eq!( - slot_deployments(&root)?, + state.slot_deployments()?, vec![Sha512HashValue::from_hex(digest)?] ); Ok(()) } + #[test] + fn pending_artifacts_and_attempt() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + deployment(&root, &digest, "aboot")?; + let mut state = AbootState::open(&root)?; + let pending = state.stage_artifacts(&digest, true, b"boot", Some(b"vbmeta"))?; + state.write_pending(&pending)?; + assert_eq!(state.read_pending()?, Some(pending.clone())); + + let paths = state.verify_artifacts(Utf8Path::new("/sysroot"), &pending)?; + assert_eq!( + paths.boot, + Utf8PathBuf::from(format!( + "/sysroot/{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}/{ABOOT_IMAGE_FNAME}" + )) + ); + assert!(paths.vbmeta.is_some()); + + let pending = state.unlock_pending(&digest)?; + assert!(!pending.finalization_locked); + state.record_attempt(&digest)?; + assert_eq!(state.read_attempted()?.unwrap().depl_id, digest); + assert!(state.write_pending(&pending).is_err()); + + let artifacts = root.open_dir(format!( + "{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}" + ))?; + artifacts.atomic_write(ABOOT_IMAGE_FNAME, b"modified")?; + assert!( + state + .verify_artifacts(Utf8Path::new("/sysroot"), &pending) + .is_err() + ); + Ok(()) + } + + #[test] + fn reconcile_attempt() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + let artifact_dir = format!("{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}"); + deployment(&root, &digest, "aboot")?; + let mut state = AbootState::open(&root)?; + let pending = state.stage_artifacts(&digest, false, b"boot", None)?; + state.write_pending(&pending)?; + assert!(root.try_exists(&artifact_dir)?); + assert!(matches!( + state.reconcile_state("booted", "new-boot")?, + ReconcileAction::Publish(_) + )); + + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: digest.clone(), + boot_id: "old-boot".into(), + }, + )?; + assert!(matches!( + state.reconcile_state("booted", "old-boot")?, + ReconcileAction::None + )); + assert!(matches!( + state.reconcile_state("booted", "new-boot")?, + ReconcileAction::None + )); + assert!(state.read_pending()?.is_some()); + assert!(root.try_exists(&artifact_dir)?); + assert!(matches!( + state.reconcile_state(&digest, "new-boot")?, + ReconcileAction::RemoveTransient + )); + assert!(state.read_pending()?.is_none()); + assert!(state.read_attempted()?.is_none()); + assert!(!root.try_exists(&artifact_dir)?); + Ok(()) + } + + #[test] + fn rollback_and_deletion() -> Result<()> { + let root = tempdir(ambient_authority())?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + let staged = "cc".repeat(64); + for digest in [&a, &b, &staged] { + deployment(&root, digest, "aboot")?; + } + let mut state = AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + + state.queue_rollback(&b)?; + assert_eq!(state.queued_rollback()?.as_deref(), Some(b.as_str())); + assert!(state.delete_deployment(&b).is_err()); + state.clear_rollback()?; + + let pending = state.stage_artifacts(&staged, false, b"boot", None)?; + state.write_pending(&pending)?; + state.delete_deployment(&staged)?; + assert!(state.read_pending()?.is_none()); + assert!(!root.try_exists(format!( + "{STATE_DIR_RELATIVE}/{staged}/{ABOOT_ARTIFACT_DIR}" + ))?); + + state.delete_deployment(&b)?; + assert_eq!(state.read_slot(Slot::B)?, None); + assert_eq!( + state.read_slot(Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + + let pending = state.stage_artifacts(&staged, false, b"boot", None)?; + state.write_pending(&pending)?; + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: staged.clone(), + boot_id: boot_id()?, + }, + )?; + assert!(state.delete_deployment(&staged).is_err()); + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: staged.clone(), + boot_id: "previous-boot".into(), + }, + )?; + state.delete_deployment(&staged)?; + assert!(state.read_pending()?.is_none()); + assert!(state.read_attempted()?.is_none()); + Ok(()) + } + + #[test] + fn rollback_marker_expires_after_boot() -> Result<()> { + let root = tempdir(ambient_authority())?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + deployment(&root, &a, "aboot")?; + deployment(&root, &b, "aboot")?; + let mut state = AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + state.queue_rollback(&b)?; + + let current_boot_id = boot_id()?; + assert!(matches!( + state.reconcile_state(&a, ¤t_boot_id)?, + ReconcileAction::RemoveTransient + )); + assert_eq!(state.queued_rollback()?.as_deref(), Some(b.as_str())); + assert!(matches!( + state.reconcile_state(&b, "next-boot")?, + ReconcileAction::RemoveTransient + )); + assert!(state.read_rollback()?.is_none()); + Ok(()) + } + #[test] fn mutation_lock() -> Result<()> { let run = tempdir(ambient_authority())?; diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 677aaa0e1..ceb9324cd 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1513,7 +1513,8 @@ pub(crate) fn ensure_correct_composefs_digest( 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. + // No UKI. Aboot digests are validated against the expected image IDs + // from this pull when the payload is installed or staged. return Ok(RecoveredBootImage { id: computed_id, expected_ids, @@ -2220,9 +2221,50 @@ fn image_bootloader(entry: &ComposefsBootEntry) -> Option Result { + let (cmdline, boot_digest) = compute_aboot_boot_digest(encoding, payload)?; + validate_aboot_composefs_candidates(id, boot_ids, &cmdline, allow_missing_fsverity)?; + Ok(boot_digest) +} + +fn validate_aboot_composefs_candidates( + id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, + cmdline: &str, + allow_missing_fsverity: bool, +) -> Result<()> { + let candidates = parse_uki_composefs_candidates(cmdline) + .context("Parsing composefs kernel arguments in aboot image")?; + let insecure = uki_candidates_policy(&candidates)?; + validate_uki_candidates(&candidates, boot_ids, None)?; + ensure!( + primary_uki_candidate(&candidates).digest == *id, + "aboot primary composefs digest does not match the selected boot image" + ); + ensure!( + !insecure || allow_missing_fsverity, + "The aboot image requests insecure composefs operation, but this repository requires fs-verity" + ); + match (allow_missing_fsverity, insecure) { + (true, false) => tracing::warn!( + "--allow-missing-fsverity was requested but the aboot image requires fs-verity" + ), + _ => {} + } + + Ok(()) +} + fn setup_composefs_aboot_boot( repo: &ComposefsRepository, id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, entry: &ComposefsBootEntry, allow_missing_fsverity: bool, ) -> Result { @@ -2230,20 +2272,49 @@ fn setup_composefs_aboot_boot( bail!("Expected an aboot boot entry") }; let payload = read_aboot_payload(entry, repo)?; - let (cmdline, boot_digest) = compute_aboot_boot_digest(entry.encoding, &payload)?; - let composefs = ComposefsBootCmdline::::from_cmdline(&cmdline) - .context("Parsing composefs=")? - .ok_or_else(|| anyhow!("No composefs image in aboot command line"))?; - composefs.validate_digest([id])?; - match (allow_missing_fsverity, composefs.is_insecure()) { - (true, false) => tracing::warn!( - "--allow-missing-fsverity was requested but the aboot image requires fs-verity" - ), - (false, true) => tracing::warn!("aboot image permits booting without fs-verity"), - _ => {} - } + validate_composefs_aboot_payload( + id, + boot_ids, + entry.encoding, + &payload, + allow_missing_fsverity, + ) +} - Ok(boot_digest) +pub(crate) struct PreparedAbootArtifacts { + pub(crate) boot_digest: String, + pub(crate) payload: Box<[u8]>, + pub(crate) vbmeta: Option>, +} + +pub(crate) fn prepare_composefs_aboot_update( + repo: &ComposefsRepository, + id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, + entry: &ComposefsBootEntry, + allow_missing_fsverity: bool, +) -> Result { + let ComposefsBootEntry::Aboot(entry) = entry else { + bail!("Expected an aboot boot entry") + }; + let payload = read_aboot_payload(entry, repo)?; + let boot_digest = validate_composefs_aboot_payload( + id, + boot_ids, + entry.encoding, + &payload, + allow_missing_fsverity, + )?; + let vbmeta = entry + .vbmeta + .as_ref() + .map(|artifact| read_file(&artifact.file, repo).context("Reading vbmeta payload")) + .transpose()?; + Ok(PreparedAbootArtifacts { + boot_digest, + payload, + vbmeta, + }) } fn install_composefs_bootloader( @@ -2431,7 +2502,7 @@ pub(crate) async fn setup_composefs_boot( &fs, )?, BootType::Aboot => ( - setup_composefs_aboot_boot(&repo, &id, entry, allow_missing_fsverity)?, + setup_composefs_aboot_boot(&repo, &id, &boot_ids, entry, allow_missing_fsverity)?, provisional_deploy_id, ), }; @@ -2440,7 +2511,7 @@ pub(crate) async fn setup_composefs_boot( &root_setup.physical_root_path, &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), - None, + false, boot_type, boot_digest, &pull_result.manifest_digest.to_string(), @@ -2820,6 +2891,32 @@ mod tests { } } + #[test] + fn test_aboot_composefs_candidates() { + let v1 = Sha512HashValue::EMPTY; + let v2 = other_digest(); + let ids = ExpectedBootImageIds { + v1: vec![v1.clone()], + v2: vec![v2.clone()], + }; + let dual = format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(v2.clone(), false) + ); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &dual, false).is_ok()); + let stale_fallback = format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false) + ); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &stale_fallback, false).is_err()); + assert!(validate_aboot_composefs_candidates(&v2, &ids, &dual, false).is_err()); + let insecure = format!("{} {}", uki_v1(v1.clone(), true), uki_v2(v2, true)); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, false).is_err()); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, true).is_ok()); + } + #[test] fn test_uki_composefs_candidates() { let v1 = Sha512HashValue::EMPTY; diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 0dae7bd0b..71b0d260b 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -5,9 +5,11 @@ use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use crate::{ bootc_composefs::{ + aboot, boot::{BootType, get_efi_uuid_source}, gc::{GCOpts, composefs_gc}, rollback::{composefs_rollback, rename_exchange_user_cfg}, + state::remove_staged_deployment, status::{get_composefs_status, get_sorted_grub_uki_boot_entries}, }, composefs_consts::{ @@ -154,7 +156,7 @@ fn delete_depl_boot_entries( BootType::Uki => { remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } - BootType::Aboot => anyhow::bail!("aboot deletion is not implemented"), + BootType::Aboot => anyhow::bail!("Unexpected aboot deployment in boot-entry deletion"), }, BootloaderKind::BLSCompatible => { @@ -229,10 +231,6 @@ pub(crate) async fn delete_composefs_deployment( anyhow::bail!("Cannot delete currently booted deployment"); } - if booted.boot_type == BootType::Aboot { - anyhow::bail!("aboot deployment deletion is not implemented"); - } - let all_depls = host.all_composefs_deployments()?; let depl_to_del = all_depls @@ -265,7 +263,14 @@ pub(crate) async fn delete_composefs_deployment( tracing::info!("Deleting {kind}deployment '{deployment_id}'"); - delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; + if booted.boot_type == BootType::Aboot { + aboot::AbootState::open(&storage.physical_root)?.delete_deployment(deployment_id)?; + if deleting_staged { + remove_staged_deployment()?; + } + } else { + delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; + } composefs_gc( storage, diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 845a448fe..4486e8bb9 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -1,9 +1,9 @@ -use std::path::Path; +use std::{path::Path, process::Command}; -use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}; use crate::bootc_composefs::status::get_composefs_status; +use crate::bootc_composefs::{aboot, boot::BootType}; use crate::composefs_consts::STATE_DIR_ABS; use crate::install::BOOT; use crate::spec::BootloaderKind; @@ -96,6 +96,16 @@ pub(crate) async fn composefs_backend_finalize( "Staged deployment is not a composefs deployment" ))?; + let mut aboot_state = (staged_composefs.boot_type == BootType::Aboot) + .then(|| aboot::AbootState::open(&storage.physical_root)) + .transpose()?; + if let Some(state) = aboot_state.as_ref() + && state.read_attempted()?.is_some() + { + tracing::warn!("Aboot deployment was already attempted; not flashing it again"); + return Ok(()); + } + // Mount the booted EROFS image to get pristine etc let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; let composefs_fd = mount_composefs_image( @@ -136,6 +146,14 @@ pub(crate) async fn composefs_backend_finalize( // Unmount EROFS drop(erofs_tmp_mnt); + if staged_composefs.boot_type == BootType::Aboot { + return finalize_staged_aboot( + storage, + aboot_state.as_mut().unwrap(), + &staged_composefs.verity, + ); + } + let boot_dir = storage.require_boot_dir()?; match booted_composefs.bootloader.kind()? { @@ -145,7 +163,7 @@ pub(crate) async fn composefs_backend_finalize( rename_exchange_bls_entries(&entries_dir)?; } BootType::Uki => finalize_staged_grub_uki(boot_dir)?, - BootType::Aboot => anyhow::bail!("aboot finalization is not implemented"), + BootType::Aboot => unreachable!(), }, BootloaderKind::BLSCompatible => { @@ -196,6 +214,46 @@ pub(crate) fn hold_boot() -> Result<()> { } } +#[context("Finalizing staged aboot deployment {deployment}")] +fn finalize_staged_aboot( + storage: &Storage, + state: &mut aboot::AbootState<'_>, + deployment: &str, +) -> Result<()> { + let pending = state + .read_pending()? + .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?; + anyhow::ensure!( + pending.depl_id == deployment, + "Pending aboot deployment changed" + ); + anyhow::ensure!( + !pending.finalization_locked, + "Pending aboot deployment is download-only" + ); + let artifacts = state.verify_artifacts(&storage.physical_root_path, &pending)?; + let kernel_cmdline = + std::fs::read_to_string("/proc/cmdline").context("Reading kernel command line")?; + let booted_slot = aboot::Slot::from_cmdline(&linux_kernel_cmdline::utf8::Cmdline::from( + kernel_cmdline.as_str(), + )); + + state.record_attempt(deployment)?; + if let Some(slot) = booted_slot { + state.invalidate_other(slot)?; + } + + let mut command = Command::new("aboot-deploy"); + command.args(["--local", "--boot-image"]); + command.arg(artifacts.boot); + if let Some(vbmeta) = artifacts.vbmeta { + command.arg("--vbmeta-image").arg(vbmeta); + } + let status = command.status().context("Running aboot-deploy")?; + anyhow::ensure!(status.success(), "aboot-deploy exited with status {status}"); + Ok(()) +} + #[context("Grub: Finalizing staged UKI")] fn finalize_staged_grub_uki(boot_fd: &Dir) -> Result<()> { let entries_dir = boot_fd.open_dir("grub2")?; diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 2dc4816ac..d4e41c177 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -339,7 +339,9 @@ pub(crate) async fn composefs_gc( delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)? } BootType::Uki => delete_uki(storage, verity, gc_opts.dry_run)?, - BootType::Aboot => anyhow::bail!("aboot garbage collection is not implemented"), + BootType::Aboot => { + anyhow::bail!("Aboot artifact unexpectedly reached boot binary garbage collection") + } } } @@ -708,13 +710,12 @@ mod tests { for digest in [&booted, &rollback, &orphan] { write_aboot_origin(&root, digest)?; } - aboot::record_booted( - &root, + let mut aboot_state = aboot::AbootState::open(&root)?; + aboot_state.record_booted( &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_a"), &booted, )?; - aboot::record_booted( - &root, + aboot_state.record_booted( &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_b"), &rollback, )?; diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 0b8a8026a..fe686c69e 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -1,4 +1,4 @@ -use std::io::Write; +use std::{io::Write, process::Command}; use anyhow::{Context, Result, anyhow}; use cap_std_ext::cap_std::fs::Dir; @@ -7,6 +7,7 @@ use fn_error_context::context; use ocidir::cap_std::ambient_authority; use rustix::fs::{AtFlags, RenameFlags, fsync, renameat_with}; +use crate::bootc_composefs::aboot; use crate::bootc_composefs::boot::{ BootType, FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key, secondary_sort_key, type1_entry_conf_file_name, @@ -226,10 +227,6 @@ pub(crate) async fn composefs_rollback( let host = get_composefs_status(storage, booted_cfs).await?; - if host.require_composefs_booted()?.boot_type == BootType::Aboot { - anyhow::bail!("aboot rollback is not implemented"); - } - let new_spec = { let mut new_spec = host.spec.clone(); new_spec.boot_order = new_spec.boot_order.swap(); @@ -254,22 +251,43 @@ pub(crate) async fn composefs_rollback( anyhow::bail!("Rollback deployment not a composefs deployment") }; - let boot_dir = storage.require_boot_dir()?; + if host.require_composefs_booted()?.boot_type == BootType::Aboot { + anyhow::ensure!( + rollback_entry.boot_type == BootType::Aboot, + "Rollback deployment is not aboot" + ); + let mut state = aboot::AbootState::open(&storage.physical_root)?; + state.discard_pending()?; + + let status = Command::new("aboot-deploy") + .arg("--rollback") + .status() + .context("Running aboot-deploy --rollback")?; + anyhow::ensure!(status.success(), "aboot-deploy exited with status {status}"); - match &rollback_entry.bootloader.kind()? { - BootloaderKind::GRUBClassic => match rollback_entry.boot_type { - BootType::Bls => { + if reverting { + state.clear_rollback()?; + } else { + state.queue_rollback(&rollback_entry.verity)?; + } + } else { + let boot_dir = storage.require_boot_dir()?; + + match &rollback_entry.bootloader.kind()? { + BootloaderKind::GRUBClassic => match rollback_entry.boot_type { + BootType::Bls => { + rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; + } + BootType::Uki => { + rollback_grub_uki_entries(boot_dir)?; + } + BootType::Aboot => anyhow::bail!("Unexpected aboot rollback deployment"), + }, + + BootloaderKind::BLSCompatible => { + // We use BLS entries for systemd UKI as well rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; } - BootType::Uki => { - rollback_grub_uki_entries(boot_dir)?; - } - BootType::Aboot => anyhow::bail!("aboot rollback is not implemented"), - }, - - BootloaderKind::BLSCompatible => { - // We use BLS entries for systemd UKI as well - rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; } } diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 8f7c82623..464ad79a5 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -237,7 +237,7 @@ pub(crate) fn update_boot_digest_in_origin( /// * `root_path` - The root filesystem path (typically `/sysroot`) /// * `deployment_id` - Unique SHA512 hash identifier for this deployment /// * `imgref` - Container image reference for the deployment -/// * `staged` - Whether this is a staged deployment (writes to transient state dir) +/// * `staged` - Whether this is a staged deployment /// * `boot_type` - Boot artifact type (`Bls`, `Uki`, or `Aboot`) /// * `boot_digest` - Optional boot digest for verification /// * `manifest_digest` - OCI manifest content digest, stored in the origin file so the @@ -250,13 +250,12 @@ pub(crate) fn update_boot_digest_in_origin( /// * `var` - Symlink to shared `/var` directory /// * `{deployment_id}.origin` - Origin configuration with image ref, boot, and image metadata /// -/// For staged deployments, also writes to `/run/composefs/staged-deployment`. #[context("Writing composefs state")] pub(crate) async fn write_composefs_state( root_path: &Utf8PathBuf, deployment_id: &Sha512HashValue, target_imgref: &ImageReference, - staged: Option, + staged: bool, boot_type: BootType, boot_digest: String, manifest_digest: &str, @@ -282,7 +281,7 @@ pub(crate) async fn write_composefs_state( &root_path, &deployment_id.to_hex(), &state_path, - staged.is_none(), + !staged, allow_missing_fsverity, )?; @@ -318,24 +317,30 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to write to .origin file")?; - if let Some(staged) = staged { - std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) - .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; - - let staged_depl_dir = - Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) - .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; - - staged_depl_dir - .atomic_write( - COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - staged - .to_canon_json_vec() - .context("Failed to serialize staged deployment JSON")?, - ) - .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; - } + Ok(()) +} + +pub(crate) fn write_staged_deployment(staged: &StagedDeployment) -> Result<()> { + std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) + .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + let dir = Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + dir.atomic_write( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + staged + .to_canon_json_vec() + .context("Failed to serialize staged deployment JSON")?, + ) + .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}")) +} +pub(crate) fn remove_staged_deployment() -> Result<()> { + let Some(dir) = + Dir::open_ambient_dir("/run", ambient_authority())?.open_dir_optional("composefs")? + else { + return Ok(()); + }; + dir.remove_file_optional(COMPOSEFS_STAGED_DEPLOYMENT_FNAME)?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 5b035a5c6..545d8ea20 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -953,12 +953,17 @@ fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> } fn composefs_aboot_status(storage: &Storage, cmdline: &ComposefsCmdline) -> Result { - let staged = read_staged_deployment()?; - composefs_aboot_status_from(storage, cmdline, staged.as_ref()) + let state = aboot::AbootState::open(&storage.physical_root)?; + let staged = match state.read_pending()? { + Some(pending) => Some(pending.staged()), + None => read_staged_deployment()?, + }; + composefs_aboot_status_from(storage, &state, cmdline, staged.as_ref()) } fn composefs_aboot_status_from( storage: &Storage, + state: &aboot::AbootState<'_>, cmdline: &ComposefsCmdline, staged: Option<&StagedDeployment>, ) -> Result { @@ -983,7 +988,7 @@ fn composefs_aboot_status_from( if let Some(staged) = host.status.staged.as_ref() { seen.push(staged.require_composefs()?.verity.clone()); } - for deployment in aboot::slot_deployments(&storage.physical_root)? { + for deployment in state.slot_deployments()? { let verity = deployment.to_hex(); if seen.contains(&verity) { continue; @@ -997,6 +1002,19 @@ fn composefs_aboot_status_from( } } + if let Some(queued) = state.queued_rollback()? { + let rollback = + host.status.rollback.as_ref().ok_or_else(|| { + anyhow::anyhow!("Queued aboot rollback deployment is unavailable") + })?; + ensure!( + rollback.require_composefs()?.verity == queued, + "Queued aboot rollback does not match the rollback deployment" + ); + host.status.rollback_queued = true; + host.spec.boot_order = BootOrder::Rollback; + } + host.spec.image = host .status .staged @@ -1155,7 +1173,9 @@ async fn composefs_deployment_status_from( (is_rollback_queued, None, Some(menuentries)) } - BootType::Aboot => anyhow::bail!("aboot status is not implemented"), + BootType::Aboot => { + anyhow::bail!("Aboot deployment unexpectedly reached GRUB status handling") + } }, // We will have BLS stuff and the UKI stuff in the same DIR @@ -1267,11 +1287,12 @@ mod tests { for digest in [&a, &b, &staged_id] { write_aboot_origin(&root, digest)?; } - aboot::record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_a"), &a)?; - aboot::record_booted(&root, &Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + let mut aboot_state = aboot::AbootState::open(&root)?; + aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; let cmdline = ComposefsCmdline::new(&a); - let host = composefs_aboot_status_from(&storage, &cmdline, None)?; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; let booted = host.require_composefs_booted()?; assert_eq!(booted.verity, a); assert_eq!(booted.boot_type, BootType::Aboot); @@ -1289,11 +1310,17 @@ mod tests { assert!(host.status.other_deployments.is_empty()); assert!(!host.status.rollback_queued); + aboot_state.queue_rollback(&b)?; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + assert!(host.status.rollback_queued); + assert_eq!(host.spec.boot_order, BootOrder::Rollback); + aboot_state.clear_rollback()?; + let staged = StagedDeployment { depl_id: staged_id.clone(), finalization_locked: true, }; - let host = composefs_aboot_status_from(&storage, &cmdline, Some(&staged))?; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; let staged_entry = host.status.staged.as_ref().unwrap(); assert_eq!(staged_entry.require_composefs()?.verity, staged_id); assert!(staged_entry.download_only); @@ -1311,7 +1338,7 @@ mod tests { depl_id: b.clone(), finalization_locked: false, }; - let host = composefs_aboot_status_from(&storage, &cmdline, Some(&staged))?; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; assert_eq!( host.status .staged @@ -1324,9 +1351,14 @@ mod tests { assert!(host.status.rollback.is_none()); root.atomic_write(format!("{ABOOT_STATE_DIR}/slots/b"), "invalid")?; - let host = composefs_aboot_status_from(&storage, &cmdline, None)?; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; assert!(host.status.rollback.is_none()); assert_eq!(host.list_deployments().len(), 1); + + let pending = aboot_state.stage_artifacts(&staged_id, true, b"boot", None)?; + aboot_state.write_pending(&pending)?; + let host = composefs_aboot_status(&storage, &cmdline)?; + assert!(host.status.staged.as_ref().unwrap().download_only); Ok(()) } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 336403c9a..88e19612a 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use camino::Utf8PathBuf; -use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use cap_std_ext::cap_std::fs::Dir; use composefs::erofs::format::FormatVersion; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; @@ -18,15 +18,17 @@ use crate::bootc_composefs::gc::GCOpts; use crate::spec::BootloaderKind; use crate::{ bootc_composefs::{ + aboot, boot::{ - BootSetupType, BootType, print_uki_dumpfile_diff_on_mismatch, setup_composefs_bls_boot, + BootSetupType, BootType, prepare_composefs_aboot_update, + print_uki_dumpfile_diff_on_mismatch, setup_composefs_bls_boot, setup_composefs_uki_boot, }, gc::composefs_gc, repo::pull_composefs_repo, service::start_finalize_stated_svc, soft_reboot::prepare_soft_reboot_composefs, - state::write_composefs_state, + state::{write_composefs_state, write_staged_deployment}, status::{ ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status, get_container_manifest_and_config, get_imginfo, @@ -184,26 +186,26 @@ pub(crate) fn validate_update( } let booted = host.require_composefs_booted()?; - let boot_dir = storage.require_boot_dir()?; - - // Remove staged bootloader entries, if any - // GC should take care of the UKI PEs and other binaries - match get_bootloader()?.kind()? { - BootloaderKind::GRUBClassic => match booted.boot_type { - BootType::Bls => rm_staged_type1_ent(boot_dir)?, - - BootType::Uki => { - let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?; - - if grub.exists(USER_CFG_STAGED) { - grub.remove_file(USER_CFG_STAGED) - .context("Removing staged grub user config")?; + if booted.boot_type == BootType::Aboot { + ensure_no_aboot_attempt(storage)?; + } else { + // Remove staged bootloader entries, if any + // GC should take care of the UKI PEs and other binaries + let boot_dir = storage.require_boot_dir()?; + match get_bootloader()?.kind()? { + BootloaderKind::GRUBClassic => match booted.boot_type { + BootType::Bls => rm_staged_type1_ent(boot_dir)?, + BootType::Uki => { + let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?; + if grub.exists(USER_CFG_STAGED) { + grub.remove_file(USER_CFG_STAGED) + .context("Removing staged grub user config")?; + } } - } - BootType::Aboot => anyhow::bail!("aboot update cleanup is not implemented"), - }, - - BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, + BootType::Aboot => unreachable!(), + }, + BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, + } } // Remove state directories for either serialisation of the same rootfs. @@ -224,6 +226,19 @@ pub(crate) fn validate_update( Ok(UpdateAction::Proceed) } +fn ensure_no_aboot_attempt(storage: &Storage) -> Result<()> { + let state = aboot::AbootState::open(&storage.physical_root)?; + anyhow::ensure!( + state.read_attempted()?.is_none(), + "An attempted aboot update has not been reconciled" + ); + anyhow::ensure!( + state.queued_rollback()?.is_none(), + "An aboot rollback is queued" + ); + Ok(()) +} + /// This is just an intersection of SwitchOpts and UpgradeOpts pub(crate) struct DoUpgradeOpts { pub(crate) apply: bool, @@ -271,6 +286,11 @@ pub(crate) async fn do_upgrade( opts: &DoUpgradeOpts, manifest: &ostree_ext::oci_spec::image::ImageManifest, ) -> Result<()> { + // Unpulled images bypass validate_update(), so check aboot state here too. + if host.require_composefs_booted()?.boot_type == BootType::Aboot { + ensure_no_aboot_attempt(storage)?; + } + // Pre-flight disk space check before pulling. crate::deploy::check_disk_space_composefs(&*booted_cfs.repo, manifest, imgref)?; @@ -311,8 +331,12 @@ pub(crate) async fn do_upgrade( anyhow::bail!("No boot entries!"); }; let boot_type = BootType::from(entry); - if boot_type == BootType::Aboot { - anyhow::bail!("aboot boot setup is not implemented"); + let booted_type = host.require_composefs_booted()?.boot_type; + if boot_type == BootType::Aboot || booted_type == BootType::Aboot { + anyhow::ensure!( + boot_type == booted_type, + "Cannot switch between aboot and non-aboot boot layouts" + ); } let mounted_fs = Dir::reopen_dir( @@ -334,6 +358,7 @@ pub(crate) async fn do_upgrade( } let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version()); + let mut aboot_artifacts = None; let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( @@ -359,7 +384,18 @@ pub(crate) async fn do_upgrade( &repo, &oci_fs, )?, - BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), + BootType::Aboot => { + let prepared = prepare_composefs_aboot_update( + &repo, + &id, + &boot_ids, + entry, + booted_cfs.cmdline.allow_missing_fsverity, + )?; + let boot_digest = prepared.boot_digest.clone(); + aboot_artifacts = Some(prepared); + (boot_digest, provisional_deploy_id) + } }; // `repo` holds its own flock(LOCK_SH) on /sysroot/composefs, taken out by @@ -385,7 +421,7 @@ pub(crate) async fn do_upgrade( &Utf8PathBuf::from("/sysroot"), &deploy_id, imgref, - Some(staged_state), + true, boot_type, boot_digest, &manifest_digest, @@ -393,6 +429,18 @@ pub(crate) async fn do_upgrade( ) .await?; + if let Some(artifacts) = aboot_artifacts { + let mut state = aboot::AbootState::open(&storage.physical_root)?; + let pending = state.stage_artifacts( + &staged_state.depl_id, + staged_state.finalization_locked, + &artifacts.payload, + artifacts.vbmeta.as_deref(), + )?; + state.write_pending(&pending)?; + } + write_staged_deployment(&staged_state)?; + // We take into account the staged bootloader entries so this won't remove // the currently staged entry // @@ -432,37 +480,27 @@ pub(crate) async fn apply_upgrade_from_downloaded( return Ok(()); } - start_finalize_stated_svc()?; - - let staged_depl_dir = Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) - .context("Opening transient state directory")?; - - let current = staged_depl_dir - .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME) - .context("Reading staged file")?; - - let mut new_staged: StagedDeployment = - serde_json::from_str(¤t).context("Deserialzing staged file")?; - - // Make the staged deployment not download_only - new_staged.finalization_locked = false; + let staged_id = &staged.require_composefs()?.verity; + if host.require_composefs_booted()?.boot_type == BootType::Aboot { + let mut state = aboot::AbootState::open(&storage.physical_root)?; + let pending = state.unlock_pending(staged_id)?; + write_staged_deployment(&pending.staged())?; + } else { + let staged_depl_dir = + Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .context("Opening transient state directory")?; + let current = staged_depl_dir + .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME) + .context("Reading staged file")?; + let mut new_staged: StagedDeployment = + serde_json::from_str(¤t).context("Deserialzing staged file")?; + new_staged.finalization_locked = false; + write_staged_deployment(&new_staged)?; + } - staged_depl_dir - .atomic_replace_with( - COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - |f| -> std::io::Result<()> { - serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from) - }, - ) - .context("Writing staged file")?; + start_finalize_stated_svc()?; - return apply_upgrade( - storage, - composefs, - &staged.require_composefs()?.verity, - &do_upgrade_opts, - ) - .await; + return apply_upgrade(storage, composefs, staged_id, &do_upgrade_opts).await; } #[context("Upgrading composefs")] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index db01f9391..f875481b9 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -43,9 +43,11 @@ use crate::bootc_composefs::delete::delete_composefs_deployment; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::{ + aboot, digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, rollback::composefs_rollback, + service::start_finalize_stated_svc, state::composefs_usr_overlay, switch::switch_composefs, update::upgrade_composefs, @@ -2427,10 +2429,21 @@ async fn run_from_opt(opt: Opt) -> Result { Opt::Internals(opts) => match opts { InternalsOpts::ComposefsAbootReconcile => { let storage = get_storage().await?; + let digest = match storage.kind()? { + BootedStorageKind::Composefs(booted) => booted.cmdline.digest.to_string(), + BootedStorageKind::Ostree(_) => { + anyhow::bail!("Aboot reconciliation requires the composefs backend") + } + }; ensure!( - matches!(storage.kind()?, BootedStorageKind::Composefs(_)), - "Aboot reconciliation requires the composefs backend" + crate::bootc_composefs::state::read_boot_type(&storage.physical_root, &digest)? + == Some(crate::bootc_composefs::boot::BootType::Aboot), + "Aboot reconciliation requires an aboot deployment" ); + let mut state = aboot::AbootState::open(&storage.physical_root)?; + if state.reconcile(&digest)? { + start_finalize_stated_svc()?; + } Ok(()) } InternalsOpts::SystemdGenerator { diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 34293e703..2cf1e6655 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -8,6 +8,12 @@ pub(crate) const STATE_DIR_ABS: &str = "/sysroot/state/deploy"; /// Relative path to composefs-backend state directory. Relative to /sysroot pub(crate) const STATE_DIR_RELATIVE: &str = "state/deploy"; pub(crate) const ABOOT_STATE_DIR: &str = "state/boot/aboot"; +pub(crate) const ABOOT_PENDING_FNAME: &str = "pending"; +pub(crate) const ABOOT_ATTEMPTED_FNAME: &str = "attempted"; +pub(crate) const ABOOT_ROLLBACK_FNAME: &str = "rollback"; +pub(crate) const ABOOT_ARTIFACT_DIR: &str = "aboot"; +pub(crate) const ABOOT_IMAGE_FNAME: &str = "aboot.img"; +pub(crate) const ABOOT_VBMETA_FNAME: &str = "vbmeta.img"; pub(crate) const ABOOT_RECONCILE_SERVICE: &str = "bootc-aboot-reconcile.service"; /// Relative path to the shared 'var' directory. Relative to /sysroot pub(crate) const SHARED_VAR_PATH: &str = "state/os/default/var"; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index d65cb2477..36f23028c 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -477,8 +477,7 @@ impl BootedStorage { let lock = aboot::lock(&run)?; let kernel_cmdline = std::fs::read_to_string("/proc/cmdline") .context("Reading kernel command line")?; - aboot::record_booted( - &physical_root, + aboot::AbootState::open(&physical_root)?.record_booted( &linux_kernel_cmdline::utf8::Cmdline::from(kernel_cmdline.as_str()), &cmdline.digest, )?; From 05705fe771d6dc6f1b80b3370283dde864726f2d Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 23 Sep 2026 16:40:27 +0200 Subject: [PATCH 11/11] Add basic experimental aboot support docs. Signed-off-by: Alexander Larsson --- docs/src/SUMMARY.md | 1 + docs/src/experimental-composefs-aboot.md | 77 ++++++++++++++++++++++++ docs/src/experimental-composefs.md | 13 +++- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 docs/src/experimental-composefs-aboot.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 164f695c3..7eb9abe3c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -68,6 +68,7 @@ - [bootc image](experimental-bootc-image.md) - [composefs backend](experimental-composefs.md) +- [composefs aboot support](experimental-composefs-aboot.md) - [unified storage](experimental-unified-storage.md) - [`man bootc-root-setup.service`](man/bootc-root-setup.service.5.md) - [`man bootc-setup-root-conf.toml`](man/bootc-setup-root-conf.5.md) diff --git a/docs/src/experimental-composefs-aboot.md b/docs/src/experimental-composefs-aboot.md new file mode 100644 index 000000000..ec1a616d7 --- /dev/null +++ b/docs/src/experimental-composefs-aboot.md @@ -0,0 +1,77 @@ +# Experimental composefs aboot support + +This page covers booting the composefs backend with an Android-style A/B boot +image. For the repository layout, fsverity modes, and general composefs +installation model, see [the composefs backend documentation](experimental-composefs.md). +Both the aboot integration and its on-disk state format are experimental. + +## Boot layouts + +There are two supported ways to consume an `aboot.img` artifact: + +| Artifact encoding | Inferred install bootloader | Boot partitions | +| --- | --- | --- | +| Android boot image v2 | `none` | Android `boot_a` and `boot_b`; no ESP | +| ukiboot image | `ukiboot` | ESP with ukiboot, plus A/B boot images | + +The real Android layout deliberately has **no ESP**. For that payload, bootc infers +bootloader `none` and does not install an EFI bootloader; the platform firmware and +`aboot-deploy` manage the A/B boot partitions. Both layouts use the composefs backend +for the root filesystem. They cannot be switched in place to or from the BLS/UKI +layouts managed by GRUB or systemd-boot. + +## Building an image + +`bootc container aboot` computes the V1 and V2 composefs digests of the rootfs, +adds both to the kernel command line, and invokes `aboot-update` to create the +artifact. See [EROFS formats](experimental-composefs.md#erofs-formats) for why +both digests are included. `/etc/aboot.cfg` controls whether `aboot-update` +emits an Android boot image or a ukiboot image (as well as other details). As +with [building a composefs UKI](experimental-composefs.md#building-sealed-images), +split the kernel and initramfs from the rootfs before generating the boot +artifact, then copy the generated `aboot-.img` into the final +image's `/boot`. + +```sh +bootc container aboot --rootfs /target \ + --kernel-dir /kernel/KVER --out /out +``` + +The command checks that the generated artifact contains both expected composefs +arguments. It requires `aboot-update` in the build environment; the installed +system needs `aboot-deploy` for updates and rollbacks. If the root filesystem +cannot enforce fsverity, pass `--allow-missing-verity` when building the artifact. +This produces an **unsealed** image, as explained in +[composefs integrity modes](experimental-composefs.md#overview). + +Bootc automatically infers the aboot backend, and bootloader `none` from an Android boot +v2 payload and `ukiboot` from a ukiboot payload. An explicit bootloader setting is +optional, but must agree with the payload. +`bootc container inspect --json` reports `"type": "aboot"` for Android payloads +and `"type": "aboot-efi"` for ukiboot payloads; image-builder uses this to +select UEFI boot mode for ukiboot without a bootloader configuration file. + +The disk layout must (in `disk.yaml`) provide the platform's `boot_a` and `boot_b` +partitions and a root filesystem, but not an ESP. Add `vbmeta_a` and `vbmeta_b` if the +platform uses them. The partition names, sizes, payloads, device tree, and +`/etc/aboot.cfg` settings are platform-specific. For ukiboot, provide an ESP +(which will have ukiboot installed on it during bootc install). + +## Updates, rollback, and recovery + +An update stores the new boot image (and optional vbmeta image) under +`/state/deploy//aboot/`. A persistent pending record under +`/state/boot/aboot/` tracks those artifacts and their hashes. Staging does not write +either boot partition. At shutdown, bootc verifies the artifacts, records the attempt, and +calls `aboot-deploy` to flash the inactive slot. + +On the next boot, bootc records the observed `androidboot.slot_suffix` kernel commandline +option and reconciles the attempted deployment. The previous slot becomes the rollback +deployment; `bootc rollback` queues it through `aboot-deploy --rollback`. `bootc status` +indicates when a rollback is queued. A failed boot is **not** automatically retried: bootc +retains the attempted update for inspection and will not stage another update until that +state is resolved. + +The firmware's slot-selection and success-marking behavior is outside bootc. For example, +the ukiboot enables `ukiboot-set-success.service` to mark a successful boot. Validate that +behavior on the target hardware before relying on automatic fallback. diff --git a/docs/src/experimental-composefs.md b/docs/src/experimental-composefs.md index 4218d016f..61c6194b4 100644 --- a/docs/src/experimental-composefs.md +++ b/docs/src/experimental-composefs.md @@ -7,6 +7,9 @@ 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. +For Android A/B boot images and ukiboot on this backend, see +[experimental composefs aboot support](experimental-composefs-aboot.md). + The composefs backend has two independent integrity controls: - **fs-verity enforcement.** By default every object in the composefs @@ -95,7 +98,7 @@ Unlike the ostree backend, which keeps its repository at `/ostree/repo`, the com - `/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) + - `.origin`: an INI file recording the image reference, boot type (BLS, UKI, or aboot) 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 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. @@ -108,7 +111,10 @@ 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. -Transient, not-yet-finalized deployment state (used while staging an update before reboot) lives under `/run/composefs/staged-deployment` and is never persisted to disk. +The transient view of a staged deployment lives under +`/run/composefs/staged-deployment`. Aboot additionally persists pending and +attempted state so an interrupted A/B update can be reconciled after reboot; +see [aboot update state](experimental-composefs-aboot.md#updates-rollback-and-recovery). ## How Sealed Images Work @@ -272,6 +278,9 @@ Whenever the container image has a UKI, bootc automatically selects the composef 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. +Android A/B boot images use a separate [aboot boot path](experimental-composefs-aboot.md) +instead of BLS entries; a real aboot install does not have an ESP. + ## 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.