diff --git a/src/efi.rs b/src/efi.rs index 1022f4af..98e3be3a 100644 --- a/src/efi.rs +++ b/src/efi.rs @@ -30,6 +30,8 @@ use crate::freezethaw::fsfreeze_thaw_cycle; use crate::model::*; use crate::ostreeutil; use crate::util; +use uapi_version::Version; + use crate::{component::*, packagesystem::*}; use crate::{filetree, grubconfigs}; @@ -80,6 +82,11 @@ fn is_mount_point(path: &Path) -> Result { } } +/// Copy options that merge source contents into an existing destination +/// directory instead of creating a subdirectory (used for non-EFI +/// components like firmware that live at the ESP root). +const OPTIONS_MERGE: &[&str] = &["-rp", "--reflink=auto", "-T"]; + /// Return `true` if the system is booted via EFI pub(crate) fn is_efi_booted() -> Result { Path::new("/sys/firmware/efi") @@ -236,6 +243,65 @@ impl Efi { let device_path = device.path(); create_efi_boot_entry(&device_path, esp_part_num.trim(), &loader, &product_name) } + + fn ensure_efi_prefix(&self, mut ft: filetree::FileTree) -> filetree::FileTree { + let needs_prefix = + !ft.children.is_empty() && ft.children.keys().all(|k| !k.starts_with("EFI/")); + if needs_prefix { + ft.prepend_prefix("EFI"); + } + ft + } + + /// Build a `FileTree` from either the new `usr/lib/efi/` layout (when + /// pre-resolved components are provided) or the legacy + /// `usr/lib/bootupd/updates/EFI` directory. + /// + /// For the new layout, only the latest version of each component is + /// included so that duplicate destination keys cannot arise. + fn build_filetree( + &self, + root_dir: &openat::Dir, + components: Option<&[EFIComponent]>, + ) -> Result<(PathBuf, filetree::FileTree)> { + if let Some(components) = components { + let p = PathBuf::from(EFILIB); + let dir = root_dir + .sub_dir(&p) + .with_context(|| format!("opening {}", p.display()))?; + let latest = latest_versions(components); + let prefixes: Vec = latest + .iter() + .map(|c| format!("{}/{}", c.name, c.version)) + .collect(); + let ft = filetree::FileTree::new_from_dir_strip_prefix_for(&dir, &prefixes)?; + Ok((p, ft)) + } else { + let p = component_updatedirname(self); + let dir = root_dir + .sub_dir(&p) + .with_context(|| format!("opening {}", p.display()))?; + let mut ft = filetree::FileTree::new_from_dir(&dir).context("reading update dir")?; + ft = self.ensure_efi_prefix(ft); + Ok((p, ft)) + } + } + + /// Build the update `FileTree`, resolving EFI components from the + /// sysroot and delegating to `build_filetree`. + fn build_update_filetree( + &self, + sysroot: &openat::Dir, + sysroot_path: &Utf8Path, + ) -> Result<(PathBuf, filetree::FileTree)> { + let efilib_path = sysroot_path.join(EFILIB); + let components = if efilib_path.exists() { + get_efi_component_from_usr(sysroot_path, EFILIB)? + } else { + None + }; + self.build_filetree(sysroot, components.as_deref()) + } } #[context("Get product name")] @@ -344,9 +410,9 @@ impl Component for Efi { anyhow::bail!("Failed to find efi vendor"); }; - // destdir is /boot/efi/EFI + // destdir is /boot/efi let efidir = destdir - .sub_dir(&vendor) + .sub_dir(&format!("EFI/{}", vendor)) .with_context(|| format!("Opening EFI/{}", vendor))?; if !efidir.exists(grubconfigs::GRUBCONFIG_BACKUP)? { @@ -375,33 +441,25 @@ impl Component for Efi { return Ok(None); }; - let updated_path = { - let efilib_path = rootcxt.path.join(EFILIB); - if efilib_path.exists() && get_efi_component_from_usr(&rootcxt.path, EFILIB)?.is_some() - { - PathBuf::from(EFILIB) - } else { - component_updatedirname(self) - } - }; + let (updated_path, updatef) = + self.build_update_filetree(&rootcxt.sysroot, &rootcxt.path)?; let updated = rootcxt .sysroot .sub_dir(&updated_path) .with_context(|| format!("opening update dir {}", updated_path.display()))?; - let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; let esp_devices = esp_devices.unwrap_or_default(); for esp in esp_devices { let destpath = &self.ensure_mounted_esp(rootcxt.path.as_ref(), Path::new(&esp.path()))?; - let efidir = openat::Dir::open(&destpath.join("EFI")).context("opening EFI dir")?; - validate_esp_fstype(&efidir)?; + let destdir = openat::Dir::open(destpath).context("opening ESP dir")?; + validate_esp_fstype(&destdir)?; // For adoption, we should only touch files that we know about. - let diff = updatef.relative_diff_to(&efidir)?; + let diff = updatef.relative_diff_to(&destdir)?; log::trace!("applying adoption diff: {}", &diff); - filetree::apply_diff(&updated, &efidir, &diff, None) + filetree::apply_diff(&updated, &destdir, &diff, None) .context("applying filesystem changes")?; // Backup current config and install static config @@ -413,13 +471,13 @@ impl Component for Efi { ); } else { println!("ostree repo 'sysroot.bootloader' config option is not set yet"); - self.migrate_static_grub_config(rootcxt.path.as_str(), &efidir)?; + self.migrate_static_grub_config(rootcxt.path.as_str(), &destdir)?; }; } // Do the sync before unmount - fsfreeze_thaw_cycle(efidir.open_file(".")?)?; - drop(efidir); + fsfreeze_thaw_cycle(destdir.open_file(".")?)?; + drop(destdir); self.unmount().context("unmount after adopt")?; } Ok(Some(InstalledContent { @@ -480,27 +538,30 @@ impl Component for Efi { ) })?; - let efi_path = if let Some(efi_components) = efi_comps { + // Copy files to the ESP + if let Some(ref efi_components) = efi_comps { for efi in efi_components { - filetree::copy_dir_with_args(&src_dir, efi.path.as_str(), dest, OPTIONS)?; + if efi.has_efi_subdir { + filetree::copy_dir_with_args(&src_dir, efi.path.as_str(), dest, OPTIONS)?; + } else { + filetree::copy_dir_with_args(&src_dir, efi.path.as_str(), dest, OPTIONS_MERGE)?; + } } - EFILIB } else { let updates = component_updatedirname(self); let src = updates .to_str() .context("Include invalid UTF-8 characters in path")?; filetree::copy_dir_with_args(&src_dir, src, dest, OPTIONS)?; - &src.to_owned() }; - // Get filetree from efi path - let ft = crate::filetree::FileTree::new_from_dir(&src_dir.sub_dir(efi_path)?)?; + // Build the filetree from the update source + let (update_path, ft) = self.build_filetree(&src_dir, efi_comps.as_deref())?; + let efi_vendor_search = src_path.as_std_path().join(update_path); + if update_firmware { if let Some(dev) = device { - if let Some(vendordir) = - self.get_efi_vendor(src_path.join(efi_path).as_std_path())? - { + if let Some(vendordir) = self.get_efi_vendor(efi_vendor_search.as_path())? { self.update_firmware(dev, destd, &vendordir)? } } @@ -517,28 +578,23 @@ impl Component for Efi { rootcxt: &RootContext, current: &InstalledContent, ) -> Result { - let currentf = current + let mut currentf = current .filetree - .as_ref() + .clone() .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; + currentf = self.ensure_efi_prefix(currentf); let sysroot_dir = &rootcxt.sysroot; let updatemeta = self.query_update(sysroot_dir)?.expect("update available"); - let updated_path = { - let efilib_path = rootcxt.path.join(EFILIB); - if efilib_path.exists() && get_efi_component_from_usr(&rootcxt.path, EFILIB)?.is_some() - { - PathBuf::from(EFILIB) - } else { - component_updatedirname(self) - } - }; + + let (updated_path, updatef) = + self.build_update_filetree(&rootcxt.sysroot, &rootcxt.path)?; + + let diff = currentf.diff(&updatef)?; let updated = rootcxt .sysroot .sub_dir(&updated_path) .with_context(|| format!("opening update dir {}", updated_path.display()))?; - let updatef = filetree::FileTree::new_from_dir(&updated).context("reading update dir")?; - let diff = currentf.diff(&updatef)?; let Some(esp_devices) = rootcxt.device.find_colocated_esps()? else { anyhow::bail!("Failed to find all esp devices"); @@ -547,7 +603,7 @@ impl Component for Efi { for esp in esp_devices { let destpath = &self.ensure_mounted_esp(rootcxt.path.as_ref(), Path::new(&esp.path()))?; - let destdir = openat::Dir::open(&destpath.join("EFI")).context("opening EFI dir")?; + let destdir = openat::Dir::open(destpath).context("opening ESP dir")?; validate_esp_fstype(&destdir)?; log::trace!("applying diff: {}", &diff); filetree::apply_diff(&updated, &destdir, &diff, None) @@ -579,11 +635,11 @@ impl Component for Efi { for p in cruft.iter() { ostreeboot.remove_all_optional(p)?; } - // Transfer ostree-boot EFI files to usr/lib/efi + // Transfer ostree-boot efi/ files to usr/lib/efi transfer_ostree_boot_to_usr(sysroot_path)?; - // Remove usr/lib/ostree-boot/efi/EFI dir (after transfer) or if it is empty - ostreeboot.remove_all_optional("efi/EFI")?; + // Remove the entire efi/ tree after transfer, or if it is empty + ostreeboot.remove_all_optional("efi")?; } if let Some(efi_components) = @@ -628,19 +684,20 @@ impl Component for Efi { if !is_efi_booted()? && esp_devices.is_none() { return Ok(ValidationResult::Skip); } - let currentf = current + let mut currentf = current .filetree - .as_ref() + .clone() .ok_or_else(|| anyhow::anyhow!("No filetree for installed EFI found!"))?; + currentf = self.ensure_efi_prefix(currentf); let mut errs = Vec::new(); let esp_devices = esp_devices.unwrap_or_default(); for esp in esp_devices.iter() { let destpath = &self.ensure_mounted_esp(Path::new("/"), Path::new(&esp.path()))?; - let efidir = openat::Dir::open(&destpath.join("EFI")) - .with_context(|| format!("opening EFI dir {}", destpath.display()))?; - let diff = currentf.relative_diff_to(&efidir)?; + let destdir = openat::Dir::open(destpath) + .with_context(|| format!("opening ESP dir {}", destpath.display()))?; + let diff = currentf.relative_diff_to(&destdir)?; for f in diff.changes.iter() { errs.push(format!("Changed: {}", f)); @@ -649,7 +706,7 @@ impl Component for Efi { errs.push(format!("Removed: {}", f)); } assert_eq!(diff.additions.len(), 0); - drop(efidir); + drop(destdir); self.unmount().context("unmount after validate")?; } @@ -806,9 +863,15 @@ pub struct EFIComponent { pub name: String, pub version: String, path: Utf8PathBuf, + has_efi_subdir: bool, } -/// Get EFIComponents from e.g. usr/lib/efi, like "usr/lib/efi///EFI" +/// Get EFIComponents from e.g. usr/lib/efi. +/// +/// Each component lives at `///`. When the version +/// directory contains an `EFI/` subdirectory the content is EFI-specific +/// (shim, grub, etc.) and gets the `EFI/` prefix on the ESP. Otherwise the +/// files are copied directly to the root of the ESP (e.g. RPi firmware). fn get_efi_component_from_usr<'a>( sysroot: &'a Utf8Path, usr_path: &'a str, @@ -816,34 +879,62 @@ fn get_efi_component_from_usr<'a>( let efilib_path = sysroot.join(usr_path); let skip_count = Utf8Path::new(usr_path).components().count(); - let mut components: Vec = WalkDir::new(&efilib_path) - .min_depth(3) // //EFI: so 3 levels down - .max_depth(3) - .into_iter() - .filter_map(|entry| { - let entry = entry.ok()?; - if !entry.file_type().is_dir() || entry.file_name() != "EFI" { - return None; - } + let mut components: Vec = Vec::new(); - let abs_path = entry.path(); - let rel_path = abs_path.strip_prefix(sysroot).ok()?; - let utf8_rel_path = Utf8PathBuf::from_path_buf(rel_path.to_path_buf()).ok()?; + for entry in WalkDir::new(&efilib_path) + .min_depth(2) + .max_depth(2) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_dir() { + continue; + } - let mut components = utf8_rel_path.components(); + let abs_path = entry.path(); + let rel_path = match abs_path.strip_prefix(sysroot) { + Ok(p) => p, + Err(_) => continue, + }; + let utf8_rel_path = match Utf8PathBuf::from_path_buf(rel_path.to_path_buf()) { + Ok(p) => p, + Err(_) => continue, + }; - let name = components.nth(skip_count)?.to_string(); - let version = components.next()?.to_string(); + let mut comps = utf8_rel_path.components(); + let Some(name) = comps.nth(skip_count).map(|c| c.to_string()) else { + continue; + }; + let Some(version) = comps.next().map(|c| c.to_string()) else { + continue; + }; - Some(EFIComponent { + let efi_subdir = abs_path.join("EFI"); + if efi_subdir.exists() && efi_subdir.is_dir() { + components.push(EFIComponent { name, version, - path: utf8_rel_path, - }) - }) - .collect(); + path: utf8_rel_path.join("EFI"), + has_efi_subdir: true, + }); + } else { + let has_content = WalkDir::new(abs_path) + .min_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + .any(|e| e.file_type().is_file()); + if has_content { + components.push(EFIComponent { + name, + version, + path: utf8_rel_path, + has_efi_subdir: false, + }); + } + } + } - if components.len() == 0 { + if components.is_empty() { return Ok(None); } components.sort_by(|a, b| a.name.cmp(&b.name)); @@ -851,55 +942,92 @@ fn get_efi_component_from_usr<'a>( Ok(Some(components)) } -/// Copy files from usr/lib/ostree-boot/efi/EFI to /usr/lib/efi/// +/// Given a list of EFI components (potentially with multiple versions per +/// component name), return only the latest version for each name. +/// +/// Versions are compared lexicographically; this is sufficient for RPM EVR +/// strings where the epoch:version-release ordering is consistent. +fn latest_versions(components: &[EFIComponent]) -> Vec<&EFIComponent> { + let mut by_name: std::collections::HashMap<&str, &EFIComponent> = + std::collections::HashMap::new(); + for c in components { + let entry = by_name.entry(c.name.as_str()).or_insert(c); + if Version::from(c.version.as_str()) > Version::from(entry.version.as_str()) { + *entry = c; + } + } + let mut result: Vec<_> = by_name.into_values().collect(); + result.sort_by(|a, b| a.name.cmp(&b.name)); + result +} + +/// Copy files from usr/lib/ostree-boot/efi/ to usr/lib/efi/// +/// +/// Walks the entire `efi/` directory (both `EFI/` subdirectories and +/// root-level firmware files) and uses `rpm -qf` to determine which +/// package owns each file so it can be placed in the right component +/// directory under `usr/lib/efi/`. fn transfer_ostree_boot_to_usr(sysroot: &Path) -> Result<()> { + transfer_ostree_boot_to_usr_impl(sysroot, |sysroot_path, filepath| { + let boot_filepath = Path::new("/boot/efi").join(filepath); + crate::packagesystem::query_file( + sysroot_path.to_str().unwrap(), + boot_filepath.to_str().unwrap(), + ) + }) +} + +/// Inner implementation that accepts a package-resolver callback so it +/// can be unit-tested without a real RPM database. +/// +/// `resolve_pkg(sysroot, filepath)` must return `" "`. +fn transfer_ostree_boot_to_usr_impl(sysroot: &Path, resolve_pkg: F) -> Result<()> +where + F: Fn(&Path, &Path) -> Result, +{ let ostreeboot_efi = Path::new(ostreeutil::BOOT_PREFIX).join("efi"); let ostreeboot_efi_path = sysroot.join(&ostreeboot_efi); - let efi = ostreeboot_efi_path.join("EFI"); - if !efi.exists() { + if !ostreeboot_efi_path.exists() { return Ok(()); } - for entry in WalkDir::new(&efi) { - let entry = entry?; - if entry.file_type().is_file() { - let entry_path = entry.path(); + let sysroot_dir = openat::Dir::open(sysroot)?; + // Source dir is usr/lib/ostree-boot/efi + let src = sysroot_dir + .sub_dir(&ostreeboot_efi) + .context("Opening ostree-boot/efi dir")?; - // get path EFI/{BOOT,}/ - let filepath = entry_path.strip_prefix(&ostreeboot_efi_path)?; - // get path /boot/efi/EFI/{BOOT,}/ - let boot_filepath = Path::new("/boot/efi").join(filepath); + for entry in WalkDir::new(&ostreeboot_efi_path) { + let entry = entry?; + if !entry.file_type().is_file() { + continue; + } - // Run `rpm -qf ` - let pkg = crate::packagesystem::query_file( - sysroot.to_str().unwrap(), - boot_filepath.to_str().unwrap(), - )?; + // get path relative to the efi/ root (e.g. EFI/BOOT/shim.efi or start4.elf) + let filepath = entry.path().strip_prefix(&ostreeboot_efi_path)?; - let (name, evr) = pkg.split_once(' ').unwrap(); - let component = name.split('-').next().unwrap_or(""); - // get path usr/lib/efi// - let efilib_path = Path::new(EFILIB).join(component).join(evr); + // Run `rpm -qf /boot/efi/` to find the owning package + let pkg = resolve_pkg(sysroot, filepath)?; - let sysroot_dir = openat::Dir::open(sysroot)?; - // Ensure dest parent directory exists - if let Some(parent) = efilib_path.join(filepath).parent() { - sysroot_dir.ensure_dir_all(parent, 0o755)?; - } + let (name, evr) = pkg + .split_once(' ') + .with_context(|| format!("parsing rpm output: {}", pkg))?; + // get path usr/lib/efi// + let efilib_path = Path::new(EFILIB).join(name).join(evr); - // Source dir is usr/lib/ostree-boot/efi - let src = sysroot_dir - .sub_dir(&ostreeboot_efi) - .context("Opening ostree-boot dir")?; - // Dest dir is usr/lib/efi// - let dest = sysroot_dir - .sub_dir(&efilib_path) - .context("Opening usr/lib/efi dir")?; - // Copy file from ostree-boot to usr/lib/efi - src.copy_file_at(filepath, &dest, filepath) - .context("Copying file to usr/lib/efi")?; + // Ensure dest parent directory exists + if let Some(parent) = efilib_path.join(filepath).parent() { + sysroot_dir.ensure_dir_all(parent, 0o755)?; } + + // Dest dir is usr/lib/efi// + let dest = sysroot_dir + .sub_dir(&efilib_path) + .context("Opening usr/lib/efi dir")?; + // Copy file from ostree-boot to usr/lib/efi + src.copy_file_at(filepath, &dest, filepath) + .context("Copying file to usr/lib/efi")?; } Ok(()) } @@ -1043,11 +1171,13 @@ Boot0003* test"; name: "BAR".to_string(), version: "1.1".to_string(), path: Utf8PathBuf::from("usr/lib/efi/BAR/1.1/EFI"), + has_efi_subdir: true, }, EFIComponent { name: "FOO".to_string(), version: "1.1".to_string(), path: Utf8PathBuf::from("usr/lib/efi/FOO/1.1/EFI"), + has_efi_subdir: true, }, ]) ); @@ -1057,4 +1187,215 @@ Boot0003* test"; assert_eq!(efi_comps, None); Ok(()) } + + #[test] + fn test_get_efi_component_mixed() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let tpath = tmpdir.path(); + let efi_path = tpath.join("usr/lib/efi"); + + // EFI component (has EFI/ subdirectory) + let efi_comp_dir = efi_path.join("FOO/1.0/EFI/vendor"); + std::fs::create_dir_all(&efi_comp_dir)?; + std::fs::write(efi_comp_dir.join("foo.efi"), "foo data")?; + + // Non-EFI component (files directly in version dir) + let non_efi_dir = efi_path.join("BAR/2.0"); + std::fs::create_dir_all(&non_efi_dir)?; + std::fs::write(non_efi_dir.join("bar.dtb"), "bar data")?; + std::fs::write(non_efi_dir.join("baz.bin"), "baz data")?; + + // Empty version dir (should be ignored) + std::fs::create_dir_all(efi_path.join("EMPTY/1.0"))?; + + let utf8_tpath = + Utf8Path::from_path(tpath).ok_or_else(|| anyhow::anyhow!("Path is not valid UTF-8"))?; + let comps = get_efi_component_from_usr(utf8_tpath, EFILIB)?; + let comps = comps.expect("components should be found"); + + assert_eq!(comps.len(), 2); + assert_eq!(comps[0].name, "BAR"); + assert!(!comps[0].has_efi_subdir); + assert_eq!(comps[0].path, Utf8PathBuf::from("usr/lib/efi/BAR/2.0")); + + assert_eq!(comps[1].name, "FOO"); + assert!(comps[1].has_efi_subdir); + assert_eq!(comps[1].path, Utf8PathBuf::from("usr/lib/efi/FOO/1.0/EFI")); + Ok(()) + } + + #[test] + fn test_new_from_dir_strip_prefix() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let efilib = tmpdir.path().join("efilib"); + + // EFI component: FOO/1.0/EFI/vendor/foo.efi + std::fs::create_dir_all(efilib.join("FOO/1.0/EFI/vendor"))?; + std::fs::write(efilib.join("FOO/1.0/EFI/vendor/foo.efi"), "foo data")?; + + // Non-EFI component: BAR/2.0/{bar.dtb,baz.bin} + std::fs::create_dir_all(efilib.join("BAR/2.0"))?; + std::fs::write(efilib.join("BAR/2.0/bar.dtb"), "bar data")?; + std::fs::write(efilib.join("BAR/2.0/baz.bin"), "baz data")?; + + let dir = openat::Dir::open(&efilib)?; + let ft = filetree::FileTree::new_from_dir_strip_prefix_for(&dir, &["FOO/1.0", "BAR/2.0"])?; + + assert!(ft.children.contains_key("EFI/vendor/foo.efi")); + assert!(ft.children.contains_key("bar.dtb")); + assert!(ft.children.contains_key("baz.bin")); + + // Source paths should point back to the original relative paths + assert_eq!( + ft.children["EFI/vendor/foo.efi"].source.as_deref(), + Some("FOO/1.0/EFI/vendor/foo.efi") + ); + assert_eq!( + ft.children["bar.dtb"].source.as_deref(), + Some("BAR/2.0/bar.dtb") + ); + + // ensure_efi_prefix should NOT re-prefix (some keys already have EFI/) + let efi = Efi::default(); + let ft2 = efi.ensure_efi_prefix(ft.clone()); + assert_eq!( + ft.children.keys().collect::>(), + ft2.children.keys().collect::>() + ); + + Ok(()) + } + + #[test] + fn test_rpi4_esp_update_flow() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let p = tmpdir.path(); + + // Source directory simulating EFILIB with mixed EFI and non-EFI + // components (e.g. bootloader + root-level firmware) + let src = p.join("src"); + std::fs::create_dir_all(src.join("FOO/1.0/EFI/vendor"))?; + std::fs::write(src.join("FOO/1.0/EFI/vendor/foo.efi"), "foo data")?; + let fw_dir = src.join("BAR/2.0"); + std::fs::create_dir_all(&fw_dir)?; + std::fs::write(fw_dir.join("bar.dtb"), "bar data")?; + std::fs::write(fw_dir.join("baz.bin"), "baz data")?; + std::fs::write(fw_dir.join("quux.dat"), "quux data")?; + std::fs::write(fw_dir.join("conf.txt"), "conf data")?; + + let dst = p.join("dst"); + std::fs::create_dir_all(&dst)?; + + let src_dir = openat::Dir::open(&src)?; + let dst_dir = openat::Dir::open(&dst)?; + + let ft = + filetree::FileTree::new_from_dir_strip_prefix_for(&src_dir, &["FOO/1.0", "BAR/2.0"])?; + + assert!(ft.children.contains_key("EFI/vendor/foo.efi")); + assert!(ft.children.contains_key("bar.dtb")); + assert!(ft.children.contains_key("baz.bin")); + assert!(ft.children.contains_key("quux.dat")); + assert!(ft.children.contains_key("conf.txt")); + + // Install to empty ESP + let empty_ft = filetree::FileTree { + children: std::collections::BTreeMap::new(), + }; + let diff = empty_ft.diff(&ft)?; + assert_eq!(diff.additions.len(), 5); + + filetree::apply_diff(&src_dir, &dst_dir, &diff, None)?; + + assert!(dst_dir.exists("EFI/vendor/foo.efi")?); + assert!(dst_dir.exists("bar.dtb")?); + assert!(dst_dir.exists("baz.bin")?); + assert!(dst_dir.exists("quux.dat")?); + assert!(dst_dir.exists("conf.txt")?); + assert_eq!( + std::fs::read_to_string(dst.join("EFI/vendor/foo.efi"))?, + "foo data" + ); + assert_eq!(std::fs::read_to_string(dst.join("bar.dtb"))?, "bar data"); + + // Simulate update (change one non-EFI file) + std::fs::write(fw_dir.join("bar.dtb"), "bar data v2")?; + let ft_v2 = + filetree::FileTree::new_from_dir_strip_prefix_for(&src_dir, &["FOO/1.0", "BAR/2.0"])?; + + let diff2 = ft.diff(&ft_v2)?; + assert_eq!(diff2.changes.len(), 1); + assert!(diff2.changes.contains("bar.dtb")); + + filetree::apply_diff(&src_dir, &dst_dir, &diff2, None)?; + assert_eq!(std::fs::read_to_string(dst.join("bar.dtb"))?, "bar data v2"); + assert_eq!( + std::fs::read_to_string(dst.join("EFI/vendor/foo.efi"))?, + "foo data" + ); + + Ok(()) + } + + #[test] + fn test_transfer_ostree_boot_to_usr() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let sysroot = tmpdir.path(); + + // Simulate usr/lib/ostree-boot/efi/ with both EFI/ and root-level files + let efi_dir = sysroot.join("usr/lib/ostree-boot/efi"); + std::fs::create_dir_all(efi_dir.join("EFI/vendor"))?; + std::fs::write(efi_dir.join("EFI/vendor/foo.efi"), "foo data")?; + std::fs::create_dir_all(efi_dir.join("EFI/BOOT"))?; + std::fs::write(efi_dir.join("EFI/BOOT/BOOTAA64.EFI"), "boot data")?; + // Root-level files + std::fs::write(efi_dir.join("bar.dtb"), "bar data")?; + std::fs::write(efi_dir.join("baz.bin"), "baz data")?; + std::fs::create_dir_all(efi_dir.join("sub"))?; + std::fs::write(efi_dir.join("sub/quux.dat"), "quux data")?; + + // Ensure the destination base directory exists + std::fs::create_dir_all(sysroot.join(EFILIB))?; + + // Fake resolver: EFI files belong to "FOO 1.0", root-level + // files to "BAR 2.0" + let resolve = |_sysroot: &Path, filepath: &Path| -> Result { + let s = filepath.to_str().unwrap(); + if s.starts_with("EFI") { + Ok("FOO 1.0".to_string()) + } else { + Ok("BAR 2.0".to_string()) + } + }; + + transfer_ostree_boot_to_usr_impl(sysroot, resolve)?; + + // EFI files should be under EFILIB/FOO/1.0/EFI/... + let foo_base = sysroot.join("usr/lib/efi/FOO/1.0"); + assert_eq!( + std::fs::read_to_string(foo_base.join("EFI/vendor/foo.efi"))?, + "foo data" + ); + assert_eq!( + std::fs::read_to_string(foo_base.join("EFI/BOOT/BOOTAA64.EFI"))?, + "boot data" + ); + + // Root-level files should be under EFILIB/BAR/2.0/ + let bar_base = sysroot.join("usr/lib/efi/BAR/2.0"); + assert_eq!( + std::fs::read_to_string(bar_base.join("bar.dtb"))?, + "bar data" + ); + assert_eq!( + std::fs::read_to_string(bar_base.join("baz.bin"))?, + "baz data" + ); + assert_eq!( + std::fs::read_to_string(bar_base.join("sub/quux.dat"))?, + "quux data" + ); + + Ok(()) + } } diff --git a/src/filetree.rs b/src/filetree.rs index a9eee816..05959b62 100644 --- a/src/filetree.rs +++ b/src/filetree.rs @@ -41,6 +41,12 @@ use openssl::hash::{Hasher, MessageDigest}; ))] use rustix::fd::BorrowedFd; use serde::{Deserialize, Serialize}; +#[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" +))] +use std::cmp::Ordering; #[allow(unused_imports)] use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt::Display; @@ -103,6 +109,8 @@ pub(crate) struct FileTreeDiff { pub(crate) additions: HashSet, pub(crate) removals: HashSet, pub(crate) changes: HashSet, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub(crate) source_map: HashMap, } impl Display for FileTreeDiff { @@ -207,6 +215,54 @@ impl FileTree { Ok(Self { children }) } + /// Like [`new_from_dir`] but only walks the specified + /// `//` subdirectories and strips that prefix from each + /// entry. Each entry in `prefixes` is a relative path like + /// `"shim/15.9-1"` that identifies the component version directory to + /// include. + /// + /// "FOO/1.0/EFI/vendor/foo.efi" -> "EFI/vendor/foo.efi" + /// "BAR/2.0/bar.dtb" -> "bar.dtb" + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + pub(crate) fn new_from_dir_strip_prefix_for>( + dir: &openat::Dir, + prefixes: &[S], + ) -> Result { + let mut children = BTreeMap::new(); + for prefix_str in prefixes { + let prefix = prefix_str.as_ref(); + let sub = dir + .sub_dir(prefix) + .with_context(|| format!("opening component dir {}", prefix))?; + for (k, mut v) in Self::unsorted_from_dir(&sub)?.drain() { + let source = format!("{}/{}", prefix, k); + v.source = Some(source); + children.insert(k, v); + } + } + Ok(Self { children }) + } + + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + pub(crate) fn prepend_prefix(&mut self, prefix: &str) { + let old = std::mem::take(&mut self.children); + let mut new_children = BTreeMap::new(); + for (k, v) in old { + let mut p = Utf8PathBuf::from(prefix); + p.push(k); + new_children.insert(p.into_string(), v); + } + self.children = new_children; + } + /// Determine the changes *from* self to the updated tree #[cfg(any( target_arch = "x86_64", @@ -241,12 +297,18 @@ impl FileTree { let mut additions = HashSet::new(); let mut removals = HashSet::new(); let mut changes = HashSet::new(); + let mut source_map = HashMap::new(); for (k, v1) in self.children.iter() { if let Some(v2) = updated.children.get(k) { if v1 != v2 { - // Save the source path for changes - changes.insert(v2.source.as_ref().unwrap_or(k).clone()); + // Save the destination key and record the source path for changes + changes.insert(k.clone()); + if let Some(src) = v2.source.as_ref() { + if src != k { + source_map.insert(k.clone(), src.clone()); + } + } } } else { removals.insert(k.clone()); @@ -257,14 +319,20 @@ impl FileTree { if self.children.contains_key(k) { continue; } - // Save the source path for additions - additions.insert(v.source.as_ref().unwrap_or(k).clone()); + // Save the destination key and record the source path for additions + additions.insert(k.clone()); + if let Some(src) = v.source.as_ref() { + if src != k { + source_map.insert(k.clone(), src.clone()); + } + } } } Ok(FileTreeDiff { additions, removals, changes, + source_map, }) } @@ -278,6 +346,7 @@ impl FileTree { pub(crate) fn relative_diff_to(&self, dir: &openat::Dir) -> Result { let mut removals = HashSet::new(); let mut changes = HashSet::new(); + let mut source_map = HashMap::new(); for (path, info) in self.children.iter() { assert!(!path.starts_with('/')); @@ -288,12 +357,22 @@ impl FileTree { let target_info = FileMetadata::new_from_path(dir, path)?; if info != &target_info { // Save the source path for changes - changes.insert(info.source.as_ref().unwrap_or(path).clone()); + changes.insert(path.clone()); + if let Some(src) = info.source.as_ref() { + if src != path { + source_map.insert(path.clone(), src.clone()); + } + } } } _ => { // If a file became a directory - changes.insert(info.source.as_ref().unwrap_or(path).clone()); + changes.insert(path.clone()); + if let Some(src) = info.source.as_ref() { + if src != path { + source_map.insert(path.clone(), src.clone()); + } + } } } } else { @@ -304,6 +383,7 @@ impl FileTree { additions: HashSet::new(), removals, changes, + source_map, }) } } @@ -474,9 +554,13 @@ pub(crate) fn apply_diff( } // Write changed or new files to temp dir or temp file for pathstr in diff.changes.iter().chain(diff.additions.iter()) { - let src_path = Utf8Path::new(pathstr); - let path = get_dest_efi_path(src_path); - let (first_dir, first_dir_tmp) = get_first_dir(&path)?; + let path = Utf8Path::new(pathstr); + let copy_src = if let Some(src) = diff.source_map.get(pathstr) { + Utf8Path::new(src) + } else { + path + }; + let (first_dir, first_dir_tmp) = get_first_dir(path)?; let mut path_tmp = Utf8PathBuf::from(&first_dir_tmp); if first_dir != path { if !destdir.exists(&first_dir_tmp)? && destdir.exists(first_dir.as_std_path())? { @@ -497,13 +581,53 @@ pub(crate) fn apply_diff( } updates.insert(first_dir, first_dir_tmp); srcdir - .copy_file_at(src_path.as_std_path(), destdir, path_tmp.as_std_path()) - .with_context(|| format!("copying {:?} to {:?}", src_path, path_tmp))?; + .copy_file_at(copy_src.as_std_path(), destdir, path_tmp.as_std_path()) + .with_context(|| format!("copying {:?} to {:?}", copy_src, path_tmp))?; } + // Sort updates to enforce stable order and atomic sequence + let mut update_list: Vec<_> = updates.iter().collect(); + update_list.sort_by(|(dst_a, tmp_a), (dst_b, tmp_b)| { + let a_is_efi = dst_a.starts_with("EFI"); + let b_is_efi = dst_b.starts_with("EFI"); + + // 1. EFI subfolders first + if a_is_efi && !b_is_efi { + return Ordering::Less; + } + if !a_is_efi && b_is_efi { + return Ordering::Greater; + } + + // 2. Directories next, then files + let a_is_dir = destdir + .metadata(tmp_a.as_str()) + .map(|m| m.is_dir()) + .unwrap_or(false); + let b_is_dir = destdir + .metadata(tmp_b.as_str()) + .map(|m| m.is_dir()) + .unwrap_or(false); + + if a_is_dir && !b_is_dir { + return Ordering::Less; + } + if !a_is_dir && b_is_dir { + return Ordering::Greater; + } + + // 3. Stable string order + dst_a.cmp(dst_b) + }); + // do local exchange or rename - for (dst, tmp) in updates.iter() { + for (dst, tmp) in update_list { let dst = dst.as_std_path(); + if let Some(parent) = dst.parent() { + if !parent.as_os_str().is_empty() { + destdir.ensure_dir_all(parent, DEFAULT_FILE_MODE)?; + } + } log::trace!("doing local exchange for {} and {:?}", tmp, dst); if destdir.exists(dst)? { destdir @@ -889,4 +1013,92 @@ mod tests { } Ok(()) } + + /// Test that apply_diff() uses source_map to copy from the original + /// source path when it differs from the destination key. + #[test] + fn test_apply_diff_source_map() -> Result<()> { + let tmpd = tempfile::tempdir()?; + let p = tmpd.path(); + let src = p.join("src"); + let dst = p.join("dst"); + std::fs::create_dir(&src)?; + std::fs::create_dir(&dst)?; + + let src_dir = openat::Dir::open(&src)?; + let dst_dir = openat::Dir::open(&dst)?; + + // "original/data.bin" -> "remapped/data.bin" via source_map + src_dir.ensure_dir_all("original", 0o755)?; + src_dir.write_file("original/data.bin", 0o644)?; + // Additional files in subdirectories and at the root, + // added without remapping. + src_dir.ensure_dir_all("sub", 0o755)?; + src_dir.write_file("sub/foo.dat", 0o644)?; + src_dir.write_file("top.dat", 0o644)?; + + let mut additions = HashSet::new(); + additions.insert("remapped/data.bin".to_string()); + additions.insert("sub/foo.dat".to_string()); + additions.insert("top.dat".to_string()); + + // source_map: dest path -> source path, only needed when the + // two differ (here a prefix was added to the dest key). + let mut source_map = HashMap::new(); + source_map.insert( + "remapped/data.bin".to_string(), + "original/data.bin".to_string(), + ); + + let diff = FileTreeDiff { + additions, + removals: HashSet::new(), + changes: HashSet::new(), + source_map, + }; + + apply_diff(&src_dir, &dst_dir, &diff, None)?; + + assert!(dst_dir.exists("remapped/data.bin")?); + assert!(dst_dir.exists("sub/foo.dat")?); + assert!(dst_dir.exists("top.dat")?); + Ok(()) + } + + #[test] + fn test_new_from_dir_strip_prefix_for() -> Result<()> { + let tmpdir = tempfile::tempdir()?; + let root = tmpdir.path().join("root"); + + // Two-level // layout with an EFI subtree + // and a flat (non-EFI) component. + std::fs::create_dir_all(root.join("FOO/1.0/EFI/vendor"))?; + std::fs::write(root.join("FOO/1.0/EFI/vendor/foo.efi"), "foo data")?; + std::fs::create_dir_all(root.join("BAR/2.0"))?; + std::fs::write(root.join("BAR/2.0/bar.dtb"), "bar data")?; + std::fs::write(root.join("BAR/2.0/baz.bin"), "baz data")?; + + let dir = openat::Dir::open(&root)?; + + // With all prefixes, every component is included. + let ft = FileTree::new_from_dir_strip_prefix_for(&dir, &["FOO/1.0", "BAR/2.0"])?; + assert!(ft.children.contains_key("EFI/vendor/foo.efi")); + assert!(ft.children.contains_key("bar.dtb")); + assert!(ft.children.contains_key("baz.bin")); + assert_eq!( + ft.children["EFI/vendor/foo.efi"].source.as_deref(), + Some("FOO/1.0/EFI/vendor/foo.efi") + ); + assert_eq!( + ft.children["bar.dtb"].source.as_deref(), + Some("BAR/2.0/bar.dtb") + ); + + // With a subset of prefixes, only matching components are included. + let ft2 = FileTree::new_from_dir_strip_prefix_for(&dir, &["FOO/1.0"])?; + assert!(ft2.children.contains_key("EFI/vendor/foo.efi")); + assert!(!ft2.children.contains_key("bar.dtb")); + + Ok(()) + } } diff --git a/tests/e2e-update/e2e-update-in-vm.sh b/tests/e2e-update/e2e-update-in-vm.sh index 389f50cd..475f6af3 100755 --- a/tests/e2e-update/e2e-update-in-vm.sh +++ b/tests/e2e-update/e2e-update-in-vm.sh @@ -53,7 +53,7 @@ ok validate bootupctl status | tee out.txt assert_file_has_content_literal out.txt 'Component EFI' -assert_file_has_content_literal out.txt ' Installed: grub2-1:' +assert_file_has_content out.txt " Installed: ${TARGET_GRUB_NAME}-" assert_not_file_has_content out.txt ' Installed:.*test_bootupd_payload' assert_not_file_has_content out.txt ' Installed:.*'"${TARGET_GRUB_EVR}" assert_file_has_content out.txt 'Update: Available:.*'"${TARGET_GRUB_EVR}" diff --git a/tests/e2e-update/e2e-update.sh b/tests/e2e-update/e2e-update.sh index f2a25f4f..f4bd062b 100755 --- a/tests/e2e-update/e2e-update.sh +++ b/tests/e2e-update/e2e-update.sh @@ -74,6 +74,7 @@ grubarch= case $(arch) in x86_64) grubarch=x64;; aarch64) grubarch=aa64;; + riscv64) grubarch=riscv64;; *) fatal "Unhandled arch $(arch)";; esac target_grub_name=grub2-efi-${grubarch} diff --git a/tests/kola/test-bootupd b/tests/kola/test-bootupd index 3d557267..38d8d96a 100755 --- a/tests/kola/test-bootupd +++ b/tests/kola/test-bootupd @@ -58,8 +58,15 @@ test -n "${shimx64_path}" && test -n "${grubx64_path}" bootupctl status > out.txt evr=$(rpm -q grub2-common --qf '%{EVR}') +case $(arch) in + x86_64) grubarch=x64;; + aarch64) grubarch=aa64;; + riscv64) grubarch=riscv64;; + *) fatal "Unhandled arch $(arch)";; +esac +grub_efi_name=grub2-efi-${grubarch} assert_file_has_content_literal out.txt 'Component EFI' -assert_file_has_content_literal out.txt ' Installed: grub2-'"${evr}" +assert_file_has_content_literal out.txt ' Installed: '"${grub_efi_name}"'-'"${evr}" assert_file_has_content_literal out.txt 'Update: At latest version' assert_file_has_content out.txt '^CoreOS aleph version:' ok status @@ -92,21 +99,21 @@ mv new.json ${bootupdir}/EFI.json bootupctl status | tee out.txt assert_file_has_content_literal out.txt 'Component EFI' -assert_file_has_content_literal out.txt ' Installed: grub2-'"${evr}" -assert_not_file_has_content out.txt ' Installed: grub2-.*,test' +assert_file_has_content_literal out.txt ' Installed: '"${grub_efi_name}"'-'"${evr}" +assert_not_file_has_content out.txt ' Installed: '"${grub_efi_name}"'-.*,test' assert_file_has_content_literal out.txt 'Update: Available:' ok update avail bootupctl status --json > status.json jq -r '.components.EFI.installed.version' < status.json > installed.txt -assert_file_has_content installed.txt '^grub2-'"${evr}" +assert_file_has_content installed.txt '^'"${grub_efi_name}"'-'"${evr}" bootupctl update | tee out.txt -assert_file_has_content out.txt 'Updated EFI: grub2-.*,test' +assert_file_has_content out.txt 'Updated EFI: '"${grub_efi_name}"'-.*,test' bootupctl status > out.txt assert_file_has_content_literal out.txt 'Component EFI' -assert_file_has_content out.txt ' Installed: grub2-.*,test' +assert_file_has_content out.txt ' Installed: '"${grub_efi_name}"'-.*,test' assert_file_has_content_literal out.txt 'Update: At latest version' ok status after update