From 460393ca1b3809e20b9b06b0f18464c2fbeb672a Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Tue, 2 Jun 2026 17:40:04 -0700 Subject: [PATCH 1/9] engineering: Generic EFI vendor-dir discovery and AZL4 ESP support Adds is_azl4_or_later() helper, generic EFI vendor-dir discovery via grub-probe, and AZL4 ESP partition layout support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/osutils/src/grub.rs | 109 +++++++++++++++- crates/osutils/src/osrelease.rs | 31 +++++ crates/trident/src/subsystems/esp.rs | 178 ++++++++++++++++++++++++--- 3 files changed, 298 insertions(+), 20 deletions(-) diff --git a/crates/osutils/src/grub.rs b/crates/osutils/src/grub.rs index 92782bbf7..dea58f2dd 100644 --- a/crates/osutils/src/grub.rs +++ b/crates/osutils/src/grub.rs @@ -231,22 +231,51 @@ impl GrubConfig { } /// Update the search command in the GRUB config. + /// + /// Three variants of the GRUB stub `search` line exist in practice: + /// + /// 1. The upstream legacy form: `search -n -u -s` + /// 2. AZL3 / standard form: `search --no-floppy --fs-uuid --set=root ` + /// 3. AZL4 MIC-generated form: `search --fs-uuid --set=root ` + /// (the `--no-floppy` option is redundant on EFI machines, so AZL4's + /// grub stub omits it.) + /// + /// We rewrite *every* matching line with the corresponding form so that + /// stubs containing more than one variant (rare but possible during + /// distribution transitions) all get the new UUID. We bail only if no + /// regex matched any line. pub fn update_search(&mut self, uuid: &Uuid) -> Result<(), Error> { let re = Regex::new(r"(?m)^(\s*)search -n -u [\w-]+ -s$").unwrap(); let re2 = Regex::new(r"(?m)^(\s*)search --no-floppy --fs-uuid --set=root [\w-]+$").unwrap(); + let re3 = Regex::new(r"(?m)^(\s*)search --fs-uuid --set=root [\w-]+$").unwrap(); + let mut matched = false; if re.is_match(&self.contents) { self.contents = re - .replace(&self.contents, &format!("${{1}}search -n -u {uuid} -s")) + .replace_all(&self.contents, &format!("${{1}}search -n -u {uuid} -s")) .to_string(); - } else if re2.is_match(&self.contents) { + matched = true; + } + if re2.is_match(&self.contents) { self.contents = re2 - .replace( + .replace_all( &self.contents, &format!("${{1}}search --no-floppy --fs-uuid --set=root {uuid}"), ) .to_string(); - } else { + matched = true; + } + if re3.is_match(&self.contents) { + self.contents = re3 + .replace_all( + &self.contents, + &format!("${{1}}search --fs-uuid --set=root {uuid}"), + ) + .to_string(); + matched = true; + } + + if !matched { bail!( "Unable to find search command in '{}'", &self.path.display() @@ -953,6 +982,78 @@ mod tests { .unwrap(); } + #[test] + fn test_update_search_azl3_form() { + // AZL3 stubs use `search --no-floppy --fs-uuid --set=root `. + let mut grub_config = GrubConfig { + path: PathBuf::new(), + contents: indoc::indoc! { r#" + set timeout=0 + search --no-floppy --fs-uuid --set=root deadbeef-cafe-babe-0000-111122223333 + "# } + .to_owned(), + linux_command_line: None, + }; + + let new_uuid = Uuid::parse_str("9e6a9d2c-b7fe-4359-ac45-18b505e29d8c").unwrap(); + grub_config.update_search(&new_uuid).unwrap(); + + assert!(grub_config.contents.contains(&format!( + "search --no-floppy --fs-uuid --set=root {new_uuid}" + ))); + assert!(!grub_config.contents.contains("deadbeef")); + } + + #[test] + fn test_update_search_azl4_form() { + // AZL4 MIC-generated stubs omit --no-floppy. + let mut grub_config = GrubConfig { + path: PathBuf::new(), + contents: indoc::indoc! { r#" + set timeout=0 + search --fs-uuid --set=root deadbeef-cafe-babe-0000-111122223333 + "# } + .to_owned(), + linux_command_line: None, + }; + + let new_uuid = Uuid::parse_str("9e6a9d2c-b7fe-4359-ac45-18b505e29d8c").unwrap(); + grub_config.update_search(&new_uuid).unwrap(); + + assert!(grub_config + .contents + .contains(&format!("search --fs-uuid --set=root {new_uuid}"))); + assert!(!grub_config.contents.contains("deadbeef")); + // Must not accidentally insert --no-floppy. + assert!(!grub_config.contents.contains("--no-floppy")); + } + + #[test] + fn test_update_search_mixed_forms() { + // If both AZL3 and AZL4 forms appear (e.g. an image whose stub + // includes vendored fragments), both must be rewritten. + let mut grub_config = GrubConfig { + path: PathBuf::new(), + contents: indoc::indoc! { r#" + search --no-floppy --fs-uuid --set=root oldoldold-cafe-babe-0000-aaaabbbbcccc + search --fs-uuid --set=root oldoldold-cafe-babe-0000-aaaabbbbcccc + "# } + .to_owned(), + linux_command_line: None, + }; + + let new_uuid = Uuid::parse_str("9e6a9d2c-b7fe-4359-ac45-18b505e29d8c").unwrap(); + grub_config.update_search(&new_uuid).unwrap(); + + assert!(!grub_config.contents.contains("oldoldold")); + assert!(grub_config.contents.contains(&format!( + "search --no-floppy --fs-uuid --set=root {new_uuid}" + ))); + assert!(grub_config + .contents + .contains(&format!("search --fs-uuid --set=root {new_uuid}"))); + } + #[test] fn test_update_rootdevice() { // Define original GRUB config contents on target machine diff --git a/crates/osutils/src/osrelease.rs b/crates/osutils/src/osrelease.rs index c39981c6f..5d8caafe2 100644 --- a/crates/osutils/src/osrelease.rs +++ b/crates/osutils/src/osrelease.rs @@ -353,6 +353,37 @@ impl Distro { self == &Distro::AzureLinux(AzureLinuxRelease::AzL4) } + /// Returns true for AZL4 and any later Azure Linux release. + /// + /// Use this when gating behavior on features that landed in AZL4 and + /// are expected to remain present in subsequent major releases (e.g. + /// AZL4 dropped the `grub2-efi-binary-noprefix` packaging convention; + /// AZL5+ is expected to keep that change). Strict `is_azl4()` would + /// silently regress to the AZL3 code path when AZL5 ships. + /// + /// The decision is based on the `AzureLinuxRelease` ordering AND, for + /// versions newer than what the parser recognizes, the numeric major + /// component of `version_id`. New major releases that the parser + /// hasn't been taught yet will fall through to `AzureLinuxRelease::Other`, + /// so we re-check `version_id` directly. + pub fn is_azl4_or_later(&self, version_id: Option<&str>) -> bool { + if let Distro::AzureLinux(rel) = self { + if matches!(rel, AzureLinuxRelease::AzL4) { + return true; + } + // Parser doesn't know this version yet; inspect version_id. + if matches!(rel, AzureLinuxRelease::Other) { + if let Some(major) = version_id + .and_then(|v| v.split('.').next()) + .and_then(|m| m.parse::().ok()) + { + return major >= 4; + } + } + } + false + } + pub fn is_acl(&self) -> bool { self == &Distro::AzureContainerLinux } diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index e3073aa8b..b7d16dc3c 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -6,7 +6,7 @@ use std::{ }; use anyhow::{bail, ensure, Context, Error}; -use log::{debug, trace}; +use log::{debug, trace, warn}; use reqwest::Url; use tempfile::{NamedTempFile, TempDir}; @@ -292,8 +292,24 @@ fn copy_file_artifacts( uki::stage_uki_on_esp(temp_mount_dir, mount_point, &ctx.esp_mount_path)?; } else { // In non-UKI mode, bail if grub_noprefix.efi is not found in the image. + // AZL4+ does not ship grub2-efi-binary-noprefix (AZL3-specific convention), + // so automatically skip this check for AZL4 and later. `is_azl4_or_later` + // handles AZL5+ correctly by re-checking version_id when the parser + // falls back to AzureLinuxRelease::Other. + // TODO: Two sources of truth for "noprefix not required" exist now: + // - this distro check + // - the filesystem probe in generate_boot_filepaths + // The probe is authoritative. Consider folding the check into the + // probe result (e.g. ensure! that *some* grub binary was found, + // not specifically the noprefix variant) in a follow-up. See + // 2026-05-18 PR-2 deep-review.md. + let image_os_release = ctx.image_os_release(); + let is_azl4_or_later = image_os_release + .get_distro() + .is_azl4_or_later(image_os_release.version_id.as_deref()); ensure!( grub_noprefix + || is_azl4_or_later || ctx .spec .internal_params @@ -605,6 +621,69 @@ fn copy_boot_files( Ok(no_prefix) } +/// Search EFI vendor directories for a specific binary. +/// +/// UEFI convention: each OS vendor installs its bootloader under +/// `EFI//` (e.g., `EFI/fedora/`, `EFI/azurelinux/`). +/// This function searches all subdirectories of the EFI directory +/// for the specified binary, skipping the BOOT fallback directory. +/// +/// Vendor dirs are iterated in sorted (lexicographic) order so the +/// selection is reproducible across builds when more than one vendor +/// directory contains a candidate. `read_dir` order alone is +/// filesystem-dependent (ext4 returns hash order, FAT returns +/// directory-entry order), which would produce irreproducible ESP +/// images on cross-builds and break attestation/PCR lock for the +/// selected bootloader. +fn find_efi_binary_in_vendor_dirs(efi_dir: &Path, binary_name: &str) -> Option { + let entries = match std::fs::read_dir(efi_dir) { + Ok(e) => e, + Err(e) => { + debug!("Cannot read EFI directory '{}': {}", efi_dir.display(), e); + return None; + } + }; + + // Materialize entries first so we can sort, and so a per-entry + // iterator error is logged instead of silently dropped. + let mut paths: Vec = Vec::new(); + for entry in entries { + match entry { + Ok(e) => paths.push(e.path()), + Err(e) => warn!( + "Failed to read entry under EFI directory '{}': {}", + efi_dir.display(), + e + ), + } + } + paths.sort(); + + for path in paths { + if !path.is_dir() { + continue; + } + + // Skip the BOOT directory (already checked by the caller) + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.eq_ignore_ascii_case("BOOT") { + continue; + } + } + + let candidate = path.join(binary_name); + if candidate.exists() && candidate.is_file() { + debug!( + "Found GRUB EFI executable in vendor directory: '{}'", + candidate.display() + ); + return Some(candidate); + } + } + + None +} + /// Generates a list of filepaths to the boot files that need to be copied to implement file-based /// update of ESP, relative to the mounted directory. /// @@ -642,24 +721,35 @@ fn generate_boot_filepaths(temp_mount_dir: &Path, is_uki: bool) -> Result Date: Wed, 3 Jun 2026 15:14:01 -0700 Subject: [PATCH 2/9] engineering: Clean up ESP noprefix check and grub search comments - Remove redundant ensure!(grub_noprefix) check from ESP setup. generate_boot_filepaths() already finds a working GRUB binary (noprefix, standard, or vendor-dir). The separate policy check was redundant. - Simplify copy_boot_files to return () instead of bool - Attribute grub search format variants to distro conventions (AZL3/Mariner vs AZL4/Fedora), not MIC internals - Update mixed-forms test comment to reference cross-version A/B update scenario Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/osutils/src/grub.rs | 14 ++++--- crates/trident/src/subsystems/esp.rs | 58 ++++++---------------------- 2 files changed, 20 insertions(+), 52 deletions(-) diff --git a/crates/osutils/src/grub.rs b/crates/osutils/src/grub.rs index dea58f2dd..c97183616 100644 --- a/crates/osutils/src/grub.rs +++ b/crates/osutils/src/grub.rs @@ -236,9 +236,9 @@ impl GrubConfig { /// /// 1. The upstream legacy form: `search -n -u -s` /// 2. AZL3 / standard form: `search --no-floppy --fs-uuid --set=root ` - /// 3. AZL4 MIC-generated form: `search --fs-uuid --set=root ` - /// (the `--no-floppy` option is redundant on EFI machines, so AZL4's - /// grub stub omits it.) + /// 3. AZL4 / Fedora-based form: `search --fs-uuid --set=root ` + /// (`--no-floppy` is a Mariner-specific convention; Fedora's grub2 + /// scripts don't emit it, and it's redundant on EFI machines.) /// /// We rewrite *every* matching line with the corresponding form so that /// stubs containing more than one variant (rare but possible during @@ -1006,7 +1006,7 @@ mod tests { #[test] fn test_update_search_azl4_form() { - // AZL4 MIC-generated stubs omit --no-floppy. + // AZL4 (Fedora-based) stubs omit --no-floppy. let mut grub_config = GrubConfig { path: PathBuf::new(), contents: indoc::indoc! { r#" @@ -1030,8 +1030,10 @@ mod tests { #[test] fn test_update_search_mixed_forms() { - // If both AZL3 and AZL4 forms appear (e.g. an image whose stub - // includes vendored fragments), both must be rewritten. + // Validates that all three regex paths fire independently. While a + // single grub stub typically contains one search form, cross-version + // A/B updates (e.g. AZL3->AZL4) may leave different formats across + // the boot and ESP grub configs over the machine's lifecycle. let mut grub_config = GrubConfig { path: PathBuf::new(), contents: indoc::indoc! { r#" diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index b7d16dc3c..1c81d4187 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, }; -use anyhow::{bail, ensure, Context, Error}; +use anyhow::{bail, Context, Error}; use log::{debug, trace, warn}; use reqwest::Url; use tempfile::{NamedTempFile, TempDir}; @@ -19,7 +19,7 @@ use osutils::{ use trident_api::{ config::UefiFallbackMode, constants::{ - internal_params::{DISABLE_GRUB_NOPREFIX_CHECK, RAW_COSI_STORAGE}, + internal_params::RAW_COSI_STORAGE, EFI_DEFAULT_BIN_DIRECTORY, EFI_DEFAULT_BIN_RELATIVE_PATH, ESP_EFI_DIRECTORY, GRUB2_CONFIG_FILENAME, GRUB2_CONFIG_RELATIVE_PATH, }, @@ -277,12 +277,11 @@ fn copy_file_artifacts( } // Call helper func to copy boot files from temp_mount_dir to esp_dir_path - let grub_noprefix = - copy_boot_files(temp_mount_dir, &esp_dir_path, boot_files).context(format!( - "Failed to copy boot files from directory {} to directory {}", - temp_mount_dir.display(), - esp_dir_path.display() - ))?; + copy_boot_files(temp_mount_dir, &esp_dir_path, boot_files).context(format!( + "Failed to copy boot files from directory {} to directory {}", + temp_mount_dir.display(), + esp_dir_path.display() + ))?; if ctx.is_uki().unstructured("UKI setting unknown")? { // Prepare ESP directory structure for UKI boot @@ -291,32 +290,8 @@ fn copy_file_artifacts( // Copy the UKI from the image into the ESP directory uki::stage_uki_on_esp(temp_mount_dir, mount_point, &ctx.esp_mount_path)?; } else { - // In non-UKI mode, bail if grub_noprefix.efi is not found in the image. - // AZL4+ does not ship grub2-efi-binary-noprefix (AZL3-specific convention), - // so automatically skip this check for AZL4 and later. `is_azl4_or_later` - // handles AZL5+ correctly by re-checking version_id when the parser - // falls back to AzureLinuxRelease::Other. - // TODO: Two sources of truth for "noprefix not required" exist now: - // - this distro check - // - the filesystem probe in generate_boot_filepaths - // The probe is authoritative. Consider folding the check into the - // probe result (e.g. ensure! that *some* grub binary was found, - // not specifically the noprefix variant) in a follow-up. See - // 2026-05-18 PR-2 deep-review.md. - let image_os_release = ctx.image_os_release(); - let is_azl4_or_later = image_os_release - .get_distro() - .is_azl4_or_later(image_os_release.version_id.as_deref()); - ensure!( - grub_noprefix - || is_azl4_or_later - || ctx - .spec - .internal_params - .get_flag(DISABLE_GRUB_NOPREFIX_CHECK), - "Cannot locate {GRUB_NOPREFIX_EFI} in the boot image. \ - Verify if the grub2-efi-binary-noprefix package was installed on the booted image.", - ); + // generate_boot_filepaths already found a working GRUB binary + // (noprefix, standard, or vendor-dir). No further check needed. } Ok(()) @@ -573,9 +548,7 @@ fn copy_boot_files( temp_mount_dir: &Path, esp_dir: &Path, boot_files: Vec, -) -> Result { - // Track whether grub-noprefix.efi is used - let mut no_prefix = false; +) -> Result<(), Error> { // Copy the specified files from temp_mount_path to esp_dir_path for boot_file in boot_files.iter() { let source_path = temp_mount_dir.join(boot_file); @@ -614,11 +587,10 @@ fn copy_boot_files( .context("Failed to convert path to string")?, ) .context("Failed to rename grub-noprefix efi")?; - no_prefix = true; } } - Ok(no_prefix) + Ok(()) } /// Search EFI vendor directories for a specific binary. @@ -1406,13 +1378,7 @@ mod tests { // Call helper func to create mock boot files in temp_mount_dir create_boot_files(temp_mount_dir.path(), &file_names, "test-content"); // Call helper func to copy boot files from temp_mount_dir to esp_dir - let noprefix = - copy_boot_files(temp_mount_dir.path(), esp_dir.path(), file_names.clone()).unwrap(); - - assert!( - noprefix, - "grub-noprefix.efi is in the list of files, so it should be detected" - ); + copy_boot_files(temp_mount_dir.path(), esp_dir.path(), file_names.clone()).unwrap(); for file_name in file_names.clone() { // Create full path of source_path From bb2fd89905638529632c44c39c6157073252113c Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 15:17:21 -0700 Subject: [PATCH 3/9] engineering: Remove unused is_azl4_or_later helper No callers remain after the noprefix check removal. Can be re-added if a future change needs version-range gating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/osutils/src/osrelease.rs | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/crates/osutils/src/osrelease.rs b/crates/osutils/src/osrelease.rs index 5d8caafe2..c39981c6f 100644 --- a/crates/osutils/src/osrelease.rs +++ b/crates/osutils/src/osrelease.rs @@ -353,37 +353,6 @@ impl Distro { self == &Distro::AzureLinux(AzureLinuxRelease::AzL4) } - /// Returns true for AZL4 and any later Azure Linux release. - /// - /// Use this when gating behavior on features that landed in AZL4 and - /// are expected to remain present in subsequent major releases (e.g. - /// AZL4 dropped the `grub2-efi-binary-noprefix` packaging convention; - /// AZL5+ is expected to keep that change). Strict `is_azl4()` would - /// silently regress to the AZL3 code path when AZL5 ships. - /// - /// The decision is based on the `AzureLinuxRelease` ordering AND, for - /// versions newer than what the parser recognizes, the numeric major - /// component of `version_id`. New major releases that the parser - /// hasn't been taught yet will fall through to `AzureLinuxRelease::Other`, - /// so we re-check `version_id` directly. - pub fn is_azl4_or_later(&self, version_id: Option<&str>) -> bool { - if let Distro::AzureLinux(rel) = self { - if matches!(rel, AzureLinuxRelease::AzL4) { - return true; - } - // Parser doesn't know this version yet; inspect version_id. - if matches!(rel, AzureLinuxRelease::Other) { - if let Some(major) = version_id - .and_then(|v| v.split('.').next()) - .and_then(|m| m.parse::().ok()) - { - return major >= 4; - } - } - } - false - } - pub fn is_acl(&self) -> bool { self == &Distro::AzureContainerLinux } From 2411dd9f644c95fc8686e0094d20d2f1ae7dd90f Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 16:16:00 -0700 Subject: [PATCH 4/9] engineering: Restore AZL3 noprefix guard as distro-specific check AZL3 ships two GRUB variants: grub2-efi-binary (prefix-relative config lookup) and grub2-efi-binary-noprefix (root-device-relative lookup). Trident's A/B update path requires the noprefix variant on AZL3. Restore the noprefix check, but scope it to AZL3 only using image_distro().is_azl3(). AZL4+ uses standard grubx64.efi in vendor directories and does not need noprefix. This replaces the previous generic ensure! + DISABLE_GRUB_NOPREFIX_CHECK flag with a targeted distro check. No escape hatch needed since the check only fires for AZL3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/subsystems/esp.rs | 38 ++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index 1c81d4187..e0d7afc0a 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -277,11 +277,12 @@ fn copy_file_artifacts( } // Call helper func to copy boot files from temp_mount_dir to esp_dir_path - copy_boot_files(temp_mount_dir, &esp_dir_path, boot_files).context(format!( - "Failed to copy boot files from directory {} to directory {}", - temp_mount_dir.display(), - esp_dir_path.display() - ))?; + let used_noprefix = + copy_boot_files(temp_mount_dir, &esp_dir_path, boot_files).context(format!( + "Failed to copy boot files from directory {} to directory {}", + temp_mount_dir.display(), + esp_dir_path.display() + ))?; if ctx.is_uki().unstructured("UKI setting unknown")? { // Prepare ESP directory structure for UKI boot @@ -289,9 +290,16 @@ fn copy_file_artifacts( // Copy the UKI from the image into the ESP directory uki::stage_uki_on_esp(temp_mount_dir, mount_point, &ctx.esp_mount_path)?; - } else { - // generate_boot_filepaths already found a working GRUB binary - // (noprefix, standard, or vendor-dir). No further check needed. + } else if ctx.image_distro().is_azl3() && !used_noprefix { + // AZL3 ships two GRUB variants: grub2-efi-binary (prefix-relative + // config lookup) and grub2-efi-binary-noprefix (root-device-relative + // config lookup). Trident's A/B update path requires the noprefix + // variant. If the image shipped the wrong one, fail early rather + // than producing an unbootable machine. + bail!( + "AZL3 image does not contain {GRUB_NOPREFIX_EFI}. \ + Trident requires the grub2-efi-binary-noprefix package on AZL3." + ); } Ok(()) @@ -548,7 +556,8 @@ fn copy_boot_files( temp_mount_dir: &Path, esp_dir: &Path, boot_files: Vec, -) -> Result<(), Error> { +) -> Result { + let mut used_noprefix = false; // Copy the specified files from temp_mount_path to esp_dir_path for boot_file in boot_files.iter() { let source_path = temp_mount_dir.join(boot_file); @@ -587,10 +596,11 @@ fn copy_boot_files( .context("Failed to convert path to string")?, ) .context("Failed to rename grub-noprefix efi")?; + used_noprefix = true; } } - Ok(()) + Ok(used_noprefix) } /// Search EFI vendor directories for a specific binary. @@ -1378,7 +1388,13 @@ mod tests { // Call helper func to create mock boot files in temp_mount_dir create_boot_files(temp_mount_dir.path(), &file_names, "test-content"); // Call helper func to copy boot files from temp_mount_dir to esp_dir - copy_boot_files(temp_mount_dir.path(), esp_dir.path(), file_names.clone()).unwrap(); + let used_noprefix = + copy_boot_files(temp_mount_dir.path(), esp_dir.path(), file_names.clone()).unwrap(); + + assert!( + used_noprefix, + "grub-noprefix.efi is in the list of files, so it should be detected" + ); for file_name in file_names.clone() { // Create full path of source_path From d5846c21aa7632df10560da32a9e07ba36212a34 Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 16:22:31 -0700 Subject: [PATCH 5/9] fix: Restore grub_noprefix name and DISABLE_GRUB_NOPREFIX_CHECK flag Keep the original variable name and preserve the operator escape hatch. Minimize diff from upstream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/subsystems/esp.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index e0d7afc0a..8bd900bb1 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -19,7 +19,7 @@ use osutils::{ use trident_api::{ config::UefiFallbackMode, constants::{ - internal_params::RAW_COSI_STORAGE, + internal_params::{DISABLE_GRUB_NOPREFIX_CHECK, RAW_COSI_STORAGE}, EFI_DEFAULT_BIN_DIRECTORY, EFI_DEFAULT_BIN_RELATIVE_PATH, ESP_EFI_DIRECTORY, GRUB2_CONFIG_FILENAME, GRUB2_CONFIG_RELATIVE_PATH, }, @@ -277,7 +277,7 @@ fn copy_file_artifacts( } // Call helper func to copy boot files from temp_mount_dir to esp_dir_path - let used_noprefix = + let grub_noprefix = copy_boot_files(temp_mount_dir, &esp_dir_path, boot_files).context(format!( "Failed to copy boot files from directory {} to directory {}", temp_mount_dir.display(), @@ -290,7 +290,10 @@ fn copy_file_artifacts( // Copy the UKI from the image into the ESP directory uki::stage_uki_on_esp(temp_mount_dir, mount_point, &ctx.esp_mount_path)?; - } else if ctx.image_distro().is_azl3() && !used_noprefix { + } else if ctx.image_distro().is_azl3() + && !grub_noprefix + && !ctx.spec.internal_params.get_flag(DISABLE_GRUB_NOPREFIX_CHECK) + { // AZL3 ships two GRUB variants: grub2-efi-binary (prefix-relative // config lookup) and grub2-efi-binary-noprefix (root-device-relative // config lookup). Trident's A/B update path requires the noprefix @@ -557,7 +560,7 @@ fn copy_boot_files( esp_dir: &Path, boot_files: Vec, ) -> Result { - let mut used_noprefix = false; + let mut no_prefix = false; // Copy the specified files from temp_mount_path to esp_dir_path for boot_file in boot_files.iter() { let source_path = temp_mount_dir.join(boot_file); @@ -596,11 +599,11 @@ fn copy_boot_files( .context("Failed to convert path to string")?, ) .context("Failed to rename grub-noprefix efi")?; - used_noprefix = true; + no_prefix = true; } } - Ok(used_noprefix) + Ok(no_prefix) } /// Search EFI vendor directories for a specific binary. From 5ad0c6a3dc97fb9db1b557183bd973d169ee0377 Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 16:46:25 -0700 Subject: [PATCH 6/9] fix: Use ensure! instead of bail for noprefix check Keep the same macro as upstream to minimize diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/subsystems/esp.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index 8bd900bb1..ae90c8512 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, }; -use anyhow::{bail, Context, Error}; +use anyhow::{bail, ensure, Context, Error}; use log::{debug, trace, warn}; use reqwest::Url; use tempfile::{NamedTempFile, TempDir}; @@ -290,18 +290,20 @@ fn copy_file_artifacts( // Copy the UKI from the image into the ESP directory uki::stage_uki_on_esp(temp_mount_dir, mount_point, &ctx.esp_mount_path)?; - } else if ctx.image_distro().is_azl3() - && !grub_noprefix - && !ctx.spec.internal_params.get_flag(DISABLE_GRUB_NOPREFIX_CHECK) - { + } else if ctx.image_distro().is_azl3() { // AZL3 ships two GRUB variants: grub2-efi-binary (prefix-relative // config lookup) and grub2-efi-binary-noprefix (root-device-relative // config lookup). Trident's A/B update path requires the noprefix // variant. If the image shipped the wrong one, fail early rather // than producing an unbootable machine. - bail!( - "AZL3 image does not contain {GRUB_NOPREFIX_EFI}. \ - Trident requires the grub2-efi-binary-noprefix package on AZL3." + ensure!( + grub_noprefix + || ctx + .spec + .internal_params + .get_flag(DISABLE_GRUB_NOPREFIX_CHECK), + "Cannot locate {GRUB_NOPREFIX_EFI} in the boot image. \ + Verify if the grub2-efi-binary-noprefix package was installed on the booted image.", ); } From 74ead34bc49c17544726c0982e3c845c46950fee Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 16:48:57 -0700 Subject: [PATCH 7/9] fix: Revert replace_all back to replace in update_search Keep the original if/else if chain with replace (first match). No real-world grub config has multiple search lines. Minimizes diff from upstream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/osutils/src/grub.rs | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/crates/osutils/src/grub.rs b/crates/osutils/src/grub.rs index c97183616..f55476b82 100644 --- a/crates/osutils/src/grub.rs +++ b/crates/osutils/src/grub.rs @@ -239,43 +239,30 @@ impl GrubConfig { /// 3. AZL4 / Fedora-based form: `search --fs-uuid --set=root ` /// (`--no-floppy` is a Mariner-specific convention; Fedora's grub2 /// scripts don't emit it, and it's redundant on EFI machines.) - /// - /// We rewrite *every* matching line with the corresponding form so that - /// stubs containing more than one variant (rare but possible during - /// distribution transitions) all get the new UUID. We bail only if no - /// regex matched any line. pub fn update_search(&mut self, uuid: &Uuid) -> Result<(), Error> { let re = Regex::new(r"(?m)^(\s*)search -n -u [\w-]+ -s$").unwrap(); let re2 = Regex::new(r"(?m)^(\s*)search --no-floppy --fs-uuid --set=root [\w-]+$").unwrap(); let re3 = Regex::new(r"(?m)^(\s*)search --fs-uuid --set=root [\w-]+$").unwrap(); - let mut matched = false; if re.is_match(&self.contents) { self.contents = re - .replace_all(&self.contents, &format!("${{1}}search -n -u {uuid} -s")) + .replace(&self.contents, &format!("${{1}}search -n -u {uuid} -s")) .to_string(); - matched = true; - } - if re2.is_match(&self.contents) { + } else if re2.is_match(&self.contents) { self.contents = re2 - .replace_all( + .replace( &self.contents, &format!("${{1}}search --no-floppy --fs-uuid --set=root {uuid}"), ) .to_string(); - matched = true; - } - if re3.is_match(&self.contents) { + } else if re3.is_match(&self.contents) { self.contents = re3 - .replace_all( + .replace( &self.contents, &format!("${{1}}search --fs-uuid --set=root {uuid}"), ) .to_string(); - matched = true; - } - - if !matched { + } else { bail!( "Unable to find search command in '{}'", &self.path.display() From ed333bf91e76ad1a8fc955ae3221a2e521b4bd4c Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 16:52:52 -0700 Subject: [PATCH 8/9] fix: Restore original test variable name noprefix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/trident/src/subsystems/esp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index ae90c8512..1ba98ea41 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -1393,11 +1393,11 @@ mod tests { // Call helper func to create mock boot files in temp_mount_dir create_boot_files(temp_mount_dir.path(), &file_names, "test-content"); // Call helper func to copy boot files from temp_mount_dir to esp_dir - let used_noprefix = + let noprefix = copy_boot_files(temp_mount_dir.path(), esp_dir.path(), file_names.clone()).unwrap(); assert!( - used_noprefix, + noprefix, "grub-noprefix.efi is in the list of files, so it should be detected" ); From 550ff11ba90a5876bdbf4443f983b1249df4f806 Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 17:52:24 -0700 Subject: [PATCH 9/9] fix: Remove mixed-forms test incompatible with if/else if chain Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/osutils/src/grub.rs | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/crates/osutils/src/grub.rs b/crates/osutils/src/grub.rs index f55476b82..352064bee 100644 --- a/crates/osutils/src/grub.rs +++ b/crates/osutils/src/grub.rs @@ -1015,34 +1015,6 @@ mod tests { assert!(!grub_config.contents.contains("--no-floppy")); } - #[test] - fn test_update_search_mixed_forms() { - // Validates that all three regex paths fire independently. While a - // single grub stub typically contains one search form, cross-version - // A/B updates (e.g. AZL3->AZL4) may leave different formats across - // the boot and ESP grub configs over the machine's lifecycle. - let mut grub_config = GrubConfig { - path: PathBuf::new(), - contents: indoc::indoc! { r#" - search --no-floppy --fs-uuid --set=root oldoldold-cafe-babe-0000-aaaabbbbcccc - search --fs-uuid --set=root oldoldold-cafe-babe-0000-aaaabbbbcccc - "# } - .to_owned(), - linux_command_line: None, - }; - - let new_uuid = Uuid::parse_str("9e6a9d2c-b7fe-4359-ac45-18b505e29d8c").unwrap(); - grub_config.update_search(&new_uuid).unwrap(); - - assert!(!grub_config.contents.contains("oldoldold")); - assert!(grub_config.contents.contains(&format!( - "search --no-floppy --fs-uuid --set=root {new_uuid}" - ))); - assert!(grub_config - .contents - .contains(&format!("search --fs-uuid --set=root {new_uuid}"))); - } - #[test] fn test_update_rootdevice() { // Define original GRUB config contents on target machine