From f56da8801a568f329a7cd75bf4bd6411006d8ef8 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Tue, 27 Oct 2026 13:07:04 +0100 Subject: [PATCH 1/4] aboot: Require aboot-deploy in images at install/update Without this, later updates will fail, so ensure we don't get into this bad state. Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 9 +++++++++ crates/lib/src/bootc_composefs/boot.rs | 4 ++++ crates/lib/src/bootc_composefs/update.rs | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index a0c93d0d1..66f5c4fbb 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -52,6 +52,15 @@ use super::{ const SLOT_SUFFIX: &str = "androidboot.slot_suffix"; +/// Require update and rollback tooling in every installed or staged aboot image. +pub(crate) fn require_aboot_deploy_in_image(root: &Dir) -> Result<()> { + ensure!( + crate::utils::have_executable_in_root(root, "aboot-deploy")?, + "Target image must contain aboot-deploy for aboot updates and rollbacks" + ); + Ok(()) +} + /// Logical A/B slot observed from the running kernel command line. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Slot { diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 155bd6dce..042f9e286 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2596,6 +2596,10 @@ pub(crate) async fn setup_composefs_boot( .context("Failed to mount composefs image")?; let image_root = Dir::reopen_dir(&composefs_mnt_fd)?; + if boot_type == BootType::Aboot { + super::aboot::require_aboot_deploy_in_image(&image_root)?; + } + let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?; validate_bootloader(entry, postfetch.detected_bootloader)?; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 8e42186b0..a9940432b 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -386,6 +386,10 @@ pub(crate) async fn do_upgrade( .context("Failed to mount composefs image")?, )?; + if boot_type == BootType::Aboot { + aboot::require_aboot_deploy_in_image(&mounted_fs)?; + } + // Check if three way etc merge is possible without conflicts let new_etc = mounted_fs .open_dir("etc") From 8338a85e7fc51ff6e1e4bc88eb10d2c6505a67e1 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Tue, 27 Oct 2026 15:52:44 +0100 Subject: [PATCH 2/4] aboot: Allow replacing failed updates after reboot Retaining an attempted aboot deployment currently blocks further updates and switches after a failed flash or firmware fallback. Allow an explicit update or switch to discard it after reboot, preserving the selected image source and refreshing status before checking deployment collisions. Keep the same-boot guard and leave failed attempts disarmed across ordinary reboots. Rollback can also discard an older failed attempt when a valid rollback deployment remains available. Document recovery and extend the unit and VM tests to cover failed finalization, repeated reboots without reflashing, and explicit retry. Validation: 320 library tests passed, with one ignored. The VM regression test was extended but not run. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 96 ++++++++++++++++--- crates/lib/src/bootc_composefs/switch.rs | 12 ++- crates/lib/src/bootc_composefs/update.rs | 22 ++++- .../bootc-experimental-composefs-aboot.7.md | 16 +++- tmt/tests/booted/aboot_testlib.py | 4 +- .../booted/test-aboot-update-rollback.py | 33 ++++++- 6 files changed, 157 insertions(+), 26 deletions(-) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index 66f5c4fbb..13998080d 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -27,7 +27,8 @@ //! 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 +//! the write; an explicit update or switch can discard them after that reboot. +//! An unattempted pending update is projected back into `/run` and rearmed //! unless it was staged download-only. use anyhow::{Context, Result, ensure}; @@ -365,18 +366,34 @@ impl<'a> AbootState<'a> { } pub(crate) fn discard_pending(&self) -> Result<()> { - ensure!( - self.read_attempted()?.is_none(), - "An attempted aboot update has not been reconciled" - ); + if let Some(attempted) = self.read_attempted()? { + ensure!( + attempted.boot_id != boot_id()?, + "Cannot discard an aboot update attempted during this boot" + ); + } + // Remove pending before attempted so interrupted cleanup cannot rearm the payload. if let Some(pending) = self.read_pending()? { self.remove_state_file(ABOOT_PENDING_FNAME)?; self.remove_artifacts(&pending.depl_id)?; } + self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?; remove_staged_deployment()?; Ok(()) } + /// Discard a failed attempt only when an explicit update replaces it after reboot. + pub(crate) fn discard_failed_attempt(&self, booted: &str) -> Result { + let Some(attempted) = self.read_attempted()? else { + return Ok(false); + }; + if attempted.depl_id == booted { + return Ok(false); + } + self.discard_pending()?; + Ok(true) + } + /// Mark an update attempted before invalidating a slot or writing partitions. pub(crate) fn record_attempt(&mut self, deployment: &str) -> Result<()> { ensure!( @@ -767,15 +784,14 @@ mod tests { 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()); + for current_boot in ["old-boot", "new-boot", "another-boot"] { + assert!(matches!( + state.reconcile_state("booted", current_boot)?, + ReconcileAction::None + )); + assert_eq!(state.read_pending()?, Some(pending.clone())); + assert_eq!(state.read_attempted()?.unwrap().boot_id, "old-boot"); + } assert!(root.try_exists(&artifact_dir)?); assert!(matches!( state.reconcile_state(&digest, "new-boot")?, @@ -787,6 +803,58 @@ mod tests { Ok(()) } + #[test] + fn replace_failed_attempt_after_reboot() -> 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"failed", None)?; + state.write_pending(&pending)?; + state.record_attempt(&digest)?; + + assert!(state.discard_failed_attempt("booted").is_err()); + assert!(state.discard_pending().is_err()); + assert_eq!(state.read_pending()?, Some(pending)); + assert!(root.try_exists(&artifact_dir)?); + + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: digest.clone(), + boot_id: "previous-boot".into(), + }, + )?; + // Leave successful attempts for normal boot reconciliation. + assert!(!state.discard_failed_attempt(&digest)?); + assert!(state.read_attempted()?.is_some()); + assert!(state.discard_failed_attempt("booted")?); + assert!(state.read_pending()?.is_none()); + assert!(state.read_attempted()?.is_none()); + assert!(!root.try_exists(&artifact_dir)?); + + // The same image can be staged and attempted again explicitly. + let pending = state.stage_artifacts(&digest, false, b"retry", None)?; + state.write_pending(&pending)?; + state.record_attempt(&digest)?; + assert_eq!(state.read_attempted()?.unwrap().boot_id, boot_id()?); + + // Rollback can also discard an older failed attempt. + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: digest, + boot_id: "previous-boot".into(), + }, + )?; + state.discard_pending()?; + 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())?; diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 3a580a628..7cc05ddea 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -5,9 +5,9 @@ use crate::{ bootc_composefs::{ status::get_composefs_status, update::{ - DoUpgradeOpts, UpdateAction, apply_upgrade_from_downloaded, do_upgrade, - ensure_delta_source_present, is_image_pulled, lookup_config_splitstream, - validate_update, + DoUpgradeOpts, UpdateAction, apply_upgrade_from_downloaded, + discard_failed_aboot_update, do_upgrade, ensure_delta_source_present, is_image_pulled, + lookup_config_splitstream, validate_update, }, }, cli::{SwitchOpts, imgref_for_switch}, @@ -22,7 +22,7 @@ pub(crate) async fn switch_composefs( booted_cfs: &BootedComposefs, ) -> Result<()> { // TODO: Handle in-place - let host = get_composefs_status(storage, booted_cfs) + let mut host = get_composefs_status(storage, booted_cfs) .await .context("Getting composefs deployment status")?; @@ -49,6 +49,10 @@ pub(crate) async fn switch_composefs( delta.validate_image_reference(&target)?; } + if discard_failed_aboot_update(storage, &host)? { + host = get_composefs_status(storage, booted_cfs).await?; + } + let new_spec = { let mut new_spec = host.spec.clone(); new_spec.image = Some(target.clone()); diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index a9940432b..6d9f59b95 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -268,6 +268,15 @@ fn ensure_no_aboot_attempt(storage: &Storage) -> Result<()> { Ok(()) } +/// Drop a failed aboot candidate before constructing the next update's target and state. +pub(crate) fn discard_failed_aboot_update(storage: &Storage, host: &Host) -> Result { + let booted = host.require_composefs_booted()?; + if booted.boot_type != BootType::Aboot { + return Ok(false); + } + aboot::AbootState::open(&storage.physical_root)?.discard_failed_attempt(&booted.verity) +} + /// This is just an intersection of SwitchOpts and UpgradeOpts pub(crate) struct DoUpgradeOpts<'a> { pub(crate) apply: bool, @@ -565,11 +574,20 @@ pub(crate) async fn upgrade_composefs( "Starting composefs upgrade operation" ); - let host = get_composefs_status(storage, composefs) + let mut host = get_composefs_status(storage, composefs) .await .context("Getting composefs deployment status")?; - let current_image = host.spec.image.as_ref(); + // Preserve the selected source when discarding a failed staged switch. + let update_image = host.spec.image.clone(); + if !opts.check + && !opts.download_opts.from_downloaded + && discard_failed_aboot_update(storage, &host)? + { + host = get_composefs_status(storage, composefs).await?; + } + + let current_image = update_image.as_ref(); // Handle --tag: derive target from current image + new tag let derived_image = if let Some(ref tag) = opts.tag { diff --git a/docs/src/bootc-experimental-composefs-aboot.7.md b/docs/src/bootc-experimental-composefs-aboot.7.md index bd4d2ab9e..ca0b1156a 100644 --- a/docs/src/bootc-experimental-composefs-aboot.7.md +++ b/docs/src/bootc-experimental-composefs-aboot.7.md @@ -86,9 +86,19 @@ requires an unambiguous booted slot and calls `aboot-deploy` to flash the inacti 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. +indicates when a rollback is queued. + +After a failed flash or firmware fallback, bootc retains the attempted update for +inspection. Bootc does **not** automatically retry the flash, including on subsequent reboots. +Starting with the first boot after the failed attempt, `bootc upgrade` or +`bootc switch` discards that staged update before preparing a new one. This also +allows explicitly retrying the same image. `bootc upgrade --check` leaves the failed +attempt untouched. During the boot that recorded the attempt, replacing or discarding +it is blocked because its boot outcome is not yet known. + +`bootc rollback` also discards the failed pending update when a valid rollback +deployment is available. A failed flash may have overwritten the inactive slot, +so discarding the attempt does not necessarily restore a rollback deployment. 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 diff --git a/tmt/tests/booted/aboot_testlib.py b/tmt/tests/booted/aboot_testlib.py index 07c94e05d..40e429fa9 100644 --- a/tmt/tests/booted/aboot_testlib.py +++ b/tmt/tests/booted/aboot_testlib.py @@ -13,7 +13,7 @@ def status(): return json.loads(run("bootc", "status", "--json"))["status"] -def verify_boot(slot, version): +def verify_boot(slot, version, *, staged=False): assert run("systemctl", "is-enabled", "ukiboot-set-success.service") == "enabled" for service in ("ukiboot-set-success.service", "bootc-aboot-reconcile.service"): for attempt in range(60): @@ -30,7 +30,7 @@ def verify_boot(slot, version): assert booted["ostree"] is None, booted assert booted["composefs"]["bootType"] == "Aboot", booted assert run("findmnt", "--noheadings", "--output", "FSTYPE", "--target", "/sysroot") == "ext4" - assert st["staged"] is None, st + assert (st["staged"] is not None) == staged, st assert not st["rollbackQueued"], st assert Path("/usr/share/bootc-aboot-test-version").read_text().strip() == str(version) assert f"androidboot.slot_suffix=_{slot}" in Path("/proc/cmdline").read_text().split() diff --git a/tmt/tests/booted/test-aboot-update-rollback.py b/tmt/tests/booted/test-aboot-update-rollback.py index 7ff790827..3402aed1e 100644 --- a/tmt/tests/booted/test-aboot-update-rollback.py +++ b/tmt/tests/booted/test-aboot-update-rollback.py @@ -18,6 +18,9 @@ from aboot_testlib import run, status, verify_boot ORIGINAL = Path("/var/tmp/bootc-aboot-original.json") +ABOOT_CONFIG = Path("/etc/aboot.cfg") +ABOOT_CONFIG_BACKUP = Path("/var/tmp/bootc-aboot-original.cfg") +ABOOT_STATE = Path("/sysroot/state/boot/aboot") def main(): @@ -33,8 +36,36 @@ def main(): assert staged["staged"]["image"]["image"]["image"] == target, staged assert staged["staged"]["composefs"]["bootType"] == "Aboot", staged assert staged["staged"]["composefs"]["verity"] != initial["booted"]["composefs"]["verity"], staged + # Make aboot-deploy fail while reading its configuration, before flashing. + original_config = ABOOT_CONFIG.read_bytes() + ABOOT_CONFIG_BACKUP.write_bytes(original_config) + ABOOT_CONFIG.write_bytes(original_config + b"\nBOOTC_TEST_FAIL_DEPLOY=1\n") subprocess.run(["tmt-reboot"], check=True) elif count == 1: + ABOOT_CONFIG.write_bytes(ABOOT_CONFIG_BACKUP.read_bytes()) + ABOOT_CONFIG_BACKUP.unlink() + failed = verify_boot("a", 1, staged=True) + attempted = (ABOOT_STATE / "attempted").read_bytes() + pending = (ABOOT_STATE / "pending").read_bytes() + attempt = json.loads(attempted) + assert attempt["depl_id"] == failed["staged"]["composefs"]["verity"], attempt + assert attempt["boot_id"] != Path("/proc/sys/kernel/random/boot_id").read_text().strip(), attempt + assert not (ABOOT_STATE / "slots/b").exists() + run("bootc", "upgrade", "--check") + assert (ABOOT_STATE / "attempted").read_bytes() == attempted + assert (ABOOT_STATE / "pending").read_bytes() == pending + # Even with the configuration repaired, another reboot must not retry it. + subprocess.run(["tmt-reboot"], check=True) + elif count == 2: + failed = verify_boot("a", 1, staged=True) + assert (ABOOT_STATE / "attempted").exists() + run("bootc", "upgrade") + retried = status() + assert retried["staged"]["image"]["image"]["image"] == target, retried + assert retried["staged"]["composefs"]["verity"] == failed["staged"]["composefs"]["verity"], retried + assert not (ABOOT_STATE / "attempted").exists() + subprocess.run(["tmt-reboot"], check=True) + elif count == 3: original = json.loads(ORIGINAL.read_text()) updated = verify_boot("b", 2) assert updated["booted"]["image"]["image"]["image"] == target, updated @@ -44,7 +75,7 @@ def main(): run("bootc", "rollback") assert status()["rollbackQueued"] subprocess.run(["tmt-reboot"], check=True) - elif count == 2: + elif count == 4: original = json.loads(ORIGINAL.read_text()) restored = verify_boot("a", 1) assert restored["booted"]["image"] == original["image"], restored From 227224798a818b1ed9fe7173347e78b12ee35223 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 28 Oct 2026 10:04:39 +0100 Subject: [PATCH 3/4] aboot: Detect the host bootloader at runtime We're currently always showing bootloader None in the aboot entires, this is not right for ukiboot hosts. Read BOOT_TYPE from the running system's /etc/aboot.cfg instead, reporting ukiboot for ukiboot configurations and none for other values. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 56 ++++++++++++ crates/lib/src/bootc_composefs/status.rs | 88 ++++++++++++++++--- tmt/tests/booted/aboot_testlib.py | 1 + .../booted/test-aboot-update-rollback.py | 2 + 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs index 13998080d..f4b5fdb5b 100644 --- a/crates/lib/src/bootc_composefs/aboot.rs +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -44,6 +44,7 @@ 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 crate::spec::Bootloader; use super::{ boot::BootType, @@ -52,6 +53,33 @@ use super::{ }; const SLOT_SUFFIX: &str = "androidboot.slot_suffix"; +const ABOOT_CONFIG: &str = "/etc/aboot.cfg"; + +/// Determine the host bootloader from the running system's aboot configuration. +pub(crate) fn get_bootloader() -> Result { + let config = match std::fs::read_to_string(ABOOT_CONFIG) { + Ok(config) => config, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Bootloader::None), + Err(err) => return Err(err).with_context(|| format!("Reading {ABOOT_CONFIG}")), + }; + Ok(bootloader_from_config(&config)) +} + +fn bootloader_from_config(config: &str) -> Bootloader { + // Match aboot_config_load(): trim keys and values, keep the last assignment, + // and treat quotes and inline comments literally rather than as shell syntax. + let boot_type = config + .lines() + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.split_once('=')) + .filter(|(key, _)| key.trim_ascii() == "BOOT_TYPE") + .map(|(_, value)| value.trim_ascii()) + .next_back(); + match boot_type { + Some("ukiboot") => Bootloader::Ukiboot, + _ => Bootloader::None, + } +} /// Require update and rollback tooling in every installed or staged aboot image. pub(crate) fn require_aboot_deploy_in_image(root: &Dir) -> Result<()> { @@ -593,6 +621,34 @@ mod tests { use crate::composefs_consts::STATE_DIR_RELATIVE; use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir}; + #[test] + fn bootloader_config() { + for (config, expected) in [ + ("", Bootloader::None), + ( + "PARTITION_A=/dev/disk/by-partlabel/boot_a\n", + Bootloader::None, + ), + ("BOOT_TYPE=aboot\n", Bootloader::None), + ("BOOT_TYPE=unknown\n", Bootloader::None), + ("BOOT_TYPE=\n", Bootloader::None), + ("BOOT_TYPE=ukiboot", Bootloader::Ukiboot), + (" \tBOOT_TYPE \t= \tukiboot \t\r\n", Bootloader::Ukiboot), + ( + "#BOOT_TYPE=ukiboot\n #BOOT_TYPE=ukiboot\n", + Bootloader::None, + ), + ("BOOT_TYPE\nCMDLINE=a=b\n", Bootloader::None), + ("BOOT_TYPE=ukiboot\nBOOT_TYPE=aboot\n", Bootloader::None), + ("BOOT_TYPE=aboot\nBOOT_TYPE=ukiboot\n", Bootloader::Ukiboot), + ("BOOT_TYPE=\"ukiboot\"\n", Bootloader::None), + ("BOOT_TYPE='ukiboot'\n", Bootloader::None), + ("BOOT_TYPE=ukiboot # comment\n", Bootloader::None), + ] { + assert_eq!(bootloader_from_config(config), expected, "{config:?}"); + } + } + fn deployment(root: &Dir, digest: &str, boot_type: &str) -> Result<()> { let path = format!("{STATE_DIR_RELATIVE}/{digest}"); root.create_dir_all(&path)?; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe..b0efe2b1a 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -953,7 +953,12 @@ fn read_staged_deployment() -> Result> { .map(Some) } -fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> Result { +fn aboot_entry( + storage: &Storage, + verity: &str, + missing_verity_allowed: bool, + bootloader: Bootloader, +) -> 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( @@ -961,7 +966,7 @@ fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> origin, verity, missing_verity_allowed, - Bootloader::None, + bootloader, )?; ensure!( entry.require_composefs()?.boot_type == BootType::Aboot, @@ -970,13 +975,17 @@ fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> Ok(entry) } -fn composefs_aboot_status(storage: &Storage, cmdline: &ComposefsCmdline) -> Result { +fn composefs_aboot_status( + storage: &Storage, + cmdline: &ComposefsCmdline, + bootloader: Bootloader, +) -> Result { 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()) + composefs_aboot_status_from(storage, &state, cmdline, staged.as_ref(), bootloader) } fn composefs_aboot_status_from( @@ -984,6 +993,7 @@ fn composefs_aboot_status_from( state: &aboot::AbootState<'_>, cmdline: &ComposefsCmdline, staged: Option<&StagedDeployment>, + bootloader: Bootloader, ) -> Result { let mut host = Host::new(HostSpec { image: None, @@ -994,10 +1004,16 @@ fn composefs_aboot_status_from( storage, &cmdline.digest, cmdline.allow_missing_fsverity, + bootloader, )?); 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)?; + let mut entry = aboot_entry( + storage, + &staged.depl_id, + cmdline.allow_missing_fsverity, + bootloader, + )?; entry.download_only = staged.finalization_locked; host.status.staged = Some(entry); } @@ -1012,7 +1028,7 @@ fn composefs_aboot_status_from( continue; } seen.push(verity.clone()); - let entry = aboot_entry(storage, &verity, cmdline.allow_missing_fsverity)?; + let entry = aboot_entry(storage, &verity, cmdline.allow_missing_fsverity, bootloader)?; if host.status.rollback.is_none() { host.status.rollback = Some(entry); } else { @@ -1052,7 +1068,7 @@ async fn composefs_deployment_status_from( 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); + return composefs_aboot_status(storage, cmdline, aboot::get_bootloader()?); } let boot_dir = storage.require_boot_dir()?; @@ -1293,6 +1309,37 @@ mod tests { Ok(()) } + #[test] + fn aboot_status_uses_host_bootloader() -> 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)?; + } + let mut state = aboot::AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + let cmdline = ComposefsCmdline::new(&a); + let staged = StagedDeployment { + depl_id: staged_id, + finalization_locked: false, + }; + for bootloader in [Bootloader::None, Bootloader::Ukiboot] { + let host = + composefs_aboot_status_from(&storage, &state, &cmdline, Some(&staged), bootloader)?; + assert_eq!(host.list_deployments().len(), 3); + for entry in host.list_deployments() { + assert_eq!(entry.require_composefs()?.bootloader, bootloader); + } + } + Ok(()) + } + #[test] fn aboot_status_from_slots() -> Result<()> { let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; @@ -1310,7 +1357,8 @@ mod tests { aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; let cmdline = ComposefsCmdline::new(&a); - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + let host = + composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?; let booted = host.require_composefs_booted()?; assert_eq!(booted.verity, a); assert_eq!(booted.boot_type, BootType::Aboot); @@ -1329,7 +1377,8 @@ mod tests { assert!(!host.status.rollback_queued); aboot_state.queue_rollback(&b)?; - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + let host = + composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?; assert!(host.status.rollback_queued); assert_eq!(host.spec.boot_order, BootOrder::Rollback); aboot_state.clear_rollback()?; @@ -1338,7 +1387,13 @@ mod tests { depl_id: staged_id.clone(), finalization_locked: true, }; - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; + let host = composefs_aboot_status_from( + &storage, + &aboot_state, + &cmdline, + Some(&staged), + Bootloader::None, + )?; let staged_entry = host.status.staged.as_ref().unwrap(); assert_eq!(staged_entry.require_composefs()?.verity, staged_id); assert!(staged_entry.download_only); @@ -1356,7 +1411,13 @@ mod tests { depl_id: b.clone(), finalization_locked: false, }; - let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; + let host = composefs_aboot_status_from( + &storage, + &aboot_state, + &cmdline, + Some(&staged), + Bootloader::None, + )?; assert_eq!( host.status .staged @@ -1369,13 +1430,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, &aboot_state, &cmdline, None)?; + let host = + composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::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)?; + let host = composefs_aboot_status(&storage, &cmdline, Bootloader::None)?; assert!(host.status.staged.as_ref().unwrap().download_only); Ok(()) } diff --git a/tmt/tests/booted/aboot_testlib.py b/tmt/tests/booted/aboot_testlib.py index 40e429fa9..c38db873a 100644 --- a/tmt/tests/booted/aboot_testlib.py +++ b/tmt/tests/booted/aboot_testlib.py @@ -29,6 +29,7 @@ def verify_boot(slot, version, *, staged=False): booted = st["booted"] assert booted["ostree"] is None, booted assert booted["composefs"]["bootType"] == "Aboot", booted + assert booted["composefs"]["bootloader"] == "ukiboot", booted assert run("findmnt", "--noheadings", "--output", "FSTYPE", "--target", "/sysroot") == "ext4" assert (st["staged"] is not None) == staged, st assert not st["rollbackQueued"], st diff --git a/tmt/tests/booted/test-aboot-update-rollback.py b/tmt/tests/booted/test-aboot-update-rollback.py index 3402aed1e..d9ec5b032 100644 --- a/tmt/tests/booted/test-aboot-update-rollback.py +++ b/tmt/tests/booted/test-aboot-update-rollback.py @@ -35,6 +35,7 @@ def main(): assert staged["booted"] == initial["booted"], staged assert staged["staged"]["image"]["image"]["image"] == target, staged assert staged["staged"]["composefs"]["bootType"] == "Aboot", staged + assert staged["staged"]["composefs"]["bootloader"] == "ukiboot", staged assert staged["staged"]["composefs"]["verity"] != initial["booted"]["composefs"]["verity"], staged # Make aboot-deploy fail while reading its configuration, before flashing. original_config = ABOOT_CONFIG.read_bytes() @@ -72,6 +73,7 @@ def main(): assert updated["booted"]["composefs"]["verity"] != original["composefs"]["verity"], updated assert updated["rollback"]["image"] == original["image"], updated assert updated["rollback"]["composefs"]["verity"] == original["composefs"]["verity"], updated + assert updated["rollback"]["composefs"]["bootloader"] == "ukiboot", updated run("bootc", "rollback") assert status()["rollbackQueued"] subprocess.run(["tmt-reboot"], check=True) From efaa31089bebab836fe12eade4ab58990b3083e1 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 28 Oct 2026 10:05:37 +0100 Subject: [PATCH 4/4] aboot: Reject updates that change the bootloader Android aboot and ukiboot share the Aboot boot type but require different bootloaders. Checking only the boot type allows updates across incompatible layouts. Reuse the install validator against the running host's detected bootloader before staging an update. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/boot.rs | 2 +- crates/lib/src/bootc_composefs/update.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 042f9e286..26cc81dbb 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2217,7 +2217,7 @@ fn install_ukiboot( Ok(()) } -fn validate_bootloader( +pub(crate) fn validate_bootloader( entry: &ComposefsBootEntry, bootloader: Bootloader, ) -> Result<()> { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 6d9f59b95..1e0873e43 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -381,13 +381,16 @@ pub(crate) async fn do_upgrade( let entry = super::boot::primary_boot_entry(&entries)?; let boot_type = BootType::from(entry); - let booted_type = host.require_composefs_booted()?.boot_type; + let booted = host.require_composefs_booted()?; + let bootloader = booted.bootloader; + let booted_type = 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" ); } + super::boot::validate_bootloader(entry, bootloader)?; let mounted_fs = Dir::reopen_dir( &repo