diff --git a/crates/trident/src/engine/boot/uki.rs b/crates/trident/src/engine/boot/uki.rs index 4b5728d6c..4b8414b98 100644 --- a/crates/trident/src/engine/boot/uki.rs +++ b/crates/trident/src/engine/boot/uki.rs @@ -396,6 +396,81 @@ pub fn find_previous_uki(esp_dir_path: &Path) -> Result { } } +/// Path within the image ESP where ACL stores verity addon templates for A/B slots. +const VERITY_ADDON_TEMPLATES_DIR: &str = "acl/uki-addons"; + +/// Filename of the active verity addon placed in the UKI's `.extra.d/` directory. +const VERITY_ADDON_FILENAME: &str = "verity.addon.efi"; + +/// After staging the UKI, activate the correct verity addon for the target +/// A/B volume. ACL images ship with slot-A active by default and include +/// templates for both slots in `acl/uki-addons/` on the ESP image. +/// +/// This is ACL-specific: if no verity addon templates exist on the image +/// (i.e. a non-ACL image), this function is a silent no-op. However, if +/// templates exist but the selected slot's template is missing, an error +/// is returned to prevent booting with the wrong slot's PARTUUIDs. +pub fn activate_verity_addon_for_target_volume( + image_esp_mount: &Path, + mount_point: &Path, + esp_mount_path: &Path, + target_volume: AbVolumeSelection, +) -> Result<(), Error> { + let template_dir = image_esp_mount.join(VERITY_ADDON_TEMPLATES_DIR); + if !template_dir.exists() { + // Image does not use PARTUUID-based verity addons (non-ACL or older ACL). + trace!( + "No verity addon template directory at '{}', skipping", + template_dir.display() + ); + return Ok(()); + } + + let template_name = match target_volume { + AbVolumeSelection::VolumeA => "verity-a.addon.efi", + AbVolumeSelection::VolumeB => "verity-b.addon.efi", + }; + + let template_path = template_dir.join(template_name); + ensure!( + template_path.exists(), + "Verity addon template '{}' not found in '{}' — cannot activate {:?}", + template_name, + template_dir.display(), + target_volume + ); + + let staging_addon_dir = join_relative(mount_point, esp_mount_path) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + + if !staging_addon_dir.exists() { + fs::create_dir_all(&staging_addon_dir).with_context(|| { + format!( + "Failed to create staged addon directory '{}'", + staging_addon_dir.display() + ) + })?; + } + + let dest = staging_addon_dir.join(VERITY_ADDON_FILENAME); + debug!( + "Activating verity addon for {:?}: '{}' → '{}'", + target_volume, + template_path.display(), + dest.display() + ); + fs::copy(&template_path, &dest).with_context(|| { + format!( + "Failed to copy verity addon template '{}' to '{}'", + template_path.display(), + dest.display() + ) + })?; + + Ok(()) +} + /// Construct the previous UKI filename and set it as the default boot entry. pub fn use_previous_uki_as_default(esp_dir_path: &Path) -> Result<(), TridentError> { let previous_uki_entry_path = find_previous_uki(esp_dir_path)?; @@ -858,4 +933,194 @@ mod tests { assert!(uki_dir.join("vmlinuz-100-azla2.efi").exists()); assert!(!uki_dir.join("vmlinuz-100-azla2.efi.extra.d").exists()); } + + // ── activate_verity_addon_for_target_volume tests ────────────────────── + + /// Helper: creates a mock image ESP with verity addon templates. + fn setup_image_with_verity_templates(image_esp: &Path) -> (PathBuf, PathBuf) { + let template_dir = image_esp.join(VERITY_ADDON_TEMPLATES_DIR); + fs::create_dir_all(&template_dir).unwrap(); + let a_path = template_dir.join("verity-a.addon.efi"); + let b_path = template_dir.join("verity-b.addon.efi"); + fs::write(&a_path, b"verity-a-content").unwrap(); + fs::write(&b_path, b"verity-b-content").unwrap(); + (a_path, b_path) + } + + /// Activating for VolumeA copies verity-a template into the staged addon dir. + #[test] + fn test_activate_verity_addon_volume_a() { + let image_esp = tempdir().unwrap(); + setup_image_with_verity_templates(image_esp.path()); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + // Create the staged addon dir as stage_uki_on_esp would + let staged_addon_dir = join_relative(mount_point.path(), DEFAULT_ESP_MOUNT_POINT_PATH) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + fs::create_dir_all(&staged_addon_dir).unwrap(); + + activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeA, + ) + .unwrap(); + + let active = staged_addon_dir.join(VERITY_ADDON_FILENAME); + assert!(active.exists()); + assert_eq!(fs::read(&active).unwrap(), b"verity-a-content"); + } + + /// Activating for VolumeB copies verity-b template into the staged addon dir. + #[test] + fn test_activate_verity_addon_volume_b() { + let image_esp = tempdir().unwrap(); + setup_image_with_verity_templates(image_esp.path()); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + let staged_addon_dir = join_relative(mount_point.path(), DEFAULT_ESP_MOUNT_POINT_PATH) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + fs::create_dir_all(&staged_addon_dir).unwrap(); + + activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeB, + ) + .unwrap(); + + let active = staged_addon_dir.join(VERITY_ADDON_FILENAME); + assert!(active.exists()); + assert_eq!(fs::read(&active).unwrap(), b"verity-b-content"); + } + + /// No template directory at all → silent no-op (backward compat with non-ACL). + #[test] + fn test_activate_verity_addon_no_template_dir() { + let image_esp = tempdir().unwrap(); + // No acl/uki-addons/ directory + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + // Should succeed silently + activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeA, + ) + .unwrap(); + + // No addon dir should have been created + let staged_addon_dir = join_relative(mount_point.path(), DEFAULT_ESP_MOUNT_POINT_PATH) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + assert!(!staged_addon_dir.exists()); + } + + /// Template directory exists but selected slot template is missing → error. + #[test] + fn test_activate_verity_addon_missing_selected_template() { + let image_esp = tempdir().unwrap(); + let template_dir = image_esp.path().join(VERITY_ADDON_TEMPLATES_DIR); + fs::create_dir_all(&template_dir).unwrap(); + // Only write verity-a, not verity-b + fs::write(template_dir.join("verity-a.addon.efi"), b"a-content").unwrap(); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + let result = activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeB, + ); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("verity-b.addon.efi"), + "Error should mention the missing template" + ); + } + + /// Creates the staged addon dir when templates exist but no addon dir was staged. + #[test] + fn test_activate_verity_addon_creates_addon_dir() { + let image_esp = tempdir().unwrap(); + setup_image_with_verity_templates(image_esp.path()); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + // Do NOT pre-create the staged addon dir + activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeB, + ) + .unwrap(); + + let staged_addon_dir = join_relative(mount_point.path(), DEFAULT_ESP_MOUNT_POINT_PATH) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + assert!(staged_addon_dir.join(VERITY_ADDON_FILENAME).exists()); + assert_eq!( + fs::read(staged_addon_dir.join(VERITY_ADDON_FILENAME)).unwrap(), + b"verity-b-content" + ); + } + + /// Other addons in the staged dir are preserved when activating verity addon. + #[test] + fn test_activate_verity_addon_preserves_other_addons() { + let image_esp = tempdir().unwrap(); + setup_image_with_verity_templates(image_esp.path()); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path(), Path::new(DEFAULT_ESP_MOUNT_POINT_PATH)).unwrap(); + + let staged_addon_dir = join_relative(mount_point.path(), DEFAULT_ESP_MOUNT_POINT_PATH) + .join(UKI_DIRECTORY) + .join(TMP_UKI_ADDON_DIR_NAME); + fs::create_dir_all(&staged_addon_dir).unwrap(); + // Pre-existing addon that should not be touched + fs::write( + staged_addon_dir.join("firstboot.addon.efi"), + b"firstboot-data", + ) + .unwrap(); + + activate_verity_addon_for_target_volume( + image_esp.path(), + mount_point.path(), + Path::new(DEFAULT_ESP_MOUNT_POINT_PATH), + AbVolumeSelection::VolumeA, + ) + .unwrap(); + + // Verity addon should be activated + assert_eq!( + fs::read(staged_addon_dir.join(VERITY_ADDON_FILENAME)).unwrap(), + b"verity-a-content" + ); + // Other addon should be untouched + assert_eq!( + fs::read(staged_addon_dir.join("firstboot.addon.efi")).unwrap(), + b"firstboot-data" + ); + } } diff --git a/crates/trident/src/engine/newroot.rs b/crates/trident/src/engine/newroot.rs index e21d1fa96..c687be1e8 100644 --- a/crates/trident/src/engine/newroot.rs +++ b/crates/trident/src/engine/newroot.rs @@ -232,11 +232,16 @@ impl NewrootMount { })?; }, _ => { + // ensure temp_fsid is an option + let mut options = mp.options.to_string_vec(); + if !options.contains(&"temp_fsid".to_string()) { + options.push("temp_fsid".into()); + } mount::mount( device_path, &target_path, MountFileSystemType::Auto, - &mp.options.to_string_vec(), + &options, ) .context(format!( "Failed to mount block device '{}' with device path '{}' to '{}'", diff --git a/crates/trident/src/osimage/cosi/metadata.rs b/crates/trident/src/osimage/cosi/metadata.rs index dad49ff56..c5ca32156 100644 --- a/crates/trident/src/osimage/cosi/metadata.rs +++ b/crates/trident/src/osimage/cosi/metadata.rs @@ -360,6 +360,19 @@ pub(crate) struct BootloaderEntry { #[allow(dead_code)] pub cmdline: String, + + #[allow(dead_code)] + #[serde(default)] + pub addons: Vec, +} + +#[derive(Debug, Deserialize, Clone, Eq, PartialEq)] +pub(crate) struct UkiAddon { + #[allow(dead_code)] + pub path: String, + + #[allow(dead_code)] + pub cmdline: String, } #[derive(Debug, Deserialize, Clone, Eq, PartialEq, Display)] diff --git a/crates/trident/src/subsystems/esp.rs b/crates/trident/src/subsystems/esp.rs index e3073aa8b..36879aaaf 100644 --- a/crates/trident/src/subsystems/esp.rs +++ b/crates/trident/src/subsystems/esp.rs @@ -290,6 +290,21 @@ 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)?; + + // For ACL A/B images, activate the verity addon matching the target slot. + // The image ships with slot A's addon active; this swaps it when updating + // to slot B (or confirms slot A for clean installs). Non-ACL images have + // no template directory and this is a no-op. + if ctx.image_distro().is_acl() { + if let Some(target_volume) = ctx.get_ab_update_volume() { + uki::activate_verity_addon_for_target_volume( + temp_mount_dir, + mount_point, + &ctx.esp_mount_path, + target_volume, + )?; + } + } } else { // In non-UKI mode, bail if grub_noprefix.efi is not found in the image. ensure!( diff --git a/crates/trident/src/subsystems/storage/osimage.rs b/crates/trident/src/subsystems/storage/osimage.rs index e699f00ac..4279bdd76 100644 --- a/crates/trident/src/subsystems/storage/osimage.rs +++ b/crates/trident/src/subsystems/storage/osimage.rs @@ -189,6 +189,9 @@ fn validate_filesystems(os_image: &OsImage, ctx: &EngineContext) -> Result<(), T /// Validates that all filesystems within an OS image have unique FS UUIDs. Additionally, validates /// that A/B volume pairs have distinct FS UUIDs. +/// +/// The A/B cross-check is skipped for ACL, which uses identical FS UUIDs across A/B slots by +/// design (partitions are distinguished by PARTUUID instead). fn validate_filesystem_uniqueness( os_image: &OsImage, ctx: &EngineContext, @@ -204,8 +207,9 @@ fn validate_filesystem_uniqueness( } } - // For A/B Update, check that no A/B volumes share filesystem UUIDs - if ctx.servicing_type == ServicingType::AbUpdate { + // For A/B Update, check that no A/B volumes share filesystem UUIDs. + // ACL uses the same FS UUID for both A/B slots — partitions are identified by PARTUUID. + if ctx.servicing_type == ServicingType::AbUpdate && !ctx.image_distro().is_acl() { if let Some(ab) = &ctx.spec.storage.ab_update { for pair in ab.volume_pairs.iter() { if let Some(mp_info) = ctx.spec.storage.device_id_to_mount_point_info(&pair.id) { diff --git a/tools/cmd/mkcosi/generator/cih.go b/tools/cmd/mkcosi/generator/cih.go index d6681d28f..16089dc25 100644 --- a/tools/cmd/mkcosi/generator/cih.go +++ b/tools/cmd/mkcosi/generator/cih.go @@ -268,9 +268,9 @@ func populateCIHFilesystemMetadata(cosiMeta *metadata.MetadataJson, partInfos [] return nil } -// extractUsrhashFromUKIEntries searches the UKI boot entries for a -// "usrhash=" kernel command-line parameter and returns the hash value. -// Returns an empty string if not found. +// extractUsrhashFromUKIEntries searches the UKI boot entries and their addons +// for a "usrhash=" kernel command-line parameter and returns the hash +// value. Returns an empty string if not found. func extractUsrhashFromUKIEntries(entries []metadata.SystemDBootEntry) string { for _, entry := range entries { for _, field := range strings.Fields(entry.Cmdline) { @@ -278,6 +278,13 @@ func extractUsrhashFromUKIEntries(entries []metadata.SystemDBootEntry) string { return after } } + for _, addon := range entry.Addons { + for _, field := range strings.Fields(addon.Cmdline) { + if after, found := strings.CutPrefix(field, "usrhash="); found { + return after + } + } + } } return "" } diff --git a/tools/cmd/mkcosi/generator/generator.go b/tools/cmd/mkcosi/generator/generator.go index ac50b49fe..289b9c4b4 100644 --- a/tools/cmd/mkcosi/generator/generator.go +++ b/tools/cmd/mkcosi/generator/generator.go @@ -1085,17 +1085,63 @@ func findUkiEntries(espMountPath string, espMountPoint string) []metadata.System continue } + // Scan for UKI addons in .extra.d/ + addons := findUkiAddons(ukiDir, name, espMountPoint) + entries = append(entries, metadata.SystemDBootEntry{ Type: metadata.SystemDBootEntryTypeUkiStandalone, Path: absFsPath, Kernel: kernel, Cmdline: cmdline, + Addons: addons, }) } return entries } +// findUkiAddons scans the .extra.d/ directory for UKI addon files +// (*.addon.efi) and extracts their .cmdline PE sections. systemd-stub loads +// these addons at boot and appends their cmdline args to the UKI's own cmdline. +func findUkiAddons(ukiDir string, ukiName string, espMountPoint string) []metadata.UkiAddon { + addonDirName := ukiName + ".extra.d" + addonHostDir := filepath.Join(ukiDir, addonDirName) + + addonEntries, err := os.ReadDir(addonHostDir) + if err != nil { + // No .extra.d directory is normal for UKIs without addons + return nil + } + + var addons []metadata.UkiAddon + for _, ae := range addonEntries { + if ae.IsDir() { + continue + } + aName := ae.Name() + if !strings.HasSuffix(strings.ToLower(aName), ".addon.efi") { + continue + } + + addonHostPath := filepath.Join(addonHostDir, aName) + addonFsPath := filepath.Join(espMountPoint, "EFI", "Linux", addonDirName, aName) + + cmdline := extractUkiSection(addonHostPath, ".cmdline") + if cmdline == "" { + log.WithField("path", addonFsPath).Debug("Skipping addon with no .cmdline section") + continue + } + + log.WithField("path", addonFsPath).Debugf("Found UKI addon: %s", cmdline) + addons = append(addons, metadata.UkiAddon{ + Path: addonFsPath, + Cmdline: cmdline, + }) + } + + return addons +} + // extractUkiSection extracts a named PE section from a UKI .efi file using // objcopy. The UKI is first copied to a writable temp directory because objcopy // creates a temporary file next to the input, which fails on read-only mounts. diff --git a/tools/cmd/mkcosi/metadata/metadata.go b/tools/cmd/mkcosi/metadata/metadata.go index 03a4f9f2e..b24639169 100644 --- a/tools/cmd/mkcosi/metadata/metadata.go +++ b/tools/cmd/mkcosi/metadata/metadata.go @@ -65,6 +65,12 @@ type SystemDBootEntry struct { Path string `json:"path"` Cmdline string `json:"cmdline"` Kernel string `json:"kernel"` + Addons []UkiAddon `json:"addons,omitempty"` +} + +type UkiAddon struct { + Path string `json:"path"` + Cmdline string `json:"cmdline"` } type Compression struct {