From a2ba538ead5630394a0407ef7b4317c12e1b6b84 Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Wed, 29 Apr 2026 19:33:58 -0700 Subject: [PATCH 1/7] bootctl: fix removing variables on uninstall remove_variables looks up the EFI boot entry by matching both the path and the partition UUID and it wasn't actually removing any entries because verb_remove was passing SD_ID128_NULL, so the lookup never matched and Boot#### entries were left behind on uninstall. Fixes 38433a6 --- src/bootctl/bootctl-install.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index 77d03e72b6..535019e1e9 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -1859,7 +1859,6 @@ static int remove_loader_variables(void) { } int verb_remove(int argc, char *argv[], uintptr_t _data, void *userdata) { - sd_id128_t uuid = SD_ID128_NULL; int r; _cleanup_(install_context_done) InstallContext c = INSTALL_CONTEXT_NULL; @@ -1927,7 +1926,7 @@ int verb_remove(int argc, char *argv[], uintptr_t _data, void *userdata) { } char *path = strjoina("/EFI/systemd/systemd-boot", get_efi_arch(), ".efi"); - RET_GATHER(r, remove_variables(uuid, path, /* in_order= */ true)); + RET_GATHER(r, remove_variables(c.esp_uuid, path, /* in_order= */ true)); return RET_GATHER(r, remove_loader_variables()); } From 1027cbd8ae6a01799e8f4c002a2d46ee3a8043a9 Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Tue, 12 May 2026 10:47:17 -0700 Subject: [PATCH 2/7] bootctl: rename install_variables/remove_variables to install/remove_boot_option These functions install or remove a single EFI Boot#### entry, not "all variables," so this renames them to better reflect what they do. --- src/bootctl/bootctl-install.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index 535019e1e9..4478f78eb3 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -1352,7 +1352,7 @@ fallback: return 0; } -static int install_variables( +static int install_boot_option( InstallContext *c, const char *path) { @@ -1632,7 +1632,7 @@ static int run_install(InstallContext *c) { } char *path = strjoina("/EFI/systemd/systemd-boot", arch, ".efi"); - return install_variables(c, path); + return install_boot_option(c, path); } int verb_install(int argc, char *argv[], uintptr_t _data, void *userdata) { @@ -1810,7 +1810,7 @@ static int remove_binaries(InstallContext *c) { return RET_GATHER(r, remove_boot_efi(c)); } -static int remove_variables(sd_id128_t uuid, const char *path, bool in_order) { +static int remove_boot_option(sd_id128_t uuid, const char *path, bool in_order) { uint16_t slot; int r; @@ -1926,7 +1926,7 @@ int verb_remove(int argc, char *argv[], uintptr_t _data, void *userdata) { } char *path = strjoina("/EFI/systemd/systemd-boot", get_efi_arch(), ".efi"); - RET_GATHER(r, remove_variables(c.esp_uuid, path, /* in_order= */ true)); + RET_GATHER(r, remove_boot_option(c.esp_uuid, path, /* in_order= */ true)); return RET_GATHER(r, remove_loader_variables()); } From bb520fd6a578263cf6394733a5f316d0255a787a Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Wed, 29 Apr 2026 17:10:05 -0700 Subject: [PATCH 3/7] bootctl: add description and ret_slot parameters to install_boot_option() This moves creation of the EFI boot option description out of install_boot_option and into the caller, and adds a ret_slot output parameter for capturing the assigned BootOrder slot. This allows reusing the function for installing variables with different descriptions. --- src/bootctl/bootctl-install.c | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index 4478f78eb3..db5d982a58 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -1354,12 +1354,16 @@ fallback: static int install_boot_option( InstallContext *c, - const char *path) { + const char *path, + const char *description, + uint16_t *ret_slot) { uint16_t slot; int r; assert(c); + assert(path); + assert(description); if (c->esp_fd < 0) return c->esp_fd; @@ -1396,12 +1400,6 @@ static int install_boot_option( bool existing = r > 0; if (c->operation == INSTALL_NEW || !existing) { - _cleanup_free_ char *description = NULL; - - r = pick_efi_boot_option_description(c->esp_fd, &description); - if (r < 0) - return r; - r = efi_add_boot_option( slot, description, @@ -1424,7 +1422,14 @@ static int install_boot_option( description); } - return insert_into_order(c, slot); + r = insert_into_order(c, slot); + if (r < 0) + return r; + + if (ret_slot) + *ret_slot = slot; + + return 0; } static int are_we_installed(InstallContext *c) { @@ -1632,7 +1637,13 @@ static int run_install(InstallContext *c) { } char *path = strjoina("/EFI/systemd/systemd-boot", arch, ".efi"); - return install_boot_option(c, path); + + _cleanup_free_ char *description = NULL; + r = pick_efi_boot_option_description(c->esp_fd, &description); + if (r < 0) + return r; + + return install_boot_option(c, path, description, /* ret_slot= */ NULL); } int verb_install(int argc, char *argv[], uintptr_t _data, void *userdata) { From 6c356b6a8bbb6248533a471acc6852a06eb64e47 Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Wed, 29 Apr 2026 17:35:05 -0700 Subject: [PATCH 4/7] bootctl: add after_slot parameter to insert_into_order() This adds an after_slot parameter that, when not set to UINT16_MAX, requests that the new slot be placed immediately after the given slot in BootOrder. When after_slot is set and the new slot already exists in BootOrder, it will leave its position alone. This is so that if a user reorders it, we don't stomp on their changes. --- src/bootctl/bootctl-install.c | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index db5d982a58..cb46877e6e 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -1233,7 +1233,7 @@ static int find_slot(sd_id128_t uuid, const char *path, uint16_t *id) { return 0; } -static int insert_into_order(InstallContext *c, uint16_t slot) { +static int insert_into_order(InstallContext *c, uint16_t slot, uint16_t after_slot) { _cleanup_free_ uint16_t *order = NULL; uint16_t *t; int n; @@ -1255,7 +1255,8 @@ static int insert_into_order(InstallContext *c, uint16_t slot) { continue; /* we do not require to be the first one, all is fine */ - if (c->operation != INSTALL_NEW) + /* if after_slot is set, leave existing position alone to preserve user reordering. */ + if (i == 0 || c->operation != INSTALL_NEW || after_slot != UINT16_MAX) return 0; /* move us to the first slot */ @@ -1264,6 +1265,27 @@ static int insert_into_order(InstallContext *c, uint16_t slot) { return efi_set_boot_order(order, n); } + /* slot is not yet in the order, so insert after a specific slot if requested */ + if (after_slot != UINT16_MAX) { + t = reallocarray(order, n + 1, sizeof(uint16_t)); + if (!t) + return -ENOMEM; + order = t; + + for (int i = 0; i < n; i++) { + if (order[i] != after_slot) + continue; + + memmove(order + i + 2, order + i + 1, (n - i - 1) * sizeof(uint16_t)); + order[i + 1] = slot; + return efi_set_boot_order(order, n + 1); + } + + log_warning("Boot entry %04" PRIx16 " not found in BootOrder, appending new entry at the end.", after_slot); + order[n] = slot; + return efi_set_boot_order(order, n + 1); + } + /* extend array */ t = reallocarray(order, n + 1, sizeof(uint16_t)); if (!t) @@ -1422,7 +1444,7 @@ static int install_boot_option( description); } - r = insert_into_order(c, slot); + r = insert_into_order(c, slot, /* after_slot= */ UINT16_MAX); if (r < 0) return r; From 037b9a0680b6dd07dec5c2ef1a13da6eabd398d2 Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Wed, 29 Apr 2026 18:24:21 -0700 Subject: [PATCH 5/7] bootctl: back up sd-boot binary to fallback path on update When a primary sd-boot binary already exists on the ESP and is being updated, it is copied to systemd-boot-fallback{arch}.efi before installing the new version. This gives firmware a fallback Boot#### entry pointing to the previous binary in case the new one fails to load. The fallback is preserved (not overwritten) when its product and version match the currently booted bootloader (read from the LoaderInfo EFI variable), since that means it already holds the known good binary that booted this session. In all other cases it is overwritten with the current primary, when no fallback exists yet, when LoaderInfo is unavailable, or when the fallback's product or version differs from what booted. This also moves the version_check() call up so its result determines both the rotation decision and the main copy, and avoids a duplicate check (and duplicate "Skipping..." log) when the binary is already current. --- src/bootctl/bootctl-install.c | 54 ++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index cb46877e6e..b20857375f 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -731,11 +731,57 @@ static int copy_one_file( if (dest_fd < 0 && dest_fd != -ENOENT) return log_error_errno(dest_fd, "Failed to open '%s' under '%s/EFI/systemd' directory: %m", dest_name, j); - /* Note that if this fails we do the second copy anyway, but return this error code, - * so we stash it away in a separate variable. */ - ret = copy_file_with_version_check(source_path, source_fd, dest_path, dest_parent_fd, dest_name, dest_fd, force); - const char *e = startswith(dest_name, "systemd-boot"); + + /* If a primary sd-boot binary already exists and the source is a newer version, copy + * the existing primary to systemd-boot-fallback{arch}.efi before installing the new + * one, so firmware has a fallback to the previous binary. The fallback is left alone + * when its product and version match the currently booted bootloader (from LoaderInfo), + * so a known good binary stays as the fallback. In all other cases, like no fallback yet, + * LoaderInfo is unavailable, or product/version differs from what booted, it is + * overwritten with the current primary. */ + if (e && dest_fd >= 0 && !force) { + r = version_check(source_fd, source_path, dest_fd, dest_path); + if (r < 0) + /* Stash the error and fall through; the BOOT{arch}.EFI updates below still run. */ + ret = r; + else { + _cleanup_free_ char *fallback_name = strjoin("systemd-boot-fallback", e); + if (!fallback_name) + return log_oom(); + + _cleanup_free_ char *fallback_path = path_join(j, "/EFI/systemd", fallback_name); + if (!fallback_path) + return log_oom(); + + /* Leave the fallback alone if it already holds the currently booted product + * and version, so a known good binary stays as the fallback. If there is no + * fallback yet, LoaderInfo is unavailable, or there is a mismatch, then + * overwrite it with the current primary. */ + bool should_rotate = true; + _cleanup_close_ int fallback_fd = xopenat_full(dest_parent_fd, fallback_name, O_RDONLY|O_CLOEXEC, XO_REGULAR, MODE_INVALID); + if (fallback_fd >= 0) { + _cleanup_free_ char *loader_info = NULL, *fallback_version = NULL; + + if (efi_get_variable_string(EFI_LOADER_VARIABLE_STR("LoaderInfo"), &loader_info) >= 0 && + get_file_version(fallback_fd, &fallback_version) >= 0) + should_rotate = compare_product(loader_info, fallback_version) != 0 || + compare_version(loader_info, fallback_version) != 0; + } + + if (should_rotate) { + r = copy_file_with_version_check(dest_path, dest_fd, fallback_path, dest_parent_fd, fallback_name, /* dest_fd= */ -EBADF, /* force= */ true); + if (r < 0) + log_warning_errno(r, "Failed to back up sd-boot binary to fallback path, continuing: %m"); + } + + ret = copy_file_with_version_check(source_path, source_fd, dest_path, dest_parent_fd, dest_name, dest_fd, /* force= */ true); + } + } else + /* Note that if this fails we do the second copy anyway, but return this error code, + * so we stash it away in a separate variable. */ + ret = copy_file_with_version_check(source_path, source_fd, dest_path, dest_parent_fd, dest_name, dest_fd, force); + if (e) { /* Create the EFI default boot loader name (specified for removable devices) */ From 6e0ebe437bc2281db5caa6c3c066badb00afb71f Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Wed, 29 Apr 2026 18:46:13 -0700 Subject: [PATCH 6/7] bootctl: register fallback EFI Boot#### entry on install This adds a second install_boot_option call to register a Boot#### entry pointing at systemd-boot-fallback{arch}.efi, and place it immediately after the primary entry in BootOrder. The fallback file does not exist on the ESP on first install and is only created on first update when the existing primary binary is rotated to the fallback path. We register the variable anyway, so that the entry exists in the BootOrder once the fallback file shows up. Until then, firmware that reaches the fallback entry will fail to load it and fall through to the next entry in BootOrder, which is fine. install_boot_option gains a require_existing parameter so the existing early return on a missing ESP path can be skipped for the fallback, where a missing path is expected. This also does a bit of refactoring by splitting the bottom part of run_install() into a new install_variables() function that handles registering both the primary and fallback entries. --- src/bootctl/bootctl-install.c | 49 +++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index b20857375f..b3f8be936b 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -1424,6 +1424,8 @@ static int install_boot_option( InstallContext *c, const char *path, const char *description, + bool require_existing, + uint16_t after_slot, uint16_t *ret_slot) { uint16_t slot; @@ -1447,9 +1449,9 @@ static int install_boot_option( CHASE_PROHIBIT_SYMLINKS|CHASE_MUST_BE_REGULAR, F_OK, /* ret_path= */ NULL); - if (r == -ENOENT) + if (r == -ENOENT && require_existing) return 0; - if (r < 0) + if (r < 0 && r != -ENOENT) return log_error_errno(r, "Cannot access \"%s/%s\": %m", j, skip_leading_slash(path)); r = find_slot(c->esp_uuid, path, &slot); @@ -1490,7 +1492,7 @@ static int install_boot_option( description); } - r = insert_into_order(c, slot, /* after_slot= */ UINT16_MAX); + r = insert_into_order(c, slot, after_slot); if (r < 0) return r; @@ -1608,6 +1610,38 @@ static int load_secure_boot_auto_enroll( } #endif +static int install_variables(InstallContext *c, const char *arch) { + int r; + + assert(c); + + const char *path = strjoina("/EFI/systemd/systemd-boot", arch, ".efi"); + + _cleanup_free_ char *description = NULL; + r = pick_efi_boot_option_description(c->esp_fd, &description); + if (r < 0) + return r; + + uint16_t primary_slot = UINT16_MAX; + r = install_boot_option(c, path, description, /* require_existing= */ true, /* after_slot= */ UINT16_MAX, &primary_slot); + if (r < 0) + return r; + /* If primary registration was skipped (e.g. binary not on ESP), skip the fallback too + * or else it would land at position 0 in BootOrder with no primary ahead of it. */ + if (primary_slot == UINT16_MAX) + return 0; + + const char *fallback_path = strjoina("/EFI/systemd/systemd-boot-fallback", arch, ".efi"); + + _cleanup_free_ char *fallback_description = strjoin("Fallback ", description); + if (!fallback_description) + return log_oom(); + + strshorten(fallback_description, EFI_BOOT_OPTION_DESCRIPTION_MAX); + + return install_boot_option(c, fallback_path, fallback_description, /* require_existing= */ false, /* after_slot= */ primary_slot, /* ret_slot= */ NULL); +} + static int run_install(InstallContext *c) { int r; @@ -1704,14 +1738,7 @@ static int run_install(InstallContext *c) { return 0; } - char *path = strjoina("/EFI/systemd/systemd-boot", arch, ".efi"); - - _cleanup_free_ char *description = NULL; - r = pick_efi_boot_option_description(c->esp_fd, &description); - if (r < 0) - return r; - - return install_boot_option(c, path, description, /* ret_slot= */ NULL); + return install_variables(c, arch); } int verb_install(int argc, char *argv[], uintptr_t _data, void *userdata) { From 301b4193a0f595008699b8f191bd23dfa3cb4b4b Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Wed, 29 Apr 2026 20:32:19 -0700 Subject: [PATCH 7/7] bootctl: remove fallback EFI Boot#### variable on uninstall This cleans up the fallback Boot#### entry that was registered on install. The logic cleaning up variables was moved from verb_remove into a new remove_variables function, which mirrors the install side. --- src/bootctl/bootctl-install.c | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/bootctl/bootctl-install.c b/src/bootctl/bootctl-install.c index b3f8be936b..b609d8f227 100644 --- a/src/bootctl/bootctl-install.c +++ b/src/bootctl/bootctl-install.c @@ -1964,6 +1964,18 @@ static int remove_loader_variables(void) { return r; } +static int remove_variables(sd_id128_t uuid) { + int r = 0; + + const char *path = strjoina("/EFI/systemd/systemd-boot", get_efi_arch(), ".efi"); + RET_GATHER(r, remove_boot_option(uuid, path, /* in_order= */ true)); + + const char *fallback_path = strjoina("/EFI/systemd/systemd-boot-fallback", get_efi_arch(), ".efi"); + RET_GATHER(r, remove_boot_option(uuid, fallback_path, /* in_order= */ true)); + + return RET_GATHER(r, remove_loader_variables()); +} + int verb_remove(int argc, char *argv[], uintptr_t _data, void *userdata) { int r; @@ -2031,9 +2043,7 @@ int verb_remove(int argc, char *argv[], uintptr_t _data, void *userdata) { return r; } - char *path = strjoina("/EFI/systemd/systemd-boot", get_efi_arch(), ".efi"); - RET_GATHER(r, remove_boot_option(c.esp_uuid, path, /* in_order= */ true)); - return RET_GATHER(r, remove_loader_variables()); + return remove_variables(c.esp_uuid); } int verb_is_installed(int argc, char *argv[], uintptr_t _data, void *userdata) {