1use std::cell::Cell;
65use std::fs::create_dir_all;
66use std::io::{Cursor, Read, Seek, SeekFrom, Write};
67use std::os::fd::AsFd;
68use std::path::Path;
69use std::sync::Arc;
70
71use anyhow::{Context, Result, anyhow, bail, ensure};
72use bootc_mount::tempmount::TempMount;
73use camino::{Utf8Path, Utf8PathBuf};
74use cap_std_ext::{
75 cap_std::{ambient_authority, fs::Dir},
76 dirext::CapStdExtDirExt,
77};
78use clap::ValueEnum;
79use composefs::erofs::format::FormatVersion;
80use composefs::fs::read_file;
81use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
82use composefs::tree::{FileSystem, ImageError, RegularFile};
83use composefs_boot::bootloader::{
84 AbootEncoding, AbootEntry, BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT,
85 EFI_ADDON_FILE_EXT, EFI_EXT, PEType, UsrLibModulesVmlinuz,
86};
87use composefs_boot::cmdline::{KARG_COMPOSEFS_DIGEST, KARG_V2};
88use composefs_boot::{
89 android_boot::{AndroidBootError, AndroidBootImage, Component},
90 cmdline::ComposefsCmdline as ComposefsBootCmdline,
91 os_release::OsReleaseInfo,
92 uki,
93};
94use composefs_ctl::composefs;
95use composefs_ctl::composefs_boot;
96use composefs_ctl::composefs_oci;
97use fn_error_context::context;
98use linux_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey};
99use ostree_ext::composefs::dumpfile;
100use ostree_ext::composefs_boot::bootloader::Type2Entry;
101use rustix::{mount::MountFlags, path::Arg};
102use schemars::JsonSchema;
103use serde::{Deserialize, Serialize};
104
105use crate::bootc_composefs::boot_partition::BootPartition;
106use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state};
107use crate::bootc_composefs::status::build_composefs_karg;
108use crate::bootc_composefs::uki_addon::{UkiAddonType, UkiAddonsList, list_installed_uki_addons};
109use crate::bootc_kargs::compute_new_kargs;
110use crate::composefs_consts::{
111 BLS_ENTRY_FILE_PREFIX, TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED,
112};
113use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey};
114use crate::spec::BootloaderKind;
115use crate::task::Task;
116use crate::{
117 bootc_composefs::repo::open_composefs_repo,
118 store::{ComposefsRepository, Storage},
119};
120use crate::{
121 bootc_composefs::status::get_sorted_grub_uki_boot_entries,
122 install::{AbootDiskLayout, PostFetchState},
123};
124use crate::{
125 composefs_consts::{STATE_DIR_RELATIVE, UKI_NAME_PREFIX, USER_CFG, USER_CFG_STAGED},
126 spec::{Bootloader, Host},
127};
128use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs};
129
130use crate::install::{BOOT, RootSetup, State, UkiAddonOpts};
131
132pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg";
134pub(crate) const EFI_LINUX: &str = "EFI/Linux";
136
137pub(crate) const INITRD: &str = "initrd";
138pub(crate) const VMLINUZ: &str = "vmlinuz";
139
140const BOOTC_AUTOENROLL_PATH: &str = "usr/lib/bootc/install/secureboot-keys";
141
142const AUTH_EXT: &str = "auth";
143
144pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc";
149
150pub(crate) const GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons";
163
164pub(crate) const STAGED_GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons.staged";
166
167#[derive(thiserror::Error, Debug)]
168pub(crate) enum UKIDigestMismatch {
169 #[error("The UKI has the wrong composefs= parameter (is '{actual}', should be '{expected}')")]
170 DigestParameter {
171 actual: String,
172 expected: String,
173 uki_name: Option<String>,
174 },
175 #[error(
176 "The UKI '{uki_name}' embedded composefs= digest ({actual:?}) doesn't match any of \
177 {combinations_tried} supported xattr filtering mode/EROFS format version combinations. \
178 The image may be corrupt, or was built with an incompatible composefs-rs version."
179 )]
180 UnsupportedCompatibility {
181 actual: Sha512HashValue,
182 uki_name: String,
183 combinations_tried: usize,
184 },
185}
186
187impl UKIDigestMismatch {
188 fn digest_parameter(actual: String, expected: String, uki_name: Option<String>) -> Self {
189 Self::DigestParameter {
190 actual,
191 expected,
192 uki_name,
193 }
194 }
195
196 fn unsupported_compatibility(
197 actual: Sha512HashValue,
198 uki_name: String,
199 combinations_tried: usize,
200 ) -> Self {
201 Self::UnsupportedCompatibility {
202 actual,
203 uki_name,
204 combinations_tried,
205 }
206 }
207
208 fn uki_name(&self) -> Option<&str> {
209 match self {
210 Self::DigestParameter { uki_name, .. } => uki_name.as_deref(),
211 Self::UnsupportedCompatibility { uki_name, .. } => Some(uki_name),
212 }
213 }
214}
215
216pub(crate) fn print_uki_dumpfile_diff(
217 mismatch: &UKIDigestMismatch,
218 repo: &ComposefsRepository,
219 fs: &FileSystem<Sha512HashValue>,
220) {
221 let dumpfile_name = mismatch
222 .uki_name()
223 .and_then(|x| x.strip_suffix(EFI_EXT).map(|x| format!("{x}.dump")));
224
225 let Some(dumpfile_name) = &dumpfile_name else {
226 return;
227 };
228
229 let Some(stored_content) = read_dumpfile_from_fs(fs, dumpfile_name, repo) else {
230 tracing::debug!("Dumpfile {dumpfile_name} not found in filesystem");
231 return;
232 };
233
234 let Ok(tempdir) = tempfile::tempdir() else {
235 tracing::debug!("Creating tempdir failed");
236 return;
237 };
238 let path = tempdir.path();
239 let Ok(tempdir_cap) = Dir::open_ambient_dir(path, ambient_authority()) else {
240 tracing::debug!("Opening tempdir failed");
241 return;
242 };
243
244 let Ok(mut stored_file) = tempdir_cap.create("stored") else {
245 tracing::debug!("Creating 'stored' tmpfile failed");
246 return;
247 };
248 if stored_file.write_all(&stored_content).is_err() {
249 tracing::debug!("Writing to tmpfile failed");
250 return;
251 }
252
253 let Ok(mut current_file) = tempdir_cap.create("current") else {
254 tracing::debug!("Creating 'current' tmpfile failed");
255 return;
256 };
257 if let Err(e) = dumpfile::write_dumpfile(&mut current_file, fs) {
258 tracing::debug!("Writing dumpfile failed: {e}");
259 return;
260 }
261
262 let mut cmd = std::process::Command::new("diff");
263 cmd.arg("--color=auto")
264 .arg(format!("{}/stored", path.display()))
265 .arg(format!("{}/current", path.display()));
266
267 if let Ok(fd) = std::io::stderr().as_fd().try_clone_to_owned() {
269 cmd.stdout(fd);
270 }
271
272 if let Err(e) = cmd.status() {
273 tracing::warn!("diffing dumpfiles failed with Err: {e:?}");
274 }
275}
276
277pub(crate) fn print_uki_dumpfile_diff_on_mismatch<T>(
279 result: Result<T>,
280 repo: &ComposefsRepository,
281 fs: &FileSystem<Sha512HashValue>,
282) -> Result<T> {
283 if let Err(error) = &result {
284 if let Some(mismatch) = error.downcast_ref::<UKIDigestMismatch>() {
285 print_uki_dumpfile_diff(mismatch, repo, fs);
286 }
287 }
288 result
289}
290
291fn read_regular_file(
292 file: &RegularFile<Sha512HashValue>,
293 repo: &ComposefsRepository,
294) -> Option<Vec<u8>> {
295 match file {
296 RegularFile::External(object_id, _) | RegularFile::ExternalNoVerity(object_id, _) => {
297 repo.read_object(object_id).ok()
298 }
299 RegularFile::Inline(data) => Some(data.to_vec()),
300 RegularFile::Sparse(_) => None,
301 }
302}
303
304fn read_dumpfile_from_fs(
305 fs: &FileSystem<Sha512HashValue>,
306 dumpfile_name: &str,
307 repo: &ComposefsRepository,
308) -> Option<Vec<u8>> {
309 let root = fs.as_dir();
310 let dumpfile_os = std::ffi::OsStr::new(dumpfile_name);
311
312 if let Ok(boot_dir) = root.get_directory_ref(BOOT.as_ref()) {
313 if let Ok(file) = boot_dir.get_file(dumpfile_os) {
314 return read_regular_file(file, repo);
315 }
316 }
317
318 None
319}
320
321pub(crate) enum BootSetupType<'a> {
322 Setup((&'a RootSetup, &'a State, &'a PostFetchState, bool)),
324 Upgrade(
326 (
327 &'a Storage,
328 &'a BootedComposefs,
329 &'a Host,
330 Option<&'a UkiAddonOpts>,
331 ),
332 ),
333}
334
335impl BootSetupType<'_> {
336 fn grub_boot_path(&self) -> Result<Utf8PathBuf> {
340 match self {
341 Self::Setup((root_setup, ..)) => Ok(root_setup.physical_root_path.join(BOOT)),
342 Self::Upgrade((storage, ..)) => Ok(storage.require_grub_boot_path()?.to_owned()),
343 }
344 }
345}
346
347fn grub_bls_abs_entries_path(grub_boot_path: &Utf8Path) -> Result<Utf8PathBuf> {
350 let (Some(parent), Some(name)) = (grub_boot_path.parent(), grub_boot_path.file_name()) else {
351 anyhow::bail!("Invalid boot path {grub_boot_path}");
352 };
353 let parent = Dir::open_ambient_dir(parent, ambient_authority())
354 .with_context(|| format!("Opening {parent}"))?;
355 let is_mountpoint = parent
357 .is_mountpoint(name)
358 .with_context(|| format!("Checking if {grub_boot_path} is a mountpoint"))?;
359 let root = Utf8Path::new("/");
361 Ok(if is_mountpoint == Some(true) {
362 root.to_owned()
363 } else {
364 root.join(BOOT)
365 })
366}
367
368#[derive(
369 ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema,
370)]
371pub enum BootType {
372 #[default]
373 Bls,
374 Uki,
375 Aboot,
376}
377
378impl ::std::fmt::Display for BootType {
379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380 let s = match self {
381 BootType::Bls => "bls",
382 BootType::Uki => "uki",
383 BootType::Aboot => "aboot",
384 };
385
386 write!(f, "{}", s)
387 }
388}
389
390impl TryFrom<&str> for BootType {
391 type Error = anyhow::Error;
392
393 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
394 match value {
395 "bls" => Ok(Self::Bls),
396 "uki" => Ok(Self::Uki),
397 "aboot" => Ok(Self::Aboot),
398 unrecognized => Err(anyhow::anyhow!(
399 "Unrecognized boot option: '{unrecognized}'"
400 )),
401 }
402 }
403}
404
405impl From<&ComposefsBootEntry<Sha512HashValue>> for BootType {
406 fn from(entry: &ComposefsBootEntry<Sha512HashValue>) -> Self {
407 match entry {
408 ComposefsBootEntry::Type1(..) => Self::Bls,
409 ComposefsBootEntry::Type2(..) => Self::Uki,
410 ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls,
411 ComposefsBootEntry::Aboot(..) => Self::Aboot,
412 }
413 }
414}
415
416pub(crate) fn get_efi_uuid_source() -> String {
419 format!(
420 r#"
421if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then
422 source ${{config_directory}}/{EFI_UUID_FILE}
423fi
424"#
425 )
426}
427
428const ESP_MOUNT_FLAGS: MountFlags =
430 MountFlags::from_bits_retain(MountFlags::NOEXEC.bits() | MountFlags::NOSUID.bits());
431
432const ESP_MOUNT_DATA: &std::ffi::CStr = c"fmask=0177,dmask=0077";
434
435pub fn mount_esp(device: &str) -> Result<TempMount> {
440 TempMount::mount_dev(device, "vfat", ESP_MOUNT_FLAGS, Some(ESP_MOUNT_DATA))
441}
442
443pub fn mount_esp_readonly(device: &str) -> Result<TempMount> {
450 if let Some(existing) = bootc_mount::find_mount_target_by_source(device)? {
451 return TempMount::clone_existing_mount(&existing);
452 }
453 mount_esp(device)
454}
455
456pub fn mount_esp_writable(device: &str) -> Result<TempMount> {
476 if let Some(existing) = bootc_mount::find_mount_target_by_source(device)? {
477 rustix::mount::mount_remount(existing.as_str(), ESP_MOUNT_FLAGS, "")
478 .with_context(|| format!("Remounting {existing} read-write"))?;
479 }
480 mount_esp(device)
481}
482
483pub(crate) fn mount_esp_at(
489 device: &str,
490 path: std::path::PathBuf,
491) -> Result<bootc_mount::tempmount::MountGuard> {
492 if let Some(existing) = bootc_mount::find_mount_target_by_source(device)? {
493 rustix::mount::mount_remount(existing.as_str(), ESP_MOUNT_FLAGS, "")
494 .with_context(|| format!("Remounting {existing} read-write"))?;
495 }
496 bootc_mount::tempmount::MountGuard::mount(
497 device,
498 path,
499 "vfat",
500 ESP_MOUNT_FLAGS,
501 Some(ESP_MOUNT_DATA),
502 )
503}
504
505pub(crate) const FILENAME_PRIORITY_PRIMARY: &str = "1";
508
509pub(crate) const FILENAME_PRIORITY_SECONDARY: &str = "0";
511
512pub(crate) const SORTKEY_PRIORITY_PRIMARY: &str = "0";
515
516pub(crate) const SORTKEY_PRIORITY_SECONDARY: &str = "1";
518
519pub fn type1_entry_conf_file_name(
531 os_id: &str,
532 version: impl std::fmt::Display,
533 priority: &str,
534) -> String {
535 let os_id_safe = os_id.replace('-', "_");
536 format!("{BLS_ENTRY_FILE_PREFIX}{os_id_safe}-{version}-{priority}.conf")
537}
538
539pub(crate) fn primary_sort_key(os_id: &str) -> String {
544 format!("bootc-{os_id}-{SORTKEY_PRIORITY_PRIMARY}")
545}
546
547pub(crate) fn secondary_sort_key(os_id: &str) -> String {
550 format!("bootc-{os_id}-{SORTKEY_PRIORITY_SECONDARY}")
551}
552
553pub(crate) fn os_id_from_sort_key(sort_key: &str) -> Option<&str> {
556 let (os_id, _priority) = sort_key.strip_prefix("bootc-")?.rsplit_once('-')?;
557 Some(os_id)
558}
559
560pub(crate) fn get_type1_dir_name(depl_verity: &str) -> String {
562 format!("{TYPE1_BOOT_DIR_PREFIX}{depl_verity}")
563}
564
565pub(crate) fn get_uki_name(depl_verity: &str) -> String {
567 format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_EXT}")
568}
569
570pub(crate) fn get_uki_addon_dir_name(depl_verity: &str) -> String {
572 format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_DIR_EXT}")
573}
574
575pub(crate) fn get_scoped_uki_addon_name(name: &str) -> String {
578 let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name);
579 format!("{name_wo_suffix}{EFI_ADDON_FILE_EXT}")
580}
581
582pub(crate) fn get_global_uki_addon_name(name: &str) -> String {
585 let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name);
586 format!("{UKI_NAME_PREFIX}{name_wo_suffix}{EFI_ADDON_FILE_EXT}")
587}
588
589#[context("Computing boot digest")]
595fn compute_boot_digest(
596 entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
597 repo: &crate::store::ComposefsRepository,
598) -> Result<String> {
599 let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?;
600
601 let Some(initramfs) = &entry.initramfs else {
602 anyhow::bail!("initramfs not found");
603 };
604
605 let initramfs = read_file(initramfs, &repo).context("Reading intird")?;
606
607 let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
608 .context("Creating hasher")?;
609
610 hasher.update(&vmlinuz).context("hashing vmlinuz")?;
611 hasher.update(&initramfs).context("hashing initrd")?;
612
613 let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
614
615 Ok(hex::encode(digest))
616}
617
618#[context("Computing boot digest for Type1 entries")]
619fn compute_boot_digest_type1(dir: &Dir) -> Result<String> {
620 let mut vmlinuz = dir
621 .open(VMLINUZ)
622 .with_context(|| format!("Opening {VMLINUZ}"))?;
623
624 let mut initrd = dir
625 .open(INITRD)
626 .with_context(|| format!("Opening {INITRD}"))?;
627
628 let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
629 .context("Creating hasher")?;
630
631 std::io::copy(&mut vmlinuz, &mut hasher)?;
632 std::io::copy(&mut initrd, &mut hasher)?;
633
634 let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
635
636 Ok(hex::encode(digest))
637}
638
639#[context("Computing boot digest")]
645pub(crate) fn compute_boot_digest_uki<R: Read + Seek>(uki_reader: &mut R) -> Result<String> {
646 let vmlinuz = uki::get_section_buffered(uki_reader, ".linux").context(".linux not present")?;
647 uki_reader
648 .seek(SeekFrom::Start(0))
649 .context("Moving seek to 0")?;
650 let initramfs =
651 uki::get_section_buffered(uki_reader, ".initrd").context(".initrd not present")?;
652
653 let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
654 .context("Creating hasher")?;
655
656 hasher.update(&vmlinuz).context("hashing vmlinuz")?;
657 hasher.update(&initramfs).context("hashing initrd")?;
658
659 uki_reader.seek(SeekFrom::Start(0))?;
660 match uki::get_section_buffered(uki_reader, ".dtb") {
661 Ok(data) => hasher.update(&data).context("hashing dtb")?,
662 Err(uki::UkiError::MissingSection(_)) => {}
663 Err(error) => return Err(error.into()),
664 }
665
666 let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
667
668 Ok(hex::encode(digest))
669}
670
671#[context("Computing Android boot digest")]
672pub(crate) fn compute_boot_digest_aboot<R: Read + Seek>(
673 reader: &mut R,
674 metadata: &AndroidBootImage,
675) -> Result<String> {
676 let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
677 .context("Creating hasher")?;
678 for component in [Component::Kernel, Component::Ramdisk] {
679 hasher.update(&metadata.component(reader, component)?)?;
680 }
681 match metadata.component(reader, Component::Dtb) {
682 Ok(dtb) => hasher.update(&dtb)?,
683 Err(AndroidBootError::MissingComponent("dtb")) => {}
684 Err(error) => return Err(error.into()),
685 }
686 Ok(hex::encode(hasher.finish()?))
687}
688
689#[context("Checking boot entry duplicates")]
695pub(crate) fn find_vmlinuz_initrd_duplicate(
696 storage: &Storage,
697 digest: &str,
698) -> Result<Option<String>> {
699 let boot_dir = storage.bls_boot_binaries_dir()?;
700
701 for entry in boot_dir.entries_utf8()? {
702 let entry = entry?;
703 let dir_name = entry.file_name()?;
704
705 if !entry.file_type()?.is_dir() {
706 continue;
707 }
708
709 let Some(..) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else {
710 continue;
711 };
712
713 let entry_digest = compute_boot_digest_type1(&boot_dir.open_dir(&dir_name)?)?;
714
715 if entry_digest == digest {
716 return Ok(Some(dir_name));
717 }
718 }
719
720 Ok(None)
721}
722
723#[context("Writing BLS entries to disk")]
724fn write_bls_boot_entries_to_disk(
725 boot_dir: &Utf8PathBuf,
726 deployment_id: &Sha512HashValue,
727 entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
728 repo: &crate::store::ComposefsRepository,
729) -> Result<()> {
730 let dir_name = get_type1_dir_name(&deployment_id.to_hex());
731
732 let path = boot_dir.join(&dir_name);
734 create_dir_all(&path)?;
735
736 let entries_dir = Dir::open_ambient_dir(&path, ambient_authority())
737 .with_context(|| format!("Opening {path}"))?;
738
739 entries_dir
740 .atomic_write(
741 VMLINUZ,
742 read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?,
743 )
744 .context("Writing vmlinuz to path")?;
745
746 let Some(initramfs) = &entry.initramfs else {
747 anyhow::bail!("initramfs not found");
748 };
749
750 entries_dir
751 .atomic_write(
752 INITRD,
753 read_file(initramfs, &repo).context("Reading initrd")?,
754 )
755 .context("Writing initrd to path")?;
756
757 let owned_fd = entries_dir
759 .reopen_as_ownedfd()
760 .context("Reopen as owned fd")?;
761
762 rustix::fs::fsync(owned_fd).context("fsync")?;
763
764 Ok(())
765}
766
767pub fn parse_os_release(root: &Dir) -> Result<Option<(String, Option<String>, Option<String>)>> {
771 let file = root
773 .open_optional("usr/lib/os-release")
774 .context("Opening usr/lib/os-release")?;
775
776 let Some(mut os_rel_file) = file else {
777 return Ok(None);
778 };
779
780 let mut file_contents = String::new();
781 os_rel_file.read_to_string(&mut file_contents)?;
782
783 let parsed = OsReleaseInfo::parse(&file_contents);
784
785 let os_id = parsed
786 .get_value(&["ID"])
787 .unwrap_or_else(|| "bootc".to_string());
788
789 Ok(Some((
790 os_id,
791 parsed.get_pretty_name(),
792 parsed.get_version(),
793 )))
794}
795
796struct BLSEntryPath {
797 entries_path: Utf8PathBuf,
799 abs_entries_path: Utf8PathBuf,
801 config_path: Utf8PathBuf,
803}
804
805fn replace_composefs_karg(cmdline: &mut Cmdline, new_karg: &str) -> Result<()> {
807 cmdline.remove(&ParameterKey::from(KARG_V2));
808 cmdline.remove(&ParameterKey::from(KARG_COMPOSEFS_DIGEST));
809 let parameter = Parameter::parse(new_karg).context("Parsing composefs kernel parameter")?;
810 cmdline.add_or_modify(¶meter);
811 Ok(())
812}
813
814#[context("Setting up BLS boot")]
819pub(crate) fn setup_composefs_bls_boot(
820 setup_type: BootSetupType,
821 repo: &crate::store::ComposefsRepository,
822 id: &Sha512HashValue,
823 format_version: FormatVersion,
824 entry: &ComposefsBootEntry<Sha512HashValue>,
825 mounted_erofs: &Dir,
826) -> Result<String> {
827 let id_hex = id.to_hex();
828
829 let (boot_partition, mut cmdline_refs, bootloader) = match setup_type {
830 BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => {
831 let mut cmdline_options = Cmdline::new();
833
834 cmdline_options.extend(&root_setup.kargs);
835
836 if let Some(user_kargs) = &state.config_opts.karg {
837 for karg in user_kargs {
838 cmdline_options.extend(karg);
839 }
840 }
841
842 let composefs_cmdline =
843 build_composefs_karg(id.clone(), format_version, allow_missing_fsverity);
844 cmdline_options.extend(&Cmdline::from(&composefs_cmdline));
845
846 if let Some(boot) = root_setup.boot_mount_spec() {
850 if !boot.source.is_empty() {
851 let mount_extra = format!(
852 "systemd.mount-extra={}:/boot:{}:{}",
853 boot.source,
854 boot.fstype,
855 boot.options.as_deref().unwrap_or("defaults"),
856 );
857 cmdline_options.extend(&Cmdline::from(mount_extra.as_str()));
858 tracing::debug!("Added /boot mount karg: {mount_extra}");
859 }
860 }
861
862 let esp_part = root_setup.device_info.find_first_colocated_esp()?;
867
868 (
869 BootPartition::for_install(esp_part.path().into(), postfetch.detected_bootloader)?,
870 cmdline_options,
871 postfetch.detected_bootloader.clone(),
872 )
873 }
874
875 BootSetupType::Upgrade((storage, booted_cfs, host, _)) => {
876 let bootloader = host.require_composefs_booted()?.bootloader.clone();
877
878 let boot_dir = storage.require_boot_dir()?;
879 let current_cfg = get_booted_bls(&boot_dir, booted_cfs)?;
880
881 let mut cmdline = match current_cfg.cfg_type {
882 BLSConfigType::NonEFI { options, .. } => {
883 let options = options
884 .ok_or_else(|| anyhow::anyhow!("No 'options' found in BLS Config"))?;
885
886 Cmdline::from(options)
887 }
888
889 _ => anyhow::bail!("Found NonEFI config"),
890 };
891
892 replace_composefs_karg(
893 &mut cmdline,
894 &build_composefs_karg(
895 id.clone(),
896 format_version,
897 booted_cfs.cmdline.allow_missing_fsverity,
898 ),
899 )?;
900
901 (
902 storage.require_boot_partition()?.clone(),
903 cmdline,
904 bootloader,
905 )
906 }
907 };
908
909 let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
910
911 let current_root = if is_upgrade {
912 Some(&Dir::open_ambient_dir("/", ambient_authority()).context("Opening root")? as &Dir)
913 } else {
914 None
915 };
916
917 compute_new_kargs(mounted_erofs, current_root, &mut cmdline_refs)?;
918
919 let (entry_paths, _tmpdir_guard) = match bootloader.kind()? {
920 BootloaderKind::GRUBClassic => {
921 let boot_path = setup_type.grub_boot_path()?;
922
923 let abs_entries_path = grub_bls_abs_entries_path(&boot_path)?;
926
927 (
928 BLSEntryPath {
929 entries_path: boot_path.clone(),
930 config_path: boot_path,
931 abs_entries_path,
932 },
933 None,
934 )
935 }
936
937 BootloaderKind::BLSCompatible => {
938 let efi_mount = mount_esp_writable(boot_partition.device().as_str())
939 .context("Mounting boot partition")?;
940
941 let mounted_efi = Utf8PathBuf::from(efi_mount.dir.path().as_str()?);
942 let efi_linux_dir = mounted_efi.join(EFI_LINUX);
943
944 (
945 BLSEntryPath {
946 entries_path: efi_linux_dir,
947 config_path: mounted_efi.clone(),
948 abs_entries_path: Utf8PathBuf::from("/").join(EFI_LINUX),
949 },
950 Some(efi_mount),
951 )
952 }
953 };
954
955 let (bls_config, boot_digest, os_id) = match &entry {
956 ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"),
957 ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"),
958 ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"),
959
960 ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => {
961 let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo)
962 .context("Computing boot digest")?;
963
964 let osrel = parse_os_release(mounted_erofs)?;
965
966 let (os_id, title, version, sort_key) = match osrel {
967 Some((id_str, title_opt, version_opt)) => (
968 id_str.clone(),
969 title_opt.unwrap_or_else(|| id.to_hex()),
970 version_opt.unwrap_or_else(|| id.to_hex()),
971 primary_sort_key(&id_str),
972 ),
973 None => {
974 let default_id = "bootc".to_string();
975 (
976 default_id.clone(),
977 id.to_hex(),
978 id.to_hex(),
979 primary_sort_key(&default_id),
980 )
981 }
982 };
983
984 let mut bls_config = BLSConfig::default();
985
986 let entries_dir = get_type1_dir_name(&id_hex);
987
988 bls_config
989 .with_title(title)
990 .with_version(version)
991 .with_sort_key(sort_key)
992 .with_cfg(BLSConfigType::NonEFI {
993 linux: entry_paths
994 .abs_entries_path
995 .join(&entries_dir)
996 .join(VMLINUZ),
997 initrd: vec![entry_paths.abs_entries_path.join(&entries_dir).join(INITRD)],
998 options: Some(cmdline_refs),
999 });
1000
1001 let shared_entry = match setup_type {
1002 BootSetupType::Setup(_) => None,
1003 BootSetupType::Upgrade((storage, ..)) => {
1004 find_vmlinuz_initrd_duplicate(storage, &boot_digest)?
1005 }
1006 };
1007
1008 match shared_entry {
1009 Some(shared_entry) => {
1010 match bls_config.cfg_type {
1016 BLSConfigType::NonEFI {
1017 ref mut linux,
1018 ref mut initrd,
1019 ..
1020 } => {
1021 *linux = entry_paths
1022 .abs_entries_path
1023 .join(&shared_entry)
1024 .join(VMLINUZ);
1025
1026 *initrd = vec![
1027 entry_paths
1028 .abs_entries_path
1029 .join(&shared_entry)
1030 .join(INITRD),
1031 ];
1032 }
1033
1034 _ => unreachable!(),
1035 };
1036 }
1037
1038 None => {
1039 write_bls_boot_entries_to_disk(
1040 &entry_paths.entries_path,
1041 id,
1042 usr_lib_modules_vmlinuz,
1043 &repo,
1044 )?;
1045 }
1046 };
1047
1048 (bls_config, boot_digest, os_id)
1049 }
1050 };
1051
1052 let boot_dir = Dir::open_ambient_dir(&entry_paths.config_path, ambient_authority())
1053 .with_context(|| format!("Opening {:?}", entry_paths.config_path))?;
1054 write_type1_entries(&boot_dir, &setup_type, &os_id, &bls_config)?;
1055
1056 Ok(boot_digest)
1057}
1058
1059struct UKIInfo {
1060 boot_label: String,
1061 version: Option<String>,
1062 os_id: Option<String>,
1063 boot_digest: String,
1064 composefs_digest: Option<Sha512HashValue>,
1065}
1066
1067#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1069enum UkiCandidateFormat {
1070 V1,
1072 V2,
1074 Unspecified,
1081}
1082
1083impl UkiCandidateFormat {
1084 fn accepts(self, version: FormatVersion) -> bool {
1086 match self {
1087 Self::V1 => matches!(version, FormatVersion::V0 | FormatVersion::V1),
1088 Self::V2 => version == FormatVersion::V2,
1089 Self::Unspecified => true,
1090 }
1091 }
1092}
1093
1094impl std::fmt::Display for UkiCandidateFormat {
1095 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1096 f.write_str(match self {
1097 Self::V1 => "V1",
1098 Self::V2 => "V2",
1099 Self::Unspecified => "V1 or V2",
1100 })
1101 }
1102}
1103
1104#[derive(Clone, Debug, PartialEq, Eq)]
1106struct UkiComposefsCandidate {
1107 digest: Sha512HashValue,
1108 insecure: bool,
1109 format: UkiCandidateFormat,
1110}
1111
1112fn parse_uki_composefs_candidates(cmdline: &str) -> Result<Vec<UkiComposefsCandidate>> {
1114 let mut candidates = Vec::with_capacity(2);
1115 let mut seen_legacy = false;
1116
1117 for parameter in Cmdline::from(cmdline).iter() {
1118 let key = parameter.key();
1119 let legacy = &*key == KARG_V2;
1120 if !legacy && &*key != KARG_COMPOSEFS_DIGEST {
1121 continue;
1122 }
1123
1124 let value = parameter
1125 .value()
1126 .ok_or_else(|| anyhow!("{key}= composefs kernel argument has no value"))?;
1127 if legacy {
1128 anyhow::ensure!(
1129 !seen_legacy,
1130 "duplicate {KARG_V2}= composefs kernel argument"
1131 );
1132 seen_legacy = true;
1133 }
1134
1135 let parsed =
1138 ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(&format!("{key}={value}"))?;
1139 let (digest, insecure, format) = match parsed {
1140 None => continue,
1141 Some(ComposefsBootCmdline::V1 { digest, insecure }) => {
1142 (digest, insecure, UkiCandidateFormat::V1)
1143 }
1144 Some(ComposefsBootCmdline::V2 { digest, insecure }) if legacy => {
1145 (digest, insecure, UkiCandidateFormat::Unspecified)
1146 }
1147 Some(ComposefsBootCmdline::V2 { digest, insecure }) => {
1148 (digest, insecure, UkiCandidateFormat::V2)
1149 }
1150 };
1151 candidates.push(UkiComposefsCandidate {
1152 digest,
1153 insecure,
1154 format,
1155 });
1156 }
1157 Ok(candidates)
1158}
1159
1160fn uki_candidates_policy(candidates: &[UkiComposefsCandidate]) -> Result<bool> {
1161 let first = candidates
1162 .first()
1163 .ok_or_else(|| anyhow!("No composefs digest in boot artifact cmdline"))?
1164 .insecure;
1165 anyhow::ensure!(
1166 candidates
1167 .iter()
1168 .all(|candidate| candidate.insecure == first),
1169 "Boot artifact composefs candidates have mixed fs-verity policies"
1170 );
1171 Ok(first)
1172}
1173
1174fn primary_uki_candidate(candidates: &[UkiComposefsCandidate]) -> &UkiComposefsCandidate {
1178 candidates
1179 .iter()
1180 .find(|candidate| candidate.format == UkiCandidateFormat::V1)
1181 .or_else(|| candidates.first())
1182 .expect("UKI candidates were checked as non-empty")
1183}
1184
1185#[derive(Clone, Debug, Default)]
1186pub(crate) struct ExpectedBootImageIds {
1187 v1: Vec<Sha512HashValue>,
1188 v2: Vec<Sha512HashValue>,
1189}
1190
1191impl ExpectedBootImageIds {
1192 fn ids_for_candidate(
1194 &self,
1195 candidate: &UkiComposefsCandidate,
1196 ) -> impl Iterator<Item = &Sha512HashValue> {
1197 let (v1, v2): (&[_], &[_]) = match candidate.format {
1198 UkiCandidateFormat::V1 => (&self.v1, &[]),
1199 UkiCandidateFormat::V2 => (&[], &self.v2),
1200 UkiCandidateFormat::Unspecified => (&self.v1, &self.v2),
1201 };
1202 v1.iter().chain(v2)
1203 }
1204
1205 fn contains(&self, candidate: &UkiComposefsCandidate) -> bool {
1206 self.ids_for_candidate(candidate)
1207 .any(|id| id == &candidate.digest)
1208 }
1209
1210 fn add(&mut self, version: FormatVersion, digest: Sha512HashValue) {
1211 let ids = match version {
1212 FormatVersion::V0 | FormatVersion::V1 => &mut self.v1,
1213 FormatVersion::V2 => &mut self.v2,
1214 };
1215 if !ids.contains(&digest) {
1216 ids.push(digest);
1217 }
1218 }
1219}
1220
1221fn validate_uki_candidates(
1222 candidates: &[UkiComposefsCandidate],
1223 expected: &ExpectedBootImageIds,
1224 uki_name: Option<String>,
1225) -> Result<()> {
1226 for candidate in candidates {
1227 if expected.contains(candidate) {
1228 continue;
1229 }
1230 let expected_digests = expected
1231 .ids_for_candidate(candidate)
1232 .map(Sha512HashValue::to_hex)
1233 .collect::<Vec<_>>();
1234 let expected = if expected_digests.is_empty() {
1235 format!("a {} boot image", candidate.format)
1236 } else {
1237 expected_digests.join(", ")
1238 };
1239 return Err(UKIDigestMismatch::digest_parameter(
1240 candidate.digest.to_hex(),
1241 expected,
1242 uki_name,
1243 )
1244 .into());
1245 }
1246 Ok(())
1247}
1248
1249fn pe_output_dir(
1256 pe_type: &PEType,
1257 mounted_efi: &Path,
1258 file_path: &Utf8Path,
1259 uki_id: &Sha512HashValue,
1260 is_upgrade: bool,
1261) -> std::path::PathBuf {
1262 if matches!(pe_type, PEType::GlobalUkiAddon) {
1263 if is_upgrade {
1264 return mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR);
1265 }
1266
1267 return mounted_efi.join(GLOBAL_UKI_ADDONS_DIR);
1268 }
1269
1270 let efi_linux_path = mounted_efi.join(BOOTC_UKI_DIR);
1271
1272 match file_path.parent() {
1273 Some(parent) if parent.as_str().ends_with(EFI_ADDON_DIR_EXT) => {
1274 let dir_name = get_uki_addon_dir_name(&uki_id.to_hex());
1275 let renamed_path = parent
1276 .parent()
1277 .map(|p| p.join(&dir_name))
1278 .unwrap_or(dir_name.into());
1279
1280 efi_linux_path.join(renamed_path)
1281 }
1282
1283 Some(parent) => efi_linux_path.join(parent),
1284
1285 None => efi_linux_path,
1286 }
1287}
1288
1289fn file_from_type2_entry(
1291 repo: &crate::store::ComposefsRepository,
1292 entry: &Type2Entry<Sha512HashValue>,
1293) -> Result<std::fs::File> {
1294 match &entry.file {
1295 RegularFile::Inline(..) => {
1296 anyhow::bail!("File too small to be UKI/Addon")
1298 }
1299 RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
1300 Ok(std::fs::File::from(repo.open_object(id)?))
1301 }
1302 RegularFile::Sparse(..) => {
1303 anyhow::bail!("Sparse file cannot be a UKI/Addon")
1304 }
1305 }
1306}
1307
1308#[context("Parsing UKI cmdline from {uki_name}")]
1309fn parse_uki_cmdline<R: Read + Seek>(
1312 pe_type: &PEType,
1313 missing_fsverity_allowed: bool,
1314 boot_ids: &ExpectedBootImageIds,
1315 uki_reader: &mut R,
1316 uki_name: &str,
1317 uki_info: &mut UKIInfo,
1318) -> Result<()> {
1319 if matches!(pe_type, PEType::Uki) {
1321 let osrel = uki::get_text_section_buffered(uki_reader, ".osrel")?;
1322
1323 let parsed_osrel = OsReleaseInfo::parse(&osrel);
1324
1325 uki_reader.seek(SeekFrom::Start(0))?;
1326 let boot_digest = compute_boot_digest_uki(uki_reader)?;
1327
1328 uki_reader.seek(SeekFrom::Start(0))?;
1329
1330 uki_info.boot_label =
1331 uki::get_boot_label_buffered(uki_reader).context("Getting UKI boot label")?;
1332 uki_info.version = parsed_osrel.get_version();
1333 uki_info.os_id = parsed_osrel.get_value(&["ID"]);
1334 uki_info.boot_digest = boot_digest;
1335
1336 uki_reader.seek(SeekFrom::Start(0))?;
1337 }
1338
1339 let cmdline = uki::get_cmdline_buffered(uki_reader);
1341
1342 let cmdline_str = match cmdline {
1343 Ok(ref cmdline) => cmdline,
1344 Err(uki::UkiError::MissingSection(..)) => {
1345 return Ok(());
1348 }
1349 Err(e) => {
1350 return Err(e).context("Getting UKI cmdline");
1351 }
1352 };
1353
1354 tracing::debug!("cmdline found in {pe_type:?}: {cmdline_str}");
1355
1356 let composefs_candidates = parse_uki_composefs_candidates(&cmdline_str)
1357 .context("Parsing composefs kernel arguments")?;
1358
1359 if composefs_candidates.is_empty() {
1362 return Ok(());
1363 };
1364
1365 if matches!(pe_type, PEType::GlobalUkiAddon) {
1367 anyhow::bail!("Composefs cmdline {cmdline:?} found in a Global UKI Addon");
1368 }
1369
1370 let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?;
1371 let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone();
1372
1373 if let Some(found_cmdline) = &uki_info.composefs_digest {
1376 anyhow::bail!("Already had cmdline {found_cmdline:?}, found another {composefs_digest:?}");
1377 };
1378
1379 anyhow::ensure!(
1380 !missing_verity_allowed_cmdline || missing_fsverity_allowed,
1381 "The UKI requests insecure composefs operation, but this repository requires fs-verity.
1382 Use --allow-missing-verity only when missing fs-verity is explicitly supported for this install."
1383 );
1384
1385 match missing_fsverity_allowed {
1388 true if !missing_verity_allowed_cmdline => {
1389 tracing::warn!(
1390 "--allow-missing-verity passed as option but UKI cmdline does not support it"
1391 );
1392 }
1393
1394 false if missing_verity_allowed_cmdline => {
1395 tracing::warn!("UKI cmdline has composefs set as insecure");
1396 }
1397
1398 _ => { }
1399 }
1400
1401 validate_uki_candidates(&composefs_candidates, boot_ids, Some(uki_name.into()))?;
1402
1403 uki_info.composefs_digest = Some(composefs_digest);
1404
1405 Ok(())
1406}
1407
1408#[context("Staging Global UKI Addons")]
1411fn stage_global_uki_addons(esp_fd: &Dir) -> Result<()> {
1412 if esp_fd.exists(STAGED_GLOBAL_UKI_ADDONS_DIR) {
1414 esp_fd
1415 .remove_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR)
1416 .context("Removing previously staged global addons")?;
1417 };
1418
1419 let Some(global_addons_dir) = esp_fd.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? else {
1420 return Ok(());
1422 };
1423
1424 esp_fd
1425 .create_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR)
1426 .context("Creating directory for staged Global UKI Addons")?;
1427
1428 let staged_addons_dir = esp_fd
1429 .open_dir(STAGED_GLOBAL_UKI_ADDONS_DIR)
1430 .context("Opening staged Global UKI Addons dir")?;
1431
1432 for ent in global_addons_dir
1434 .entries_utf8()
1435 .context("Reading entries in Global UKI directory")?
1436 {
1437 let entry = ent?;
1438 let entry_name = entry.file_name().context("Reading file name")?;
1439 global_addons_dir
1440 .copy(&entry_name, &staged_addons_dir, &entry_name)
1441 .with_context(|| format!("Copying addon {entry_name}"))?;
1442 }
1443
1444 Ok(())
1445}
1446
1447#[context("Writing {} to ESP", entry.file_path.display())]
1449fn write_pe_to_esp(
1450 repo: &crate::store::ComposefsRepository,
1451 entry: &Type2Entry<Sha512HashValue>,
1452 uki_id: &Sha512HashValue,
1453 mounted_efi: impl AsRef<Path>,
1454 is_upgrade: bool,
1455) -> Result<()> {
1456 let mut uki_reader = file_from_type2_entry(repo, entry)?;
1457
1458 let file_path = Utf8Path::from_path(&entry.file_path)
1459 .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?;
1460
1461 let final_pe_path = pe_output_dir(
1462 &entry.pe_type,
1463 mounted_efi.as_ref(),
1464 file_path,
1465 uki_id,
1466 is_upgrade,
1467 );
1468 create_dir_all(&final_pe_path).with_context(|| format!("Creating {final_pe_path:?}"))?;
1469
1470 let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority())
1471 .with_context(|| format!("Opening {final_pe_path:?}"))?;
1472
1473 let pe_name = match entry.pe_type {
1474 PEType::Uki => get_uki_name(&uki_id.to_hex()),
1475 PEType::UkiAddon | PEType::GlobalUkiAddon => file_path
1476 .components()
1477 .last()
1478 .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))?
1479 .to_string(),
1480 };
1481
1482 let pe_name = if matches!(entry.pe_type, PEType::GlobalUkiAddon) {
1484 get_global_uki_addon_name(&pe_name)
1485 } else if matches!(entry.pe_type, PEType::UkiAddon) {
1486 get_scoped_uki_addon_name(&pe_name)
1487 } else {
1488 pe_name
1489 };
1490
1491 uki_reader.seek(SeekFrom::Start(0))?;
1492 pe_dir
1493 .atomic_replace_with(pe_name, |writer| std::io::copy(&mut uki_reader, writer))
1494 .context("Writing UKI")?;
1495
1496 rustix::fs::fsync(
1497 pe_dir
1498 .reopen_as_ownedfd()
1499 .context("Reopening as owned fd")?,
1500 )
1501 .context("fsync")?;
1502
1503 Ok(())
1504}
1505
1506fn uki_file_name(file_path: &Path) -> Result<String> {
1507 let file_path = Utf8Path::from_path(file_path)
1508 .ok_or_else(|| anyhow!("UKI path is not valid UTF-8: {file_path:?}"))?;
1509 file_path
1510 .file_name()
1511 .map(str::to_owned)
1512 .ok_or_else(|| anyhow!("Could not get UKI file name from {file_path}"))
1513}
1514
1515pub(crate) fn image_fsverity_policy(
1519 repo: &crate::store::ComposefsRepository,
1520 entries: &[ComposefsBootEntry<Sha512HashValue>],
1521) -> Result<Option<bool>> {
1522 let mut policy = None;
1523 for entry in entries {
1524 let cmdline = match entry {
1525 ComposefsBootEntry::Type2(entry) if matches!(entry.pe_type, PEType::Uki) => {
1526 let mut reader = match &entry.file {
1527 RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
1528 std::fs::File::from(repo.open_object(id)?)
1529 }
1530 RegularFile::Inline(..) | RegularFile::Sparse(..) => {
1531 anyhow::bail!("UKI file is not a regular external object")
1532 }
1533 };
1534 uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")?
1535 }
1536 ComposefsBootEntry::Aboot(entry) => {
1537 let payload = read_aboot_payload(entry, repo)?;
1538 compute_aboot_boot_digest(entry.encoding, &payload)?.0
1539 }
1540 _ => continue,
1541 };
1542 let candidates = parse_uki_composefs_candidates(&cmdline)
1543 .context("Parsing composefs kernel arguments in boot artifact")?;
1544 let current = uki_candidates_policy(&candidates)?;
1545 if let Some(previous) = policy {
1546 anyhow::ensure!(
1547 previous == current,
1548 "Primary boot artifacts request conflicting composefs fs-verity policies"
1549 );
1550 } else {
1551 policy = Some(current);
1552 }
1553 }
1554 Ok(policy)
1555}
1556
1557struct ExpectedComposefsDigest {
1560 candidates: Vec<UkiComposefsCandidate>,
1561 uki_name: String,
1562}
1563
1564fn find_expected_composefs_digest(
1570 repo: &crate::store::ComposefsRepository,
1571 entries: &[ComposefsBootEntry<Sha512HashValue>],
1572) -> Result<Option<ExpectedComposefsDigest>> {
1573 for entry in entries {
1574 let (cmdline, uki_name) = match entry {
1575 ComposefsBootEntry::Aboot(entry) => {
1576 let payload = read_aboot_payload(entry, repo)?;
1577 (
1578 compute_aboot_boot_digest(entry.encoding, &payload)?.0,
1579 entry.payload.path.display().to_string(),
1580 )
1581 }
1582 ComposefsBootEntry::Type2(entry) if matches!(entry.pe_type, PEType::Uki) => {
1583 let mut reader = match &entry.file {
1584 RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
1585 std::fs::File::from(repo.open_object(id)?)
1586 }
1587 RegularFile::Inline(..) | RegularFile::Sparse(..) => {
1588 anyhow::bail!("UKI file is not a regular external object")
1589 }
1590 };
1591 (
1592 uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")?,
1593 uki_file_name(&entry.file_path)?,
1594 )
1595 }
1596 _ => continue,
1597 };
1598 let candidates = parse_uki_composefs_candidates(&cmdline)
1599 .context("Parsing composefs kernel arguments")?;
1600 uki_candidates_policy(&candidates)?;
1601 return Ok(Some(ExpectedComposefsDigest {
1602 candidates,
1603 uki_name,
1604 }));
1605 }
1606 Ok(None)
1607}
1608
1609#[context("Verifying composefs digest against boot artifact")]
1618pub(crate) fn ensure_correct_composefs_digest(
1619 repo: &Arc<crate::store::ComposefsRepository>,
1620 manifest_digest: &composefs_oci::OciDigest,
1621 computed_id: Sha512HashValue,
1622 expected_ids: ExpectedBootImageIds,
1623 entries: &[ComposefsBootEntry<Sha512HashValue>],
1624) -> Result<RecoveredBootImage> {
1625 let Some(expected) = find_expected_composefs_digest(repo, entries)
1626 .context("Checking boot artifact composefs digest")?
1627 else {
1628 return Ok(RecoveredBootImage {
1630 id: computed_id,
1631 expected_ids,
1632 });
1633 };
1634 reconcile_uki_candidates(&expected, expected_ids, |digest| {
1635 composefs_oci::find_matching_boot_image(repo, manifest_digest, digest)
1636 })
1637}
1638
1639fn reconcile_uki_candidates(
1643 expected: &ExpectedComposefsDigest,
1644 mut expected_ids: ExpectedBootImageIds,
1645 mut find_matching: impl FnMut(
1646 &Sha512HashValue,
1647 ) -> Result<composefs_oci::BootImageMatch<Sha512HashValue>>,
1648) -> Result<RecoveredBootImage> {
1649 for candidate in &expected.candidates {
1650 if expected_ids.contains(candidate) {
1651 continue;
1652 }
1653 tracing::debug!(
1656 "UKI {} names {} boot image {:?}, which is not among the images generated \
1657 for this pull; searching other xattr filtering modes and EROFS format versions",
1658 expected.uki_name,
1659 candidate.format,
1660 candidate.digest,
1661 );
1662 let mismatch = UKIDigestMismatch::unsupported_compatibility(
1663 candidate.digest.clone(),
1664 expected.uki_name.clone(),
1665 0,
1666 );
1667 let (version, digest) =
1668 resolve_boot_image_match(mismatch, candidate, find_matching(&candidate.digest))?;
1669 expected_ids.add(version, digest);
1670 }
1671 let selected = primary_uki_candidate(&expected.candidates);
1672 anyhow::ensure!(
1673 expected_ids.contains(selected),
1674 "Recovered boot image does not match the primary UKI composefs candidate"
1675 );
1676 Ok(RecoveredBootImage {
1677 id: selected.digest.clone(),
1678 expected_ids,
1679 })
1680}
1681
1682pub(crate) struct RecoveredBootImage {
1683 pub(crate) id: Sha512HashValue,
1684 pub(crate) expected_ids: ExpectedBootImageIds,
1685}
1686
1687fn resolve_boot_image_match(
1695 mismatch: UKIDigestMismatch,
1696 candidate: &UkiComposefsCandidate,
1697 find_matching_result: Result<composefs_oci::BootImageMatch<Sha512HashValue>>,
1698) -> Result<(FormatVersion, Sha512HashValue)> {
1699 match find_matching_result.context(
1700 "Searching for a boot image xattr filtering mode/format version matching the UKI digest",
1701 )? {
1702 composefs_oci::BootImageMatch::Found {
1703 mode,
1704 version,
1705 digest,
1706 } => {
1707 tracing::info!(
1708 "Boot image built with {mode:?} xattr filtering (EROFS {version:?}) matches \
1709 the UKI; using it"
1710 );
1711 anyhow::ensure!(
1712 candidate.format.accepts(version),
1713 "UKI composefs candidate format ({}) does not match recovered EROFS format \
1714 ({version:?}) for {:?}",
1715 candidate.format,
1716 candidate.digest,
1717 );
1718 Ok((version, digest))
1719 }
1720 composefs_oci::BootImageMatch::NotFound(tried) => match mismatch {
1721 UKIDigestMismatch::UnsupportedCompatibility {
1722 actual, uki_name, ..
1723 } => Err(UKIDigestMismatch::unsupported_compatibility(actual, uki_name, tried).into()),
1724 _ => unreachable!("compatibility search always creates an unsupported mismatch"),
1725 },
1726 }
1727}
1728
1729#[context("Writing Grub menuentry")]
1730fn write_grub_uki_menuentry(
1731 setup_type: &BootSetupType,
1732 boot_label: String,
1733 id: &Sha512HashValue,
1734 esp_device: &str,
1735) -> Result<()> {
1736 let boot_dir = setup_type.grub_boot_path()?;
1737 create_dir_all(&boot_dir).context("Failed to create boot dir")?;
1738
1739 let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
1740
1741 let efi_uuid_source = get_efi_uuid_source();
1742
1743 let user_cfg_name = if is_upgrade {
1744 USER_CFG_STAGED
1745 } else {
1746 USER_CFG
1747 };
1748
1749 let grub_dir = Dir::open_ambient_dir(boot_dir.join("grub2"), ambient_authority())
1750 .context("opening boot/grub2")?;
1751
1752 if is_upgrade {
1754 let mut str_buf = String::new();
1755 let boot_dir =
1756 Dir::open_ambient_dir(boot_dir, ambient_authority()).context("Opening boot dir")?;
1757 let entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str_buf)?;
1758
1759 grub_dir
1760 .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
1761 f.write_all(efi_uuid_source.as_bytes())?;
1762 f.write_all(
1763 MenuEntry::new(&boot_label, &id.to_hex())
1764 .to_string()
1765 .as_bytes(),
1766 )?;
1767
1768 f.write_all(entries[0].to_string().as_bytes())?;
1772
1773 Ok(())
1774 })
1775 .with_context(|| format!("Writing to {user_cfg_name}"))?;
1776
1777 rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
1778
1779 return Ok(());
1780 }
1781
1782 let esp_uuid = Task::new("blkid for ESP UUID", "blkid")
1785 .args(["-s", "UUID", "-o", "value", esp_device])
1786 .read()?;
1787
1788 grub_dir.atomic_write(
1789 EFI_UUID_FILE,
1790 format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(),
1791 )?;
1792
1793 grub_dir
1795 .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
1796 f.write_all(efi_uuid_source.as_bytes())?;
1797 f.write_all(
1798 MenuEntry::new(&boot_label, &id.to_hex())
1799 .to_string()
1800 .as_bytes(),
1801 )?;
1802
1803 Ok(())
1804 })
1805 .with_context(|| format!("Writing to {user_cfg_name}"))?;
1806
1807 rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
1808
1809 Ok(())
1810}
1811
1812fn write_type1_entries(
1816 boot_dir: &Dir,
1817 setup_type: &BootSetupType,
1818 os_id: &str,
1819 entry: &BLSConfig,
1820) -> Result<()> {
1821 let (entries_path, booted_bls) = match setup_type {
1822 BootSetupType::Setup(..) => (TYPE1_ENT_PATH, None),
1823
1824 BootSetupType::Upgrade((_, booted_cfs, ..)) => {
1825 boot_dir
1826 .remove_all_optional(TYPE1_ENT_PATH_STAGED)
1827 .with_context(|| format!("Removing {TYPE1_ENT_PATH_STAGED}"))?;
1828
1829 let mut booted_bls = get_booted_bls(boot_dir, booted_cfs)?;
1830 booted_bls.sort_key = Some(secondary_sort_key(os_id));
1831
1832 (TYPE1_ENT_PATH_STAGED, Some(booted_bls))
1833 }
1834 };
1835
1836 boot_dir
1837 .create_dir_all(entries_path)
1838 .with_context(|| format!("Creating {entries_path}"))?;
1839 let entries_dir = boot_dir.open_dir(entries_path)?;
1840
1841 entries_dir
1842 .atomic_write(
1843 type1_entry_conf_file_name(os_id, &entry.version(), FILENAME_PRIORITY_PRIMARY),
1844 entry.to_string().as_bytes(),
1845 )
1846 .context("Writing conf file")?;
1847
1848 if let Some(booted_bls) = booted_bls {
1849 entries_dir.atomic_write(
1850 type1_entry_conf_file_name(os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
1851 booted_bls.to_string().as_bytes(),
1852 )?;
1853 }
1854
1855 rustix::fs::fsync(entries_dir.reopen_as_ownedfd()?).context("fsync")?;
1856
1857 Ok(())
1858}
1859
1860#[context("Writing systemd UKI config")]
1861fn write_systemd_uki_config(
1862 esp_dir: &Dir,
1863 setup_type: &BootSetupType,
1864 boot_label: String,
1865 version: Option<String>,
1866 os_id: Option<String>,
1867 id: &Sha512HashValue,
1868 bootloader: &Bootloader,
1869) -> Result<()> {
1870 let os_id = os_id.as_deref().unwrap_or("bootc");
1871
1872 let mut bls_conf = BLSConfig::default();
1873 bls_conf
1874 .with_title(boot_label)
1875 .with_cfg(BLSConfigType::EFI {
1876 key: EFIKey::for_bootloader(
1877 format!("/{BOOTC_UKI_DIR}/{}", get_uki_name(&id.to_hex())).into(),
1878 bootloader,
1879 ),
1880 })
1881 .with_sort_key(primary_sort_key(os_id))
1882 .with_version(version.unwrap_or_else(|| id.to_hex()));
1883
1884 write_type1_entries(esp_dir, setup_type, os_id, &bls_conf)
1885}
1886
1887#[context("Setting up UKI boot")]
1888pub(crate) fn setup_composefs_uki_boot(
1889 setup_type: BootSetupType,
1890 repo: &crate::store::ComposefsRepository,
1891 id: &Sha512HashValue,
1892 boot_ids: &ExpectedBootImageIds,
1893 entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
1894) -> Result<(String, Sha512HashValue)> {
1895 let (
1896 boot_partition,
1897 bootloader,
1898 missing_fsverity_allowed,
1899 uki_addons_via_cli,
1900 installed_uki_addons,
1901 ) = match setup_type {
1902 BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => {
1903 state.require_no_kargs_for_immutable_boot()?;
1904
1905 let esp_part = root_setup.device_info.find_first_colocated_esp()?;
1910
1911 let mut addons: Vec<UkiAddonsList> = vec![];
1912
1913 let addon_opts = &state.composefs_options.uki_addon_opts;
1914
1915 for addon in addon_opts.scoped.iter().flatten() {
1916 addons.push(UkiAddonsList {
1917 name: addon.into(),
1918 addon_type: UkiAddonType::Scoped {
1919 depl_id: id.to_hex(),
1920 },
1921 staged: false,
1922 });
1923 }
1924
1925 for addon in addon_opts.global.iter().flatten() {
1926 addons.push(UkiAddonsList {
1927 name: addon.into(),
1928 addon_type: UkiAddonType::Global,
1929 staged: false,
1930 });
1931 }
1932
1933 (
1934 BootPartition::for_install(esp_part.path().into(), postfetch.detected_bootloader)?,
1935 postfetch.detected_bootloader.clone(),
1936 allow_missing_fsverity,
1937 addons,
1938 vec![],
1939 )
1940 }
1941
1942 BootSetupType::Upgrade((storage, booted_cfs, host, uki_addon_opts)) => {
1943 let bootloader = host.require_composefs_booted()?.bootloader.clone();
1944
1945 let installed_addons = list_installed_uki_addons(storage, &host.status.staged)?
1950 .into_iter()
1951 .filter(|addon| {
1952 !addon.staged
1953 && match &addon.addon_type {
1954 UkiAddonType::Scoped { depl_id } => {
1955 *depl_id == *booted_cfs.cmdline.digest
1956 }
1957 UkiAddonType::Global => true,
1958 }
1959 })
1960 .collect::<Vec<_>>();
1961
1962 let target_depl_id = id.to_hex();
1963
1964 let mut addons_via_cli: Vec<UkiAddonsList> = vec![];
1965
1966 if let Some(addons) = &uki_addon_opts {
1967 for addon in addons.global.iter().flatten() {
1968 addons_via_cli.push(UkiAddonsList {
1969 name: addon.into(),
1970 addon_type: UkiAddonType::Global,
1971 staged: false,
1972 });
1973 }
1974
1975 for addon in addons.scoped.iter().flatten() {
1976 addons_via_cli.push(UkiAddonsList {
1977 name: addon.into(),
1978 addon_type: UkiAddonType::Scoped {
1979 depl_id: target_depl_id.clone(),
1980 },
1981 staged: false,
1982 });
1983 }
1984 }
1985
1986 (
1987 storage.require_boot_partition()?.clone(),
1988 bootloader,
1989 booted_cfs.cmdline.allow_missing_fsverity,
1990 addons_via_cli,
1991 installed_addons,
1992 )
1993 }
1994 };
1995
1996 for addon in &uki_addons_via_cli {
2000 let is_global = matches!(addon.addon_type, UkiAddonType::Global);
2001
2002 let found = entries.iter().any(|entry| {
2003 let ComposefsBootEntry::Type2(entry) = entry else {
2004 return false;
2005 };
2006
2007 if matches!(entry.pe_type, PEType::Uki) {
2008 return false;
2009 }
2010
2011 let entry_is_global = matches!(entry.pe_type, PEType::GlobalUkiAddon);
2012
2013 entry_is_global == is_global
2014 && entry
2015 .file_path
2016 .file_name()
2017 .and_then(|n| n.to_str())
2018 .and_then(|n| n.strip_suffix(EFI_ADDON_FILE_EXT))
2019 == Some(addon.name.as_str())
2020 });
2021
2022 if !found {
2023 let kind = if is_global { "global" } else { "scoped" };
2024 anyhow::bail!(
2025 "Requested {kind} UKI addon '{}' was not found in the image",
2026 addon.name
2027 );
2028 }
2029 }
2030
2031 let boot_mount =
2032 mount_esp_writable(boot_partition.device().as_str()).context("Mounting boot partition")?;
2033
2034 let mut uki_info = UKIInfo {
2035 boot_label: "".into(),
2036 version: None,
2037 os_id: None,
2038 boot_digest: "".into(),
2039 composefs_digest: None,
2040 };
2041
2042 let mut entries_to_write: Vec<Type2Entry<Sha512HashValue>> = vec![];
2043
2044 for entry in entries {
2045 match entry {
2046 ComposefsBootEntry::Type1(..) => tracing::debug!("Skipping Type1 Entry"),
2047 ComposefsBootEntry::UsrLibModulesVmLinuz(..) => {
2048 tracing::debug!("Skipping vmlinuz in /usr/lib/modules")
2049 }
2050 ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"),
2051
2052 ComposefsBootEntry::Type2(entry) => {
2053 if matches!(entry.pe_type, PEType::UkiAddon | PEType::GlobalUkiAddon) {
2056 let addon_name = entry
2057 .file_path
2058 .components()
2059 .last()
2060 .ok_or_else(|| anyhow::anyhow!("Could not get UKI addon name"))?;
2061
2062 let addon_name = addon_name.as_str()?;
2063
2064 let addon_name =
2065 addon_name.strip_suffix(EFI_ADDON_FILE_EXT).ok_or_else(|| {
2066 anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_FILE_EXT}")
2067 })?;
2068
2069 match entry.pe_type {
2070 PEType::Uki => unreachable!("Outer match should've only caught UKI Addons"),
2071 PEType::UkiAddon => {
2072 let found = uki_addons_via_cli.iter().chain(&installed_uki_addons).any(
2073 |addon| {
2074 matches!(addon.addon_type, UkiAddonType::Scoped { .. })
2075 && addon.name == addon_name
2076 },
2077 );
2078
2079 if !found {
2080 tracing::info!("Not installing found UKI Addon: {addon_name}");
2081 continue;
2082 }
2083 }
2084 PEType::GlobalUkiAddon => {
2085 let found = uki_addons_via_cli.iter().chain(&installed_uki_addons).any(
2086 |addon| {
2087 matches!(addon.addon_type, UkiAddonType::Global)
2088 && addon.name == addon_name
2089 },
2090 );
2091
2092 if !found {
2093 tracing::info!(
2094 "Not installing found global UKI Addon: {addon_name}"
2095 );
2096 continue;
2097 }
2098 }
2099 }
2100 }
2101
2102 let utf8_file_path = Utf8Path::from_path(&entry.file_path)
2103 .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?;
2104
2105 let mut uki_reader = file_from_type2_entry(repo, &entry)?;
2106
2107 parse_uki_cmdline(
2108 &entry.pe_type,
2109 missing_fsverity_allowed,
2110 boot_ids,
2111 &mut uki_reader,
2112 utf8_file_path
2113 .file_name()
2114 .context("Filename not found for PE binary")?,
2115 &mut uki_info,
2116 )?;
2117
2118 entries_to_write.push(entry);
2119 }
2120 };
2121 }
2122
2123 let Some(deploy_id) = uki_info.composefs_digest else {
2124 anyhow::bail!("No composefs cmdline found in UKI or UKI Addons");
2125 };
2126
2127 if uki_info.boot_label.is_empty() {
2128 anyhow::bail!("Failed to get boot label from UKI");
2129 }
2130
2131 let UKIInfo {
2132 boot_label,
2133 version,
2134 os_id,
2135 boot_digest,
2136 ..
2137 } = uki_info;
2138
2139 let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
2140
2141 if is_upgrade {
2143 stage_global_uki_addons(&boot_mount.fd)?;
2144 }
2145
2146 for entry in entries_to_write {
2147 write_pe_to_esp(repo, &entry, &deploy_id, boot_mount.dir.path(), is_upgrade)?;
2148 }
2149
2150 match bootloader.kind()? {
2151 BootloaderKind::GRUBClassic => write_grub_uki_menuentry(
2152 &setup_type,
2153 boot_label,
2154 &deploy_id,
2155 boot_partition.device().as_str(),
2156 )?,
2157
2158 BootloaderKind::BLSCompatible => write_systemd_uki_config(
2159 &boot_mount.fd,
2160 &setup_type,
2161 boot_label,
2162 version,
2163 os_id,
2164 &deploy_id,
2165 &bootloader,
2166 )?,
2167 };
2168
2169 Ok((boot_digest, deploy_id))
2170}
2171
2172pub(crate) struct MountedImageRoot {
2185 esp_device: Utf8PathBuf,
2196 esp_mounted: Cell<bool>,
2203 _tmp: bootc_mount::tempmount::MountGuard,
2205 composefs: TempMount,
2206 pub(crate) esp_subdir: &'static str,
2207}
2208
2209impl MountedImageRoot {
2210 #[context("Preparing image root for bootloader installation")]
2214 pub(crate) fn new(
2215 composefs_mnt_fd: std::os::fd::OwnedFd,
2216 device: &bootc_blockdev::Device,
2217 ) -> Result<Self> {
2218 let roots = device.find_all_roots()?;
2219 let mut esp_part = None;
2220 for root in &roots {
2221 if let Some(esp) = root.find_partition_of_esp_optional()? {
2222 esp_part = Some(esp);
2223 break;
2224 }
2225 }
2226 let esp_part = esp_part.ok_or_else(|| anyhow!("ESP partition not found"))?;
2227
2228 let composefs = TempMount::mount_fd(composefs_mnt_fd)
2231 .context("Attaching composefs image to temporary directory")?;
2232
2233 let esp_subdir = "boot";
2239
2240 let tmp_path = composefs.dir.path().join("tmp");
2243 let tmp = bootc_mount::tempmount::MountGuard::mount(
2244 "tmpfs",
2245 tmp_path,
2246 "tmpfs",
2247 MountFlags::NOEXEC | MountFlags::NOSUID | MountFlags::NODEV,
2248 None::<&std::ffi::CStr>,
2249 )
2250 .context("Mounting tmpfs into composefs root")?;
2251
2252 Ok(Self {
2253 esp_device: esp_part.path().into(),
2254 esp_mounted: Cell::new(false),
2255 _tmp: tmp,
2256 composefs,
2257 esp_subdir,
2258 })
2259 }
2260
2261 pub(crate) fn dir(&self) -> &Dir {
2263 &self.composefs.fd
2264 }
2265
2266 pub(crate) fn root_path(&self) -> &std::path::Path {
2268 self.composefs.dir.path()
2269 }
2270
2271 pub(crate) fn open_esp_dir(&self) -> Result<Dir> {
2278 if !self.esp_mounted.get() {
2279 bail!("BUG: attempted to open the ESP directory while it is not mounted");
2280 }
2281 self.composefs
2282 .fd
2283 .open_dir(self.esp_subdir)
2284 .with_context(|| format!("Opening ESP at /{}", self.esp_subdir))
2285 }
2286
2287 pub(crate) fn with_esp<T>(&self, f: impl FnOnce(&Dir) -> Result<T>) -> Result<T> {
2294 let esp_path = self.root_path().join(self.esp_subdir);
2295 let _guard = mount_esp_at(self.esp_device.as_str(), esp_path)
2296 .context("Mounting ESP into composefs root")?;
2297
2298 self.esp_mounted.set(true);
2299 struct ResetOnDrop<'a>(&'a Cell<bool>);
2303 impl Drop for ResetOnDrop<'_> {
2304 fn drop(&mut self) {
2305 self.0.set(false);
2306 }
2307 }
2308 let _reset = ResetOnDrop(&self.esp_mounted);
2309
2310 let dir = self.open_esp_dir()?;
2311 f(&dir)
2312 }
2313}
2314
2315pub struct SecurebootKeys {
2316 pub dir: Dir,
2317 pub keys: Vec<Utf8PathBuf>,
2318}
2319
2320fn get_secureboot_keys(fs: &Dir, p: &str) -> Result<Option<SecurebootKeys>> {
2321 let mut entries = vec![];
2322
2323 let keys_dir = match fs.open_dir_optional(p)? {
2325 Some(d) => d,
2326 _ => return Ok(None),
2327 };
2328
2329 for entry in keys_dir.entries()? {
2332 let dir_e = entry?;
2333 let dirname = dir_e.file_name();
2334 if !dir_e.file_type()?.is_dir() {
2335 bail!("/{p}/{dirname:?} is not a directory");
2336 }
2337
2338 let dir_path: Utf8PathBuf = dirname.try_into()?;
2339 let dir = dir_e.open_dir()?;
2340 for entry in dir.entries()? {
2341 let e = entry?;
2342 let local: Utf8PathBuf = e.file_name().try_into()?;
2343 let path = dir_path.join(local);
2344
2345 if path.extension() != Some(AUTH_EXT) {
2346 continue;
2347 }
2348
2349 if !e.file_type()?.is_file() {
2350 bail!("/{p}/{path:?} is not a file");
2351 }
2352 entries.push(path);
2353 }
2354 }
2355 return Ok(Some(SecurebootKeys {
2356 dir: keys_dir,
2357 keys: entries,
2358 }));
2359}
2360
2361fn read_aboot_payload(
2362 entry: &AbootEntry<Sha512HashValue>,
2363 repo: &ComposefsRepository,
2364) -> Result<Box<[u8]>> {
2365 read_file(&entry.payload.file, repo).context("Reading aboot payload")
2366}
2367
2368fn compute_aboot_boot_digest(encoding: AbootEncoding, payload: &[u8]) -> Result<(String, String)> {
2369 let mut image = Cursor::new(payload);
2370 match encoding {
2371 AbootEncoding::Uki => {
2372 let cmdline = uki::get_cmdline(payload)
2373 .context("Getting UKI command line")?
2374 .to_string();
2375 let digest = compute_boot_digest_uki(&mut image)?;
2376 Ok((cmdline, digest))
2377 }
2378 AbootEncoding::AndroidV2 => {
2379 let metadata = AndroidBootImage::parse(&mut image)?;
2380 let cmdline = metadata.cmdline()?.to_string();
2381 let digest = compute_boot_digest_aboot(&mut image, &metadata)?;
2382 Ok((cmdline, digest))
2383 }
2384 }
2385}
2386
2387fn install_ukiboot(
2388 root: &Dir,
2389 source: &FileSystem<Sha512HashValue>,
2390 repo: &ComposefsRepository,
2391 esp: &Dir,
2392) -> Result<()> {
2393 let (efi_arch, fallback_name) = match std::env::consts::ARCH {
2394 "x86_64" => ("x64", "BOOTX64.EFI"),
2395 "aarch64" => ("aa64", "BOOTAA64.EFI"),
2396 arch => bail!("ukiboot is not supported on {arch}"),
2397 };
2398 let get_file = |path: &str| {
2399 source
2400 .as_dir()
2401 .split_ref(path.as_ref())
2402 .and_then(|(dir, name)| dir.get_file(name))
2403 };
2404
2405 let loader_name = format!("ukiboot{efi_arch}.efi");
2406 let files = [
2407 (
2408 loader_name.clone(),
2409 Some(fallback_name.to_string()),
2410 loader_name.clone(),
2411 ),
2412 (
2413 "ukiboot_a.efi.extra.d/slot_a.addon.efi".into(),
2414 None,
2415 "slot_a.addon.efi".into(),
2416 ),
2417 (
2418 "ukiboot_b.efi.extra.d/slot_b.addon.efi".into(),
2419 None,
2420 "slot_b.addon.efi".into(),
2421 ),
2422 ];
2423 let packaged_root_path = "usr/libexec/ukiboot/efi";
2424 let packaged_root = root
2425 .open_dir_optional(packaged_root_path)
2426 .context("Opening packaged ukiboot EFI directory")?;
2427 let mut vendor = "BOOT".to_string();
2428 let mut packaged_path = Path::new(packaged_root_path).to_path_buf();
2429 if let Some(efidir) = &packaged_root {
2430 for entry in efidir.entries_utf8()? {
2432 let entry = entry?;
2433 let vendor_name = entry.file_name()?;
2434 let vendor_path = Path::new(packaged_root_path).join(&vendor_name);
2435 if !root.is_file(vendor_path.join(&loader_name)) {
2436 continue;
2437 }
2438 vendor = vendor_name;
2439 packaged_path = vendor_path;
2440 break;
2441 }
2442 }
2443
2444 let boot_dir = format!("boot/efi/EFI/{vendor}");
2446 let use_boot_files = files.iter().all(|(ukiboot_part_path, _, _)| {
2447 !matches!(
2449 get_file(&format!("{boot_dir}/{ukiboot_part_path}")),
2450 Err(ImageError::NotFound(..))
2451 )
2452 });
2453
2454 for (vendor_path, fallback_path, packaged_name) in files {
2455 let data = if use_boot_files {
2456 let source_path = format!("{boot_dir}/{vendor_path}");
2457 let file = get_file(&source_path)
2458 .with_context(|| format!("Finding ukiboot EFI file {source_path}"))?;
2459 read_file(file, repo).context("Reading ukiboot EFI file")?
2460 } else {
2461 root.read(packaged_path.join(&packaged_name))
2462 .with_context(|| format!("Reading packaged ukiboot EFI file {packaged_name}"))?
2463 .into_boxed_slice()
2464 };
2465 for target in std::iter::once(format!("EFI/{vendor}/{vendor_path}"))
2466 .chain(fallback_path.map(|path| format!("EFI/BOOT/{path}")))
2467 {
2468 let target = Utf8Path::new(&target);
2469 if let Some(parent) = target.parent() {
2470 esp.create_dir_all(parent)?;
2471 }
2472 esp.atomic_write(target, &data)
2473 .with_context(|| format!("Writing ukiboot EFI file to ESP at {target}"))?;
2474 }
2475 }
2476 Ok(())
2477}
2478
2479pub(crate) fn validate_bootloader(
2480 entry: &ComposefsBootEntry<Sha512HashValue>,
2481 bootloader: Bootloader,
2482) -> Result<()> {
2483 if let ComposefsBootEntry::Aboot(entry) = entry {
2484 match (entry.encoding, bootloader) {
2485 (AbootEncoding::AndroidV2, Bootloader::None) => {}
2486 (AbootEncoding::Uki, Bootloader::Ukiboot) => {}
2487 (encoding, bootloader) => {
2488 bail!("aboot encoding {encoding:?} cannot be used with bootloader {bootloader}")
2489 }
2490 }
2491 } else {
2492 ensure!(
2493 bootloader != Bootloader::None,
2494 "Bootloader set to none is not supported with the composefs backend unless using an aboot artifact"
2495 );
2496 ensure!(
2497 bootloader != Bootloader::Ukiboot,
2498 "Bootloader ukiboot requires an aboot artifact with the composefs backend"
2499 );
2500 }
2501 Ok(())
2502}
2503
2504pub(crate) fn primary_boot_entry(
2509 entries: &[ComposefsBootEntry<Sha512HashValue>],
2510) -> Result<&ComposefsBootEntry<Sha512HashValue>> {
2511 entries.iter().find(|entry| !matches!(entry, ComposefsBootEntry::Type2(e) if !matches!(e.pe_type, PEType::Uki)))
2512 .ok_or_else(|| anyhow!("No primary boot artifact"))
2513}
2514
2515fn image_bootloader(entry: &ComposefsBootEntry<Sha512HashValue>) -> Option<Bootloader> {
2516 match entry {
2517 ComposefsBootEntry::Aboot(entry) => Some(match entry.encoding {
2518 AbootEncoding::AndroidV2 => Bootloader::None,
2519 AbootEncoding::Uki => Bootloader::Ukiboot,
2520 }),
2521 _ => None,
2522 }
2523}
2524
2525fn validate_composefs_aboot_payload(
2526 id: &Sha512HashValue,
2527 boot_ids: &ExpectedBootImageIds,
2528 encoding: AbootEncoding,
2529 payload: &[u8],
2530 allow_missing_fsverity: bool,
2531) -> Result<String> {
2532 let (cmdline, boot_digest) = compute_aboot_boot_digest(encoding, payload)?;
2533 validate_aboot_composefs_candidates(id, boot_ids, &cmdline, allow_missing_fsverity)?;
2534 Ok(boot_digest)
2535}
2536
2537fn validate_aboot_composefs_candidates(
2538 id: &Sha512HashValue,
2539 boot_ids: &ExpectedBootImageIds,
2540 cmdline: &str,
2541 allow_missing_fsverity: bool,
2542) -> Result<()> {
2543 let candidates = parse_uki_composefs_candidates(cmdline)
2544 .context("Parsing composefs kernel arguments in aboot image")?;
2545 let insecure = uki_candidates_policy(&candidates)?;
2546 validate_uki_candidates(&candidates, boot_ids, None)?;
2547 ensure!(
2548 primary_uki_candidate(&candidates).digest == *id,
2549 "aboot primary composefs digest does not match the selected boot image"
2550 );
2551 ensure!(
2552 !insecure || allow_missing_fsverity,
2553 "The aboot image requests insecure composefs operation, but this repository requires fs-verity"
2554 );
2555 match (allow_missing_fsverity, insecure) {
2556 (true, false) => tracing::warn!(
2557 "--allow-missing-fsverity was requested but the aboot image requires fs-verity"
2558 ),
2559 _ => {}
2560 }
2561
2562 Ok(())
2563}
2564
2565#[cfg(feature = "install-to-disk")]
2566fn aboot_partition_path(
2567 disk: &bootc_blockdev::Device,
2568 label: &str,
2569 payload_size: usize,
2570) -> Result<String> {
2571 let mut partitions = disk
2572 .children
2573 .iter()
2574 .flatten()
2575 .filter(|part| part.partlabel.as_deref() == Some(label));
2576 let partition = partitions
2577 .next()
2578 .ok_or_else(|| anyhow::anyhow!("Missing {label} partition"))?;
2579 ensure!(
2580 partitions.next().is_none(),
2581 "Multiple {label} partitions found"
2582 );
2583 ensure!(
2584 payload_size as u64 <= partition.size,
2585 "{label} image is larger than its partition"
2586 );
2587 Ok(partition.path())
2588}
2589
2590#[cfg(feature = "install-to-disk")]
2591fn write_aboot_partition(path: &str, label: &str, payload: &[u8]) -> Result<()> {
2592 let mut target = std::fs::OpenOptions::new()
2593 .write(true)
2594 .open(&path)
2595 .with_context(|| format!("Opening {label} partition at {path}"))?;
2596 target
2597 .write_all(payload)
2598 .with_context(|| format!("Writing {label} partition at {path}"))?;
2599 target
2600 .sync_all()
2601 .with_context(|| format!("Syncing {path}"))?;
2602 Ok(())
2603}
2604
2605#[cfg(feature = "install-to-disk")]
2606fn aboot_disk_partition_payloads(
2607 layout: AbootDiskLayout,
2608 artifacts: &PreparedAbootArtifacts,
2609) -> Result<Vec<(&'static str, &[u8])>> {
2610 let partitions = match layout {
2611 AbootDiskLayout::Android => vec![
2612 ("boot_b", artifacts.payload.as_ref()),
2613 ("vbmeta_b", artifacts.vbmeta.as_deref().unwrap_or(&[0])),
2614 ("boot_a", artifacts.payload.as_ref()),
2615 ("vbmeta_a", artifacts.vbmeta.as_deref().unwrap_or(&[0])),
2616 ],
2617 AbootDiskLayout::Ukiboot => {
2618 ensure!(
2619 artifacts.vbmeta.is_none(),
2620 "ukiboot layout has no vbmeta partition"
2621 );
2622 vec![
2623 ("ukiboot_b", artifacts.payload.as_ref()),
2624 ("ukiboot_a", artifacts.payload.as_ref()),
2625 ]
2626 }
2627 };
2628 Ok(partitions)
2629}
2630
2631#[cfg(feature = "install-to-disk")]
2632fn install_aboot_disk_payload(
2633 root_setup: &RootSetup,
2634 layout: AbootDiskLayout,
2635 artifacts: &PreparedAbootArtifacts,
2636) -> Result<()> {
2637 let disk = &root_setup.device_info;
2638 let partitions = aboot_disk_partition_payloads(layout, artifacts)?;
2639 let targets = partitions
2640 .iter()
2641 .map(|(label, payload)| aboot_partition_path(disk, label, payload.len()))
2642 .collect::<Result<Vec<_>>>()?;
2643 for ((label, payload), path) in partitions.into_iter().zip(targets) {
2644 write_aboot_partition(&path, label, payload)?;
2645 }
2646 Ok(())
2647}
2648
2649pub(crate) struct PreparedAbootArtifacts {
2650 pub(crate) boot_digest: String,
2651 pub(crate) payload: Box<[u8]>,
2652 pub(crate) vbmeta: Option<Box<[u8]>>,
2653}
2654
2655pub(crate) fn prepare_composefs_aboot_artifacts(
2656 repo: &ComposefsRepository,
2657 id: &Sha512HashValue,
2658 boot_ids: &ExpectedBootImageIds,
2659 entry: &ComposefsBootEntry<Sha512HashValue>,
2660 allow_missing_fsverity: bool,
2661) -> Result<PreparedAbootArtifacts> {
2662 let ComposefsBootEntry::Aboot(entry) = entry else {
2663 bail!("Expected an aboot boot entry")
2664 };
2665 let payload = read_aboot_payload(entry, repo)?;
2666 let boot_digest = validate_composefs_aboot_payload(
2667 id,
2668 boot_ids,
2669 entry.encoding,
2670 &payload,
2671 allow_missing_fsverity,
2672 )?;
2673 let vbmeta = entry
2674 .vbmeta
2675 .as_ref()
2676 .map(|artifact| read_file(&artifact.file, repo).context("Reading vbmeta payload"))
2677 .transpose()?;
2678 Ok(PreparedAbootArtifacts {
2679 boot_digest,
2680 payload,
2681 vbmeta,
2682 })
2683}
2684
2685fn install_composefs_bootloader(
2686 root_setup: &RootSetup,
2687 state: &State,
2688 bootloader: Bootloader,
2689 composefs_mnt_fd: std::os::fd::OwnedFd,
2690 repo: &ComposefsRepository,
2691 fs: &FileSystem<Sha512HashValue>,
2692) -> Result<()> {
2693 if bootloader == Bootloader::None {
2694 return Ok(());
2695 }
2696 let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?;
2697
2698 if bootloader == Bootloader::Ukiboot {
2699 return mounted_root.with_esp(|esp| install_ukiboot(mounted_root.dir(), fs, repo, esp));
2700 }
2701 let boot_uuid = root_setup
2702 .get_boot_uuid()?
2703 .or(root_setup.rootfs_uuid.as_deref())
2704 .ok_or_else(|| anyhow!("No uuid for boot/root"))?;
2705
2706 if cfg!(target_arch = "s390x") {
2707 crate::bootloader::install_via_zipl(
2709 &root_setup.device_info.require_single_root()?,
2710 boot_uuid,
2711 )?;
2712 } else if matches!(bootloader, Bootloader::Grub | Bootloader::GrubCC) {
2713 let chroot_target = Utf8Path::from_path(mounted_root.root_path())
2714 .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?;
2715 let bind_boot_path = root_setup.physical_root_path.join(BOOT);
2723 crate::bootloader::install_via_bootupd(
2724 &root_setup.device_info,
2725 &root_setup.physical_root_path,
2726 &state.config_opts,
2727 Some(chroot_target),
2728 Some(bind_boot_path.as_path()),
2729 )?;
2730
2731 if matches!(bootloader, Bootloader::GrubCC) {
2733 root_setup
2736 .physical_root
2737 .remove_all_optional("boot/grub2")
2738 .context("removing grub2")?;
2739
2740 mounted_root.with_esp(|esp_dir| {
2743 let (os_id, ..) = parse_os_release(mounted_root.dir())?
2744 .ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
2745
2746 let dir = format!("EFI/{os_id}");
2747
2748 let efis_dir = esp_dir
2750 .open_dir(&dir)
2751 .with_context(|| format!("Opening {dir}"))?;
2752
2753 efis_dir
2754 .remove_file_optional("bootuuid.cfg")
2755 .context("Removing bootuuid.cfg")?;
2756 efis_dir
2757 .remove_file_optional("grub.cfg")
2758 .context("Removing grub.cfg")?;
2759
2760 let final_name = match std::env::consts::ARCH {
2761 "x86_64" => "grubx64.efi",
2762 "aarch64" => "grubaa64-cc.efi",
2763 arch => anyhow::bail!("GrubCC not supported for: {arch}"),
2764 };
2765
2766 mounted_root
2767 .dir()
2768 .copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
2769 .context("Copying grub-cc binary")?;
2770
2771 Ok(())
2772 })?;
2773 }
2774 } else {
2775 mounted_root.with_esp(|_esp_dir| {
2776 crate::bootloader::install_systemd_boot(
2777 &mounted_root,
2778 &state.config_opts,
2779 get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
2780 )
2781 })?;
2782 }
2783 Ok(())
2784}
2785
2786fn validate_aboot_disk_layout(
2787 encoding: Option<AbootEncoding>,
2788 layout: Option<AbootDiskLayout>,
2789) -> Result<()> {
2790 match (encoding, layout) {
2791 (Some(encoding), Some(layout)) => {
2792 let expected = match encoding {
2793 AbootEncoding::AndroidV2 => AbootDiskLayout::Android,
2794 AbootEncoding::Uki => AbootDiskLayout::Ukiboot,
2795 };
2796 ensure!(
2797 layout == expected,
2798 "Selected aboot disk layout {layout:?} does not match pulled artifact encoding {encoding:?}"
2799 );
2800 }
2801 (Some(_), None) => bail!(
2802 "Pulled image contains an aboot artifact but no aboot disk layout was selected; run to-disk from a container with matching boot artifacts"
2803 ),
2804 (None, Some(layout)) => {
2805 bail!("Selected aboot disk layout {layout:?} but pulled image has no aboot artifact")
2806 }
2807 (None, None) => {}
2808 }
2809 Ok(())
2810}
2811
2812#[context("Setting up composefs boot")]
2813pub(crate) async fn setup_composefs_boot(
2814 root_setup: &RootSetup,
2815 state: &State,
2816 pull_result: &composefs_oci::PullResult<Sha512HashValue>,
2817 allow_missing_fsverity: bool,
2818) -> Result<()> {
2819 const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5";
2820
2821 tracing::info!(
2822 message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID,
2823 bootc.operation = "boot_setup",
2824 bootc.config_digest = %pull_result.config_digest,
2825 bootc.allow_missing_fsverity = allow_missing_fsverity,
2826 "Setting up composefs boot",
2827 );
2828
2829 let mut repo = open_composefs_repo(&root_setup.physical_root)?;
2830 if allow_missing_fsverity {
2831 repo.set_insecure()?;
2832 }
2833
2834 let repo = Arc::new(repo);
2835
2836 let crate::bootc_composefs::repo::BootImage {
2837 id,
2838 boot_ids,
2839 fs,
2840 entries,
2841 } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?;
2842 let entry = primary_boot_entry(&entries)?;
2843 let boot_type = BootType::from(entry);
2844
2845 if root_setup.disk_install {
2846 let encoding = match entry {
2847 ComposefsBootEntry::Aboot(aboot) => Some(aboot.encoding),
2848 _ => None,
2849 };
2850 validate_aboot_disk_layout(encoding, root_setup.aboot_disk_layout)?;
2851 }
2852
2853 let composefs_mnt_fd = repo
2854 .mount(&id.to_hex())
2855 .context("Failed to mount composefs image")?;
2856 let image_root = Dir::reopen_dir(&composefs_mnt_fd)?;
2857
2858 if boot_type == BootType::Aboot {
2859 super::aboot::require_aboot_deploy_in_image(&image_root)?;
2860 }
2861
2862 let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?;
2863
2864 validate_bootloader(entry, postfetch.detected_bootloader)?;
2865 if matches!(boot_type, BootType::Uki | BootType::Aboot) {
2866 state.require_no_kargs_for_immutable_boot()?;
2867 }
2868
2869 install_composefs_bootloader(
2870 root_setup,
2871 state,
2872 postfetch.detected_bootloader,
2873 composefs_mnt_fd,
2874 &repo,
2875 &fs,
2876 )?;
2877
2878 let repo = Arc::try_unwrap(repo).map_err(|_| {
2879 anyhow::anyhow!(
2880 "BUG: Arc<Repository> still has other references after boot image generation"
2881 )
2882 })?;
2883
2884 let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version());
2885 let (boot_digest, deploy_id) = match boot_type {
2886 BootType::Bls => (
2887 setup_composefs_bls_boot(
2888 BootSetupType::Setup((&root_setup, &state, &postfetch, allow_missing_fsverity)),
2889 &repo,
2890 &provisional_deploy_id,
2891 provisional_format,
2892 entry,
2893 &image_root,
2894 )?,
2895 provisional_deploy_id,
2896 ),
2897 BootType::Uki => print_uki_dumpfile_diff_on_mismatch(
2898 setup_composefs_uki_boot(
2899 BootSetupType::Setup((&root_setup, &state, &postfetch, allow_missing_fsverity)),
2900 &repo,
2901 &provisional_deploy_id,
2902 &boot_ids,
2903 entries,
2904 ),
2905 &repo,
2906 &fs,
2907 )?,
2908 BootType::Aboot => {
2909 let artifacts = prepare_composefs_aboot_artifacts(
2910 &repo,
2911 &id,
2912 &boot_ids,
2913 entry,
2914 allow_missing_fsverity,
2915 )?;
2916 #[cfg(feature = "install-to-disk")]
2917 if let Some(layout) = root_setup.aboot_disk_layout {
2918 install_aboot_disk_payload(root_setup, layout, &artifacts)?;
2919 }
2920 (artifacts.boot_digest, provisional_deploy_id)
2921 }
2922 };
2923
2924 write_composefs_state(
2925 &root_setup.physical_root_path,
2926 &deploy_id,
2927 &crate::spec::ImageReference::from(state.target_imgref.clone()),
2928 true,
2929 boot_type,
2930 boot_digest,
2931 &pull_result.manifest_digest.to_string(),
2932 allow_missing_fsverity,
2933 )
2934 .await?;
2935
2936 if let Some(contents) = state.root_ssh_authorized_keys.as_deref() {
2937 let deployment = root_setup
2938 .physical_root
2939 .open_dir(format!("{STATE_DIR_RELATIVE}/{}", deploy_id.to_hex()))
2940 .context("Opening deployment state")?;
2941 crate::install::osconfig::inject_root_ssh_authorized_keys(
2942 &image_root,
2943 &deployment,
2944 state.load_policy()?.as_ref(),
2945 contents,
2946 )?;
2947 }
2948
2949 Ok(())
2950}
2951
2952pub(crate) fn expected_boot_image_ids(
2957 v1: Option<Sha512HashValue>,
2958 v2: Option<Sha512HashValue>,
2959 selected: &Sha512HashValue,
2960 selected_format: FormatVersion,
2961) -> ExpectedBootImageIds {
2962 let mut ids = ExpectedBootImageIds::default();
2963 if let Some(v1) = v1 {
2964 ids.add(FormatVersion::V1, v1);
2965 }
2966 if let Some(v2) = v2 {
2967 ids.add(FormatVersion::V2, v2);
2968 }
2969 ids.add(selected_format, selected.clone());
2970 ids
2971}
2972
2973#[cfg(test)]
2974mod tests {
2975 use super::*;
2976 use composefs::erofs::format::FormatVersion;
2977 use composefs_boot::bootloader::{AbootArtifact, AbootEncoding, AbootEntry, Type2Entry};
2978
2979 #[test]
2980 fn aboot_disk_layout_matches_pulled_artifact() {
2981 assert!(validate_aboot_disk_layout(None, None).is_ok());
2982 assert!(
2983 validate_aboot_disk_layout(
2984 Some(AbootEncoding::AndroidV2),
2985 Some(AbootDiskLayout::Android)
2986 )
2987 .is_ok()
2988 );
2989 assert!(
2990 validate_aboot_disk_layout(Some(AbootEncoding::Uki), Some(AbootDiskLayout::Ukiboot))
2991 .is_ok()
2992 );
2993 assert!(
2994 validate_aboot_disk_layout(Some(AbootEncoding::Uki), Some(AbootDiskLayout::Android))
2995 .is_err()
2996 );
2997 assert!(validate_aboot_disk_layout(Some(AbootEncoding::Uki), None).is_err());
2998 assert!(validate_aboot_disk_layout(None, Some(AbootDiskLayout::Ukiboot)).is_err());
2999 }
3000
3001 #[cfg(feature = "install-to-disk")]
3002 #[test]
3003 fn aboot_initial_partition_payloads() -> Result<()> {
3004 let mut artifacts = PreparedAbootArtifacts {
3005 boot_digest: String::new(),
3006 payload: Box::from(&b"boot"[..]),
3007 vbmeta: Some(Box::from(&b"vbmeta"[..])),
3008 };
3009 let android = aboot_disk_partition_payloads(AbootDiskLayout::Android, &artifacts)?;
3010 assert_eq!(
3011 android
3012 .iter()
3013 .map(|(label, payload)| (*label, *payload))
3014 .collect::<Vec<_>>(),
3015 [
3016 ("boot_b", &b"boot"[..]),
3017 ("vbmeta_b", &b"vbmeta"[..]),
3018 ("boot_a", &b"boot"[..]),
3019 ("vbmeta_a", &b"vbmeta"[..]),
3020 ]
3021 );
3022 assert!(aboot_disk_partition_payloads(AbootDiskLayout::Ukiboot, &artifacts).is_err());
3023
3024 artifacts.vbmeta = None;
3025 let android = aboot_disk_partition_payloads(AbootDiskLayout::Android, &artifacts)?;
3026 assert_eq!(android[1].1, &[0]);
3027 assert_eq!(android[3].1, &[0]);
3028 let ukiboot = aboot_disk_partition_payloads(AbootDiskLayout::Ukiboot, &artifacts)?;
3029 assert_eq!(
3030 ukiboot
3031 .iter()
3032 .map(|(label, payload)| (*label, *payload))
3033 .collect::<Vec<_>>(),
3034 [("ukiboot_b", &b"boot"[..]), ("ukiboot_a", &b"boot"[..])]
3035 );
3036 Ok(())
3037 }
3038
3039 #[test]
3040 fn test_grub_bls_abs_entries_path() -> Result<()> {
3041 let td = tempfile::tempdir()?;
3042 let td = Utf8Path::from_path(td.path()).unwrap();
3043 let boot = td.join(BOOT);
3044 std::fs::create_dir(&boot)?;
3045 assert_eq!(grub_bls_abs_entries_path(&boot)?, "/boot");
3047 assert_eq!(grub_bls_abs_entries_path(Utf8Path::new("/proc"))?, "/");
3049 assert!(grub_bls_abs_entries_path(Utf8Path::new("/")).is_err());
3051 assert!(grub_bls_abs_entries_path(&td.join("nonexistent/boot")).is_err());
3052 Ok(())
3053 }
3054
3055 #[test]
3056 fn test_replace_composefs_karg() {
3057 let mut cmdline =
3058 Cmdline::from("root=UUID=abc composefs=old composefs.digest=v1-sha512-12:stale");
3059 replace_composefs_karg(
3060 &mut cmdline,
3061 "composefs.digest=v1-sha512-12:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3062 )
3063 .unwrap();
3064 let rendered = cmdline.to_string();
3065 assert!(!rendered.contains("composefs=old"));
3066 assert!(!rendered.contains(":stale"));
3067 assert!(rendered.contains("root=UUID=abc"));
3068 }
3069
3070 #[tokio::test]
3071 async fn test_install_ukiboot() -> Result<()> {
3072 use composefs::fs::read_filesystem;
3073 use composefs::repository::RepositoryConfig;
3074
3075 let temp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
3076 let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure();
3077 let (repo, _) = ComposefsRepository::init_path(&*temp, "repo", config)?;
3078 let repo = Arc::new(repo);
3079
3080 let (loader, fallback) = match std::env::consts::ARCH {
3081 "x86_64" => ("ukibootx64.efi", "BOOTX64.EFI"),
3082 "aarch64" => ("ukibootaa64.efi", "BOOTAA64.EFI"),
3083 _ => return Ok(()),
3084 };
3085 let files = [
3086 (loader, loader),
3087 ("slot_a.addon.efi", "ukiboot_a.efi.extra.d/slot_a.addon.efi"),
3088 ("slot_b.addon.efi", "ukiboot_b.efi.extra.d/slot_b.addon.efi"),
3089 ];
3090 let all_signed = (1 << files.len()) - 1;
3091 for (vendor, os_id, layout) in [
3092 ("redhat", Some("rhel"), "symlink"),
3093 ("centos", Some("autosd"), "symlink"),
3094 ("custom", None, "directory"),
3095 ("custom", None, "nested-symlink"),
3096 ("custom", None, "parent-symlink"),
3097 ("BOOT", None, "symlink"),
3098 ("Linux", None, "directory"),
3099 ("BOOT", None, "flat"),
3100 ] {
3101 for signed_mask in 0..=all_signed {
3102 let source = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
3103 let esp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
3104 let packaged_root = "usr/libexec/ukiboot/efi";
3105 let packaged_path = match layout {
3106 "directory" => format!("{packaged_root}/{vendor}"),
3107 "nested-symlink" => format!("{packaged_root}/nested/files"),
3108 "parent-symlink" => "usr/libexec/ukiboot/files".to_string(),
3109 _ => packaged_root.to_string(),
3110 };
3111 source.create_dir_all(packaged_root)?;
3112 source.create_dir_all(&packaged_path)?;
3113 match layout {
3114 "symlink" => source.symlink(".", format!("{packaged_root}/{vendor}"))?,
3115 "nested-symlink" => {
3116 source.symlink("nested/files", format!("{packaged_root}/{vendor}"))?
3117 }
3118 "parent-symlink" => {
3119 source.symlink("../files", format!("{packaged_root}/{vendor}"))?
3120 }
3121 _ => {}
3122 }
3123 source.symlink("missing", format!("{packaged_root}/broken"))?;
3125 source.write(format!("{packaged_root}/a-file"), "not a directory")?;
3126 source.create_dir_all(format!("{packaged_root}/empty/{loader}"))?;
3127 if let Some(os_id) = os_id {
3128 source.create_dir_all("usr/lib")?;
3129 source.write("usr/lib/os-release", format!("ID={os_id}\n"))?;
3130 }
3131 for (index, (name, vendor_path)) in files.iter().enumerate() {
3132 source.write(
3133 format!("{packaged_path}/{name}"),
3134 format!("unsigned-{name}").repeat(if index == 0 { 512 } else { 1 }),
3135 )?;
3136 let unrelated = format!("boot/efi/EFI/aaa/{vendor_path}");
3138 source.create_dir_all(Utf8Path::new(&unrelated).parent().unwrap())?;
3139 source.write(unrelated, "other-vendor")?;
3140 if signed_mask & (1 << index) != 0 {
3141 let path = format!("boot/efi/EFI/{vendor}/{vendor_path}");
3142 source.create_dir_all(Utf8Path::new(&path).parent().unwrap())?;
3143 source.write(
3144 path,
3145 format!("signed-{name}").repeat(if index == 0 { 512 } else { 1 }),
3146 )?;
3147 }
3148 }
3149 let fs =
3150 read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone()))
3151 .await?;
3152 install_ukiboot(&source, &fs, &repo, &esp)?;
3153 for (index, (name, vendor_path)) in files.iter().enumerate() {
3154 let expected = if signed_mask == all_signed {
3155 format!("signed-{name}")
3156 } else {
3157 format!("unsigned-{name}")
3158 }
3159 .repeat(if index == 0 { 512 } else { 1 });
3160 assert_eq!(
3161 esp.read_to_string(format!("EFI/{vendor}/{vendor_path}"))?,
3162 expected,
3163 "vendor={vendor}, layout={layout}, signed_mask={signed_mask}"
3164 );
3165 if index == 0 {
3166 assert_eq!(
3167 esp.read_to_string(format!("EFI/BOOT/{fallback}"))?,
3168 expected
3169 );
3170 } else if vendor != "BOOT" {
3171 assert!(!esp.exists(format!("EFI/BOOT/{vendor_path}")));
3172 }
3173 }
3174 assert!(!esp.exists("EFI/aaa"));
3175
3176 let preferred = format!("boot/efi/EFI/{vendor}/{loader}");
3177 source.remove_file_optional(&preferred)?;
3178 if signed_mask == all_signed {
3179 source.create_dir_all(&preferred)?;
3180 let fs = read_filesystem(
3181 source.reopen_as_ownedfd()?,
3182 ".".into(),
3183 Some(repo.clone()),
3184 )
3185 .await?;
3186 assert!(install_ukiboot(&source, &fs, &repo, &esp).is_err());
3187 source.remove_dir(&preferred)?;
3188 }
3189 source.remove_file(format!("{packaged_path}/slot_b.addon.efi"))?;
3191 let fs =
3192 read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone()))
3193 .await?;
3194 let error = install_ukiboot(&source, &fs, &repo, &esp).unwrap_err();
3195 assert!(format!("{error:#}").contains("slot_b.addon.efi"));
3196 }
3197 }
3198 Ok(())
3199 }
3200
3201 fn fake_aboot_entry() -> ComposefsBootEntry<Sha512HashValue> {
3202 ComposefsBootEntry::Aboot(AbootEntry {
3203 kver: "1.0".into(),
3204 encoding: AbootEncoding::AndroidV2,
3205 payload: AbootArtifact {
3206 path: "/boot/aboot-1.0.img".into(),
3207 file: RegularFile::External(Sha512HashValue::EMPTY, 4096),
3208 },
3209 vbmeta: None,
3210 })
3211 }
3212
3213 #[test]
3214 fn test_validate_bootloader() {
3215 let bls = ComposefsBootEntry::UsrLibModulesVmLinuz(UsrLibModulesVmlinuz {
3216 kver: "1.0".into(),
3217 vmlinuz: RegularFile::Inline(Default::default()),
3218 initramfs: Some(RegularFile::Inline(Default::default())),
3219 os_release: None,
3220 });
3221 let android = fake_aboot_entry();
3222 let mut ukiboot = fake_aboot_entry();
3223 if let ComposefsBootEntry::Aboot(entry) = &mut ukiboot {
3224 entry.encoding = AbootEncoding::Uki;
3225 }
3226 let uki = ComposefsBootEntry::Type2(Type2Entry {
3227 kver: None,
3228 file_path: "test.efi".into(),
3229 file: RegularFile::Inline(Default::default()),
3230 pe_type: PEType::Uki,
3231 });
3232 assert_eq!(image_bootloader(&android), Some(Bootloader::None));
3233 assert_eq!(image_bootloader(&ukiboot), Some(Bootloader::Ukiboot));
3234 assert_eq!(image_bootloader(&uki), None);
3235 assert_eq!(image_bootloader(&bls), None);
3236 for bootloader in [
3237 Bootloader::None,
3238 Bootloader::Ukiboot,
3239 Bootloader::Grub,
3240 Bootloader::GrubCC,
3241 Bootloader::Systemd,
3242 ] {
3243 for (entry, expected) in [
3244 (&android, bootloader == Bootloader::None),
3245 (&ukiboot, bootloader == Bootloader::Ukiboot),
3246 (
3247 &uki,
3248 !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot),
3249 ),
3250 (
3251 &bls,
3252 !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot),
3253 ),
3254 ] {
3255 assert_eq!(
3256 validate_bootloader(entry, bootloader).is_ok(),
3257 expected,
3258 "{entry:?}, {bootloader:?}"
3259 );
3260 }
3261 }
3262 }
3263
3264 #[test]
3265 fn test_pe_output_dir() {
3266 let mounted_efi = Path::new("/esp");
3267 let uki_id = Sha512HashValue::EMPTY;
3268 let uki_hex = uki_id.to_hex();
3269
3270 assert_eq!(
3275 pe_output_dir(
3276 &PEType::Uki,
3277 mounted_efi,
3278 Utf8Path::new("foo.efi"),
3279 &uki_id,
3280 false
3281 ),
3282 mounted_efi.join(BOOTC_UKI_DIR)
3283 );
3284
3285 assert_eq!(
3288 pe_output_dir(
3289 &PEType::UkiAddon,
3290 mounted_efi,
3291 Utf8Path::new("foo.efi.extra.d/bar.addon.efi"),
3292 &uki_id,
3293 false,
3294 ),
3295 mounted_efi
3296 .join(BOOTC_UKI_DIR)
3297 .join(get_uki_addon_dir_name(&uki_hex))
3298 );
3299
3300 assert_eq!(
3303 pe_output_dir(
3304 &PEType::GlobalUkiAddon,
3305 mounted_efi,
3306 Utf8Path::new("bar.addon.efi"),
3307 &uki_id,
3308 false
3309 ),
3310 mounted_efi.join(GLOBAL_UKI_ADDONS_DIR)
3311 );
3312
3313 assert_eq!(
3315 pe_output_dir(
3316 &PEType::GlobalUkiAddon,
3317 mounted_efi,
3318 Utf8Path::new("bar.addon.efi"),
3319 &uki_id,
3320 true
3321 ),
3322 mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR)
3323 );
3324 }
3325
3326 #[test]
3327 fn test_type1_filename_generation() {
3328 let filename =
3330 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3331 assert_eq!(filename, "bootc_fedora-41.20251125.0-1.conf");
3332
3333 let primary =
3335 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3336 let secondary =
3337 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
3338 assert_eq!(primary, "bootc_fedora-41.20251125.0-1.conf");
3339 assert_eq!(secondary, "bootc_fedora-41.20251125.0-0.conf");
3340
3341 let filename =
3343 type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3344 assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
3345
3346 let filename =
3348 type1_entry_conf_file_name("my-custom-os", "1.0.0", FILENAME_PRIORITY_PRIMARY);
3349 assert_eq!(filename, "bootc_my_custom_os-1.0.0-1.conf");
3350
3351 let filename = type1_entry_conf_file_name("rhel", "9.3.0", FILENAME_PRIORITY_SECONDARY);
3353 assert_eq!(filename, "bootc_rhel-9.3.0-0.conf");
3354 }
3355
3356 #[test]
3357 fn test_grub_filename_parsing() {
3358 let filename = type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", "1");
3367 assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
3368
3369 let without_ext = filename.strip_suffix(".conf").unwrap();
3375 let parts: Vec<&str> = without_ext.rsplitn(3, '-').collect();
3376 assert_eq!(parts.len(), 3);
3377 assert_eq!(parts[0], "1"); assert_eq!(parts[1], "41.20251125.0"); assert_eq!(parts[2], "bootc_fedora_coreos"); }
3381
3382 #[test]
3383 fn test_os_id_from_sort_key() {
3384 for (sort_key, expected) in [
3385 (primary_sort_key("fedora"), Some("fedora")),
3386 (secondary_sort_key("fedora"), Some("fedora")),
3387 (primary_sort_key("fedora-coreos"), Some("fedora-coreos")),
3388 (secondary_sort_key("bootc"), Some("bootc")),
3389 ("1".to_owned(), None),
3390 ("bootc".to_owned(), None),
3391 ("other-fedora-0".to_owned(), None),
3392 ] {
3393 assert_eq!(os_id_from_sort_key(&sort_key), expected, "{sort_key}");
3394 }
3395 }
3396
3397 #[test]
3398 fn test_sort_keys() {
3399 let primary = primary_sort_key("fedora");
3401 let secondary = secondary_sort_key("fedora");
3402
3403 assert_eq!(primary, "bootc-fedora-0");
3404 assert_eq!(secondary, "bootc-fedora-1");
3405
3406 assert!(primary < secondary);
3408
3409 let primary_coreos = primary_sort_key("fedora-coreos");
3411 assert_eq!(primary_coreos, "bootc-fedora-coreos-0");
3412 }
3413
3414 #[test]
3415 fn test_filename_sorting_grub_style() {
3416 let primary =
3420 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3421 let secondary =
3422 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
3423
3424 assert!(
3426 primary > secondary,
3427 "Primary should sort before secondary in descending order"
3428 );
3429
3430 let newer =
3432 type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY);
3433 let older =
3434 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3435
3436 assert!(
3438 newer > older,
3439 "Newer version should sort before older in descending order"
3440 );
3441
3442 let fedora = type1_entry_conf_file_name("fedora", "41.0", FILENAME_PRIORITY_PRIMARY);
3444 let rhel = type1_entry_conf_file_name("rhel", "9.0", FILENAME_PRIORITY_PRIMARY);
3445
3446 assert!(
3448 rhel > fedora,
3449 "RHEL should sort before Fedora in descending order"
3450 );
3451 }
3452
3453 fn other_digest() -> Sha512HashValue {
3456 Sha512HashValue::from_hex("aa".repeat(64)).unwrap()
3457 }
3458
3459 fn uki_v1(digest: Sha512HashValue, insecure: bool) -> String {
3460 ComposefsBootCmdline::new_v1(digest, insecure).to_cmdline_arg()
3461 }
3462
3463 fn uki_v2(digest: Sha512HashValue, insecure: bool) -> String {
3465 ComposefsBootCmdline::new_v2(digest, insecure).to_cmdline_arg()
3466 }
3467
3468 fn uki_explicit_v2(digest: &Sha512HashValue) -> String {
3469 format!("composefs.digest=v2-sha512-12:{}", digest.to_hex())
3470 }
3471
3472 fn candidate(format: UkiCandidateFormat, digest: Sha512HashValue) -> UkiComposefsCandidate {
3473 UkiComposefsCandidate {
3474 digest,
3475 insecure: false,
3476 format,
3477 }
3478 }
3479
3480 #[test]
3481 fn test_aboot_composefs_candidates() {
3482 let v1 = Sha512HashValue::EMPTY;
3483 let v2 = other_digest();
3484 let ids = ExpectedBootImageIds {
3485 v1: vec![v1.clone()],
3486 v2: vec![v2.clone()],
3487 };
3488 let dual = format!(
3489 "{} {}",
3490 uki_v1(v1.clone(), false),
3491 uki_v2(v2.clone(), false)
3492 );
3493 assert!(validate_aboot_composefs_candidates(&v1, &ids, &dual, false).is_ok());
3494 let stale_fallback = format!(
3495 "{} {}",
3496 uki_v1(v1.clone(), false),
3497 uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false)
3498 );
3499 assert!(validate_aboot_composefs_candidates(&v1, &ids, &stale_fallback, false).is_err());
3500 assert!(validate_aboot_composefs_candidates(&v2, &ids, &dual, false).is_err());
3501 let insecure = format!("{} {}", uki_v1(v1.clone(), true), uki_v2(v2, true));
3502 assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, false).is_err());
3503 assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, true).is_ok());
3504 }
3505
3506 #[test]
3507 fn test_uki_composefs_candidates() {
3508 let v1 = Sha512HashValue::EMPTY;
3509 let v2 = other_digest();
3510 let secure = format!(
3511 "{} {}",
3512 uki_v1(v1.clone(), false),
3513 uki_v2(v2.clone(), false)
3514 );
3515 let insecure = format!("{} {}", uki_v1(v1.clone(), true), uki_v2(v2.clone(), true));
3516 let cases = [
3517 ("secure dual", secure.clone(), true),
3518 (
3519 "secure dual reversed",
3520 format!(
3521 "{} {}",
3522 uki_v2(v2.clone(), false),
3523 uki_v1(v1.clone(), false)
3524 ),
3525 true,
3526 ),
3527 ("insecure dual", insecure, true),
3528 (
3529 "insecure dual reversed",
3530 format!("{} {}", uki_v2(v2.clone(), true), uki_v1(v1.clone(), true)),
3531 true,
3532 ),
3533 (
3534 "stale secondary",
3535 format!(
3536 "{} {}",
3537 uki_v1(v1.clone(), false),
3538 uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false)
3539 ),
3540 true,
3541 ),
3542 (
3543 "swapped digests",
3544 format!(
3545 "{} {}",
3546 uki_v1(v2.clone(), false),
3547 uki_v2(v1.clone(), false)
3548 ),
3549 true,
3550 ),
3551 (
3552 "mixed policy",
3553 format!("{} {}", uki_v1(v1.clone(), false), uki_v2(v2.clone(), true)),
3554 false,
3555 ),
3556 (
3557 "malformed secondary",
3558 format!("{} composefs=not-a-digest", uki_v1(v1.clone(), false)),
3559 false,
3560 ),
3561 (
3562 "malformed sha512 descriptor",
3563 "composefs.digest=v1-sha512-12:bad".to_string(),
3564 false,
3565 ),
3566 (
3567 "explicit V2 descriptor",
3568 format!("composefs.digest=v2-sha512-12:{}", v2.to_hex()),
3569 true,
3570 ),
3571 (
3572 "duplicate V2 key",
3573 format!(
3574 "{} {}",
3575 uki_v2(v2.clone(), false),
3576 uki_v2(v2.clone(), false)
3577 ),
3578 false,
3579 ),
3580 ("missing value", "composefs".to_string(), false),
3581 ("no candidates", "quiet rw".to_string(), false),
3582 ];
3583
3584 for (name, cmdline, should_pass) in cases {
3585 let result = parse_uki_composefs_candidates(&cmdline).and_then(|candidates| {
3586 uki_candidates_policy(&candidates)?;
3587 Ok(candidates)
3588 });
3589 assert_eq!(result.is_ok(), should_pass, "case {name}: {result:?}");
3590 }
3591 for (cmdline, format) in [
3594 (uki_explicit_v2(&v2), UkiCandidateFormat::V2),
3595 (uki_v1(v1.clone(), false), UkiCandidateFormat::V1),
3596 (uki_v2(v2.clone(), false), UkiCandidateFormat::Unspecified),
3597 ] {
3598 let candidates = parse_uki_composefs_candidates(&cmdline).unwrap();
3599 let formats = candidates.iter().map(|c| c.format).collect::<Vec<_>>();
3600 assert_eq!(formats, [format], "{cmdline}");
3601 }
3602 let candidates = parse_uki_composefs_candidates(&format!(
3603 "composefs.digest=v1-sha256-12:{} {} {}",
3604 "aa".repeat(32),
3605 uki_v1(v1.clone(), false),
3606 uki_v2(v2.clone(), false)
3607 ))
3608 .unwrap();
3609 assert_eq!(
3610 candidates
3611 .iter()
3612 .map(|candidate| candidate.digest.clone())
3613 .collect::<Vec<_>>(),
3614 vec![v1.clone(), v2.clone()]
3615 );
3616
3617 let expected = ExpectedBootImageIds {
3618 v1: vec![v1.clone()],
3619 v2: vec![v2.clone()],
3620 };
3621 for (name, cmdline, valid) in [
3622 ("valid dual", secure, true),
3623 ("legacy V1", uki_v2(v1.clone(), false), true),
3624 ("legacy V2", uki_v2(v2.clone(), false), true),
3625 ("explicit V2", uki_explicit_v2(&v2), true),
3626 ("explicit V2 naming V1 image", uki_explicit_v2(&v1), false),
3627 (
3628 "stale secondary",
3629 format!(
3630 "{} {}",
3631 uki_v1(v1.clone(), false),
3632 uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false)
3633 ),
3634 false,
3635 ),
3636 (
3637 "swapped digests",
3638 format!(
3639 "{} {}",
3640 uki_v1(v2.clone(), false),
3641 uki_v2(v1.clone(), false)
3642 ),
3643 false,
3644 ),
3645 ] {
3646 let candidates = parse_uki_composefs_candidates(&cmdline).unwrap();
3647 assert_eq!(
3648 validate_uki_candidates(&candidates, &expected, Some("uki.efi".into())).is_ok(),
3649 valid,
3650 "case {name}"
3651 );
3652 }
3653 }
3654
3655 #[test]
3656 fn test_resolve_boot_image_match() {
3657 let expected = other_digest();
3658 let uki_name = "uki.efi";
3659 let combinations_tried = 4;
3661
3662 #[derive(Copy, Clone)]
3663 enum FindMatching {
3664 Found,
3665 NotFound,
3666 Errors,
3667 }
3668
3669 let cases = [
3670 (FindMatching::Found, true, vec![]),
3671 (
3672 FindMatching::NotFound,
3673 false,
3674 vec![
3675 uki_name.into(),
3676 format!("{expected:?}"),
3677 format!("doesn't match any of {combinations_tried} supported"),
3678 ],
3679 ),
3680 (
3681 FindMatching::Errors,
3682 false,
3683 vec![
3684 "search blew up".to_string(),
3685 "Searching for a boot image xattr filtering mode/format version matching \
3686 the UKI digest"
3687 .to_string(),
3688 ],
3689 ),
3690 ];
3691
3692 for (find_matching, should_succeed, want_substrings) in cases {
3693 let find_matching_result = match find_matching {
3694 FindMatching::Found => Ok(composefs_oci::BootImageMatch::Found {
3695 mode: composefs_oci::XattrFiltering::KeepUserXattrs,
3696 version: FormatVersion::V2,
3697 digest: expected.clone(),
3698 }),
3699 FindMatching::NotFound => {
3700 Ok(composefs_oci::BootImageMatch::NotFound(combinations_tried))
3701 }
3702 FindMatching::Errors => Err(anyhow::anyhow!("search blew up")),
3703 };
3704 let mismatch =
3705 UKIDigestMismatch::unsupported_compatibility(expected.clone(), uki_name.into(), 0);
3706 let candidate = candidate(UkiCandidateFormat::V2, expected.clone());
3707 let result = resolve_boot_image_match(mismatch, &candidate, find_matching_result);
3708 if should_succeed {
3709 assert_eq!(result.unwrap(), (FormatVersion::V2, expected.clone()));
3710 continue;
3711 }
3712 let error = result.unwrap_err();
3713 let msg = format!("{error:#}");
3714 for want in &want_substrings {
3715 assert!(msg.contains(want), "expected {msg:?} to contain {want:?}");
3716 }
3717 if matches!(find_matching, FindMatching::NotFound) {
3718 let mismatch = error.downcast_ref::<UKIDigestMismatch>().unwrap();
3719 assert_eq!(mismatch.uki_name(), Some(uki_name));
3720 } else {
3721 assert!(error.downcast_ref::<UKIDigestMismatch>().is_none());
3722 }
3723 }
3724 }
3725
3726 #[test]
3727 fn test_expected_boot_image_ids_keep_recovered_ids_by_format() {
3728 let standard_v1 = Sha512HashValue::EMPTY;
3729 let standard_v2 = other_digest();
3730 let recovered_v1 = Sha512HashValue::from_hex("bb".repeat(64)).unwrap();
3731 let recovered_v2 = Sha512HashValue::from_hex("cc".repeat(64)).unwrap();
3732
3733 let mut ids = expected_boot_image_ids(
3734 Some(standard_v1.clone()),
3735 Some(standard_v2.clone()),
3736 &recovered_v1,
3737 FormatVersion::V1,
3738 );
3739 ids.add(FormatVersion::V2, recovered_v2.clone());
3740
3741 for candidate in [
3742 candidate(UkiCandidateFormat::V1, standard_v1),
3743 candidate(UkiCandidateFormat::V1, recovered_v1),
3744 candidate(UkiCandidateFormat::V2, standard_v2),
3745 candidate(UkiCandidateFormat::V2, recovered_v2),
3746 ] {
3747 assert!(ids.contains(&candidate), "{candidate:?}");
3748 }
3749 }
3750
3751 #[test]
3755 fn test_reconcile_uki_candidates() {
3756 use FormatVersion::{V1, V2};
3757 let v1 = Sha512HashValue::from_hex("11".repeat(64)).unwrap();
3758 let v2 = Sha512HashValue::from_hex("22".repeat(64)).unwrap();
3759 let unknown = Sha512HashValue::from_hex("33".repeat(64)).unwrap();
3760 let dual = format!(
3761 "{} {}",
3762 uki_v1(v1.clone(), false),
3763 uki_v2(v2.clone(), false)
3764 );
3765 let dual_reversed = format!(
3766 "{} {}",
3767 uki_v2(v2.clone(), false),
3768 uki_v1(v1.clone(), false)
3769 );
3770
3771 type Images<'a> = &'a [(FormatVersion, &'a Sha512HashValue)];
3773 type Case<'a> = (
3775 &'a str,
3776 String,
3777 Images<'a>,
3778 Images<'a>,
3779 Result<&'a Sha512HashValue, &'a str>,
3780 );
3781 let cases: &[Case] = &[
3782 (
3784 "legacy V1",
3785 uki_v2(v1.clone(), false),
3786 &[(V1, &v1)],
3787 &[],
3788 Ok(&v1),
3789 ),
3790 (
3791 "legacy V1 recovered",
3792 uki_v2(v1.clone(), false),
3793 &[(V2, &v2)],
3794 &[(V1, &v1)],
3795 Ok(&v1),
3796 ),
3797 (
3799 "legacy V2",
3800 uki_v2(v2.clone(), false),
3801 &[(V1, &v1), (V2, &v2)],
3802 &[],
3803 Ok(&v2),
3804 ),
3805 (
3806 "legacy V2 recovered",
3807 uki_v2(v2.clone(), false),
3808 &[(V1, &v1)],
3809 &[(V2, &v2)],
3810 Ok(&v2),
3811 ),
3812 ("dual", dual.clone(), &[(V1, &v1), (V2, &v2)], &[], Ok(&v1)),
3813 (
3814 "dual reversed",
3815 dual_reversed,
3816 &[(V1, &v1), (V2, &v2)],
3817 &[],
3818 Ok(&v1),
3819 ),
3820 (
3821 "dual recovered V2",
3822 dual,
3823 &[(V1, &v1)],
3824 &[(V2, &v2)],
3825 Ok(&v1),
3826 ),
3827 (
3828 "explicit V2",
3829 uki_explicit_v2(&v2),
3830 &[(V2, &v2)],
3831 &[],
3832 Ok(&v2),
3833 ),
3834 (
3835 "explicit V2 naming V1 image",
3836 uki_explicit_v2(&v1),
3837 &[(V1, &v1)],
3838 &[(V1, &v1)],
3839 Err("candidate format (V2) does not match recovered EROFS format (V1)"),
3840 ),
3841 (
3842 "explicit V1 naming V2 image",
3843 uki_v1(v2.clone(), false),
3844 &[(V2, &v2)],
3845 &[(V2, &v2)],
3846 Err("candidate format (V1) does not match recovered EROFS format (V2)"),
3847 ),
3848 (
3849 "legacy unknown",
3850 uki_v2(unknown.clone(), false),
3851 &[(V1, &v1), (V2, &v2)],
3852 &[],
3853 Err("doesn't match any of"),
3854 ),
3855 ];
3856
3857 for (name, cmdline, generated, searchable, want) in cases {
3858 let mut ids = ExpectedBootImageIds::default();
3859 for (version, digest) in generated.iter() {
3860 ids.add(*version, (*digest).clone());
3861 }
3862 let expected = ExpectedComposefsDigest {
3863 candidates: parse_uki_composefs_candidates(cmdline).unwrap(),
3864 uki_name: "uki.efi".into(),
3865 };
3866 let result = reconcile_uki_candidates(&expected, ids, |wanted| {
3867 Ok(searchable
3868 .iter()
3869 .find(|(_, digest)| *digest == wanted)
3870 .map_or(composefs_oci::BootImageMatch::NotFound(4), |(v, d)| {
3871 composefs_oci::BootImageMatch::Found {
3872 mode: composefs_oci::XattrFiltering::KeepUserXattrs,
3873 version: *v,
3874 digest: (*d).clone(),
3875 }
3876 }))
3877 });
3878 match (result, want) {
3879 (Ok(recovered), Ok(want)) => {
3880 assert_eq!(&recovered.id, *want, "case {name}");
3881 validate_uki_candidates(&expected.candidates, &recovered.expected_ids, None)
3883 .unwrap_or_else(|e| panic!("case {name}: {e:#}"));
3884 }
3885 (Err(e), Err(want)) => {
3886 let msg = format!("{e:#}");
3887 assert!(msg.contains(want), "case {name}: {msg:?} lacks {want:?}");
3888 }
3889 (result, _) => panic!("case {name}: unexpected {:?}", result.map(|r| r.id)),
3890 }
3891 }
3892 }
3893}