Skip to content
62 changes: 62 additions & 0 deletions crates/osutils/src/grub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,18 @@ 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 <UUID> -s`
/// 2. AZL3 / standard form: `search --no-floppy --fs-uuid --set=root <UUID>`
/// 3. AZL4 / Fedora-based form: `search --fs-uuid --set=root <UUID>`
/// (`--no-floppy` is a Mariner-specific convention; Fedora's grub2
/// scripts don't emit it, and it's redundant on EFI machines.)
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();

if re.is_match(&self.contents) {
self.contents = re
Expand All @@ -246,6 +255,13 @@ impl GrubConfig {
&format!("${{1}}search --no-floppy --fs-uuid --set=root {uuid}"),
)
.to_string();
} else if re3.is_match(&self.contents) {
self.contents = re3
.replace(
&self.contents,
&format!("${{1}}search --fs-uuid --set=root {uuid}"),
)
.to_string();
} else {
bail!(
"Unable to find search command in '{}'",
Expand Down Expand Up @@ -953,6 +969,52 @@ mod tests {
.unwrap();
}

#[test]
fn test_update_search_azl3_form() {
// AZL3 stubs use `search --no-floppy --fs-uuid --set=root <UUID>`.
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 (Fedora-based) 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_rootdevice() {
// Define original GRUB config contents on target machine
Expand Down
171 changes: 152 additions & 19 deletions crates/trident/src/subsystems/esp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -290,8 +290,12 @@ 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.
} 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.
ensure!(
grub_noprefix
|| ctx
Expand Down Expand Up @@ -558,7 +562,6 @@ fn copy_boot_files(
esp_dir: &Path,
boot_files: Vec<PathBuf>,
) -> Result<bool, Error> {
// Track whether grub-noprefix.efi is used
let mut no_prefix = false;
// Copy the specified files from temp_mount_path to esp_dir_path
for boot_file in boot_files.iter() {
Expand Down Expand Up @@ -605,6 +608,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/<vendor>/` (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<PathBuf> {
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<PathBuf> = 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.
///
Expand Down Expand Up @@ -642,24 +708,35 @@ fn generate_boot_filepaths(temp_mount_dir: &Path, is_uki: bool) -> Result<Vec<Pa
paths.push(selected_grub_config_path);
}

// Check if the grub-noprefix EFI executable exists; otherwise, use the standard
// grub EFI executable (e.g., grubx64.efi). For example, on AMD64 systems, with
// the package update to use the grub2-efi-binary-noprefix RPM, the EFI executable
// would be installed as grubx64-noprefix.efi.
let grub_efi_noprefix_path = Path::new(temp_mount_dir)
.join(EFI_DEFAULT_BIN_RELATIVE_PATH)
// Discover the GRUB EFI executable by searching known locations.
// Priority order:
// 1. EFI/BOOT/grubx64-noprefix.efi (AZL3 noprefix package)
// 2. EFI/BOOT/grubx64.efi (standard UEFI fallback location)
// 3. EFI/*/grubx64.efi (vendor directory, e.g. EFI/fedora/ on AZL4)
let efi_dir = Path::new(temp_mount_dir).join(ESP_EFI_DIRECTORY);
let grub_efi_noprefix_path = efi_dir
.join(EFI_DEFAULT_BIN_DIRECTORY)
.join(GRUB_NOPREFIX_EFI);
let grub_efi_path = Path::new(temp_mount_dir)
.join(EFI_DEFAULT_BIN_RELATIVE_PATH)
.join(GRUB_EFI);
let grub_efi_default_path = efi_dir.join(EFI_DEFAULT_BIN_DIRECTORY).join(GRUB_EFI);

let selected_grub_binary_path =
if grub_efi_noprefix_path.exists() && grub_efi_noprefix_path.is_file() {
grub_efi_noprefix_path
} else if grub_efi_path.exists() && grub_efi_path.is_file() {
grub_efi_path
} else if grub_efi_default_path.exists() && grub_efi_default_path.is_file() {
grub_efi_default_path
} else if let Some(vendor_path) = find_efi_binary_in_vendor_dirs(&efi_dir, GRUB_EFI) {
vendor_path
} else {
bail!("Failed to find GRUB EFI executable");
// Log what we searched to help diagnose image packaging issues
let searched = [
grub_efi_noprefix_path.display().to_string(),
grub_efi_default_path.display().to_string(),
format!("{}/*/{}", efi_dir.display(), GRUB_EFI),
];
bail!(
"Failed to find GRUB EFI executable. Searched: {}",
searched.join(", ")
);
};
debug!(
"Using GRUB EFI executable from '{}'",
Expand Down Expand Up @@ -1423,12 +1500,16 @@ mod tests {
// Test case 4: Run generate_boot_filepaths() without grub EFI executable
// Remove old grub EFI executable
fs::remove_file(&grub_efi_path).unwrap();
assert_eq!(
assert!(
generate_boot_filepaths(temp_mount_dir.path(), false)
.unwrap_err()
.root_cause()
.to_string()
.contains("Failed to find GRUB EFI executable"),
"Error should mention GRUB EFI, got: {}",
generate_boot_filepaths(temp_mount_dir.path(), false)
.unwrap_err()
.root_cause()
.to_string(),
"Failed to find GRUB EFI executable"
);

// Test case 5: Run generate_boot_filepaths() with a grub EFI executable with noprefix name
Expand Down Expand Up @@ -1467,4 +1548,56 @@ mod tests {
)
);
}

#[test]
fn test_find_efi_binary_in_vendor_dirs() {
// Vendor-dir discovery success path.
let efi_dir = TempDir::new().unwrap();
let vendor = efi_dir.path().join("azurelinux");
fs::create_dir(&vendor).unwrap();
let binary = vendor.join(GRUB_EFI);
File::create(&binary).unwrap();

let found = find_efi_binary_in_vendor_dirs(efi_dir.path(), GRUB_EFI)
.expect("vendor binary not found");
assert_eq!(found, binary);
}

#[test]
fn test_find_efi_binary_in_vendor_dirs_is_deterministic() {
// When multiple vendor dirs contain the binary, we pick the
// lexicographically-first one so the choice is reproducible
// across builds. read_dir alone is filesystem-order dependent.
let efi_dir = TempDir::new().unwrap();
for vendor in ["fedora", "azurelinux", "AZLA"] {
let dir = efi_dir.path().join(vendor);
fs::create_dir(&dir).unwrap();
File::create(dir.join(GRUB_EFI)).unwrap();
}

let found = find_efi_binary_in_vendor_dirs(efi_dir.path(), GRUB_EFI)
.expect("vendor binary not found");
// Lexicographic order with case-sensitive ASCII: 'A' < 'a'
assert!(
found.ends_with("AZLA/grubx64.efi") || found.ends_with("AZLA\\grubx64.efi"),
"expected AZLA to win lexicographic sort, got '{}'",
found.display()
);
}

#[test]
fn test_find_efi_binary_in_vendor_dirs_skips_boot() {
// Even if EFI/BOOT/grubx64.efi exists (the standard fallback,
// checked by the caller), vendor-dir discovery should skip
// BOOT and look for a real vendor dir.
let efi_dir = TempDir::new().unwrap();
let boot = efi_dir.path().join("BOOT");
fs::create_dir(&boot).unwrap();
File::create(boot.join(GRUB_EFI)).unwrap();

assert!(
find_efi_binary_in_vendor_dirs(efi_dir.path(), GRUB_EFI).is_none(),
"BOOT should be skipped by vendor-dir discovery"
);
}
}