diff --git a/Cargo.lock b/Cargo.lock index 2986bb9f2f..d0b0365f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -781,7 +781,7 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" 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" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" dependencies = [ "anyhow", "composefs", @@ -824,7 +824,7 @@ dependencies = [ [[package]] name = "composefs-ctl" version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" dependencies = [ "anyhow", "cap-std-ext", @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "composefs-fuse" version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" dependencies = [ "anyhow", "composefs", @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "composefs-ioctls" version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" dependencies = [ "rustix", "thiserror 2.0.20", @@ -877,7 +877,7 @@ dependencies = [ [[package]] name = "composefs-oci" version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" 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" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" dependencies = [ "openssl", "rand 0.10.3", @@ -923,7 +924,7 @@ dependencies = [ [[package]] name = "composefs-storage" version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +source = "git+https://github.com/composefs/composefs-rs?branch=aboot-support#62e7cb888f6d97b97e7b03d1b2f64236c6e84656" 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 596b2b5552..e6552c41a8 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", branch = "aboot-support", default-features = false, features = [ "pre-6.15", "pre-6.16", "oci", @@ -86,10 +86,6 @@ uzers = "0.12" linux-kernel-cmdline = { version = "0.1.1", features = ["serde"] } xshell = "0.2.6" -# To develop against a local composefs-rs checkout, add a [patch] section at the end of this file: -# [patch."https://github.com/composefs/composefs-rs"] -# composefs-ctl = { path = "/composefs-rs/crates/composefs-ctl" } - # See https://github.com/coreos/cargo-vendor-filterer [workspace.metadata.vendor-filter] # For now we only care about tier 1+2 Linux. (In practice, it's unlikely there is a tier3-only Linux dependency) @@ -131,4 +127,3 @@ todo = "deny" # to trigger, and among the least valuable to fix. needless_borrow = "allow" needless_borrows_for_generic_args = "allow" - diff --git a/crates/lib/src/aboot.rs b/crates/lib/src/aboot.rs new file mode 100644 index 0000000000..e09f0228e1 --- /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 new file mode 100644 index 0000000000..7cd8b93550 --- /dev/null +++ b/crates/lib/src/boot_artifact.rs @@ -0,0 +1,212 @@ +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 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?; + 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 + }; + + for karg in composefs_kargs_for_boot_artifact( + composefs_digest, + erofs_version, + compatibility_v2_digest, + allow_missing_fsverity, + ) { + for param in &Cmdline::from(karg) { + cmdline.add_or_modify(¶m); + } + } + + 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/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs new file mode 100644 index 0000000000..f9cff390af --- /dev/null +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -0,0 +1,895 @@ +//! 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; +use rustix::{ + fd::OwnedFd, + fs::{FlockOperation, Mode, OFlags, flock, fsync, openat}, +}; +use serde::{Deserialize, Serialize}; + +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, 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 + .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", + } + } +} + +/// Serialize aboot state mutations and partition deployment for the guard's lifetime. +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) +} + +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); + } + Ok(Some(digest)) + } + + /// 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(()); + } + + 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)] +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"); + let mut state = AbootState::open(&root)?; + + 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!( + state.read_slot(Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + assert_eq!( + state.read_slot(Slot::B)?, + Some(Sha512HashValue::from_hex(&b)?) + ); + + for slot in [Slot::A, Slot::B] { + 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!(state.read_slot(other)?, None); + assert!(!root.try_exists(format!("{ABOOT_STATE_DIR}/slots/{}", other.name()))?); + } + + state.record_booted(&cmdline_a, &a)?; + state.record_booted(&Cmdline::from(""), &b)?; + assert_eq!( + state.read_slot(Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + state.record_booted(&cmdline_a, &b)?; + assert_eq!( + state.read_slot(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)?; + 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!(state.read_slot(Slot::A)?, None); + } + root.atomic_write(format!("{slots}/a"), [0xff; 128])?; + 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!(state.read_slot(Slot::A)?, None); + assert!( + state + .record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &digest) + .is_err() + ); + assert!( + state + .record_booted(&Cmdline::from(""), "../invalid") + .is_err() + ); + Ok(()) + } + + #[test] + fn deployments_deduplicates_slots() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + deployment(&root, &digest, "aboot")?; + 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!( + 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())?; + 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/backwards_compat/bcompat_boot.rs b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs index bf203c0d41..bbc18c6f91 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 a10d41bf12..ceb9324cdf 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; @@ -327,6 +330,7 @@ pub enum BootType { #[default] Bls, Uki, + Aboot, } impl ::std::fmt::Display for BootType { @@ -334,6 +338,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 +352,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 +366,7 @@ impl From<&ComposefsBootEntry> for BootType { ComposefsBootEntry::Type1(..) => Self::Bls, ComposefsBootEntry::Type2(..) => Self::Uki, ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, + ComposefsBootEntry::Aboot(..) => Self::Aboot, } } } @@ -604,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 @@ -882,6 +907,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) @@ -1135,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) } @@ -1382,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); @@ -1488,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, @@ -1805,6 +1831,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 @@ -2072,44 +2099,240 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } -#[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, +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<()> { - const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; + 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)) + }; - 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", - ); + 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(()) +} - let mut repo = open_composefs_repo(&root_setup.physical_root)?; - if allow_missing_fsverity { - repo.set_insecure(); +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(()) +} - let repo = Arc::new(repo); +fn image_bootloader(entry: &ComposefsBootEntry) -> Option { + match entry { + ComposefsBootEntry::Aboot(entry) => Some(match entry.encoding { + AbootEncoding::AndroidV2 => Bootloader::None, + AbootEncoding::Uki => Bootloader::Ukiboot, + }), + _ => None, + } +} - let crate::bootc_composefs::repo::BootImage { +fn validate_composefs_aboot_payload( + id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, + encoding: AbootEncoding, + payload: &[u8], + allow_missing_fsverity: bool, +) -> 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 { + let ComposefsBootEntry::Aboot(entry) = entry else { + bail!("Expected an aboot boot entry") + }; + let payload = read_aboot_payload(entry, repo)?; + validate_composefs_aboot_payload( id, boot_ids, - fs, - entries, - } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; + entry.encoding, + &payload, + allow_missing_fsverity, + ) +} - 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)?; +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, + }) +} - let postfetch = PostFetchState::new(state, mounted_root.dir())?; +fn install_composefs_bootloader( + root_setup: &RootSetup, + state: &State, + 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()) @@ -2121,10 +2344,7 @@ pub(crate) async fn setup_composefs_boot( &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 @@ -2144,7 +2364,7 @@ pub(crate) async fn setup_composefs_boot( )?; // 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 @@ -2195,13 +2415,62 @@ 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); + let composefs_mnt_fd = repo + .mount(&id.to_hex()) + .context("Failed to mount composefs image")?; + let image_root = Dir::reopen_dir(&composefs_mnt_fd)?; + + let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?; + + 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!( "BUG: Arc still has other references after boot image generation" @@ -2217,7 +2486,7 @@ pub(crate) async fn setup_composefs_boot( &provisional_deploy_id, provisional_format, entry, - mounted_root.dir(), + &image_root, )?, provisional_deploy_id, ), @@ -2232,13 +2501,17 @@ pub(crate) async fn setup_composefs_boot( &repo, &fs, )?, + BootType::Aboot => ( + setup_composefs_aboot_boot(&repo, &id, &boot_ids, entry, allow_missing_fsverity)?, + provisional_deploy_id, + ), }; write_composefs_state( &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(), @@ -2274,6 +2547,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() { @@ -2290,6 +2564,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"); @@ -2471,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 97295f3896..71b0d260b5 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,6 +156,7 @@ fn delete_depl_boot_entries( BootType::Uki => { remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } + BootType::Aboot => anyhow::bail!("Unexpected aboot deployment in boot-entry deletion"), }, BootloaderKind::BLSCompatible => { @@ -260,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 eec1825949..4486e8bb97 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,6 +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 => unreachable!(), }, BootloaderKind::BLSCompatible => { @@ -195,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 a8687c741c..6ca616d1ff 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -213,6 +213,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 @@ -280,8 +300,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:?}"); @@ -308,6 +335,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 artifact unexpectedly reached boot binary garbage collection") + } } } @@ -323,18 +353,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() @@ -501,10 +547,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 { @@ -608,7 +654,85 @@ 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)?; + } + let mut aboot_state = aboot::AbootState::open(&root)?; + aboot_state.record_booted( + &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_a"), + &booted, + )?; + aboot_state.record_booted( + &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/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 42d521150a..22d7a3a27e 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/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 6a8d707b3b..9b098fa26a 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/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 307545463d..fe686c69ee 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, @@ -250,21 +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)?; - } - }, - - 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 9f15d31fca..464ad79a53 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)?; @@ -226,8 +237,8 @@ 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) -/// * `boot_type` - Boot loader type (`Bls` or `Uki`) +/// * `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 /// manifest+config can be retrieved from the composefs repo later @@ -239,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, @@ -271,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, )?; @@ -307,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 512608d824..545d8ea204 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::{ @@ -333,8 +334,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") } }; @@ -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, }), @@ -714,6 +716,7 @@ fn set_soft_reboot_capability( } BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host), + BootType::Aboot => Ok(()), } } @@ -920,6 +923,109 @@ 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 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 { + 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 state.slot_deployments()? { + 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); + } + } + + 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 + .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, @@ -927,6 +1033,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 @@ -939,20 +1049,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 @@ -982,6 +1082,7 @@ async fn composefs_deployment_status_from( ini, &verity_digest, cmdline.allow_missing_fsverity, + bootloader, )?; // SAFETY: boot_entry.composefs will always be present @@ -1005,10 +1106,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; } @@ -1074,6 +1173,9 @@ async fn composefs_deployment_status_from( (is_rollback_queued, None, Some(menuentries)) } + BootType::Aboot => { + anyhow::bail!("Aboot deployment unexpectedly reached GRUB status handling") + } }, // We will have BLS stuff and the UKI stuff in the same DIR @@ -1153,14 +1255,113 @@ 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)?; + } + 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, &aboot_state, &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); + + 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, &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); + 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, &aboot_state, &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, &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(()) + } + #[test] fn test_composefs_parsing() { const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 45e4c5ad28..88e19612ab 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,25 +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")?; + } } - } - }, - - 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. @@ -223,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, @@ -270,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)?; @@ -309,6 +330,14 @@ pub(crate) async fn do_upgrade( let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); }; + let boot_type = BootType::from(entry); + 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( &repo @@ -328,9 +357,8 @@ 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 mut aboot_artifacts = None; let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( @@ -356,6 +384,18 @@ pub(crate) async fn do_upgrade( &repo, &oci_fs, )?, + 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 @@ -381,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, @@ -389,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 // @@ -428,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 9c34dd5fed..3eaa944c4d 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, @@ -523,6 +525,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 @@ -812,6 +843,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 @@ -2260,39 +2293,35 @@ 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(), ) .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, @@ -2419,6 +2448,25 @@ 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?; + let digest = match storage.kind()? { + BootedStorageKind::Composefs(booted) => booted.cmdline.digest.to_string(), + BootedStorageKind::Ostree(_) => { + anyhow::bail!("Aboot reconciliation requires the composefs backend") + } + }; + ensure!( + 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 { normal_dir, early_dir: _, @@ -2903,6 +2951,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/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 03ccc53100..2cf1e66554 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -7,6 +7,14 @@ 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_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/generator.rs b/crates/lib/src/generator.rs index 3c4d6d973a..9cdfff7778 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/install.rs b/crates/lib/src/install.rs index 38866768f6..b09d87d316 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1723,11 +1723,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")?; @@ -1877,20 +1876,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, @@ -1899,6 +1893,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 @@ -1926,7 +1935,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 @@ -1949,7 +1958,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 => { @@ -2114,11 +2123,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 { @@ -2129,7 +2140,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 @@ -3047,6 +3058,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/kernel.rs b/crates/lib/src/kernel.rs index c7b4c34bad..fcfcddad0a 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; @@ -165,6 +165,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::*; @@ -178,6 +251,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/lib.rs b/crates/lib/src/lib.rs index d9eccc0c81..fdf7327a84 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -63,6 +63,8 @@ //! - [`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; mod bootloader; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 15ad1014fe..419a6802f3 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") + } } } } @@ -469,6 +475,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 4525f60c71..d1746917cd 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 = diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index e25ac67c1c..36f23028cf 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,51 @@ 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::AbootState::open(&physical_root)?.record_booted( + &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 +522,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 +535,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 +579,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, @@ -628,6 +658,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 diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 669876919f..4615275f79 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); - } - } -} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index bad81a3499..7ae021a198 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -67,6 +67,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 0000000000..ec1a616d7b --- /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 4218d016f4..61c6194b4e 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. diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 686b71e046..2263078c6d 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": { @@ -225,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-container-aboot.8.md b/docs/src/man/bootc-container-aboot.8.md new file mode 100644 index 0000000000..2162df475d --- /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 1b1fce62fc..94d0975aa3 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 - diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index 9c11739480..d119b3b673 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 416d9ca815..934d182eab 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -224,6 +224,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 2d2b8b6f62..bae39e799e 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** diff --git a/systemd/bootc-aboot-reconcile.service b/systemd/bootc-aboot-reconcile.service new file mode 100644 index 0000000000..bc23864f1e --- /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